Welcome to the Rivaas documentation! Rivaas is a web framework for Go. It includes high-performance routing, request binding and validation, automatic OpenAPI generation, and OpenTelemetry observability.
What is Rivaas?
Rivaas is a modular Go web framework for building production-ready APIs and web applications. The name comes from ریواس (Rivās), a wild rhubarb plant from the mountains of Iran. This plant grows in harsh conditions at high altitudes.
Like its namesake, Rivaas is:
🛡️ Resilient — Built for production. Includes graceful shutdown, health checks, and panic recovery.
⚡ Lightweight — Minimal overhead (low latency, zero allocations). No loss of features.
🔧 Adaptive — Works locally, in containers, or across distributed systems.
📦 Self-sufficient — Integrated observability. No external dependencies to add.
Key Features
High Performance — High throughput. Uses radix tree router and Bloom filter optimization. See Router Performance for benchmarks.
Production-Ready — Includes graceful shutdown, health endpoints, panic recovery, and mTLS support.
Cloud-Native — Built with OpenTelemetry. Supports Prometheus, OTLP, and Jaeger.
Modular Architecture — Each package works alone. No need for the full framework.
Type-Safe — Request binding and validation with clear error messages.
Quick Start
Installation (requires Go 1.25+):
go get rivaas.dev/app
Hello World:
packagemainimport("context""log""net/http""os""os/signal""syscall""rivaas.dev/app")funcmain(){a,err:=app.New()iferr!=nil{log.Fatal(err)}a.GET("/",func(c*app.Context){c.JSON(http.StatusOK,map[string]string{"message":"Hello from Rivaas!",})})ctx,cancel:=signal.NotifyContext(context.Background(),os.Interrupt,syscall.SIGTERM)defercancel()iferr:=a.Start(ctx);err!=nil{log.Fatal(err)}}
Documentation Structure
Getting Started
New to Rivaas? Start here. Learn the basics and get your first application running.
Guides
Step-by-step tutorials for common tasks. Learn how to set up observability, configure middleware, and deploy to production. Each package guide includes practical examples.
Reference
Detailed API documentation. Covers all packages, configuration options, and advanced features.
Package Overview
Rivaas is organized into independent, standalone packages:
Core Packages
App
Web framework with integrated observability, lifecycle management, and graceful shutdown.
packagemainimport("context""log""net/http""os""os/signal""syscall""rivaas.dev/app")funcmain(){a,err:=app.New()iferr!=nil{log.Fatal(err)}a.GET("/",func(c*app.Context){c.JSON(http.StatusOK,map[string]string{"message":"Hello from Rivaas!",})})ctx,cancel:=signal.NotifyContext(context.Background(),os.Interrupt,syscall.SIGTERM)defercancel()iferr:=a.Start(ctx);err!=nil{log.Fatal(err)}}
This creates a basic API server. Continue through the guide to learn about configuration, middleware, and production practices.
Need Help?
Guides: Check the Guides section for detailed tutorials
API Reference: Browse the Reference documentation for all options
Installing Rivaas is easy. Use it as a complete framework with the app package or use individual packages as needed.
Install the Full Framework
The app package provides a complete web framework. It includes everything you need to build production-ready APIs:
go get rivaas.dev/app
This installs the main framework with all packages (router, logging, metrics, tracing, etc.).
Install Individual Packages
Rivaas packages work independently. Install only what you need:
Core
# High-performance routergo get rivaas.dev/router
# Full frameworkgo get rivaas.dev/app
Data
# Request bindinggo get rivaas.dev/binding
# Validationgo get rivaas.dev/validation
# Configurationgo get rivaas.dev/config
Observability
# Structured logginggo get rivaas.dev/logging
# Metrics collectiongo get rivaas.dev/metrics
# Distributed tracinggo get rivaas.dev/tracing
API & Errors
# OpenAPI generationgo get rivaas.dev/openapi
# Error formattinggo get rivaas.dev/errors
Verify Installation
Create a simple test file to verify your installation:
packagemainimport("context""log""net/http""os""os/signal""syscall""rivaas.dev/app")funcmain(){a,err:=app.New()iferr!=nil{log.Fatalf("Failed to create app: %v",err)}a.GET("/",func(c*app.Context){c.JSON(http.StatusOK,map[string]string{"message":"✅ Rivaas installed successfully!",})})ctx,cancel:=signal.NotifyContext(context.Background(),os.Interrupt,syscall.SIGTERM)defercancel()log.Println("Test server running on http://localhost:8080")iferr:=a.Start(ctx);err!=nil{log.Fatal(err)}}
# Install Go tools (optional but recommended)go install golang.org/x/tools/cmd/goimports@latest
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
Troubleshooting
Go Version Issues
If you see an error about Go version:
go: module rivaas.dev/app requires go >= 1.25
Update your Go installation to 1.25 or higher from go.dev/dl.
Module Cache Issues
If installation fails, try cleaning the module cache:
go clean -modcache
go get rivaas.dev/app
Network Issues
If you’re behind a proxy or firewall:
# Set proxy (if needed)exportGOPROXY=https://proxy.golang.org,direct
# Or use a custom proxyexportGOPROXY=https://goproxy.io,direct
Next Steps
Now that you have Rivaas installed, build your first application:
Build a simple REST API to learn Rivaas basics. You’ll create a working application with multiple routes, JSON responses, and graceful shutdown.
Create Your Project
Create a new directory and initialize a Go module:
mkdir hello-rivaas
cd hello-rivaas
go mod init example.com/hello-rivaas
Install Rivaas
go get rivaas.dev/app
Write Your Application
Create a file named main.go:
packagemainimport("context""log""net/http""os""os/signal""syscall""rivaas.dev/app")funcmain(){// Create a new Rivaas applicationa:=app.MustNew(app.WithServiceName("hello-rivaas"),app.WithServiceVersion("v1.0.0"),)// Define routesa.GET("/",handleRoot)a.GET("/hello/:name",handleHello)a.POST("/echo",handleEcho)// Setup graceful shutdownctx,cancel:=signal.NotifyContext(context.Background(),os.Interrupt,syscall.SIGTERM)defercancel()// Start the serverlog.Println("🚀 Starting server on http://localhost:8080")iferr:=a.Start(ctx);err!=nil{log.Fatal(err)}}// handleRoot returns a welcome messagefunchandleRoot(c*app.Context){c.JSON(http.StatusOK,map[string]string{"message":"Welcome to Rivaas!","version":"v1.0.0",})}// handleHello greets a user by namefunchandleHello(c*app.Context){name:=c.Param("name")c.JSON(http.StatusOK,map[string]string{"message":"Hello, "+name+"!",})}// handleEcho echoes back the request bodyfunchandleEcho(c*app.Context){varbodymap[string]anyiferr:=c.Bind(&body);err!=nil{c.JSON(http.StatusBadRequest,map[string]string{"error":"Invalid JSON",})return}c.JSON(http.StatusOK,map[string]any{"echo":body,})}
Run Your Application
Start the server:
go run main.go
You should see output like:
🚀 Starting server on http://localhost:8080
Test Your API
Open a new terminal and test the endpoints:
Test the root endpoint
curl http://localhost:8080/
Response:
{"message":"Welcome to Rivaas!","version":"v1.0.0"}
a.GET("/not-found",func(c*app.Context){c.JSON(http.StatusNotFound,map[string]string{"error":"Resource not found",})})a.POST("/created",func(c*app.Context){c.JSON(http.StatusCreated,map[string]string{"message":"Resource created",})})
Testing Your Application
Rivaas provides testing utilities for integration tests:
packagemainimport("net/http""net/http/httptest""testing""rivaas.dev/app")funcTestHelloEndpoint(t*testing.T){// Create test appa,err:=app.New()iferr!=nil{t.Fatalf("Failed to create app: %v",err)}a.GET("/hello/:name",handleHello)// Create test requestreq:=httptest.NewRequest(http.MethodGet,"/hello/Gopher",nil)// Test the requestresp,err:=a.Test(req)iferr!=nil{t.Fatalf("Request failed: %v",err)}deferresp.Body.Close()// Check status codeifresp.StatusCode!=http.StatusOK{t.Errorf("Expected status 200, got %d",resp.StatusCode)}}
Key Testing Methods:
a.Test(req) - Execute a request without starting the server
a.TestJSON(method, path, body) - Test JSON endpoints
See the blog example for comprehensive testing patterns.
Common Mistakes
Forgetting Error Handling
// ❌ Bad: Ignoring errorsa:=app.MustNew()// Panics on error// ✅ Good: Handle errors properlya,err:=app.New()iferr!=nil{log.Fatalf("Failed to create app: %v",err)}
Not Using Context for Shutdown
// ❌ Bad: No graceful shutdowna.Start(context.Background())// ✅ Good: Graceful shutdown with signalsctx,cancel:=signal.NotifyContext(context.Background(),os.Interrupt,syscall.SIGTERM)defercancel()a.Start(ctx)
Registering Routes After Start
// ❌ Bad: Routes registered after Starta.Start(ctx)a.GET("/late",handler)// Won't work!// ✅ Good: Routes before Starta.GET("/early",handler)a.Start(ctx)
Production Basics
Before deploying your first application:
✅ Use environment-based configuration (see Configuration)
✅ Add health endpoints for Kubernetes/Docker
✅ Enable structured logging
✅ Set appropriate timeouts
✅ Add recovery middleware (included by default)
Quick Production Setup:
a,err:=app.New(app.WithServiceName("my-api"),app.WithServiceVersion("v1.0.0"),app.WithEnvironment("production"),app.WithHealthEndpoints(app.WithReadinessCheck("ready",func(ctxcontext.Context)error{returnnil// Add real checks here}),),)
Rivaas uses the functional options pattern for configuration. This provides a clean, self-documenting API. It’s backward-compatible. This guide covers basic configuration options.
💡 First Time? Focus on sections marked with ⭐. Skip advanced topics for now.
Configuration Philosophy
Sensible Defaults: Works out of the box.
Progressive Disclosure: Start simple. Add complexity as needed.
Type Safety: Configuration errors are caught at startup.
Environment Aware: Different defaults for dev and prod.
app.WithLogging(logging.WithJSONHandler(),// JSON outputlogging.WithLevel(logging.LevelInfo),// Log level)// Or use console handler (development)app.WithLogging(logging.WithConsoleHandler())
a:=app.MustNew(app.WithServiceName("my-api"),app.WithHealthEndpoints(app.WithLivenessCheck("process",func(ctxcontext.Context)error{returnnil// Process is alive}),app.WithReadinessCheck("database",func(ctxcontext.Context)error{returndb.PingContext(ctx)// Check DB connection}),),)
⚠️ Security Critical: Only enable pprof in controlled environments.
Enable pprof for profiling (use with caution):
// Enable conditionally (recommended for production)app.WithDebugEndpoints(app.WithPprofIf(os.Getenv("PPROF_ENABLED")=="true"),)// Always enable (development only)app.WithDebugEndpoints(app.WithPprof(),)
⚠️ Security Warning: Never expose pprof endpoints in production without proper authentication.
Advanced: Middleware Configuration
Add middleware during initialization or after app creation:
import("rivaas.dev/router/middleware/cors""rivaas.dev/router/middleware/requestid")a:=app.MustNew(app.WithServiceName("my-api"),)// Add middleware after creationa.Use(requestid.New())a.Use(cors.New(cors.WithAllowedOrigins([]string{"https://example.com"}),))
💡 Learn More: See the Middleware Guide for detailed middleware usage patterns.
Complete Example
Here’s a production-ready configuration:
packagemainimport("context""log""os""time""rivaas.dev/app""rivaas.dev/logging""rivaas.dev/metrics""rivaas.dev/tracing""rivaas.dev/router/middleware/cors""rivaas.dev/router/middleware/requestid")funcmain(){a:=app.MustNew(// Service metadataapp.WithServiceName("my-api"),app.WithServiceVersion("v1.0.0"),app.WithEnvironment("production"),// Server configurationapp.WithServerConfig(app.WithReadTimeout(10*time.Second),app.WithWriteTimeout(15*time.Second),app.WithShutdownTimeout(30*time.Second),),// Observabilityapp.WithObservability(app.WithLogging(logging.WithJSONHandler()),app.WithMetrics(metrics.WithPrometheus(":9090","/metrics")),app.WithTracing(tracing.WithOTLP("jaeger:4317")),app.WithExcludePaths("/livez","/readyz","/metrics"),),// Health checksapp.WithHealthEndpoints(app.WithReadinessCheck("database",checkDatabase),),// Debug (conditional)app.WithDebugEndpoints(app.WithPprofIf(os.Getenv("PPROF_ENABLED")=="true"),),)// Add middlewarea.Use(requestid.New())a.Use(cors.New(cors.WithAllowedOrigins([]string{"https://example.com",})))// Register routesa.GET("/",handleRoot)// Start serverctx,cancel:=signal.NotifyContext(context.Background(),os.Interrupt)defercancel()iferr:=a.Start(ctx);err!=nil{log.Fatal(err)}}funccheckDatabase(ctxcontext.Context)error{// Implement your database checkreturnnil}funchandleRoot(c*app.Context){c.JSON(200,map[string]string{"status":"ok"})}
Advanced: Configuration Validation
Rivaas validates configuration at startup and returns clear errors:
a,err:=app.New(app.WithServerConfig(app.WithReadTimeout(15*time.Second),app.WithWriteTimeout(10*time.Second),// ❌ Read > Write),)iferr!=nil{// Error: "server.readTimeout: read timeout should not exceed write timeout"}
Advanced: Environment Variables
While Rivaas doesn’t directly use environment variables, you can easily integrate them:
Each middleware is a separate Go module. Add only the ones you use so your dependency set stays small:
# Examples: add the middleware you needgo get rivaas.dev/middleware/requestid
go get rivaas.dev/middleware/cors
go get rivaas.dev/middleware/recovery
go get rivaas.dev/middleware/timeout
See the Middleware Reference for the full list and a go get command for each package.
Adding Middleware
Global Middleware
Apply middleware to all routes:
import("rivaas.dev/app""rivaas.dev/middleware/requestid""rivaas.dev/middleware/cors")funcmain(){a,err:=app.New()iferr!=nil{log.Fatal(err)}// Add middleware before registering routesa.Use(requestid.New())a.Use(cors.New(cors.WithAllowAllOrigins(true)))// Register routesa.GET("/",handleRoot)// Start serverctx,cancel:=signal.NotifyContext(context.Background(),os.Interrupt,syscall.SIGTERM)defercancel()a.Start(ctx)}
Group Middleware
Apply middleware to specific route groups:
// Public routes - no autha.GET("/",handlePublic)// API routes - with authapi:=a.Group("/api",authMiddleware)api.GET("/users",getUsers)api.POST("/users",createUser)// Admin routes - with admin authadmin:=a.Group("/admin",authMiddleware,adminMiddleware)admin.GET("/dashboard",getDashboard)
Track requests across distributed systems with unique, time-ordered IDs:
import"rivaas.dev/middleware/requestid"// UUID v7 by default (36 chars, time-ordered, RFC 9562)a.Use(requestid.New())// Or use ULID for shorter IDs (26 chars)a.Use(requestid.New(requestid.WithULID()))// In your handlera.GET("/",func(c*app.Context){reqID:=c.Response.Header().Get("X-Request-ID")c.JSON(http.StatusOK,map[string]string{"request_id":reqID,})})
Both UUID v7 and ULID are lexicographically sortable, making them ideal for debugging and log correlation.
CORS
Enable cross-origin requests:
import"rivaas.dev/middleware/cors"// Development: Allow all originsa.Use(cors.New(cors.WithAllowAllOrigins(true)))// Production: Specific originsa.Use(cors.New(cors.WithAllowedOrigins([]string{"https://example.com","https://app.example.com",}),cors.WithAllowedMethods([]string{"GET","POST","PUT","DELETE"}),cors.WithAllowCredentials(true),))
Timeout
Prevent long-running requests:
import"rivaas.dev/middleware/timeout"// Global timeouta.Use(timeout.New(timeout.WithDuration(5*time.Second)))// Skip for streaming endpointsa.Use(timeout.New(timeout.WithDuration(5*time.Second),timeout.WithSkipPaths("/stream","/sse"),))
Recovery
Automatically recover from panics (included by default):
import"rivaas.dev/middleware/recovery"// Custom recovery with stack tracesa.Use(recovery.New(recovery.WithStackTrace(true),recovery.WithHandler(func(c*router.Context,errany){log.Printf("Panic recovered: %v",err)c.JSON(http.StatusInternalServerError,map[string]string{"error":"Internal server error",})}),))
Rate Limiting
Limit request rate (single-instance only):
import"rivaas.dev/middleware/ratelimit"// 100 requests per second with burst of 20a.Use(ratelimit.New(ratelimit.WithRequestsPerSecond(100),ratelimit.WithBurst(20),))
Production Note
This uses in-memory storage. For multi-instance deployments, use a distributed rate limiter (Redis, etc.).
Middleware Execution Order
Middleware executes in the order it’s registered:
a.Use(middleware1)// Executes firsta.Use(middleware2)// Executes seconda.Use(middleware3)// Executes thirda.GET("/",handler)// Executes last (if all middleware calls Next())
Recovery should be first (catches panics from other middleware)
Logging early (captures all requests)
Auth before business logic
CORS early (handles preflight requests)
Example:
a.Use(recovery.New())// 1. Panic recoverya.Use(requestid.New())// 2. Request trackinga.Use(cors.New(...))// 3. CORS handling// App-level observability is automatic// Route handlers execute last
Creating Custom Middleware
Simple middleware example:
functimingMiddleware()app.HandlerFunc{returnfunc(c*app.Context){start:=time.Now()// Process request (call next middleware/handler)c.Next()// After handler executesduration:=time.Since(start)log.Printf("%s %s - %v",c.Request.Method,c.Request.URL.Path,duration)}}// Use ita.Use(timingMiddleware())
Authentication middleware example:
funcauthMiddleware(c*app.Context){token:=c.Request.Header.Get("Authorization")iftoken==""{c.JSON(http.StatusUnauthorized,map[string]string{"error":"Missing authorization token",})return// Don't call Next() - stop here}// Validate token (simplified)if!isValidToken(token){c.JSON(http.StatusUnauthorized,map[string]string{"error":"Invalid token",})return}// Token is valid - continue to handlerc.Next()}// Use itapi:=a.Group("/api",authMiddleware)
Middleware with Configuration
Use functional options for configurable middleware:
typeConfigstruct{MaxRequestsintWindowtime.Duration}typeOptionfunc(*Config)funcWithMaxRequests(maxint)Option{returnfunc(c*Config){c.MaxRequests=max}}funcNew(opts...Option)app.HandlerFunc{cfg:=&Config{MaxRequests:100,Window:time.Minute,}for_,opt:=rangeopts{opt(cfg)}returnfunc(c*app.Context){// Use cfg.MaxRequests, cfg.Windowc.Next()}}// Use ita.Use(New(WithMaxRequests(200),))
Default Middleware
Rivaas automatically includes some middleware based on environment:
Development Mode:
✅ Recovery middleware (panic recovery)
✅ Access logging via observability recorder
Production Mode:
✅ Recovery middleware
✅ Error-only logging via observability recorder
Disable defaults:
a,err:=app.New(app.WithMiddleware(),// Empty = no defaults)// Now add only what you needa.Use(recovery.New())a.Use(requestid.New())
Complete Example
Here’s a production-ready middleware setup:
packagemainimport("context""log""net/http""os""os/signal""syscall""time""rivaas.dev/app""rivaas.dev/middleware/cors""rivaas.dev/middleware/recovery""rivaas.dev/middleware/requestid""rivaas.dev/middleware/timeout")funcmain(){a,err:=app.New(app.WithServiceName("my-api"),app.WithEnvironment("production"),)iferr!=nil{log.Fatal(err)}// Global middleware (order matters!)a.Use(recovery.New(recovery.WithStackTrace(true)))a.Use(requestid.New())a.Use(cors.New(cors.WithAllowedOrigins([]string{"https://example.com"})))a.Use(timeout.New(timeout.WithDuration(30*time.Second)))// Public routesa.GET("/",handlePublic)a.GET("/health",handleHealth)// Protected APIapi:=a.Group("/api",authMiddleware)api.GET("/users",getUsers)api.POST("/users",createUser)// Admin routesadmin:=a.Group("/admin",authMiddleware,adminMiddleware)admin.GET("/dashboard",getDashboard)// Start serverctx,cancel:=signal.NotifyContext(context.Background(),os.Interrupt,syscall.SIGTERM)defercancel()iferr:=a.Start(ctx);err!=nil{log.Fatal(err)}}funchandlePublic(c*app.Context){c.JSON(http.StatusOK,map[string]string{"status":"ok"})}funchandleHealth(c*app.Context){c.JSON(http.StatusOK,map[string]string{"status":"healthy"})}funcauthMiddleware(c*app.Context){token:=c.Request.Header.Get("Authorization")iftoken==""||!isValidToken(token){c.JSON(http.StatusUnauthorized,map[string]string{"error":"Unauthorized"})return}c.Next()}funcadminMiddleware(c*app.Context){// Check if user is admin (simplified)if!isAdmin(c.Request.Header.Get("Authorization")){c.JSON(http.StatusForbidden,map[string]string{"error":"Forbidden"})return}c.Next()}funcgetUsers(c*app.Context){c.JSON(http.StatusOK,[]string{"user1","user2"})}funccreateUser(c*app.Context){c.JSON(http.StatusCreated,map[string]string{"status":"created"})}funcgetDashboard(c*app.Context){c.JSON(http.StatusOK,map[string]string{"dashboard":"data"})}funcisValidToken(tokenstring)bool{// Implement your token validationreturntoken!=""}funcisAdmin(tokenstring)bool{// Implement your admin checkreturntrue}
Troubleshooting
Middleware Not Executing
Problem: Middleware doesn’t run.
Solutions:
Ensure middleware is added before routes: a.Use(...) then a.GET(...)
Check if middleware calls c.Next() to continue the chain
Verify middleware isn’t returning early without calling c.Next()
Middleware Running in Wrong Order
Problem: Authentication runs after handler.
Solution: Add middleware in the correct order - they execute top to bottom:
a.Use(recovery.New())// Firsta.Use(authMiddleware)// Seconda.GET("/",handler)// Last
CORS Preflight Failing
Problem: OPTIONS requests return 404.
Solution: Add CORS middleware before routes, and ensure it handles OPTIONS:
You’ve completed the Getting Started guide. You now know how to install Rivaas, build applications, configure them, and add middleware.
What You’ve Learned
✅ Installation — Set up Rivaas and verified it works ✅ First Application — Built a REST API with routes and JSON responses ✅ Configuration — Configured service metadata, health checks, and observability ✅ Middleware — Added functionality like CORS and authentication
Choose Your Path
🚀 Building Production APIs
Learn advanced routing, error handling, and API patterns:
Routing Guide — Advanced routing patterns, groups, and constraints
Request Binding — Bind and validate JSON, XML, YAML, and form data
funchandler(c*app.Context){// Get path parameterid:=c.Param("id")// Get query parameterfilter:=c.Query("filter")// Bind request body (auto-detects JSON, form, etc.)varreqMyRequestiferr:=c.Bind(&req);err!=nil{c.JSON(400,map[string]string{"error":"Invalid request"})return}// Send JSON responsec.JSON(200,map[string]string{"status":"ok"})}
What’s Next?
Pick the topic that interests you most. The documentation works for both linear reading and jumping to specific topics.
Happy building with Rivaas!
2 - Guides
Learning-oriented guides for building applications with Rivaas
Comprehensive guides to help you learn and master Rivaas features. These learning-focused tutorials walk you through practical examples and real-world scenarios.
New to Rivaas?
Start with the Application Framework guide to learn how to build production-ready applications, then explore the HTTP Router for routing fundamentals.
Core Framework
Build web applications with integrated observability and production-ready defaults.
Application Framework
A complete web framework built on the Rivaas router. Includes integrated observability, lifecycle management, graceful shutdown, and sensible defaults for rapid application development.
High-performance HTTP routing for cloud-native applications. Features radix tree routing, middleware chains, content negotiation, API versioning, and native OpenTelemetry support.
Handle incoming requests with type-safe binding and validation.
Request Data Binding
Bind HTTP request data from various sources (query parameters, form data, JSON bodies, headers, cookies, path parameters) to Go structs with type safety and zero-allocation performance.
Flexible, multi-strategy validation for Go structs. Supports struct tags via go-playground/validator, JSON Schema, and custom interfaces with detailed error messages.
Manage application settings and generate API documentation.
Configuration Management
Configuration management following the Twelve-Factor App methodology. Load from files, environment variables, or Consul with hierarchical merging and struct binding.
Automatic OpenAPI 3.0.4 and 3.1.2 specification generation from Go code. Uses struct tags and reflection with built-in Swagger UI support and security scheme configuration.
Monitor, trace, and debug your applications in production.
Structured Logging
Production-ready structured logging using Go’s standard log/slog. Features multiple output formats, context-aware logging, sensitive data redaction, log sampling, and dynamic log levels.
OpenTelemetry-based metrics collection with support for Prometheus, OTLP, and stdout exporters. Includes built-in HTTP metrics, custom metrics support, and thread-safe operations.
OpenTelemetry-based distributed tracing with automatic context propagation across services. Supports multiple exporters including OTLP (gRPC and HTTP) with HTTP middleware integration.
A complete web framework built on the Rivaas router. Includes integrated observability, lifecycle management, and sensible defaults for production-ready applications.
The Rivaas App package provides a high-level framework with pre-configured observability, graceful shutdown, and common middleware for rapid application development.
Overview
The App package is a complete web framework built on top of the Rivaas router. It provides a simple API for building web applications. It includes integrated observability with metrics, tracing, and logging. It has lifecycle management, graceful shutdown, and common middleware patterns.
Key Features
Complete Framework - Pre-configured with sensible defaults for rapid development.
Integrated Observability - Built-in metrics with Prometheus/OTLP, tracing with OpenTelemetry, and structured logging with slog.
OpenAPI Generation - Automatic OpenAPI spec generation with Swagger UI.
Lifecycle Hooks - OnStart, OnReady, OnShutdown, OnStop for initialization and cleanup.
Health Endpoints - Kubernetes-compatible liveness and readiness probes.
Graceful Shutdown - Proper server shutdown with configurable timeouts.
Environment-Aware - Development and production modes with appropriate defaults.
When to Use
Use App Package When
Building a complete web application - Need a full framework with all features included.
Want integrated observability - Metrics and tracing configured out of the box.
Need quick development - Sensible defaults help you start immediately.
Building a REST API - Pre-configured with common middleware and patterns.
Prefer convention over configuration - Defaults that work well together.
Use Router Package Directly When
Building a library or framework - Need full control over the routing layer.
Have custom observability setup - Already using specific metrics or tracing solutions.
Maximum performance is critical - Want zero overhead from default middleware.
Need complete flexibility - Don’t want any opinions or defaults imposed.
Integrating into existing systems - Need to fit into established patterns.
Performance Note: The app package adds about 1-2% latency compared to using the router directly. See Router Performance for baseline numbers. However, it provides significant development speed and maintainability benefits. This comes through integrated observability and sensible defaults.
Quick Start
Simple Application
Create a minimal application with defaults:
packagemainimport("context""log""net/http""os""os/signal""syscall""rivaas.dev/app")funcmain(){// Create app with defaultsa,err:=app.New()iferr!=nil{log.Fatalf("Failed to create app: %v",err)}// Register routesa.GET("/",func(c*app.Context){c.JSON(http.StatusOK,map[string]string{"message":"Hello from Rivaas App!",})})// Setup graceful shutdownctx,cancel:=signal.NotifyContext(context.Background(),os.Interrupt,syscall.SIGTERM)defercancel()// Start server with graceful shutdowniferr:=a.Start(ctx);err!=nil{log.Fatalf("Server error: %v",err)}}
Full-Featured Application
Create a production-ready application with full observability:
packagemainimport("context""log""net/http""os""os/signal""syscall""time""rivaas.dev/app""rivaas.dev/logging""rivaas.dev/metrics""rivaas.dev/tracing")funcmain(){// Create app with full observabilitya,err:=app.New(app.WithServiceName("my-api"),app.WithServiceVersion("v1.0.0"),app.WithEnvironment("production"),// Observability: logging, metrics, tracingapp.WithObservability(app.WithLogging(logging.WithJSONHandler()),app.WithMetrics(),// Prometheus is defaultapp.WithTracing(tracing.WithOTLP("localhost:4317")),app.WithExcludePaths("/livez","/readyz","/metrics"),),// Health endpoints: GET /livez (liveness), GET /readyz (readiness)app.WithHealthEndpoints(app.WithHealthTimeout(800*time.Millisecond),app.WithReadinessCheck("database",func(ctxcontext.Context)error{returndb.PingContext(ctx)}),),// Server configurationapp.WithServer(app.WithReadTimeout(15*time.Second),app.WithWriteTimeout(15*time.Second),),)iferr!=nil{log.Fatalf("Failed to create app: %v",err)}// Register routesa.GET("/users/:id",func(c*app.Context){userID:=c.Param("id")// Request-scoped logger with automatic contextc.Logger().Info("processing request","user_id",userID)c.JSON(http.StatusOK,map[string]any{"user_id":userID,"name":"John Doe","trace_id":c.TraceID(),})})// Setup graceful shutdownctx,cancel:=signal.NotifyContext(context.Background(),os.Interrupt,syscall.SIGTERM)defercancel()// Start serveriferr:=a.Start(ctx);err!=nil{log.Fatalf("Server error: %v",err)}}
Learning Path
Follow this structured path to master the Rivaas App framework:
1. Getting Started
Start with the basics:
Installation - Set up the app package in your project
Basic Usage - Create your first app and register routes
Configuration - Configure service name, version, and environment
2. Request Handling
Handle requests effectively:
Context - Use the app context for binding, validation, and error handling
Routing - Organize routes with groups, versioning, and static files
Middleware - Add cross-cutting concerns with built-in middleware
3. Observability
Monitor your application:
Observability - Integrate metrics, tracing, and logging
Create a simple main.go to verify the installation:
packagemainimport("context""log""net/http""os""os/signal""syscall""rivaas.dev/app")funcmain(){a,err:=app.New()iferr!=nil{log.Fatal(err)}a.GET("/",func(c*app.Context){c.JSON(http.StatusOK,map[string]string{"message":"Installation successful!",})})ctx,cancel:=signal.NotifyContext(context.Background(),os.Interrupt,syscall.SIGTERM)defercancel()log.Println("Server starting on :8080")iferr:=a.Start(ctx);err!=nil{log.Fatal(err)}}
If you plan to use observability features, you may want to configure exporters:
# For Prometheus metrics (default, no additional setup needed)# For OTLP metrics/tracing (to send to Jaeger, Tempo, etc.)# No additional packages needed - built into the tracing package
For larger applications, organize handlers in separate files:
// handlers/users.gopackagehandlersimport("net/http""rivaas.dev/app")funcGetUser(c*app.Context){id:=c.Param("id")// Fetch user from database...c.JSON(http.StatusOK,map[string]any{"id":id,"name":"John Doe",})}funcCreateUser(c*app.Context){varreqstruct{Namestring`json:"name"`Emailstring`json:"email"`}if!c.MustBind(&req){return// Error response already sent}// Create user in database...c.JSON(http.StatusCreated,map[string]any{"id":"123","name":req.Name,"email":req.Email,})}
Configure the listen address via options when creating the app (default is :8080):
// Development (default port 8080)a,err:=app.New(app.WithServiceName("my-api"),app.WithPort(8080),)// ...a.Start(ctx)// Productiona,err:=app.New(app.WithServiceName("my-api"),app.WithPort(80),)// ...a.Start(ctx)// Bind to specific interfacea,err:=app.New(app.WithServiceName("my-api"),app.WithHost("127.0.0.1"),app.WithPort(8080),)// ...a.Start(ctx)// Use environment variableport:=8080ifp:=os.Getenv("PORT");p!=""{port,_=strconv.Atoi(p)}a,err:=app.New(app.WithServiceName("my-api"),app.WithPort(port),)// ...a.Start(ctx)
Complete Example
Here’s a complete working example:
packagemainimport("context""log""net/http""os""os/signal""syscall""rivaas.dev/app")funcmain(){// Create appa,err:=app.New(app.WithServiceName("hello-api"),app.WithServiceVersion("v1.0.0"),)iferr!=nil{log.Fatalf("Failed to create app: %v",err)}// Home routea.GET("/",func(c*app.Context){c.JSON(http.StatusOK,map[string]string{"message":"Welcome to Hello API","version":"v1.0.0",})})// Greet route with parametera.GET("/greet/:name",func(c*app.Context){name:=c.Param("name")c.JSON(http.StatusOK,map[string]string{"greeting":"Hello, "+name+"!",})})// Echo route with request bodya.POST("/echo",func(c*app.Context){varreqmap[string]anyif!c.MustBind(&req){return}c.JSON(http.StatusOK,req)})// Setup graceful shutdownctx,cancel:=signal.NotifyContext(context.Background(),os.Interrupt,syscall.SIGTERM,)defercancel()// Start serverlog.Println("Server starting on :8080")iferr:=a.Start(ctx);err!=nil{log.Fatal(err)}}
Valid values:"development", "production". Invalid values cause validation to fail.
Server Configuration
Configure server timeouts and (optionally) transport. Default port is 8080 for HTTP and 8443 for TLS/mTLS; override with WithPort. For HTTPS or mTLS, use WithTLS or WithMTLS at construction; see Server for examples.
Timeouts
Configure server timeouts for safety and performance:
You can set only the options you need - unset fields use defaults:
// Only override read and write timeoutsa,err:=app.New(app.WithServer(app.WithReadTimeout(15*time.Second),app.WithWriteTimeout(15*time.Second),// Other fields use defaults: IdleTimeout=60s, etc.),)
Configuration from Environment
Load configuration from environment variables:
packagemainimport("log""os""strconv""time""rivaas.dev/app")funcmain(){// Parse timeouts from environmentreadTimeout:=parseDuration("READ_TIMEOUT",10*time.Second)writeTimeout:=parseDuration("WRITE_TIMEOUT",10*time.Second)a,err:=app.New(app.WithServiceName(getEnv("SERVICE_NAME","my-api")),app.WithServiceVersion(getEnv("SERVICE_VERSION","v1.0.0")),app.WithEnvironment(getEnv("ENVIRONMENT","development")),app.WithServer(app.WithReadTimeout(readTimeout),app.WithWriteTimeout(writeTimeout),),)iferr!=nil{log.Fatal(err)}// ...}funcgetEnv(key,defaultValuestring)string{ifvalue:=os.Getenv(key);value!=""{returnvalue}returndefaultValue}funcparseDuration(keystring,defaultValuetime.Duration)time.Duration{ifvalue:=os.Getenv(key);value!=""{ifd,err:=time.ParseDuration(value);err==nil{returnd}}returndefaultValue}
Configuration Validation
All configuration is validated when calling app.New():
Validation errors are structured and include all issues:
validation errors (2):
1. configuration error in serviceName: must not be empty
2. configuration error in environment: must be "development" or "production", got "staging"
Complete Configuration Example
packagemainimport("log""os""time""rivaas.dev/app")funcmain(){a,err:=app.New(// Service metadataapp.WithServiceName("orders-api"),app.WithServiceVersion("v2.1.0"),app.WithEnvironment("production"),// Server configurationapp.WithServer(app.WithReadTimeout(10*time.Second),app.WithWriteTimeout(15*time.Second),app.WithIdleTimeout(120*time.Second),app.WithReadHeaderTimeout(3*time.Second),app.WithMaxHeaderBytes(2<<20),// 2MBapp.WithShutdownTimeout(30*time.Second),),)iferr!=nil{log.Fatalf("Failed to create app: %v",err)}// Register routes...// Start server...}
Next Steps
Observability - Configure metrics, tracing, and logging
Server - Learn about HTTP, HTTPS, and mTLS servers
Lifecycle - Use lifecycle hooks for initialization and cleanup
2.1.4 - Observability
Integrate metrics, tracing, and logging for complete application observability.
Overview
The app package provides unified configuration for the three pillars of observability:
Metrics - Prometheus or OTLP metrics with automatic HTTP instrumentation.
Tracing - OpenTelemetry distributed tracing with context propagation.
Logging - Structured logging with slog that includes request-scoped fields.
All three pillars use the same functional options pattern. They automatically receive service metadata (name and version) from app-level configuration.
Environment Variable Configuration
You can configure observability using environment variables. This is useful for container deployments and following 12-factor app principles.
Environment variables override code configuration, making it easy to deploy the same code to different environments.
Unified Observability Configuration
Configure all three pillars in one place.
a,err:=app.New(app.WithServiceName("orders-api"),app.WithServiceVersion("v1.2.3"),app.WithObservability(app.WithLogging(logging.WithJSONHandler()),app.WithMetrics(),// Prometheus is defaultapp.WithTracing(tracing.WithOTLP("localhost:4317")),),)
Pass the request context when you log so trace IDs are attached automatically:
a.GET("/orders/:id",func(c*app.Context){orderID:=c.Param("id")slog.InfoContext(c.RequestContext(),"processing order",slog.String("order.id",orderID),)slog.DebugContext(c.RequestContext(),"fetching from database")c.JSON(http.StatusOK,map[string]string{"order_id":orderID,})})
Handler log lines stay lean: they include trace_id and span_id (when tracing is enabled) plus whatever attributes you add. HTTP details (method, route, client IP, etc.) are in the access log, not in every handler log.
These methods are part of app.Context. They use the metrics and tracing you set up with app.WithObservability(). If observability is not configured, the methods simply do nothing (no-ops).
Record custom metrics in your handlers:
a.GET("/orders/:id",func(c*app.Context){orderID:=c.Param("id")// Increment counterc.IncrementCounter("order.lookups",attribute.String("order.id",orderID),)// Record histogramc.RecordHistogram("order.processing_time",0.250,attribute.String("order.id",orderID),)c.JSON(http.StatusOK,order)})
Service name and version are automatically injected into all observability components:
a,err:=app.New(app.WithServiceName("orders-api"),app.WithServiceVersion("v1.2.3"),app.WithObservability(app.WithLogging(),// Automatically gets service metadataapp.WithMetrics(),// Automatically gets service metadataapp.WithTracing(),// Automatically gets service metadata),)
You don’t need to pass service name/version explicitly - the app injects them automatically.
Overriding Service Metadata
If needed, you can override service metadata for specific components:
packagemainimport("log""time""rivaas.dev/app""rivaas.dev/logging""rivaas.dev/metrics""rivaas.dev/tracing")funcmain(){a,err:=app.New(// Service metadata (automatically injected into all components)app.WithServiceName("orders-api"),app.WithServiceVersion("v2.1.0"),app.WithEnvironment("production"),// Unified observability configurationapp.WithObservability(// Logging: JSON handler for productionapp.WithLogging(logging.WithJSONHandler(),logging.WithLevel(slog.LevelInfo),),// Metrics: Prometheus on separate serverapp.WithMetrics(metrics.WithPrometheus(":9090","/metrics"),),// Tracing: OTLP to Jaeger/Tempoapp.WithTracing(tracing.WithOTLP("jaeger:4317"),tracing.WithSampleRate(0.1),// 10% sampling),// Path filteringapp.WithExcludePaths("/livez","/readyz"),app.WithExcludePrefixes("/internal/"),// Access logging: errors and slow requests only (explicit; also production default when unset)app.WithAccessLogScope(app.AccessLogScopeErrorsOnly),app.WithSlowThreshold(1*time.Second),),)iferr!=nil{log.Fatal(err)}// Register routes...a.GET("/orders/:id",handleGetOrder)// Start server...}
Server - Start the server and view observability data
2.1.5 - Environment Variables
Configure your app using environment variables for easier deployment.
Overview
Want to configure your app without changing code? Use environment variables. This is helpful when you deploy to containers or cloud platforms.
The app package supports environment variables through the WithEnv() option. Just add it to your app setup, and you can control settings like port, logging, metrics, and tracing using environment variables.
This follows the 12-factor app approach, which means your code stays the same across different environments. You just change the environment variables.
Quick Start
Here’s a simple example. First, set some environment variables:
app,err:=app.New(app.WithServiceName("my-api"),app.WithEnv(),// This reads environment variables)iferr!=nil{log.Fatal(err)}// Your app now runs on port 3000 with debug logging and Prometheus metrics
That’s it! No need to set these in code anymore.
Environment Variables Reference
All environment variables start with the RIVAAS_ prefix. You can also use a custom prefix with WithEnvPrefix().
Server Configuration
Variable
Description
Default
Example
RIVAAS_PORT
Port number to listen on
8080
3000
RIVAAS_HOST
Host address to bind to
0.0.0.0
127.0.0.1
Logging Configuration
Variable
Description
Default
Example
RIVAAS_LOG_LEVEL
Log level to use
info
debug, info, warn, error
RIVAAS_LOG_FORMAT
Log output format
json
json, text, console
Metrics Configuration
Variable
Description
Default
Example
RIVAAS_METRICS_EXPORTER
Type of metrics exporter
-
prometheus, otlp, stdout
RIVAAS_METRICS_ADDR
Prometheus server address
:9090
:9000, 0.0.0.0:9090
RIVAAS_METRICS_PATH
Prometheus metrics path
/metrics
/custom-metrics
RIVAAS_METRICS_ENDPOINT
OTLP endpoint for metrics
-
http://localhost:4318
Tracing Configuration
Variable
Description
Default
Example
RIVAAS_TRACING_EXPORTER
Type of tracing exporter
-
otlp, otlp-http, stdout
RIVAAS_TRACING_ENDPOINT
OTLP endpoint for traces
-
localhost:4317
Debug Configuration
Variable
Description
Default
Example
RIVAAS_PPROF_ENABLED
Enable pprof endpoints
false
true, false
Metrics Configuration
You can set up metrics using just environment variables. No need to write code for it.
Prometheus (Default)
The simplest way to get metrics:
exportRIVAAS_METRICS_EXPORTER=prometheus
This starts a Prometheus server on :9090/metrics. Your app will expose metrics there.
This is useful when your tracing backend only supports HTTP.
Stdout Tracing (Development)
For local development, print traces to your terminal:
exportRIVAAS_TRACING_EXPORTER=stdout
You’ll see all traces in your console. Great for testing.
Logging Configuration
Control how your app logs messages.
Log Level
Set the minimum log level:
exportRIVAAS_LOG_LEVEL=debug # Show everythingexportRIVAAS_LOG_LEVEL=info # Normal logging (default)exportRIVAAS_LOG_LEVEL=warn # Only warnings and errorsexportRIVAAS_LOG_LEVEL=error # Only errors
Log Format
Choose how logs look:
exportRIVAAS_LOG_FORMAT=json # JSON format (good for production)exportRIVAAS_LOG_FORMAT=text # Simple text formatexportRIVAAS_LOG_FORMAT=console # Colored output (good for development)
Common Patterns
Here are some typical setups for different environments.
Development Setup
For local development, you want to see everything:
Use the app context for request binding, validation, error handling, and logging.
Overview
The app.Context wraps router.Context and provides app-level features:
Request Binding - Parse JSON, form, query, path, header, and cookie data automatically
Validation - Comprehensive validation with multiple strategies
Error Handling - Structured error responses with content negotiation
Logging - Request-scoped logger with automatic context
Request Binding
Binding and Validation
Bind() reads your request data and checks if it’s valid. It handles JSON, forms, query parameters, and more.
Use Bind() for most cases. It automatically validates your data:
typeCreateUserRequeststruct{Namestring`json:"name" validate:"required,min=3"`Emailstring`json:"email" validate:"required,email"`Ageint`json:"age" validate:"gte=18"`}a.POST("/users",func(c*app.Context){varreqCreateUserRequestiferr:=c.Bind(&req);err!=nil{c.Fail(err)// Handles binding and validation errorsreturn}// req is valid and ready to use})
The Bind() method does two things: it reads the request data and validates it. If either step fails, you get an error.
Binding from Multiple Sources
You can bind data from different places at once. Use struct tags to tell Rivaas where to look:
typeGetUserRequeststruct{IDint`path:"id"`// From URL pathExpandstring`query:"expand"`// From query stringAPIKeystring`header:"X-API-Key"`// From HTTP headerSessionstring`cookie:"session"`// From cookie}a.GET("/users/:id",func(c*app.Context){varreqGetUserRequestiferr:=c.Bind(&req);err!=nil{c.Fail(err)return}// All fields are populated from their sources})
Binding Without Validation
Sometimes you need to process data before validating it. Use BindOnly() for this:
a.POST("/users",func(c*app.Context){varreqCreateUserRequestiferr:=c.BindOnly(&req);err!=nil{c.Fail(err)return}// Clean up the datareq.Email=strings.ToLower(req.Email)// Now validateiferr:=c.Validate(&req);err!=nil{c.Fail(err)return}})
Multi-Source Binding
Bind from multiple sources in one call. This is useful when your request needs data from different places:
typeUpdateUserRequeststruct{IDint`path:"id"`// From URL pathNamestring`json:"name"`// From JSON bodyTokenstring`header:"X-Token"`// From header}a.PUT("/users/:id",func(c*app.Context){varreqUpdateUserRequestiferr:=c.Bind(&req);err!=nil{c.Fail(err)return}// All fields populated: ID from path, Name from JSON, Token from header})
Multipart Forms with Files
For file uploads, use the *binding.File type. The context automatically detects and handles multipart form data:
typeUploadRequeststruct{File*binding.File`form:"file"`Titlestring`form:"title"`Descriptionstring`form:"description"`// JSON in form fields is automatically parsedSettingsstruct{Qualityint`json:"quality"`Formatstring`json:"format"`}`form:"settings"`}a.POST("/upload",func(c*app.Context){varreqUploadRequestiferr:=c.Bind(&req);err!=nil{c.Fail(err)return}// Validate file typeallowedTypes:=[]string{".jpg",".png",".gif"}if!slices.Contains(allowedTypes,req.File.Ext()){c.BadRequest(fmt.Errorf("invalid file type"))return}// Save the filefilename:=fmt.Sprintf("/uploads/%d_%s",time.Now().Unix(),req.File.Name)iferr:=req.File.Save(filename);err!=nil{c.InternalError(err)return}c.JSON(http.StatusCreated,map[string]interface{}{"filename":filepath.Base(filename),"size":req.File.Size,"url":"/uploads/"+filepath.Base(filename),})})
Multiple file uploads:
typeGalleryUploadstruct{Photos[]*binding.File`form:"photos"`Titlestring`form:"title"`}a.POST("/gallery",func(c*app.Context){varreqGalleryUploadiferr:=c.Bind(&req);err!=nil{c.Fail(err)return}// Process each photofori,photo:=rangereq.Photos{filename:=fmt.Sprintf("/uploads/%s_%d%s",req.Title,i,photo.Ext())iferr:=photo.Save(filename);err!=nil{c.InternalError(err)return}}c.JSON(http.StatusCreated,map[string]int{"uploaded":len(req.Photos),})})
File security best practices:
Always validate file types using file.Ext() or check magic bytes
Limit file sizes (check file.Size)
Generate safe filenames (don’t use user-provided names directly)
Store files outside your web root
Scan for malware in production environments
See Multipart Forms for detailed examples and security patterns.
Validation
The Must Pattern
The easiest way to handle requests is with MustBind(). It reads the data, validates it, and sends an error response if something is wrong:
typeCreateUserRequeststruct{Namestring`json:"name" validate:"required,min=3,max=50"`Emailstring`json:"email" validate:"required,email"`Ageint`json:"age" validate:"required,gte=18,lte=120"`}a.POST("/users",func(c*app.Context){varreqCreateUserRequestif!c.MustBind(&req){return// Error already sent to client}// req is valid, continue with your logic})
This is the recommended approach. It keeps your code clean and handles errors automatically.
Type-Safe Binding with Generics
If you prefer working with return values instead of pointers, use the generic functions:
a.POST("/users",func(c*app.Context){req,ok:=app.MustBind[CreateUserRequest](c)if!ok{return// Error already sent}// req is type CreateUserRequest, not a pointer})
This approach is more concise. You don’t need to declare the variable first.
Manual Error Handling
When you need more control over error handling, use Bind() directly:
PATCH requests only update some fields. Use WithPartial() to validate only the fields that are present:
typeUpdateUserRequeststruct{Name*string`json:"name" validate:"omitempty,min=3,max=50"`Email*string`json:"email" validate:"omitempty,email"`}a.PATCH("/users/:id",func(c*app.Context){req,ok:=app.MustBind[UpdateUserRequest](c,app.WithPartial())if!ok{return}// Only fields in the request are validated})
You can also use the shortcut function BindPatch():
a.PATCH("/users/:id",func(c*app.Context){req,ok:=app.MustBindPatch[UpdateUserRequest](c)if!ok{return}// Same as above, but shorter})
Strict Mode (Reject Unknown Fields)
Catch typos and API mismatches by rejecting unknown fields:
a.POST("/users",func(c*app.Context){req,ok:=app.MustBind[CreateUserRequest](c,app.WithStrict())if!ok{return// Error sent if client sends unknown fields}})
Most apps use tag validation. It’s simple and works well.
Error Handling
Basic Error Handling
When something goes wrong in your handler, use Fail() to send an error response. This method formats the error, writes the HTTP response, and automatically stops the handler chain so no other handlers run after it:
Use convenience methods for common HTTP error status codes. These methods automatically format and send the error response, then stop the handler chain:
// 404 Not Foundifuser==nil{c.NotFound(fmt.Errorf("user not found"))return}// 400 Bad Requestiferr:=validateInput(input);err!=nil{c.BadRequest(fmt.Errorf("invalid input"))return}// 401 Unauthorizedif!isAuthenticated{c.Unauthorized(fmt.Errorf("authentication required"))return}// 403 Forbiddenif!hasPermission{c.Forbidden(fmt.Errorf("insufficient permissions"))return}// 409 ConflictifuserExists{c.Conflict(fmt.Errorf("user already exists"))return}// 422 Unprocessable EntityifvalidationErr!=nil{c.UnprocessableEntity(validationErr)return}// 429 Too Many RequestsifrateLimitExceeded{c.TooManyRequests(fmt.Errorf("rate limit exceeded"))return}// 500 Internal Server Erroriferr:=processRequest();err!=nil{c.InternalError(err)return}// 503 Service UnavailableifmaintenanceMode{c.ServiceUnavailable(fmt.Errorf("maintenance mode"))return}
You can also pass nil to use a generic default message:
c.NotFound(nil)// Uses "Not Found" as the messagec.BadRequest(nil)// Uses "Bad Request" as the message
Error Formatters
Configure error formatting at app level:
// Single formattera,err:=app.New(app.WithErrorFormatter(&errors.RFC9457{BaseURL:"https://api.example.com/problems",}),)// Multiple formatters with content negotiationa,err:=app.New(app.WithErrorFormatters(map[string]errors.Formatter{"application/problem+json":&errors.RFC9457{},"application/json":&errors.Simple{},}),app.WithDefaultErrorFormat("application/problem+json"),)
Request-Scoped Logging
Pass the request context when you log so trace IDs are attached automatically. Use the standard library’s context-aware logging:
trace_id and span_id are injected automatically when tracing is enabled. HTTP details (method, route, client IP, etc.) live in the access log; handler logs stay lean with just your message and attributes plus trace correlation.
Structured Logging
Use key-value pairs with any slog.*Context call:
a.POST("/orders",func(c*app.Context){req,ok:=app.MustBind[CreateOrderRequest](c)if!ok{return}slog.InfoContext(c.RequestContext(),"creating order",slog.String("customer.id",req.CustomerID),slog.Int("item.count",len(req.Items)),slog.Float64("order.total",req.Total),)// Process order...slog.InfoContext(c.RequestContext(),"order created successfully",slog.String("order.id",orderID),)})
Log Levels
Use the context-aware variants for each level:
slog.DebugContext(c.RequestContext(),"fetching from cache")slog.InfoContext(c.RequestContext(),"request processed successfully")slog.WarnContext(c.RequestContext(),"cache miss, fetching from database")slog.ErrorContext(c.RequestContext(),"failed to save to database","error",err)
What Appears in Handler Logs
Handler log lines include service metadata, trace correlation, and whatever attributes you add. They do not duplicate HTTP fields; those are in the access log:
Here’s a complete example showing binding, validation, and logging:
packagemainimport("log""log/slog""net/http""rivaas.dev/app")typeCreateOrderRequeststruct{CustomerIDstring`json:"customer_id" validate:"required,uuid"`Items[]string`json:"items" validate:"required,min=1,dive,required"`Totalfloat64`json:"total" validate:"required,gt=0"`}funcmain(){a:=app.MustNew(app.WithServiceName("orders-api"),)a.POST("/orders",func(c*app.Context){// Bind and validate in one stepreq,ok:=app.MustBind[CreateOrderRequest](c)if!ok{return// Error already sent}slog.InfoContext(c.RequestContext(),"creating order",slog.String("customer.id",req.CustomerID),slog.Int("item.count",len(req.Items)),slog.Float64("order.total",req.Total),)// Your business logic here...orderID:="order-123"slog.InfoContext(c.RequestContext(),"order created",slog.String("order.id",orderID),)// Send responsec.JSON(http.StatusCreated,map[string]string{"order_id":orderID,})})// Start server...}
Add cross-cutting concerns with built-in and custom middleware.
Overview
Middleware functions execute before and after route handlers. They add cross-cutting concerns like logging, authentication, and rate limiting.
The app package provides access to high-quality middleware from the router/middleware subpackages.
Using Middleware
Global Middleware
Apply middleware to all routes:
a:=app.MustNew()a.Use(requestid.New())a.Use(cors.New(cors.WithAllowAllOrigins(true)))// All routes registered after Use() will have this middlewarea.GET("/users",handler)a.POST("/orders",handler)
The app package automatically includes recovery middleware by default in both development and production modes.
To disable default middleware:
a,err:=app.New(app.WithoutDefaultMiddleware(),app.WithMiddleware(myCustomRecovery),// Add your own)
Built-in Middleware
Request ID
Generate unique request IDs for tracing:
import"rivaas.dev/middleware/requestid"a.Use(requestid.New())// Access in handlera.GET("/",func(c*app.Context){reqID:=c.Response.Header().Get("X-Request-ID")c.JSON(http.StatusOK,map[string]string{"request_id":reqID,})})
import"rivaas.dev/middleware/cors"// Allow all origins (development)a.Use(cors.New(cors.WithAllowAllOrigins(true)))// Specific origins (production)a.Use(cors.New(cors.WithAllowedOrigins([]string{"https://example.com"}),cors.WithAllowCredentials(true),cors.WithAllowedMethods([]string{"GET","POST","PUT","DELETE"}),cors.WithAllowedHeaders([]string{"Content-Type","Authorization"}),))
Recovery
Recover from panics gracefully (included by default):
import"rivaas.dev/middleware/ratelimit"// 100 requests per minutea.Use(ratelimit.New(100,time.Minute))
Note: This is in-memory rate limiting suitable for single-instance deployments only. For production with multiple instances, use a distributed rate limiting solution.
Compression
Compress responses with gzip or brotli:
import"rivaas.dev/middleware/compression"a.Use(compression.New(compression.WithLevel(compression.BestSpeed),compression.WithMinSize(1024),// Only compress responses > 1KB))
funcAuthMiddleware()app.HandlerFunc{returnfunc(c*app.Context){token:=c.Request.Header.Get("Authorization")iftoken==""{c.Unauthorized(fmt.Errorf("missing authorization token"))return}// Validate token...if!isValid(token){c.Unauthorized(fmt.Errorf("invalid token"))return}// Continue to next middleware/handlerc.Next()}}// Use ita.Use(AuthMiddleware())
Middleware with Configuration
Create configurable middleware:
typeAuthConfigstruct{TokenHeaderstringSkipPaths[]string}funcAuthWithConfig(configAuthConfig)app.HandlerFunc{returnfunc(c*app.Context){// Skip authentication for certain pathsfor_,path:=rangeconfig.SkipPaths{ifc.Request.URL.Path==path{c.Next()return}}token:=c.Request.Header.Get(config.TokenHeader)iftoken==""||!isValid(token){c.Unauthorized(fmt.Errorf("authentication failed"))return}c.Next()}}// Use ita.Use(AuthWithConfig(AuthConfig{TokenHeader:"X-API-Key",SkipPaths:[]string{"/health","/public"},}))
Middleware with State
Share state across requests:
typeRateLimiterstruct{requestsmap[string]intmusync.Mutex}funcNewRateLimiter()*RateLimiter{return&RateLimiter{requests:make(map[string]int),}}func(rl*RateLimiter)Middleware()app.HandlerFunc{returnfunc(c*app.Context){clientIP:=c.ClientIP()rl.mu.Lock()count:=rl.requests[clientIP]rl.requests[clientIP]++rl.mu.Unlock()ifcount>100{c.Status(http.StatusTooManyRequests)return}c.Next()}}// Use itlimiter:=NewRateLimiter()a.Use(limiter.Middleware())
Route-Specific Middleware
Per-Route Middleware
Apply middleware to specific routes:
// Using WithBefore optiona.GET("/admin",adminHandler,app.WithBefore(AuthMiddleware()),)// Multiple middlewarea.GET("/admin/users",handler,app.WithBefore(AuthMiddleware(),AdminOnlyMiddleware(),),)
// Admin routes with auth middlewareadmin:=a.Group("/admin",AuthMiddleware(),AdminOnlyMiddleware())admin.GET("/users",getUsersHandler)admin.POST("/users",createUserHandler)// API routes with rate limitingapi:=a.Group("/api",RateLimitMiddleware())api.GET("/status",statusHandler)api.GET("/version",versionHandler)
packagemainimport("log/slog""net/http""time""rivaas.dev/app""rivaas.dev/middleware/requestid""rivaas.dev/middleware/cors""rivaas.dev/middleware/timeout")funcmain(){a:=app.MustNew(app.WithServiceName("api"),app.WithMiddleware(requestid.New(),cors.New(cors.WithAllowAllOrigins(true)),timeout.New(timeout.WithDuration(30*time.Second)),),)// Custom middlewarea.Use(LoggingMiddleware())a.Use(AuthMiddleware())// Public routes (no auth)a.GET("/health",healthHandler)// Protected routes (with auth)a.GET("/users",usersHandler)// Admin routes (with auth + admin check)admin:=a.Group("/admin",AdminOnlyMiddleware())admin.GET("/dashboard",dashboardHandler)// Start server...}funcLoggingMiddleware()app.HandlerFunc{returnfunc(c*app.Context){start:=time.Now()c.Next()duration:=time.Since(start)slog.InfoContext(c.RequestContext(),"request completed","method",c.Request.Method,"path",c.Request.URL.Path,"duration",duration,)}}funcAuthMiddleware()app.HandlerFunc{returnfunc(c*app.Context){// Skip auth for health checkifc.Request.URL.Path=="/health"{c.Next()return}token:=c.Request.Header.Get("Authorization")iftoken==""{c.Unauthorized(fmt.Errorf("missing authorization token"))return}c.Next()}}funcAdminOnlyMiddleware()app.HandlerFunc{returnfunc(c*app.Context){// Check if user is admin...if!isAdmin(){c.Forbidden(fmt.Errorf("admin access required"))return}c.Next()}}
Next Steps
Routing - Organize routes with groups and versioning
Context - Access request and response in middleware
Only GET, POST, PUT, DELETE, PATCH, HEAD, and OPTIONS are supported. Using any other method (including via internal or reflection-based registration) causes a panic with a clear error so mistakes are caught early (see Fail Fast with Clear Errors).
Match All Methods
Register a route that matches all HTTP methods:
a.Any("/webhook",webhookHandler)// Handles GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS
Route Groups
Basic Groups
Organize routes with shared prefixes:
api:=a.Group("/api")api.GET("/users",getUsersHandler)api.POST("/users",createUserHandler)// Routes: GET /api/users, POST /api/users
Nested Groups
Create hierarchical route structures:
api:=a.Group("/api")v1:=api.Group("/v1")v1.GET("/users",getUsersHandler)// Route: GET /api/v1/users
Groups with Middleware
Apply middleware to all routes in a group:
admin:=a.Group("/admin",AuthMiddleware(),AdminOnlyMiddleware())admin.GET("/users",getUsersHandler)admin.POST("/users",createUserHandler)// Both routes have auth and admin middleware
// After router is frozen (after a.Start())url,err:=a.URLFor("users.get",map[string]string{"id":"123"},nil)// Returns: "/users/123"// With query parametersurl,err:=a.URLFor("users.get",map[string]string{"id":"123"},map[string][]string{"expand":{"profile"}},)// Returns: "/users/123?expand=profile"
Use lifecycle hooks for initialization, cleanup, reload, and event handling.
Overview
The app package provides lifecycle hooks for managing application state. Registering a hook after the router is frozen (e.g. after Start()) returns an error; register all hooks before calling Start().
OnStart - Called before server starts. Runs sequentially. Stops on first error.
OnReady - Called when server is ready to accept connections. Runs async. Non-blocking.
OnReload - Called when SIGHUP is received or Reload() is called. Runs sequentially. Errors logged.
OnShutdown - Called during graceful shutdown. LIFO order.
OnStop - Called after shutdown completes. Best-effort.
OnRoute - Called when a route is registered. Synchronous.
OnStart Hook
Basic Usage
Initialize resources before the server starts:
a:=app.MustNew()iferr:=a.OnStart(func(ctxcontext.Context)error{log.Println("Connecting to database...")returndb.Connect(ctx)});err!=nil{log.Fatal(err)}iferr:=a.OnStart(func(ctxcontext.Context)error{log.Println("Running migrations...")returndb.Migrate(ctx)});err!=nil{log.Fatal(err)}// Start server - hooks execute before listeninga.Start(ctx)
Error Handling
OnStart hooks run sequentially and stop on first error:
a.OnStart(func(ctxcontext.Context)error{iferr:=db.Connect(ctx);err!=nil{returnfmt.Errorf("database connection failed: %w",err)}returnnil})// If this hook fails, server won't startiferr:=a.Start(ctx);err!=nil{log.Fatalf("Startup failed: %v",err)}
a.OnReady(func(){log.Println("Server is ready!")log.Printf("Listening on :8080")})a.OnReady(func(){// Register with service discoveryconsul.Register("my-service",":8080")})
Async Execution
OnReady hooks run asynchronously and don’t block startup:
a.OnReady(func(){// Long-running warmup tasktime.Sleep(5*time.Second)cache.Preload()})// Server accepts connections immediately, warmup runs in background
Error Handling
Panics in OnReady hooks are caught and logged:
a.OnReady(func(){// If this panics, it's logged but doesn't crash the serverdoSomethingRisky()})
OnReload Hook
What is it?
The OnReload hook lets you reload your app’s configuration without stopping the server. When you register this hook, your app automatically listens for SIGHUP signals on Unix systems (Linux, macOS). No extra setup needed!
Basic Usage
Here’s how to reload configuration when you get a SIGHUP signal:
a:=app.MustNew(app.WithServiceName("my-api"),)// Register a reload hook - SIGHUP is now automatically enabled!a.OnReload(func(ctxcontext.Context)error{log.Println("Reloading configuration...")// Load new confignewConfig,err:=loadConfig("config.yaml")iferr!=nil{returnfmt.Errorf("failed to load config: %w",err)}// Apply new configapplyConfig(newConfig)returnnil})// Start serverctx,cancel:=signal.NotifyContext(context.Background(),os.Interrupt,syscall.SIGTERM)defercancel()a.Start(ctx)
Now you can reload without restarting:
# Send SIGHUP to reloadkill -HUP <pid>
# Or use killallkillall -HUP my-api
How it works
When you register an OnReload hook:
On Unix/Linux/macOS: Your app automatically listens for SIGHUP signals
On Windows: SIGHUP doesn’t exist, but you can still call Reload() programmatically
All platforms: You can trigger reload from your code using app.Reload(ctx)
When no OnReload hooks are registered, SIGHUP is ignored on Unix so the process is not terminated (e.g. by kill -HUP or terminal disconnect).
Error Handling
If reload fails, your app keeps running with the old configuration:
a.OnReload(func(ctxcontext.Context)error{cfg,err:=loadConfig("config.yaml")iferr!=nil{// Error is logged, but server keeps runningreturnerr}// Validate before applyingiferr:=cfg.Validate();err!=nil{returnfmt.Errorf("invalid config: %w",err)}applyConfig(cfg)returnnil})
The hooks run one at a time (sequentially) and stop on the first error. This means if you have multiple reload hooks and one fails, the rest won’t run.
Programmatic Reload
You can also trigger reload from your code - useful for admin endpoints:
// Create an admin endpoint to trigger reloada.POST("/admin/reload",func(c*app.Context){iferr:=a.Reload(c.Request.Context());err!=nil{c.InternalError(err)return}c.JSON(200,map[string]string{"status":"config reloaded"})})
Multiple Reload Hooks
You can register multiple hooks for different parts of your config:
Routes and middleware can’t be changed after the server starts - they’re frozen for safety. Only reload things like:
Configuration files
Database connection settings
TLS certificates
Cache contents
Log levels
Feature flags
Platform Differences
Unix/Linux/macOS: SIGHUP works automatically
Windows: SIGHUP isn’t available, use app.Reload(ctx) instead
Thread Safety
Don’t worry about multiple reload signals at the same time - the framework handles this automatically. If multiple SIGHUPs come in, they’ll run one at a time.
OnShutdown Hook
Basic Usage
Clean up resources during graceful shutdown:
a.OnShutdown(func(ctxcontext.Context){log.Println("Shutting down gracefully...")db.Close()})a.OnShutdown(func(ctxcontext.Context){log.Println("Flushing metrics...")metrics.Flush(ctx)})
LIFO Execution Order
OnShutdown hooks execute in reverse order (Last In, First Out):
a.OnShutdown(func(ctxcontext.Context){log.Println("1. First registered")})a.OnShutdown(func(ctxcontext.Context){log.Println("2. Second registered")})// During shutdown, prints:// "2. Second registered"// "1. First registered"
This ensures cleanup happens in reverse dependency order.
Timeout Handling
OnShutdown hooks must complete within the shutdown timeout:
a,err:=app.New(app.WithServer(app.WithShutdownTimeout(30*time.Second),),)a.OnShutdown(func(ctxcontext.Context){// This context has a 30s deadlineselect{case<-flushComplete:log.Println("Flush completed")case<-ctx.Done():log.Println("Flush timed out")}})
Common Use Cases
// Close database connectionsa.OnShutdown(func(ctxcontext.Context){db.Close()})// Flush metrics and tracesa.OnShutdown(func(ctxcontext.Context){metrics.Shutdown(ctx)tracing.Shutdown(ctx)})// Deregister from service discoverya.OnShutdown(func(ctxcontext.Context){consul.Deregister("my-service")})// Close external connectionsa.OnShutdown(func(ctxcontext.Context){redis.Close()messageQueue.Close()})
OnStop hooks run in best-effort mode - panics are caught and logged:
a.OnStop(func(){// Even if this panics, other hooks still runcleanupTempFiles()})
No Timeout
OnStop hooks don’t have a timeout constraint:
a.OnStop(func(){// This can take as long as neededarchiveLogs()})
OnRoute Hook
Basic Usage
Execute code when routes are registered:
a.OnRoute(func(rt*route.Route){log.Printf("Registered: %s %s",rt.Method(),rt.Path())})// Register routes - hook fires for each onea.GET("/users",handler)a.POST("/users",handler)
Route Validation
Validate routes during registration:
a.OnRoute(func(rt*route.Route){// Ensure all routes have namesifrt.Name()==""{log.Printf("Warning: Route %s %s has no name",rt.Method(),rt.Path())}})
Documentation Generation
Use for automatic documentation:
varroutes[]stringa.OnRoute(func(rt*route.Route){routes=append(routes,fmt.Sprintf("%s %s",rt.Method(),rt.Path()))})// After all routes registereda.OnReady(func(){log.Printf("Registered %d routes:",len(routes))for_,r:=rangeroutes{log.Println(" ",r)}})
Complete Example
packagemainimport("context""log""os""os/signal""syscall""rivaas.dev/app")vardb*Databasefuncmain(){a:=app.MustNew(app.WithServiceName("api"),app.WithServer(app.WithShutdownTimeout(30*time.Second),),)// OnStart: Initialize resourcesiferr:=a.OnStart(func(ctxcontext.Context)error{log.Println("Connecting to database...")varerrerrordb,err=ConnectDB(ctx)iferr!=nil{returnfmt.Errorf("database connection failed: %w",err)}returnnil});err!=nil{log.Fatalf("failed to register OnStart: %v",err)}iferr:=a.OnStart(func(ctxcontext.Context)error{log.Println("Running migrations...")returndb.Migrate(ctx)});err!=nil{log.Fatalf("failed to register OnStart: %v",err)}// OnRoute: Log route registrationiferr:=a.OnRoute(func(rt*route.Route){log.Printf("Route registered: %s %s",rt.Method(),rt.Path())});err!=nil{log.Fatalf("failed to register OnRoute: %v",err)}// OnReady: Post-startup tasksiferr:=a.OnReady(func(){log.Println("Server is ready!")log.Println("Registering with service discovery...")consul.Register("api",":8080")});err!=nil{log.Fatalf("failed to register OnReady: %v",err)}// OnShutdown: Graceful cleanupiferr:=a.OnShutdown(func(ctxcontext.Context){log.Println("Deregistering from service discovery...")consul.Deregister("api")});err!=nil{log.Fatalf("failed to register OnShutdown: %v",err)}iferr:=a.OnShutdown(func(ctxcontext.Context){log.Println("Closing database connection...")iferr:=db.Close();err!=nil{log.Printf("Error closing database: %v",err)}});err!=nil{log.Fatalf("failed to register OnShutdown: %v",err)}// OnStop: Final cleanupiferr:=a.OnStop(func(){log.Println("Cleanup complete")});err!=nil{log.Fatalf("failed to register OnStop: %v",err)}// Register routesa.GET("/",homeHandler)a.GET("/health",healthHandler)// Setup graceful shutdownctx,cancel:=signal.NotifyContext(context.Background(),os.Interrupt,syscall.SIGTERM)defercancel()// Start serverlog.Println("Starting server...")iferr:=a.Start(ctx);err!=nil{log.Fatalf("Server error: %v",err)}}
Hook Execution Flow
1. app.Start(ctx) called
2. OnStart hooks execute (sequential, stop on error)
3. Server starts listening
4. OnReady hooks execute (async, non-blocking)
5. Server handles requests...
→ OnReload hooks execute when SIGHUP received (sequential, logged on error)
6. Context canceled (SIGTERM/SIGINT)
7. OnShutdown hooks execute (LIFO order, with timeout)
8. Server shutdown complete
9. OnStop hooks execute (best-effort, no timeout)
10. Process exits
a,err:=app.New(app.WithHealthEndpoints(app.WithHealthPrefix("/_system"),),)// Endpoints:// GET /_system/livez// GET /_system/readyz
Liveness Checks
Basic Liveness Check
Liveness checks should be dependency-free and fast:
a,err:=app.New(app.WithHealthEndpoints(app.WithLivenessCheck("process",func(ctxcontext.Context)error{// Process is alive if we can execute thisreturnnil}),),)
Multiple Liveness Checks
Add multiple liveness checks.
a,err:=app.New(app.WithHealthEndpoints(app.WithLivenessCheck("process",func(ctxcontext.Context)error{returnnil}),app.WithLivenessCheck("goroutines",func(ctxcontext.Context)error{ifruntime.NumGoroutine()>10000{returnfmt.Errorf("too many goroutines: %d",runtime.NumGoroutine())}returnnil}),),)
a,err:=app.New(app.WithHealthEndpoints(app.WithHealthTimeout(800*time.Millisecond),app.WithReadinessCheck("database",func(ctxcontext.Context)error{// This check has 800ms to completereturndb.PingContext(ctx)}),),)
Default timeout: 1s
Runtime Readiness Gates
Readiness Manager
Dynamically manage readiness state at runtime:
typeDatabaseGatestruct{db*sql.DB}func(g*DatabaseGate)Ready()bool{returng.db.Ping()==nil}func(g*DatabaseGate)Name()string{return"database"}// Register gate at runtimea.Readiness().Register("db",&DatabaseGate{db:db})// Unregister during shutdowna.OnShutdown(func(ctxcontext.Context){a.Readiness().Unregister("db")})
Use Cases
Runtime gates are useful for:
Connection pools that manage their own health
Circuit breakers that track upstream failures
Dynamic dependencies that come and go at runtime
Liveness vs Readiness
When to Use Liveness
Liveness checks answer: “Should the process be restarted?”
packagemainimport("context""database/sql""log""time""rivaas.dev/app")vardb*sql.DBfuncmain(){a,err:=app.New(app.WithServiceName("api"),// Health endpoints configurationapp.WithHealthEndpoints(// Custom pathsapp.WithHealthPrefix("/_system"),// Timeout for checksapp.WithHealthTimeout(800*time.Millisecond),// Liveness: process-level healthapp.WithLivenessCheck("process",func(ctxcontext.Context)error{// Always healthy if we can execute thisreturnnil}),// Readiness: dependency healthapp.WithReadinessCheck("database",func(ctxcontext.Context)error{returndb.PingContext(ctx)}),app.WithReadinessCheck("cache",func(ctxcontext.Context)error{returncheckCache(ctx)}),),)iferr!=nil{log.Fatal(err)}// Initialize databasea.OnStart(func(ctxcontext.Context)error{varerrerrordb,err=sql.Open("postgres","...")returnerr})// Unregister readiness during shutdowna.OnShutdown(func(ctxcontext.Context){// Mark as not ready before closing connectionslog.Println("Marking service as not ready")time.Sleep(100*time.Millisecond)// Allow load balancer to notice})// Register routes...// Start server...// Endpoints available at:// GET /_system/livez - Liveness// GET /_system/readyz - Readiness}funccheckCache(ctxcontext.Context)error{// Check cache connectivityreturnnil}
a,err:=app.New(app.WithEnvironment("staging"),app.WithDebugEndpoints(app.WithPprofIf(os.Getenv("PPROF_ENABLED")=="true"),),)// Use authentication middlewarea.Use(IPAllowlistMiddleware([]string{"10.0.0.0/8"}))
Production
Enable only with proper authentication:
a,err:=app.New(app.WithEnvironment("production"),app.WithDebugEndpoints(app.WithDebugPrefix("/_internal/debug"),app.WithPprofIf(os.Getenv("PPROF_ENABLED")=="true"),),)// Protect debug endpoints with authenticationdebugAuth:=a.Group("/_internal",AdminAuthMiddleware())// pprof endpoints are automatically under this group
Using pprof
CPU Profile
Capture a 30-second CPU profile:
curl http://localhost:8080/debug/pprof/profile > cpu.prof
go tool pprof cpu.prof
Heap Profile
Capture current heap allocations:
curl http://localhost:8080/debug/pprof/heap > heap.prof
go tool pprof heap.prof
Goroutine Profile
View current goroutines:
curl http://localhost:8080/debug/pprof/goroutine > goroutine.prof
go tool pprof goroutine.prof
Configure mTLS at construction with WithMTLS, then start the server:
// Load server certificateserverCert,err:=tls.LoadX509KeyPair("server.crt","server.key")iferr!=nil{log.Fatal(err)}// Load CA certificate for client validationcaCert,err:=os.ReadFile("ca.crt")iferr!=nil{log.Fatal(err)}caCertPool:=x509.NewCertPool()caCertPool.AppendCertsFromPEM(caCert)a:=app.MustNew(app.WithServiceName("my-api"),app.WithMTLS(serverCert,app.WithClientCAs(caCertPool),app.WithMinVersion(tls.VersionTLS13),),// default port 8443; use WithPort(443) to override)// ... register routes ...ctx,cancel:=signal.NotifyContext(context.Background(),os.Interrupt,syscall.SIGTERM)defercancel()iferr:=a.Start(ctx);err!=nil{log.Fatal(err)}
Client Authorization
Authorize clients based on certificate by adding WithAuthorize to WithMTLS:
packagemainimport("context""log""os""os/signal""syscall""rivaas.dev/app")funcmain(){a:=app.MustNew(app.WithServiceName("api"),)a.GET("/",homeHandler)ctx,cancel:=signal.NotifyContext(context.Background(),os.Interrupt,syscall.SIGTERM,)defercancel()log.Println("Server starting on :8080")iferr:=a.Start(ctx);err!=nil{log.Fatal(err)}}
HTTPS with mTLS
packagemainimport("context""crypto/tls""crypto/x509""log""os""os/signal""syscall""rivaas.dev/app")funcmain(){serverCert,err:=tls.LoadX509KeyPair("server.crt","server.key")iferr!=nil{log.Fatal(err)}caCert,err:=os.ReadFile("ca.crt")iferr!=nil{log.Fatal(err)}caCertPool:=x509.NewCertPool()caCertPool.AppendCertsFromPEM(caCert)a:=app.MustNew(app.WithServiceName("secure-api"),app.WithMTLS(serverCert,app.WithClientCAs(caCertPool),app.WithMinVersion(tls.VersionTLS13),),// default port 8443)a.GET("/",homeHandler)ctx,cancel:=signal.NotifyContext(context.Background(),os.Interrupt,syscall.SIGTERM)defercancel()log.Println("mTLS server starting on :8443 (default)")iferr:=a.Start(ctx);err!=nil{log.Fatal(err)}}
Next Steps
Lifecycle - Use lifecycle hooks for initialization and cleanup
a.GET("/users/:id",getUserHandler,app.WithDoc(openapi.WithSummary("Get user by ID"),openapi.WithDescription("Retrieves a user by their unique identifier"),openapi.WithResponse(200,UserResponse{}),openapi.WithResponse(404,ErrorResponse{}),openapi.WithTags("users"),),)
a.GET("/users",listUsersHandler,app.WithDoc(openapi.WithSummary("List users"),openapi.WithQueryParam("page","integer","Page number"),openapi.WithQueryParam("limit","integer","Items per page"),openapi.WithResponse(200,UserListResponse{}),),)
packagemainimport("log""net/http""rivaas.dev/app""rivaas.dev/openapi")typeUserstruct{IDint`json:"id"`Namestring`json:"name"`Emailstring`json:"email"`}typeCreateUserRequeststruct{Namestring`json:"name" validate:"required"`Emailstring`json:"email" validate:"required,email"`}funcmain(){a,err:=app.New(app.WithServiceName("users-api"),app.WithServiceVersion("v1.0.0"),app.WithOpenAPI(openapi.WithDescription("API for managing users"),openapi.WithServer("http://localhost:8080","Development"),openapi.WithBearerAuth("bearerAuth","JWT authentication"),openapi.WithSwaggerUI(true,"/docs"),openapi.WithTags(openapi.Tag("users","User management"),),),)iferr!=nil{log.Fatal(err)}// List usersa.GET("/users",listUsersHandler,app.WithDoc(openapi.WithSummary("List users"),openapi.WithDescription("Returns a list of all users"),openapi.WithResponse(200,[]User{}),openapi.WithTags("users"),),)// Create usera.POST("/users",createUserHandler,app.WithDoc(openapi.WithSummary("Create user"),openapi.WithRequest(CreateUserRequest{}),openapi.WithResponse(201,User{}),openapi.WithResponse(400,map[string]string{}),openapi.WithTags("users"),openapi.WithSecurity("bearerAuth"),),)// Get usera.GET("/users/:id",getUserHandler,app.WithDoc(openapi.WithSummary("Get user by ID"),openapi.WithResponse(200,User{}),openapi.WithResponse(404,map[string]string{}),openapi.WithTags("users"),),)// Start server// OpenAPI spec: http://localhost:8080/openapi.json// Swagger UI: http://localhost:8080/docs}
funcTestGetUser(t*testing.T){a:=app.MustNew()a.GET("/users/:id",getUserHandler)req:=httptest.NewRequest("GET","/users/123",nil)resp,err:=a.Test(req)iferr!=nil{t.Fatal(err)}varuserUserapp.ExpectJSON(t,resp,200,&user)ifuser.ID!="123"{t.Errorf("expected ID 123, got %s",user.ID)}}
funcTestWithDatabase(t*testing.T){// Setup test databasedb:=setupTestDB(t)deferdb.Close()a:=app.MustNew()a.GET("/users/:id",func(c*app.Context){id:=c.Param("id")user,err:=db.GetUser(id)iferr!=nil{c.NotFound(fmt.Errorf("user not found"))return}c.JSON(http.StatusOK,user)})req:=httptest.NewRequest("GET","/users/123",nil)resp,err:=a.Test(req)iferr!=nil{t.Fatal(err)}varuserUserapp.ExpectJSON(t,resp,200,&user)}
funcTestAuthMiddleware(t*testing.T){a:=app.MustNew()a.Use(AuthMiddleware())a.GET("/protected",protectedHandler)// Test without tokenreq:=httptest.NewRequest("GET","/protected",nil)resp,_:=a.Test(req)ifresp.StatusCode!=401{t.Errorf("expected 401, got %d",resp.StatusCode)}// Test with tokenreq=httptest.NewRequest("GET","/protected",nil)req.Header.Set("Authorization","Bearer valid-token")resp,_=a.Test(req)ifresp.StatusCode!=200{t.Errorf("expected 200, got %d",resp.StatusCode)}}
packagemainimport("context""log""net/http""os""os/signal""syscall""rivaas.dev/app")funcmain(){a,err:=app.New()iferr!=nil{log.Fatal(err)}a.GET("/",func(c*app.Context){c.JSON(http.StatusOK,map[string]string{"message":"Hello from Rivaas!",})})ctx,cancel:=signal.NotifyContext(context.Background(),os.Interrupt,syscall.SIGTERM)defercancel()iferr:=a.Start(ctx);err!=nil{log.Fatal(err)}}
Full-Featured Production App
Complete application with all features.
packagemainimport("context""database/sql""log""net/http""os""os/signal""syscall""time""rivaas.dev/app""rivaas.dev/logging""rivaas.dev/metrics""rivaas.dev/tracing")vardb*sql.DBfuncmain(){a,err:=app.New(// Service metadataapp.WithServiceName("orders-api"),app.WithServiceVersion("v2.0.0"),app.WithEnvironment("production"),// Observability: all three pillarsapp.WithObservability(app.WithLogging(logging.WithJSONHandler()),app.WithMetrics(),app.WithTracing(tracing.WithOTLP("localhost:4317")),app.WithExcludePaths("/livez","/readyz","/metrics"),app.WithAccessLogScope(app.AccessLogScopeErrorsOnly),app.WithSlowThreshold(1*time.Second),),// Health endpointsapp.WithHealthEndpoints(app.WithHealthTimeout(800*time.Millisecond),app.WithReadinessCheck("database",func(ctxcontext.Context)error{returndb.PingContext(ctx)}),),// Server configurationapp.WithServer(app.WithReadTimeout(10*time.Second),app.WithWriteTimeout(15*time.Second),app.WithShutdownTimeout(30*time.Second),),)iferr!=nil{log.Fatal(err)}// Lifecycle hooksa.OnStart(func(ctxcontext.Context)error{log.Println("Connecting to database...")varerrerrordb,err=sql.Open("postgres",os.Getenv("DATABASE_URL"))returnerr})a.OnShutdown(func(ctxcontext.Context){log.Println("Closing database connection...")db.Close()})// Register routesa.GET("/",func(c*app.Context){c.JSON(http.StatusOK,map[string]string{"service":"orders-api","version":"v2.0.0",})})a.GET("/orders/:id",func(c*app.Context){orderID:=c.Param("id")c.Logger().Info("fetching order","order_id",orderID)c.JSON(http.StatusOK,map[string]string{"order_id":orderID,"status":"completed",})})// Start serverctx,cancel:=signal.NotifyContext(context.Background(),os.Interrupt,syscall.SIGTERM)defercancel()log.Println("Server starting on :8080")iferr:=a.Start(ctx);err!=nil{log.Fatal(err)}}
REST API Example
Complete REST API with CRUD operations:
packagemainimport("log""net/http""rivaas.dev/app")typeUserstruct{IDstring`json:"id"`Namestring`json:"name"`Emailstring`json:"email"`}typeCreateUserRequeststruct{Namestring`json:"name" validate:"required,min=3"`Emailstring`json:"email" validate:"required,email"`}funcmain(){a:=app.MustNew(app.WithServiceName("users-api"))// List usersa.GET("/users",func(c*app.Context){users:=[]User{{ID:"1",Name:"Alice",Email:"alice@example.com"},{ID:"2",Name:"Bob",Email:"bob@example.com"},}c.JSON(http.StatusOK,users)})// Create usera.POST("/users",func(c*app.Context){req,ok:=app.MustBind[CreateUserRequest](c)if!ok{return}user:=User{ID:"123",Name:req.Name,Email:req.Email,}c.JSON(http.StatusCreated,user)})// Get usera.GET("/users/:id",func(c*app.Context){id:=c.Param("id")user:=User{ID:id,Name:"Alice",Email:"alice@example.com"}c.JSON(http.StatusOK,user)})// Update usera.PUT("/users/:id",func(c*app.Context){id:=c.Param("id")req,ok:=app.MustBind[CreateUserRequest](c)if!ok{return}user:=User{ID:id,Name:req.Name,Email:req.Email}c.JSON(http.StatusOK,user)})// Delete usera.DELETE("/users/:id",func(c*app.Context){c.Status(http.StatusNoContent)})// Start server...}
More Examples
See the examples/ directory in the repository for additional examples:
01-quick-start/ - Minimal setup (~20 lines)
02-blog/ - Complete blog API with database, validation, and testing
An HTTP router for Go. Built for cloud-native applications with complete routing, middleware, and observability features.
The Rivaas Router provides a high-performance routing system. Includes built-in middleware, OpenTelemetry support, and complete request handling.
Overview
The Rivaas Router is a production-ready HTTP router for cloud-native applications. It combines high performance with a rich feature set. It offers sub-microsecond routing and high throughput. See Router Performance for current benchmark numbers. It includes content negotiation, API versioning, and OpenTelemetry support.
Key Features
Core Routing & Request Handling
Radix tree routing - Path matching with bloom filters for static route lookups.
Optional compiled route tables - For large APIs you can turn on pre-compiled routes to speed up lookups.
Path Parameters: /users/:id, /posts/:id/:action - Array-based storage for route parameters.
Wildcard Routes: /files/*filepath - Catch-all routing for file serving.
Route Groups: Organize routes with shared prefixes and middleware.
Middleware Chain: Global, group-level, and route-level middleware support.
Tracing: OpenTelemetry support via recorder interface; zero overhead when disabled
Diagnostics: Optional diagnostic events for security concerns
Performance
Sub-microsecond routing and high throughput — See Router Performance for current latency and throughput numbers.
Zero allocation — 0 allocs for routing and parameter extraction in the benchmarked scenarios (static, 1 param, 2 params). One small allocation only when a route has more than 8 path parameters.
Memory efficient — Context pooling and minimal allocations per request.
Context pooling: Automatic context reuse
Lock-free operations: Atomic operations for concurrent access
Quick Start
Get up and running in minutes with this complete example:
packagemainimport("fmt""net/http""time""rivaas.dev/router")funcmain(){r:=router.MustNew()// Panics on invalid config (use at startup)// Global middlewarer.Use(Logger(),Recovery())// Simple router.GET("/",func(c*router.Context){c.JSON(http.StatusOK,map[string]string{"message":"Hello Rivaas!","version":"1.0.0",})})// Parameter router.GET("/users/:id",func(c*router.Context){userID:=c.Param("id")c.JSON(http.StatusOK,map[string]string{"user_id":userID,})})// POST with JSON bindingr.POST("/users",func(c*router.Context){varreqstruct{Namestring`json:"name"`Emailstring`json:"email"`}iferr:=json.NewDecoder(c.Request.Body).Decode(&req);err!=nil{c.WriteErrorResponse(http.StatusBadRequest,"Invalid JSON")return}c.JSON(http.StatusCreated,req)})http.ListenAndServe(":8080",r)}// Middleware examplesfuncLogger()router.HandlerFunc{returnfunc(c*router.Context){start:=time.Now()c.Next()duration:=time.Since(start)fmt.Printf("[%s] %s - %v\n",c.Request.Method,c.Request.URL.Path,duration)}}funcRecovery()router.HandlerFunc{returnfunc(c*router.Context){deferfunc(){iferr:=recover();err!=nil{c.JSON(http.StatusInternalServerError,map[string]string{"error":"Internal server error",})}}()c.Next()}}
Learning Path
Follow this structured path to master the Rivaas Router:
Standard library only. No external dependencies for core routing.
Install the Router
Add the router to your Go project:
go get rivaas.dev/router
Verify Installation
Create a simple test file to verify the installation:
packagemainimport("net/http""rivaas.dev/router")funcmain(){r:=router.MustNew()r.GET("/",func(c*router.Context){c.String(http.StatusOK,"Router is working!")})http.ListenAndServe(":8080",r)}
Run the test:
go run main.go
Visit http://localhost:8080/ in your browser - you should see “Router is working!”
Optional Dependencies
Middleware
For built-in middleware like structured logging and metrics:
# For AccessLog middleware (structured logging)go get rivaas.dev/logging
# For Metrics middlewarego get rivaas.dev/metrics
OpenTelemetry Tracing
For OpenTelemetry tracing support:
# Core OpenTelemetry librariesgo get go.opentelemetry.io/otel
go get go.opentelemetry.io/otel/trace
go get go.opentelemetry.io/otel/sdk
# Example: Jaeger exportergo get go.opentelemetry.io/otel/exporters/jaeger
Validation
For tag-based validation (go-playground/validator):
go get github.com/go-playground/validator/v10
The router automatically detects and uses validator if available.
Project Structure
Recommended project structure for a router-based application:
Learn the fundamentals of the Rivaas Router - from your first router to handling requests.
This guide introduces the core concepts of the Rivaas Router through progressive examples.
Your First Router
Let’s start with the simplest possible router:
packagemainimport("net/http""rivaas.dev/router")funcmain(){r:=router.MustNew()// Panics on invalid config (use at startup)r.GET("/",func(c*router.Context){c.JSON(http.StatusOK,map[string]string{"message":"Hello, Rivaas Router!",})})http.ListenAndServe(":8080",r)}
What’s happening here:
router.MustNew() creates a new router instance. Panics on invalid config.
r.GET("/", handler) registers a handler for GET requests to /.
The handler function receives a *router.Context with request and response information.
funcmain(){r:=router.MustNew()r.GET("/users",listUsers)// List all usersr.POST("/users",createUser)// Create a new userr.GET("/users/:id",getUser)// Get a specific userr.PUT("/users/:id",updateUser)// Update a user (full replacement)r.PATCH("/users/:id",patchUser)// Partial updater.DELETE("/users/:id",deleteUser)// Delete a userr.HEAD("/users/:id",headUser)// Check if user existsr.OPTIONS("/users",optionsUsers)// Get available methodshttp.ListenAndServe(":8080",r)}funclistUsers(c*router.Context){c.JSON(200,[]string{"user1","user2"})}funccreateUser(c*router.Context){c.JSON(201,map[string]string{"message":"User created"})}funcgetUser(c*router.Context){c.JSON(200,map[string]string{"user_id":c.Param("id")})}funcupdateUser(c*router.Context){c.JSON(200,map[string]string{"message":"User updated"})}funcpatchUser(c*router.Context){c.JSON(200,map[string]string{"message":"User patched"})}funcdeleteUser(c*router.Context){c.Status(204)// No Content}funcheadUser(c*router.Context){c.Status(200)// OK, no body}funcoptionsUsers(c*router.Context){c.Header("Allow","GET, POST, OPTIONS")c.Status(200)}
Reading Request Data
Query Parameters
Access query string parameters with c.Query():
// GET /search?q=golang&limit=10r.GET("/search",func(c*router.Context){query:=c.Query("q")limit:=c.Query("limit")c.JSON(200,map[string]string{"query":query,"limit":limit,})})
// POST /login with form datar.POST("/login",func(c*router.Context){username:=c.FormValue("username")password:=c.FormValue("password")// Validate credentials...c.JSON(200,map[string]string{"username":username,"status":"logged in",})})
Always handle errors and provide meaningful responses:
r.GET("/users/:id",func(c*router.Context){userID:=c.Param("id")// Validate user IDifuserID==""{c.JSON(400,map[string]string{"error":"User ID is required",})return}// Simulate user lookupuser,err:=findUser(userID)iferr!=nil{iferr==ErrUserNotFound{c.JSON(404,map[string]string{"error":"User not found",})}else{c.JSON(500,map[string]string{"error":"Internal server error",})}return}c.JSON(200,user)})
Response Types
The router supports multiple response formats:
JSON Responses
// Standard JSONr.GET("/json",func(c*router.Context){c.JSON(200,map[string]string{"message":"JSON response"})})// Indented JSON (for debugging)r.GET("/json-pretty",func(c*router.Context){c.IndentedJSON(200,map[string]string{"message":"Pretty JSON"})})
Plain Text
r.GET("/text",func(c*router.Context){c.String(200,"Plain text response")})// With formattingr.GET("/text-formatted",func(c*router.Context){c.Stringf(200,"Hello, %s!","World")})
Here’s a complete example combining all the concepts:
packagemainimport("encoding/json""net/http""rivaas.dev/router")typeUserstruct{IDstring`json:"id"`Namestring`json:"name"`Emailstring`json:"email"`}varusers=map[string]User{"1":{ID:"1",Name:"Alice",Email:"alice@example.com"},"2":{ID:"2",Name:"Bob",Email:"bob@example.com"},}funcmain(){r:=router.MustNew()// List all usersr.GET("/users",func(c*router.Context){userList:=make([]User,0,len(users))for_,user:=rangeusers{userList=append(userList,user)}c.JSON(200,userList)})// Get a specific userr.GET("/users/:id",func(c*router.Context){id:=c.Param("id")user,exists:=users[id]if!exists{c.JSON(404,map[string]string{"error":"User not found",})return}c.JSON(200,user)})// Create a new userr.POST("/users",func(c*router.Context){varreqUseriferr:=json.NewDecoder(c.Request.Body).Decode(&req);err!=nil{c.JSON(400,map[string]string{"error":"Invalid JSON",})return}// Generate ID (simplified)req.ID="3"users[req.ID]=reqc.JSON(201,req)})// Update a userr.PUT("/users/:id",func(c*router.Context){id:=c.Param("id")if_,exists:=users[id];!exists{c.JSON(404,map[string]string{"error":"User not found",})return}varreqUseriferr:=json.NewDecoder(c.Request.Body).Decode(&req);err!=nil{c.JSON(400,map[string]string{"error":"Invalid JSON",})return}req.ID=idusers[id]=reqc.JSON(200,req)})// Delete a userr.DELETE("/users/:id",func(c*router.Context){id:=c.Param("id")if_,exists:=users[id];!exists{c.JSON(404,map[string]string{"error":"User not found",})return}delete(users,id)c.Status(204)})http.ListenAndServe(":8080",r)}
Next Steps
Now that you understand the basics:
Route Patterns: Learn about route patterns including wildcards and constraints
Wildcard routes capture the rest of the path using *param:
// Serve files from any path under /files/r.GET("/files/*filepath",func(c*router.Context){filepath:=c.Param("filepath")c.JSON(200,map[string]string{"filepath":filepath})})
Wildcards match everything after their position, including slashes
Only one wildcard per route
Wildcard must be the last segment
// ✅ Validr.GET("/static/*filepath",handler)r.GET("/api/v1/files/*path",handler)// ❌ Invalid - wildcard must be lastr.GET("/files/*path/metadata",handler)// Won't work// ❌ Invalid - only one wildcardr.GET("/files/*path1/other/*path2",handler)// Won't work
Route Matching Priority
When multiple routes could match a request, the router follows this priority order:
The router optimizes parameter storage for routes with ≤8 parameters using fast array-based storage. Routes with >8 parameters fall back to map-based storage.
Optimization Threshold
≤8 parameters: Array-based storage (fastest, zero allocations)
>8 parameters: Map-based storage (one allocation per request)
Instead of many path parameters, use query parameters:
// ❌ BAD: Too many path parametersr.GET("/search/:category/:subcategory/:type/:status/:sort/:order/:page/:limit",handler)// ✅ GOOD: Use query parameters for filtersr.GET("/search/:category",handler)// Query: ?subcategory=electronics&type=product&status=active&sort=price&order=asc&page=1&limit=20
3. Use Request Body for Complex Data
For complex operations, use the request body:
// ❌ BAD: Many path parametersr.POST("/api/:version/:resource/:action/:target/:scope/:context/:mode/:format",handler)// ✅ GOOD: Use request bodyr.POST("/api/v1/operations",handler)// Body: {"resource": "...", "action": "...", "target": "...", ...}
4. Restructure Routes
Flatten hierarchies or consolidate parameters:
// ❌ BAD: 10 parameters in pathr.GET("/:a/:b/:c/:d/:e/:f/:g/:h/:i/:j",handler)// ✅ GOOD: Flatten hierarchy or use query parametersr.GET("/items",handler)// Use query: ?a=...&b=...&c=...
Runtime Warnings
The router automatically logs a warning when registering routes with >8 parameters:
WARN: route has more than 8 parameters, using map storage instead of fast array
method=GET
path=/api/:v1/:r1/:r2/:r3/:r4/:r5/:r6/:r7/:r8/:r9
param_count=9
recommendation=consider restructuring route to use query parameters or request body for additional data
When >8 Parameters Are Acceptable
Low-frequency endpoints (<100 req/s)
Legacy API compatibility requirements
Complex hierarchical resource structures that can’t be flattened
Performance Impact
≤8 params: Sub-microsecond per operation, 0 allocations.
>8 params: Sub-microsecond per operation, 1 small allocation.
Real-world impact: Negligible for most applications (<1% overhead). See Router Performance for current figures.
// Standard REST endpointsr.GET("/users",listUsers)// List allr.POST("/users",createUser)// Create newr.GET("/users/:id",getUser)// Get oner.PUT("/users/:id",updateUser)// Update (full)r.PATCH("/users/:id",patchUser)// Update (partial)r.DELETE("/users/:id",deleteUser)// Delete
Nested Resources
// Comments belong to postsr.GET("/posts/:post_id/comments",listComments)r.POST("/posts/:post_id/comments",createComment)r.GET("/posts/:post_id/comments/:id",getComment)r.PUT("/posts/:post_id/comments/:id",updateComment)r.DELETE("/posts/:post_id/comments/:id",deleteComment)
Action Routes
// Actions on resourcesr.POST("/users/:id/activate",activateUser)r.POST("/users/:id/deactivate",deactivateUser)r.POST("/posts/:id/publish",publishPost)r.POST("/orders/:id/cancel",cancelOrder)
// ❌ BAD: Too deepr.GET("/api/v1/organizations/:org/teams/:team/projects/:proj/tasks/:task/comments/:id",handler)// ✅ GOOD: Flatten or use query parametersr.GET("/api/v1/comments/:id",handler)// Include org/team/proj/task in query or auth context
Next Steps
Route Groups: Learn to organize routes with groups and prefixes
Middleware: Add middleware for authentication, logging, etc.
Organize routes with groups, shared prefixes, and group-specific middleware.
Route groups help organize related routes. They share a common prefix. They can apply middleware to specific sets of routes.
Basic Groups
Create a group with a common prefix:
funcmain(){r:=router.MustNew()r.Use(Logger())// Global middleware// API v1 groupv1:=r.Group("/api/v1")v1.GET("/users",listUsersV1)v1.POST("/users",createUserV1)v1.GET("/users/:id",getUserV1)http.ListenAndServe(":8080",r)}
Routes created:
GET /api/v1/users
POST /api/v1/users
GET /api/v1/users/:id
Group-Specific Middleware
Apply middleware that only affects routes in the group:
funcmain(){r:=router.MustNew()r.Use(Logger())// Global - applies to all routes// Public API - no auth requiredpublic:=r.Group("/api/public")public.GET("/health",healthHandler)public.GET("/version",versionHandler)// Private API - auth requiredprivate:=r.Group("/api/private")private.Use(AuthRequired())// Group middlewareprivate.GET("/profile",profileHandler)private.POST("/settings",updateSettingsHandler)http.ListenAndServe(":8080",r)}funcAuthRequired()router.HandlerFunc{returnfunc(c*router.Context){token:=c.Request.Header.Get("Authorization")iftoken==""{c.JSON(401,map[string]string{"error":"Unauthorized"})return}c.Next()}}
Middleware execution:
/api/public/health → Logger only.
/api/private/profile → Logger + AuthRequired.
Nested Groups
Groups can be nested for hierarchical organization:
funcmain(){r:=router.MustNew()r.Use(Logger())api:=r.Group("/api"){v1:=api.Group("/v1")v1.Use(RateLimitV1())// V1-specific rate limiting{// User endpointsusers:=v1.Group("/users")users.Use(UserAuth()){users.GET("/",listUsers)// GET /api/v1/users/users.POST("/",createUser)// POST /api/v1/users/users.GET("/:id",getUser)// GET /api/v1/users/:idusers.PUT("/:id",updateUser)// PUT /api/v1/users/:idusers.DELETE("/:id",deleteUser)// DELETE /api/v1/users/:id}// Admin endpointsadmin:=v1.Group("/admin")admin.Use(AdminAuth()){admin.GET("/stats",getStats)// GET /api/v1/admin/statsadmin.DELETE("/users/:id",adminDeleteUser)// DELETE /api/v1/admin/users/:id}}v2:=api.Group("/v2")v2.Use(RateLimitV2())// V2-specific rate limiting{v2.GET("/users",listUsersV2)v2.POST("/users",createUsersV2)}}http.ListenAndServe(":8080",r)}
Routes created:
GET /api/v1/users/
POST /api/v1/users/
GET /api/v1/users/:id
PUT /api/v1/users/:id
DELETE /api/v1/users/:id
GET /api/v1/admin/stats
DELETE /api/v1/admin/users/:id
GET /api/v2/users
POST /api/v2/users
Middleware Execution Order
For nested groups, middleware executes from outer to inner:
funcmain(){r:=router.MustNew()r.Use(func(c*router.Context){fmt.Println("1. Global middleware")c.Next()})api:=r.Group("/api")api.Use(func(c*router.Context){fmt.Println("2. API middleware")c.Next()})v1:=api.Group("/v1")v1.Use(func(c*router.Context){fmt.Println("3. V1 middleware")c.Next()})v1.GET("/test",func(c*router.Context){fmt.Println("4. Handler")c.String(200,"OK")})http.ListenAndServe(":8080",r)}
Request to /api/v1/test prints:
1. Global middleware
2. API middleware
3. V1 middleware
4. Handler
Composing Group Middleware
Create reusable middleware bundles:
// Middleware bundlesfuncPublicAPI()[]router.HandlerFunc{return[]router.HandlerFunc{CORS(),RateLimit(1000),}}funcAuthenticatedAPI()[]router.HandlerFunc{return[]router.HandlerFunc{CORS(),RateLimit(100),AuthRequired(),}}funcAdminAPI()[]router.HandlerFunc{return[]router.HandlerFunc{CORS(),RateLimit(50),AuthRequired(),AdminOnly(),}}funcmain(){r:=router.MustNew()r.Use(Logger(),Recovery())// Public endpointspublic:=r.Group("/api/public")public.Use(PublicAPI()...)public.GET("/status",statusHandler)// User endpointsuser:=r.Group("/api/user")user.Use(AuthenticatedAPI()...)user.GET("/profile",profileHandler)// Admin endpointsadmin:=r.Group("/api/admin")admin.Use(AdminAPI()...)admin.GET("/users",listUsersAdmin)http.ListenAndServe(":8080",r)}
funcmain(){r:=router.MustNew()r.Use(Logger())// Version 1 - Stable APIv1:=r.Group("/api/v1")v1.Use(JSONContentType()){v1.GET("/users",listUsersV1)v1.GET("/users/:id",getUserV1)v1.GET("/posts",listPostsV1)}// Version 2 - New featuresv2:=r.Group("/api/v2")v2.Use(JSONContentType()){v2.GET("/users",listUsersV2)// Enhanced user listv2.GET("/users/:id",getUserV2)// Additional fieldsv2.GET("/posts",listPostsV2)// Pagination supportv2.GET("/posts/:id/likes",getPostLikesV2)// New endpoint}// Beta featuresbeta:=r.Group("/api/beta")beta.Use(JSONContentType(),BetaWarning()){beta.GET("/experimental",experimentalFeature)}http.ListenAndServe(":8080",r)}
// ✅ GOOD: Related routes groupedusers:=r.Group("/api/users")users.GET("/",listUsers)users.POST("/",createUser)users.GET("/:id",getUser)// ❌ BAD: Scattered registrationr.GET("/api/users",listUsers)r.GET("/api/posts",listPosts)r.POST("/api/users",createUser)
2. Apply Middleware at the Right Level
// ✅ GOOD: Auth only where neededpublic:=r.Group("/api/public")public.GET("/status",statusHandler)private:=r.Group("/api/private")private.Use(AuthRequired())private.GET("/profile",profileHandler)// ❌ BAD: Auth on everythingr.Use(AuthRequired())// Public endpoints won't work!r.GET("/api/status",statusHandler)
// ✅ GOOD: 2-3 levelsapi:=r.Group("/api")v1:=api.Group("/v1")v1.GET("/users",handler)// ⚠️ OKAY: 4 levels (limit)api:=r.Group("/api")v1:=api.Group("/v1")users:=v1.Group("/users")users.GET("/:id",handler)// ❌ BAD: Too deep (5+ levels)api:=r.Group("/api")v1:=api.Group("/v1")orgs:=v1.Group("/orgs")teams:=orgs.Group("/:org/teams")projects:=teams.Group("/:team/projects")projects.GET("/",handler)// /api/v1/orgs/:org/teams/:team/projects/
Complete Example
packagemainimport("fmt""net/http""rivaas.dev/router")funcmain(){r:=router.MustNew()// Global middlewarer.Use(Logger(),Recovery())// Public routes (no auth)public:=r.Group("/api/public")public.Use(CORS()){public.GET("/health",healthHandler)public.GET("/version",versionHandler)}// API v1v1:=r.Group("/api/v1")v1.Use(CORS(),JSONContentType()){// User routes (auth required)users:=v1.Group("/users")users.Use(AuthRequired()){users.GET("/",listUsers)users.POST("/",createUser)users.GET("/:id",getUser)users.PUT("/:id",updateUser)users.DELETE("/:id",deleteUser)}// Admin routes (admin auth required)admin:=v1.Group("/admin")admin.Use(AuthRequired(),AdminOnly()){admin.GET("/stats",adminStats)admin.GET("/users",adminListUsers)}}fmt.Println("Server starting on :8080")http.ListenAndServe(":8080",r)}// MiddlewarefuncLogger()router.HandlerFunc{returnfunc(c*router.Context){fmt.Printf("[%s] %s\n",c.Request.Method,c.Request.URL.Path)c.Next()}}funcRecovery()router.HandlerFunc{returnfunc(c*router.Context){deferfunc(){iferr:=recover();err!=nil{c.JSON(500,map[string]string{"error":"Internal server error"})}}()c.Next()}}funcCORS()router.HandlerFunc{returnfunc(c*router.Context){c.Header("Access-Control-Allow-Origin","*")c.Next()}}funcJSONContentType()router.HandlerFunc{returnfunc(c*router.Context){c.Header("Content-Type","application/json")c.Next()}}funcAuthRequired()router.HandlerFunc{returnfunc(c*router.Context){token:=c.Request.Header.Get("Authorization")iftoken==""{c.JSON(401,map[string]string{"error":"Unauthorized"})return}c.Next()}}funcAdminOnly()router.HandlerFunc{returnfunc(c*router.Context){// Check if user is admin...c.Next()}}// Handlers (simplified)funchealthHandler(c*router.Context){c.String(200,"OK")}funcversionHandler(c*router.Context){c.String(200,"v1.0.0")}funclistUsers(c*router.Context){c.JSON(200,[]string{"user1","user2"})}funccreateUser(c*router.Context){c.JSON(201,map[string]string{"id":"1"})}funcgetUser(c*router.Context){c.JSON(200,map[string]string{"id":c.Param("id")})}funcupdateUser(c*router.Context){c.JSON(200,map[string]string{"id":c.Param("id")})}funcdeleteUser(c*router.Context){c.Status(204)}funcadminStats(c*router.Context){c.JSON(200,map[string]int{"users":100})}funcadminListUsers(c*router.Context){c.JSON(200,[]string{"all","users"})}
Add cross-cutting concerns like logging, authentication, and error handling with middleware.
Middleware functions execute before route handlers. They perform cross-cutting concerns like authentication, logging, and rate limiting.
Basic Usage
Middleware is a function that wraps your handlers:
funcLogger()router.HandlerFunc{returnfunc(c*router.Context){start:=time.Now()path:=c.Request.URL.Pathc.Next()// Continue to next handlerduration:=time.Since(start)fmt.Printf("[%s] %s - %v\n",c.Request.Method,path,duration)}}funcmain(){r:=router.MustNew()// Apply middleware globallyr.Use(Logger())r.GET("/",handler)http.ListenAndServe(":8080",r)}
Key concepts:
c.Next() - Continues to the next middleware or handler.
Call c.Next() to proceed. Don’t call it to stop the chain.
Middleware runs in registration order.
Middleware Scope
Global Middleware
Applied to all routes:
r:=router.MustNew()// These apply to ALL routesr.Use(Logger())r.Use(Recovery())r.Use(CORS())r.GET("/",handler)r.GET("/users",usersHandler)
Group Middleware
Applied only to routes in a group:
r:=router.MustNew()r.Use(Logger())// Global// Public routes - no authpublic:=r.Group("/api/public")public.GET("/status",statusHandler)// Private routes - auth requiredprivate:=r.Group("/api/private")private.Use(AuthRequired())// Group-levelprivate.GET("/profile",profileHandler)
Route-Specific Middleware
Applied to individual routes:
r:=router.MustNew()r.Use(Logger())// Global// Auth only for this router.GET("/admin",AdminAuth(),adminHandler)// Multiple middleware for one router.POST("/upload",RateLimit(),ValidateFile(),uploadHandler)
Built-in Middleware
The router includes production-ready middleware in separate packages. Each one is its own Go module—add only what you need:
go get rivaas.dev/middleware/security
go get rivaas.dev/middleware/cors
go get rivaas.dev/middleware/accesslog
# ... and so on for each middleware you use
import"rivaas.dev/middleware/requestid"// UUID v7 by default (36 chars, time-ordered, RFC 9562)r.Use(requestid.New())// Use ULID for shorter IDs (26 chars)r.Use(requestid.New(requestid.WithULID()))// Custom header namer.Use(requestid.New(requestid.WithHeader("X-Correlation-ID")))// Later in handlers:funchandler(c*router.Context){id:=requestid.Get(c)fmt.Println("Request ID:",id)}
import"rivaas.dev/middleware/ratelimit"r.Use(ratelimit.New(ratelimit.WithRequestsPerSecond(1000),ratelimit.WithBurst(100),ratelimit.WithKeyFunc(func(c*router.Context)string{returnc.ClientIP()// Rate limit by IP}),))
The order in which middleware is applied matters. Recommended order:
r:=router.MustNew()// 1. Request ID - Generate early for logging/tracingr.Use(requestid.New())// 2. AccessLog - Log all requests including failed onesr.Use(accesslog.New())// 3. Recovery - Catch panics from all other middlewarer.Use(recovery.New())// 4. Security/CORS - Set security headers earlyr.Use(security.New())r.Use(cors.New())// 5. Body Limit - Reject large requests before processingr.Use(bodylimit.New())// 6. Rate Limit - Reject excessive requests before processingr.Use(ratelimit.New())// 7. Timeout - Set time limits for downstream processingr.Use(timeout.New())// 8. Authentication - Verify identity after rate limitingr.Use(auth.New())// 9. Compression - Compress responses (last)r.Use(compression.New())// 10. Your application routesr.GET("/",handler)
Why this order?
RequestID first - Generates a unique ID that other middleware can use
Logger early - Captures all activity including errors
Recovery early - Catches panics to prevent crashes
Security/CORS - Applies security policies before business logic
RateLimit - Blocks excessive requests before expensive operations
Timeout - Sets deadlines for request processing
Auth - Authenticates after rate limiting but before business logic
Compression - Compresses response bodies (should be last)
Writing Custom Middleware
Basic Middleware Pattern
funcMyMiddleware()router.HandlerFunc{returnfunc(c*router.Context){// Before request processingfmt.Println("Before handler")c.Next()// Execute next middleware/handler// After request processingfmt.Println("After handler")}}
Middleware with Configuration
funcRateLimit(requestsPerSecondint)router.HandlerFunc{// Setup (runs once when middleware is created)limiter:=rate.NewLimiter(rate.Limit(requestsPerSecond),requestsPerSecond)returnfunc(c*router.Context){// Per-request logicif!limiter.Allow(){c.JSON(429,map[string]string{"error":"Too many requests",})return// Don't call c.Next() - stop the chain}c.Next()}}// Usager.Use(RateLimit(100))// 100 requests per second
Middleware with Dependencies
funcAuth(db*Database)router.HandlerFunc{returnfunc(c*router.Context){token:=c.Request.Header.Get("Authorization")user,err:=db.ValidateToken(token)iferr!=nil{c.JSON(401,map[string]string{"error":"Unauthorized",})return}// Store user in request context for handlersctx:=context.WithValue(c.Request.Context(),"user",user)c.Request=c.Request.WithContext(ctx)c.Next()}}// Usagedb:=NewDatabase()r.Use(Auth(db))
Conditional Middleware
funcConditionalAuth()router.HandlerFunc{returnfunc(c*router.Context){// Skip auth for public endpointsifc.Request.URL.Path=="/public"{c.Next()return}// Require auth for other endpointstoken:=c.Request.Header.Get("Authorization")iftoken==""{c.JSON(401,map[string]string{"error":"Unauthorized",})return}c.Next()}}
Middleware Patterns
Pattern: Error Handling Middleware
funcErrorHandler()router.HandlerFunc{returnfunc(c*router.Context){deferfunc(){iferr:=recover();err!=nil{log.Printf("Panic: %v",err)c.JSON(500,map[string]string{"error":"Internal server error",})}}()c.Next()}}
funcJWTAuth(secretstring)router.HandlerFunc{returnfunc(c*router.Context){authHeader:=c.Request.Header.Get("Authorization")ifauthHeader==""{c.JSON(401,map[string]string{"error":"Missing authorization header",})return}// Extract token (Bearer <token>)parts:=strings.SplitN(authHeader," ",2)iflen(parts)!=2||parts[0]!="Bearer"{c.JSON(401,map[string]string{"error":"Invalid authorization header format",})return}token:=parts[1]claims,err:=validateJWT(token,secret)iferr!=nil{c.JSON(401,map[string]string{"error":"Invalid token",})return}// Store claims in request contextctx:=c.Request.Context()ctx=context.WithValue(ctx,"user_id",claims.UserID)ctx=context.WithValue(ctx,"user_email",claims.Email)c.Request=c.Request.WithContext(ctx)c.Next()}}
Pattern: Request ID Middleware
The built-in requestid middleware handles this pattern with UUID v7 or ULID:
import"rivaas.dev/middleware/requestid"// UUID v7 (default) - time-ordered, 36 charsr.Use(requestid.New())// ULID - shorter, 26 charsr.Use(requestid.New(requestid.WithULID()))// Access in handlersfunchandler(c*router.Context){id:=requestid.Get(c)// Get from context// Or from header: c.Response.Header().Get("X-Request-ID")}
If you need a custom implementation:
funcRequestID()router.HandlerFunc{returnfunc(c*router.Context){// Check for existing request IDrequestID:=c.Request.Header.Get("X-Request-ID")ifrequestID==""{// Generate new UUID v7requestID=uuid.Must(uuid.NewV7()).String()}// Store in request context and response headerctx:=context.WithValue(c.Request.Context(),"request_id",requestID)c.Request=c.Request.WithContext(ctx)c.Header("X-Request-ID",requestID)c.Next()}}
Best Practices
1. Always Call c.Next()
Unless you want to stop the middleware chain:
// ✅ GOOD: Calls c.Next() to continuefuncLogger()router.HandlerFunc{returnfunc(c*router.Context){start:=time.Now()c.Next()// Continue to handlerduration:=time.Since(start)log.Printf("Duration: %v",duration)}}// ✅ GOOD: Doesn't call c.Next() to stop chainfuncAuth()router.HandlerFunc{returnfunc(c*router.Context){if!isAuthorized(c){c.JSON(401,map[string]string{"error":"Unauthorized"})return// Don't call c.Next()}c.Next()}}
2. Keep Middleware Focused
Each middleware should do one thing:
// ✅ GOOD: Single responsibilityfuncLogger()router.HandlerFunc{...}funcAuth()router.HandlerFunc{...}funcRateLimit()router.HandlerFunc{...}// ❌ BAD: Does too muchfuncSuperMiddleware()router.HandlerFunc{returnfunc(c*router.Context){// Logging// Auth// Rate limiting// ...c.Next()}}
3. Use Functional Options for Configuration
typeConfigstruct{LimitintBurstint}typeOptionfunc(*Config)funcWithLimit(limitint)Option{returnfunc(c*Config){c.Limit=limit}}funcWithBurst(burstint)Option{returnfunc(c*Config){c.Burst=burst}}funcRateLimit(opts...Option)router.HandlerFunc{config:=&Config{Limit:100,Burst:10,}for_,opt:=rangeopts{opt(config)}limiter:=rate.NewLimiter(rate.Limit(config.Limit),config.Burst)returnfunc(c*router.Context){if!limiter.Allow(){c.JSON(429,map[string]string{"error":"Too many requests"})return}c.Next()}}// Usager.Use(RateLimit(WithLimit(1000),WithBurst(100),))
packagemainimport("fmt""log""log/slog""net/http""os""time""rivaas.dev/router""rivaas.dev/middleware/accesslog""rivaas.dev/middleware/cors""rivaas.dev/middleware/recovery""rivaas.dev/middleware/requestid""rivaas.dev/middleware/security")funcmain(){logger:=slog.New(slog.NewJSONHandler(os.Stdout,nil))r:=router.MustNew()// Global middleware (applies to all routes)r.Use(requestid.New())r.Use(accesslog.New(accesslog.WithLogger(logger)))r.Use(recovery.New())r.Use(security.New())r.Use(cors.New(cors.WithAllowedOrigins("*"),cors.WithAllowedMethods("GET","POST","PUT","DELETE"),))// Public routesr.GET("/health",healthHandler)r.GET("/public",publicHandler)// API routes with authapi:=r.Group("/api")api.Use(JWTAuth("your-secret-key")){api.GET("/profile",profileHandler)api.POST("/posts",createPostHandler)// Admin routes with additional middlewareadmin:=api.Group("/admin")admin.Use(RequireAdmin()){admin.GET("/users",listUsersHandler)admin.DELETE("/users/:id",deleteUserHandler)}}log.Fatal(http.ListenAndServe(":8080",r))}// Custom middlewarefuncJWTAuth(secretstring)router.HandlerFunc{returnfunc(c*router.Context){token:=c.Request.Header.Get("Authorization")iftoken==""{c.JSON(401,map[string]string{"error":"Unauthorized"})return}// Validate token...c.Next()}}funcRequireAdmin()router.HandlerFunc{returnfunc(c*router.Context){// Check if user is admin...c.Next()}}// HandlersfunchealthHandler(c*router.Context){c.JSON(200,map[string]string{"status":"OK"})}funcpublicHandler(c*router.Context){c.JSON(200,map[string]string{"message":"Public endpoint"})}funcprofileHandler(c*router.Context){c.JSON(200,map[string]string{"user":"john@example.com"})}funccreatePostHandler(c*router.Context){c.JSON(201,map[string]string{"message":"Post created"})}funclistUsersHandler(c*router.Context){c.JSON(200,[]string{"user1","user2"})}funcdeleteUserHandler(c*router.Context){c.Status(204)}
Next Steps
Context API: Learn about the Context and its lifecycle
// ✅ CORRECT: Normal handler - context used within handlerfunchandler(c*router.Context){userID:=c.Param("id")c.JSON(200,map[string]string{"id":userID})// Context automatically returned to pool by router}// ✅ CORRECT: Async operation with copied datafunchandler(c*router.Context){// Copy needed data before starting goroutineuserID:=c.Param("id")gofunc(idstring){// Process async work with copied data...processAsync(id)}(userID)}
Incorrect Usage
// ❌ WRONG: Retaining context referencevarglobalContext*router.Contextfunchandler(c*router.Context){globalContext=c// BAD! Memory leak and data corruption}// ❌ WRONG: Passing context to goroutinefunchandler(c*router.Context){gofunc(ctx*router.Context){// BAD! Context may be reused by another requestprocessAsync(ctx.Param("id"))}(c)}// ❌ WRONG: Storing context in structtypeServicestruct{ctx*router.Context// BAD! Never do this}
// GET /search?q=golang&limit=10&page=2r.GET("/search",func(c*router.Context){query:=c.Query("q")// "golang"limit:=c.Query("limit")// "10"page:=c.Query("page")// "2"c.JSON(200,map[string]string{"query":query,"limit":limit,"page":page,})})
Form Data
// POST with form datar.POST("/login",func(c*router.Context){username:=c.FormValue("username")password:=c.FormValue("password")c.JSON(200,map[string]string{"username":username,})})
Response Methods
JSON Responses
// Standard JSON (HTML-escaped)c.JSON(200,data)// Indented JSON (for debugging)c.IndentedJSON(200,data)// Pure JSON (no HTML escaping - 35% faster!)c.PureJSON(200,data)// Secure JSON (anti-hijacking prefix)c.SecureJSON(200,data)// ASCII JSON (pure ASCII with \uXXXX)c.AsciiJSON(200,data)// JSONP (with callback)c.JSONP(200,data,"callback")
Other Response Formats
// YAMLc.YAML(200,config)// Plain textc.String(200,"Hello, World!")c.Stringf(200,"Hello, %s!",name)// HTMLc.HTML(200,"<h1>Welcome</h1>")// Binary datac.Data(200,"image/png",imageBytes)// Stream from reader (zero-copy!)c.DataFromReader(200,size,"video/mp4",file,nil)// Status onlyc.Status(204)// No Content
File Serving
// Serve filec.ServeFile("/path/to/file.pdf")// Force downloadc.Download("/path/to/file.pdf","custom-name.pdf")
funchandler(c*router.Context){ifc.IsJSON(){// Request has JSON content-type}ifc.AcceptsJSON(){c.JSON(200,data)}elseifc.AcceptsHTML(){c.HTML(200,htmlContent)}}
Client Information
funchandler(c*router.Context){clientIP:=c.ClientIP()// Real IP (considers X-Forwarded-For)isSecure:=c.IsHTTPS()// HTTPS check}
// Set cookiec.SetCookie("session_id",// name"abc123",// value3600,// max age (seconds)"/",// path"",// domainfalse,// securetrue,// httpOnly)// Get cookiesessionID,err:=c.GetCookie("session_id")
Passing Values Between Middleware
Use context.WithValue() to pass values between middleware and handlers:
// Define context keys to avoid collisionstypecontextKeystringconstuserKeycontextKey="user"// In middleware - create new request with valuefuncAuthMiddleware()router.HandlerFunc{returnfunc(c*router.Context){user:=authenticateUser(c)// Create new context with valuectx:=context.WithValue(c.Request.Context(),userKey,user)c.Request=c.Request.WithContext(ctx)c.Next()}}// In handler - retrieve value from request contextfunchandler(c*router.Context){user,ok:=c.Request.Context().Value(userKey).(*User)if!ok||user==nil{c.JSON(401,map[string]string{"error":"Unauthorized"})return}c.JSON(200,user)}
Note
Use typed keys (like contextKey) instead of string keys to avoid collisions between packages.
File Uploads
r.POST("/upload",func(c*router.Context){// Single filefile,err:=c.File("avatar")iferr!=nil{c.JSON(400,map[string]string{"error":"avatar required"})return}// File infofmt.Printf("Name: %s, Size: %d, Type: %s\n",file.Name,file.Size,file.ContentType)// Save fileiferr:=file.Save("./uploads/"+file.Name);err!=nil{c.JSON(500,map[string]string{"error":"failed to save"})return}c.JSON(200,map[string]string{"filename":file.Name})})// Multiple filesr.POST("/upload-many",func(c*router.Context){files,err:=c.Files("documents")iferr!=nil{c.JSON(400,map[string]string{"error":"documents required"})return}for_,f:=rangefiles{f.Save("./uploads/"+f.Name)}c.JSON(200,map[string]int{"count":len(files)})})
Performance Tips
Extract Data Immediately
// ✅ GOOD: Extract data earlyfunchandler(c*router.Context){userID:=c.Param("id")query:=c.Query("q")// Use extracted dataresult:=processData(userID,query)c.JSON(200,result)}// ❌ BAD: Don't store context referencevarglobalContext*router.Contextfunchandler(c*router.Context){globalContext=c// Memory leak!}
Choose the Right Response Method
// Use PureJSON for HTML content (35% faster than JSON)c.PureJSON(200,dataWithHTMLStrings)// Use Data() for binary (98% faster than JSON)c.Data(200,"image/png",imageBytes)// Avoid YAML in hot paths (9x slower than JSON)// c.YAML(200, data) // Only for config/admin endpoints
Complete Example
packagemainimport("encoding/json""net/http""rivaas.dev/router")funcmain(){r:=router.MustNew()// Request parametersr.GET("/users/:id",func(c*router.Context){id:=c.Param("id")c.JSON(200,map[string]string{"id":id})})// Query parametersr.GET("/search",func(c*router.Context){q:=c.Query("q")c.JSON(200,map[string]string{"query":q})})// Form datar.POST("/login",func(c*router.Context){username:=c.FormValue("username")c.JSON(200,map[string]string{"username":username})})// JSON request bodyr.POST("/users",func(c*router.Context){varreqstruct{Namestring`json:"name"`Emailstring`json:"email"`}iferr:=json.NewDecoder(c.Request.Body).Decode(&req);err!=nil{c.JSON(400,map[string]string{"error":"Invalid JSON"})return}c.JSON(201,req)})// Headers and cookiesr.GET("/info",func(c*router.Context){userAgent:=c.Request.Header.Get("User-Agent")session,_:=c.GetCookie("session_id")c.Header("X-Custom","value")c.JSON(200,map[string]string{"user_agent":userAgent,"session":session,})})http.ListenAndServe(":8080",r)}
Request binding parses request data (query parameters, URL parameters, form data, JSON) into Go structs.
Two Approaches
For simple cases, use the standard library’s json.Decoder. For full binding capabilities (query, form, headers, cookies, multi-source), use the separate binding package. For integrated binding with validation, use the app package.
Router Context Methods
The router Context provides basic data access methods and streaming capabilities.
Simple JSON Binding
For simple JSON binding in router-only code, use the standard library:
This works well for simple cases. For more features, use the binding package.
Manual Parameter Access
For simple cases, access parameters directly:
// Query parametersr.GET("/search",func(c*router.Context){query:=c.Query("q")limit:=c.QueryDefault("limit","10")c.JSON(200,map[string]string{"query":query,"limit":limit,})})// URL parametersr.GET("/users/:id",func(c*router.Context){userID:=c.Param("id")c.JSON(200,map[string]string{"user_id":userID})})// Form datar.POST("/login",func(c*router.Context){username:=c.FormValue("username")password:=c.FormValue("password")// ...})
Content Type Validation
Check the content type before processing the body:
r.POST("/users",func(c*router.Context){if!c.RequireContentTypeJSON(){return// 415 Unsupported Media Type already sent}varreqCreateUserRequestiferr:=json.NewDecoder(c.Request.Body).Decode(&req);err!=nil{c.WriteErrorResponse(http.StatusBadRequest,"Invalid JSON")return}c.JSON(201,req)})
Streaming Large Payloads
For large arrays, stream instead of loading into memory:
Need complex business logic or request-scoped rules?
├─ Yes → Use Validate/ValidateContext interface methods
└─ No → Continue ↓
Validating against external/shared schema?
├─ Yes → Use JSON Schema validation
└─ No → Continue ↓
Simple field constraints (required, min, max, format)?
├─ Yes → Use struct tags (binding package + go-playground/validator)
└─ No → Use manual validation
Interface Validation
Implement the Validate or ValidateContext interface on your request structs:
Basic Validation
typeTransferRequeststruct{FromAccountstring`json:"from_account"`ToAccountstring`json:"to_account"`Amountfloat64`json:"amount"`}func(t*TransferRequest)Validate()error{ift.FromAccount==t.ToAccount{returnerrors.New("cannot transfer to same account")}ift.Amount>10000{returnerrors.New("amount exceeds daily limit")}returnnil}
Context-Aware Validation
typeCreatePostRequeststruct{Titlestring`json:"title"`Tags[]string`json:"tags"`}func(p*CreatePostRequest)ValidateContext(ctxcontext.Context)error{// Get user tier from contexttier:=ctx.Value("user_tier")iftier=="free"&&len(p.Tags)>3{returnerrors.New("free users can only use 3 tags")}returnnil}
Handler Integration
funccreateTransfer(c*router.Context){varreqTransferRequestiferr:=json.NewDecoder(c.Request.Body).Decode(&req);err!=nil{c.WriteErrorResponse(http.StatusBadRequest,"Invalid JSON")return}// Call interface validation methodiferr:=req.Validate();err!=nil{c.JSON(400,map[string]string{"error":err.Error()})return}// Process validated requestc.JSON(200,map[string]string{"status":"success"})}
Tag Validation with Binding Package
Use the binding package with struct tags for declarative validation:
import("rivaas.dev/binding""rivaas.dev/validation")typeCreateUserRequeststruct{Emailstring`json:"email" validate:"required,email"`Usernamestring`json:"username" validate:"required,min=3,max=20"`Ageint`json:"age" validate:"required,min=18,max=120"`}funccreateUser(c*router.Context){varreqCreateUserRequest// Bind JSON using binding packageiferr:=binding.JSON(c.Request,&req);err!=nil{c.JSON(400,map[string]string{"error":err.Error()})return}// Validate with struct tagsiferr:=validation.Validate(&req);err!=nil{c.JSON(400,map[string]string{"error":err.Error()})return}c.JSON(201,req)}
Common Validation Tags
typeExamplestruct{Requiredstring`validate:"required"`// Must be presentEmailstring`validate:"email"`// Valid email formatURLstring`validate:"url"`// Valid URLMinint`validate:"min=10"`// Minimum valueMaxint`validate:"max=100"`// Maximum valueRangeint`validate:"min=10,max=100"`// RangeLengthstring`validate:"min=3,max=50"`// String lengthOneOfstring`validate:"oneof=active pending"`// EnumOptionalstring`validate:"omitempty,email"`// Optional but validates if present}
JSON Schema Validation
Implement the JSONSchemaProvider interface for contract-based validation:
For a complete solution, combine strict binding with interface validation:
typeCreateOrderRequeststruct{CustomerIDstring`json:"customer_id"`Items[]OrderItem`json:"items"`Notesstring`json:"notes"`}func(r*CreateOrderRequest)Validate()error{iflen(r.Items)==0{returnerrors.New("order must have at least one item")}fori,item:=ranger.Items{ifitem.Quantity<=0{returnfmt.Errorf("item %d: quantity must be positive",i)}}returnnil}funccreateOrder(c*router.Context){varreqCreateOrderRequest// Simple JSON bindingiferr:=json.NewDecoder(c.Request.Body).Decode(&req);err!=nil{c.WriteErrorResponse(http.StatusBadRequest,"Invalid JSON")return}// Business logic validationiferr:=req.Validate();err!=nil{c.JSON(400,map[string]string{"error":err.Error()})return}c.JSON(201,req)}
Partial Validation (PATCH)
For PATCH requests, use pointer fields and check for presence:
typeUpdateUserRequeststruct{Email*string`json:"email,omitempty"`Username*string`json:"username,omitempty"`Bio*string`json:"bio,omitempty"`}func(r*UpdateUserRequest)Validate()error{ifr.Email!=nil&&*r.Email==""{returnerrors.New("email cannot be empty if provided")}ifr.Username!=nil&&len(*r.Username)<3{returnerrors.New("username must be at least 3 characters")}ifr.Bio!=nil&&len(*r.Bio)>500{returnerrors.New("bio cannot exceed 500 characters")}returnnil}funcupdateUser(c*router.Context){varreqUpdateUserRequestiferr:=json.NewDecoder(c.Request.Body).Decode(&req);err!=nil{c.WriteErrorResponse(http.StatusBadRequest,"Invalid JSON")return}iferr:=req.Validate();err!=nil{c.JSON(400,map[string]string{"error":err.Error()})return}// Update only non-nil fieldsifreq.Email!=nil{// Update email}c.JSON(200,map[string]string{"status":"updated"})}
Structured Validation Errors
Return detailed errors for better API usability:
typeValidationErrorstruct{Fieldstring`json:"field"`Messagestring`json:"message"`}typeValidationErrorsstruct{Errors[]ValidationError`json:"errors"`}func(r*CreateUserRequest)Validate()*ValidationErrors{varerrs[]ValidationErrorifr.Email==""{errs=append(errs,ValidationError{Field:"email",Message:"email is required",})}iflen(r.Username)<3{errs=append(errs,ValidationError{Field:"username",Message:"username must be at least 3 characters",})}iflen(errs)>0{return&ValidationErrors{Errors:errs}}returnnil}funccreateUser(c*router.Context){varreqCreateUserRequestiferr:=json.NewDecoder(c.Request.Body).Decode(&req);err!=nil{c.WriteErrorResponse(http.StatusBadRequest,"Invalid JSON")return}}ifverrs:=req.Validate();verrs!=nil{c.JSON(400,verrs)return}c.JSON(201,req)}
Best Practices
Do:
Use interface methods (Validate()) for business logic validation
Use pointer fields (*string) for optional PATCH fields
Return structured errors with field paths
Validate early, fail fast
Use the binding or app package for full binding capabilities
Don’t:
Return sensitive data in validation error messages
Perform expensive validation (DB lookups) in Validate() - use ValidateContext() for those
Skip validation for internal endpoints
Complete Example
packagemainimport("errors""net/http""rivaas.dev/router")typeCreateUserRequeststruct{Emailstring`json:"email"`Usernamestring`json:"username"`Ageint`json:"age"`}func(r*CreateUserRequest)Validate()error{ifr.Email==""{returnerrors.New("email is required")}iflen(r.Username)<3{returnerrors.New("username must be at least 3 characters")}ifr.Age<18||r.Age>120{returnerrors.New("age must be between 18 and 120")}returnnil}funcmain(){r:=router.MustNew()r.POST("/users",func(c*router.Context){varreqCreateUserRequest// Bind JSONiferr:=json.NewDecoder(c.Request.Body).Decode(&req);err!=nil{c.WriteErrorResponse(http.StatusBadRequest,"Invalid JSON")return}// Run business validationiferr:=req.Validate();err!=nil{c.JSON(400,map[string]string{"error":err.Error()})return}c.JSON(201,req)})http.ListenAndServe(":8080",r)}
Next Steps
Binding Package: Full binding documentation at binding guide
c.ServeFile("/path/to/file.pdf")c.Download("/path/to/file.pdf","custom-name.pdf")// Force download
Performance Tips
Choose the Right Method
// Use PureJSON for HTML content (35% faster than JSON)c.PureJSON(200,dataWithHTMLStrings)// Use Data() for binary (98% faster than JSON)c.Data(200,"image/png",imageBytes)// Avoid YAML in hot paths (9x slower than JSON)// c.YAML(200, data) // Only for config/admin endpoints// Reserve IndentedJSON for debugging// c.IndentedJSON(200, data) // Development only
Performance Benchmarks
Method
ns/op
Overhead vs JSON
Use Case
JSON
4,189
-
Production APIs
PureJSON
2,725
-35% ✨
HTML/markdown content
SecureJSON
4,835
+15%
Compliance/old browsers
IndentedJSON
8,111
+94%
Debug/development
AsciiJSON
1,593
-62% ✨
Legacy compatibility
YAML
36,700
+776%
Config/admin APIs
Data
90
-98% ✨
Binary/custom formats
Complete Example
packagemainimport("net/http""rivaas.dev/router")funcmain(){r:=router.MustNew()// Standard JSONr.GET("/json",func(c*router.Context){c.JSON(200,map[string]string{"message":"Hello"})})// Pure JSON (faster for HTML content)r.GET("/pure-json",func(c*router.Context){c.PureJSON(200,map[string]string{"content":"<h1>Title</h1><p>Paragraph</p>",})})// YAMLr.GET("/yaml",func(c*router.Context){c.YAML(200,map[string]interface{}{"server":map[string]interface{}{"port":8080,"host":"localhost",},})})// Binary datar.GET("/image",func(c*router.Context){imageData:=loadImage()c.Data(200,"image/png",imageData)})// File downloadr.GET("/download",func(c*router.Context){c.Download("/path/to/report.pdf","report-2024.pdf")})http.ListenAndServe(":8080",r)}
r.GET("/data",func(c*router.Context){charset:=c.AcceptsCharsets("utf-8","iso-8859-1")// Set response charset based on preferencec.Header("Content-Type","text/html; charset="+charset)})
Encoding Negotiation
r.GET("/data",func(c*router.Context){encoding:=c.AcceptsEncodings("gzip","br","deflate")ifencoding=="gzip"{// Compress response with gzip}elseifencoding=="br"{// Compress response with brotli}})
Language Negotiation
r.GET("/content",func(c*router.Context){lang:=c.AcceptsLanguages("en-US","en","es","fr")// Serve content in preferred languagecontent:=getContentInLanguage(lang)c.String(200,content)})
packagemainimport("net/http""rivaas.dev/router")funcmain(){r:=router.New(router.WithVersioning(// Choose your version detection methodrouter.WithHeaderVersioning("API-Version"),// Set default version (when client doesn't specify)router.WithDefaultVersion("v2"),// Optional: Only allow these versionsrouter.WithValidVersions("v1","v2","v3"),),)// Create version 1 routesv1:=r.Version("v1")v1.GET("/users",listUsersV1)// Create version 2 routesv2:=r.Version("v2")v2.GET("/users",listUsersV2)http.ListenAndServe(":8080",r)}
Using Multiple Methods
You can enable multiple detection methods. The router checks them in order:
r:=router.New(router.WithVersioning(router.WithHeaderVersioning("API-Version"),// Primaryrouter.WithQueryVersioning("version"),// For testingrouter.WithPathVersioning("/v{version}/"),// Legacy supportrouter.WithAcceptVersioning("application/vnd.myapi.v{version}+json"),router.WithDefaultVersion("v2"),),)
router.WithCustomVersionDetector(func(req*http.Request)string{// Your custom logicifisLegacyClient(req){return"v1"}returnextractVersionSomehow(req)})
Migration Patterns
Share Business Logic
Keep business logic the same, change only the response format:
// Business logic (shared between versions)funcgetUserByID(idstring)(*User,error){// Database query, business rules, etc.return&User{ID:id,Name:"Alice"},nil}// Version 1 handlerfunclistUsersV1(c*router.Context){users,_:=getUsersFromDB()// V1 format: flat structurec.JSON(200,map[string]any{"users":users,})}// Version 2 handlerfunclistUsersV2(c*router.Context){users,_:=getUsersFromDB()// V2 format: with metadatac.JSON(200,map[string]any{"data":users,"meta":map[string]any{"total":len(users),"version":"v2",},})}
typeUserV2struct{IDint`json:"id"`Namestring`json:"name"`Emailstring`json:"email"`// Required now}funccreateUserV2(c*router.Context){varuserUserV2iferr:=c.Bind(&user);err!=nil{c.JSON(400,map[string]string{"error":"validation failed","detail":"email is required in API v2",})return}// Create user...}
Version-Specific Middleware
Apply different middleware to different versions:
v1:=r.Version("v1")v1.Use(legacyAuthMiddleware)v1.GET("/users",listUsersV1)v2:=r.Version("v2")v2.Use(jwtAuthMiddleware)// Different auth methodv2.GET("/users",listUsersV2)
Change Data Structure
Example: Flat to nested structure
// V1: Flat structuretypeUserV1struct{IDint`json:"id"`Namestring`json:"name"`Citystring`json:"city"`Countrystring`json:"country"`}// V2: Nested structuretypeUserV2struct{IDint`json:"id"`Namestring`json:"name"`Addressstruct{Citystring`json:"city"`Countrystring`json:"country"`}`json:"address"`}// Helper to convertfuncconvertV1ToV2(v1UserV1)UserV2{v2:=UserV2{ID:v1.ID,Name:v1.Name,}v2.Address.City=v1.Cityv2.Address.Country=v1.Countryreturnv2}
Deprecation Strategy
Mark Versions as Deprecated
Tell the router when a version should stop working:
r:=router.New(router.WithVersioning(// Mark v1 as deprecated with end daterouter.WithDeprecatedVersion("v1",time.Date(2025,12,31,23,59,59,0,time.UTC),),// Track version usagerouter.WithVersionObserver(router.WithOnDetected(func(version,methodstring){// Record metricsmetrics.RecordVersionUsage(version,method)}),router.WithOnMissing(func(){// Client didn't specify versionlog.Warn("client using default version")}),router.WithOnInvalid(func(attemptedstring){// Client used invalid versionmetrics.RecordInvalidVersion(attempted)}),),),)
Deprecation Headers
The router automatically adds headers for deprecated versions:
These tell clients when the version will stop working.
Deprecation Timeline
6 months before end:
Announce in release notes
Add deprecation header
Write migration guide
Contact major users
3 months before end:
Add sunset header with date
Email active users
Monitor usage (should go down)
Offer help with migration
1 month before end:
Send final warnings
Return 410 Gone for deprecated endpoints
Link to migration guide
After end date:
Remove old version code
Always return 410 Gone
Keep migration documentation
Best Practices
1. Use Semantic Versioning
Major (v1, v2, v3): Breaking changes
Minor (v2.1, v2.2): New features, backward compatible
Patch (v2.1.1): Bug fixes only
2. Know When to Version
Don’t version for:
Bug fixes
Performance improvements
Internal refactoring
Adding optional fields
Making validation less strict
Do version for:
Removing fields
Changing field types
Making optional field required
Major behavior changes
Changing error codes
3. Keep Backward Compatibility
// Good: Add optional fieldtypeUserV2struct{IDint`json:"id"`Namestring`json:"name"`Emailstring`json:"email,omitempty"`// New, optional}// Bad: Remove field (breaks clients)typeUserV2struct{IDint`json:"id"`// Name removed - BREAKING CHANGE!}
4. Document Version Differences
Keep clear documentation for each version:
## API Versions
### v2 (Current)
- Added email field (optional)
- Added address nested object
- Added PATCH support for partial updates
### v1 (Deprecated - Ends 2025-12-31)
- Original API
- Only GET/POST/PUT/DELETE
- Flat structure only
5. Organize Routes by Version
Group version routes together:
v1:=r.Version("v1"){v1.GET("/users",listUsersV1)v1.GET("/users/:id",getUserV1)v1.POST("/users",createUserV1)}v2:=r.Version("v2"){v2.GET("/users",listUsersV2)v2.GET("/users/:id",getUserV2)v2.POST("/users",createUserV2)v2.PATCH("/users/:id",updateUserV2)// New in v2}
6. Validate Versions
Reject invalid versions early:
router.WithVersioning(router.WithValidVersions("v1","v2","v3","beta"),router.WithVersionObserver(router.WithOnInvalid(func(attemptedstring){log.Warn("invalid API version","version",attempted)}),),)
r:=router.New(router.WithVersioning(router.WithHeaderVersioning("Stripe-Version"),router.WithDefaultVersion("2024-11-20"),router.WithValidVersions("2024-11-20","2024-10-28","2024-09-30",),),)// Version by datev20241120:=r.Version("2024-11-20")v20241120.GET("/charges",listCharges)
Choose header-based versioning for most cases. Use query parameters for testing. Document your changes clearly. Give clients time to migrate before removing old versions.
2.2.12 - Observability
OpenTelemetry support via the observability recorder interface, with zero overhead when disabled, plus diagnostic events.
The router provides OpenTelemetry support via the observability recorder interface and optional diagnostic events.
When you use the app package, your handlers receive app.Context, which has built-in methods: c.TraceID(), c.SpanID(), c.SetSpanAttribute(), c.AddSpanEvent(), and more. See the app observability guide.
Diagnostics
Enable diagnostic events for security concerns and configuration issues:
The router provides methods for serving static files and directories.
Directory Serving
Serve an entire directory.
r:=router.MustNew()// Serve ./public/* at /assets/*r.Static("/assets","./public")// Serve /var/uploads/* at /uploads/*r.Static("/uploads","/var/uploads")
packagemainimport("embed""net/http""rivaas.dev/router")//go:embed web/dist/*varwebAssetsembed.FSfuncmain(){r:=router.MustNew()// Serve your frontend at the rootr.StaticEmbed("/",webAssets,"web/dist")// API routesr.GET("/api/status",func(c*router.Context){c.JSON(200,map[string]string{"status":"OK"})})http.ListenAndServe(":8080",r)}
Now http://localhost:8080/ serves index.html, and http://localhost:8080/css/style.css serves your CSS.
Tip
If you don’t need the convenience method, you can also use StaticFS with http.FS:
Testing router-based applications is straightforward using Go’s httptest package.
Testing Routes
Basic Route Test
packagemainimport("net/http""net/http/httptest""testing""rivaas.dev/router")funcTestGetUser(t*testing.T){r:=router.MustNew()r.GET("/users/:id",func(c*router.Context){c.JSON(200,map[string]string{"user_id":c.Param("id"),})})req:=httptest.NewRequest("GET","/users/123",nil)w:=httptest.NewRecorder()r.ServeHTTP(w,req)ifw.Code!=http.StatusOK{t.Errorf("Expected status 200, got %d",w.Code)}}
Testing JSON Responses
funcTestCreateUser(t*testing.T){r:=router.MustNew()r.POST("/users",func(c*router.Context){c.JSON(201,map[string]string{"id":"123"})})body:=strings.NewReader(`{"name":"John"}`)req:=httptest.NewRequest("POST","/users",body)req.Header.Set("Content-Type","application/json")w:=httptest.NewRecorder()r.ServeHTTP(w,req)ifw.Code!=201{t.Errorf("Expected status 201, got %d",w.Code)}varresponsemap[string]stringiferr:=json.Unmarshal(w.Body.Bytes(),&response);err!=nil{t.Fatal(err)}ifresponse["id"]!="123"{t.Errorf("Expected id '123', got %v",response["id"])}}
Testing Middleware
funcTestAuthMiddleware(t*testing.T){r:=router.MustNew()r.Use(AuthRequired())r.GET("/protected",func(c*router.Context){c.JSON(200,map[string]string{"message":"success"})})// Test without auth headerreq:=httptest.NewRequest("GET","/protected",nil)w:=httptest.NewRecorder()r.ServeHTTP(w,req)ifw.Code!=401{t.Errorf("Expected status 401, got %d",w.Code)}// Test with auth headerreq=httptest.NewRequest("GET","/protected",nil)req.Header.Set("Authorization","Bearer valid-token")w=httptest.NewRecorder()r.ServeHTTP(w,req)ifw.Code!=200{t.Errorf("Expected status 200, got %d",w.Code)}}
Table-Driven Tests
funcTestRoutes(t*testing.T){r:=setupRouter()tests:=[]struct{namestringmethodstringpathstringexpectedStatusint}{{"Home","GET","/",200},{"Users","GET","/users",200},{"Not Found","GET","/invalid",404},{"Method Not Allowed","POST","/",405},}for_,tt:=rangetests{t.Run(tt.name,func(t*testing.T){req:=httptest.NewRequest(tt.method,tt.path,nil)w:=httptest.NewRecorder()r.ServeHTTP(w,req)ifw.Code!=tt.expectedStatus{t.Errorf("Expected status %d, got %d",tt.expectedStatus,w.Code)}})}}
This guide provides complete, working examples for common use cases.
REST API Server
Complete REST API with CRUD operations:
packagemainimport("encoding/json""net/http""rivaas.dev/router")typeUserstruct{IDstring`json:"id"`Namestring`json:"name"`Emailstring`json:"email"`}varusers=map[string]User{"1":{ID:"1",Name:"Alice",Email:"alice@example.com"},"2":{ID:"2",Name:"Bob",Email:"bob@example.com"},}funcmain(){r:=router.MustNew()r.Use(Logger(),Recovery(),CORS())api:=r.Group("/api/v1")api.Use(JSONContentType()){api.GET("/users",listUsers)api.POST("/users",createUser)api.GET("/users/:id",getUser)api.PUT("/users/:id",updateUser)api.DELETE("/users/:id",deleteUser)}http.ListenAndServe(":8080",r)}funclistUsers(c*router.Context){userList:=make([]User,0,len(users))for_,user:=rangeusers{userList=append(userList,user)}c.JSON(200,userList)}funcgetUser(c*router.Context){id:=c.Param("id")user,exists:=users[id]if!exists{c.JSON(404,map[string]string{"error":"User not found"})return}c.JSON(200,user)}funccreateUser(c*router.Context){varuserUseriferr:=json.NewDecoder(c.Request.Body).Decode(&user);err!=nil{c.JSON(400,map[string]string{"error":"Invalid JSON"})return}users[user.ID]=userc.JSON(201,user)}funcupdateUser(c*router.Context){id:=c.Param("id")if_,exists:=users[id];!exists{c.JSON(404,map[string]string{"error":"User not found"})return}varuserUseriferr:=json.NewDecoder(c.Request.Body).Decode(&user);err!=nil{c.JSON(400,map[string]string{"error":"Invalid JSON"})return}user.ID=idusers[id]=userc.JSON(200,user)}funcdeleteUser(c*router.Context){id:=c.Param("id")if_,exists:=users[id];!exists{c.JSON(404,map[string]string{"error":"User not found"})return}delete(users,id)c.Status(204)}
Microservice Gateway
API gateway with service routing:
packagemainimport("net/http""rivaas.dev/router")funcmain(){r:=router.MustNew()r.Use(Logger(),RateLimit(),Tracing())// Service discovery and routingr.GET("/users/*path",proxyToUserService)r.GET("/orders/*path",proxyToOrderService)r.GET("/payments/*path",proxyToPaymentService)// Health checksr.GET("/health",healthCheck)r.GET("/metrics",metricsHandler)http.ListenAndServe(":8080",r)}funcproxyToUserService(c*router.Context){path:=c.Param("path")// Proxy to user service...c.JSON(200,map[string]string{"service":"users","path":path})}funcproxyToOrderService(c*router.Context){path:=c.Param("path")// Proxy to order service...c.JSON(200,map[string]string{"service":"orders","path":path})}funcproxyToPaymentService(c*router.Context){path:=c.Param("path")// Proxy to payment service...c.JSON(200,map[string]string{"service":"payments","path":path})}funchealthCheck(c*router.Context){c.JSON(200,map[string]string{"status":"OK"})}funcmetricsHandler(c*router.Context){c.String(200,"# HELP requests_total Total requests\n# TYPE requests_total counter\n")}
Static File Server with API
Serve static files alongside API routes:
packagemainimport("net/http""rivaas.dev/router")funcmain(){r:=router.MustNew()// Serve static filesr.Static("/assets","./public")r.StaticFile("/favicon.ico","./static/favicon.ico")// API routesapi:=r.Group("/api"){api.GET("/status",statusHandler)api.GET("/users",listUsersHandler)}http.ListenAndServe(":8080",r)}funcstatusHandler(c*router.Context){c.JSON(200,map[string]string{"status":"OK"})}funclistUsersHandler(c*router.Context){c.JSON(200,[]string{"user1","user2"})}
Authentication & Authorization
Complete auth example with JWT:
packagemainimport("net/http""rivaas.dev/router")funcmain(){r:=router.MustNew()r.Use(Logger(),Recovery())// Public routesr.POST("/login",loginHandler)r.POST("/register",registerHandler)// Protected routesapi:=r.Group("/api")api.Use(JWTAuth()){api.GET("/profile",profileHandler)api.PUT("/profile",updateProfileHandler)// Admin routesadmin:=api.Group("/admin")admin.Use(RequireAdmin()){admin.GET("/users",listUsersHandler)admin.DELETE("/users/:id",deleteUserHandler)}}http.ListenAndServe(":8080",r)}funcloginHandler(c*router.Context){// Authenticate user and generate JWT...c.JSON(200,map[string]string{"token":"jwt-token-here"})}funcregisterHandler(c*router.Context){// Create new user...c.JSON(201,map[string]string{"message":"User created"})}funcprofileHandler(c*router.Context){// Get user from context (set by JWT middleware)c.JSON(200,map[string]string{"user":"john@example.com"})}funcupdateProfileHandler(c*router.Context){c.JSON(200,map[string]string{"message":"Profile updated"})}funclistUsersHandler(c*router.Context){c.JSON(200,[]string{"user1","user2"})}funcdeleteUserHandler(c*router.Context){c.Status(204)}funcJWTAuth()router.HandlerFunc{returnfunc(c*router.Context){token:=c.Request.Header.Get("Authorization")iftoken==""{c.JSON(401,map[string]string{"error":"Unauthorized"})return}// Validate JWT...c.Next()}}funcRequireAdmin()router.HandlerFunc{returnfunc(c*router.Context){// Check if user is admin...c.Next()}}
Learn how to bind HTTP request data to Go structs with type safety and performance
The Rivaas Binding package provides high-performance request data binding for Go web applications. It maps values from various sources (query parameters, form data, JSON bodies, headers, cookies, path parameters) into Go structs using struct tags.
import"rivaas.dev/binding"typeCreateOrderRequeststruct{// From path parametersUserIDint`path:"user_id"`// From query stringCouponstring`query:"coupon"`// From headersAuthstring`header:"Authorization"`// From JSON bodyItems[]OrderItem`json:"items"`Totalfloat64`json:"total"`}req,err:=binding.Bind[CreateOrderRequest](binding.FromPath(pathParams),binding.FromQuery(r.URL.Query()),binding.FromHeader(r.Header),binding.FromJSON(body),)
Learning Path
Follow these guides to master request data binding with Rivaas:
Installation - Get started with the binding package
Basic Usage - Learn the fundamentals of binding data
The binding package uses Go generics for compile-time type safety:
// Generic API (preferred) - Type-safe at compile timeuser,err:=binding.JSON[CreateUserRequest](body)// Non-generic API - When type comes from variablevaruserCreateUserRequesterr:=binding.JSONTo(body,&user)
Benefits:
✅ Compile-time type checking
✅ No reflection overhead for type instantiation
✅ Better IDE autocomplete
✅ Cleaner, more readable code
Performance
First binding of a type: ~500ns overhead for reflection
For complete API documentation, visit the API Reference.
2.3.2 - Basic Usage
Learn the fundamentals of binding request data to Go structs
This guide covers the essential operations for working with the binding package. Learn how to bind from different sources, understand the API variants, and handle errors.
Generic API vs Non-Generic API
The binding package provides two API styles:
Generic API (Recommended)
Use the generic API when you know the type at compile time:
// Type is specified as a type parameteruser,err:=binding.JSON[CreateUserRequest](body)params,err:=binding.Query[ListParams](r.URL.Query())
Benefits:
Compile-time type safety.
Cleaner syntax.
Better IDE support.
No need to pre-allocate the struct.
Non-Generic API
Use the non-generic API when the type comes from a variable or when working with interfaces:
typeCreateUserRequeststruct{Namestring`json:"name"`Emailstring`json:"email"`Ageint`json:"age"`}// Read body from requestbody,err:=io.ReadAll(r.Body)iferr!=nil{// Handle error}deferr.Body.Close()// Bind JSON to structuser,err:=binding.JSON[CreateUserRequest](body)iferr!=nil{// Handle binding error}
typeUserIDParamstruct{UserIDint`path:"user_id"`}// Path params typically come from your router// Example with common router pattern:pathParams:=map[string]string{"user_id":"123",}params,err:=binding.Path[UserIDParam](pathParams)
Form Data
typeLoginFormstruct{Usernamestring`form:"username"`Passwordstring`form:"password"`Rememberbool`form:"remember"`}// Parse form firstiferr:=r.ParseForm();err!=nil{// Handle parse error}form,err:=binding.Form[LoginForm](r.Form)
For file uploads with form data, use multipart forms:
typeUploadRequeststruct{File*binding.File`form:"file"`Titlestring`form:"title"`Descriptionstring`form:"description"`}// Parse multipart form first (32MB max)iferr:=r.ParseMultipartForm(32<<20);err!=nil{// Handle parse error}// Bind form and filesreq,err:=binding.Multipart[UploadRequest](r.MultipartForm)iferr!=nil{// Handle binding error}// Work with the uploaded fileiferr:=req.File.Save("/uploads/"+req.File.Name);err!=nil{// Handle save error}
The binding.File type provides methods to work with uploaded files:
Save(path) - Save file to disk
Bytes() - Read file contents into memory
Open() - Open file for streaming
Ext() - Get file extension
See Multipart Forms for detailed examples and security considerations.
Error Handling Basics
All binding functions return an error that provides context about what went wrong:
user,err:=binding.JSON[CreateUserRequest](body)iferr!=nil{// Check for specific error typesvarbindErr*binding.BindErroriferrors.As(err,&bindErr){fmt.Printf("Field %s: %v\n",bindErr.Field,bindErr.Err)}// Or just use the error messagehttp.Error(w,err.Error(),http.StatusBadRequest)return}
Common error types:
BindError - Field-level binding error with context
UnknownFieldError - Unknown fields in strict mode
MultiError - Multiple errors when using WithAllErrors()
typeConfigstruct{Portint`query:"port" default:"8080"`Hoststring`query:"host" default:"localhost"`Debugbool`query:"debug" default:"false"`Timeoutstring`query:"timeout" default:"30s"`}// If query params don't include these values, defaults are usedcfg,err:=binding.Query[Config](r.URL.Query())
Working with Pointers
Use pointers to distinguish between “not set” and “set to zero value”:
typeUpdateUserRequeststruct{Name*string`json:"name"`// nil = not updating, "" = clear valueEmail*string`json:"email"`Age*int`json:"age"`// nil = not updating, 0 = set to zero}user,err:=binding.JSON[UpdateUserRequest](body)// Check if field was providedifuser.Name!=nil{// Update name to *user.Name}ifuser.Age!=nil{// Update age to *user.Age}
Common Patterns
API Handler Pattern
funcCreateUserHandler(whttp.ResponseWriter,r*http.Request){// Read bodybody,err:=io.ReadAll(r.Body)iferr!=nil{http.Error(w,"Failed to read body",http.StatusBadRequest)return}deferr.Body.Close()// Bind requestreq,err:=binding.JSON[CreateUserRequest](body)iferr!=nil{http.Error(w,err.Error(),http.StatusBadRequest)return}// Process requestuser:=createUser(req)// Send responsew.Header().Set("Content-Type","application/json")json.NewEncoder(w).Encode(user)}
Query + Path Parameters
typeGetUserRequeststruct{UserIDint`path:"user_id"`Formatstring`query:"format" default:"json"`}funcGetUserHandler(whttp.ResponseWriter,r*http.Request){req,err:=binding.Bind[GetUserRequest](binding.FromPath(pathParams),// From routerbinding.FromQuery(r.URL.Query()),)iferr!=nil{http.Error(w,err.Error(),http.StatusBadRequest)return}user:=getUserByID(req.UserID)// Format response according to req.Format}
Form with CSRF Token
typeEditFormstruct{Titlestring`form:"title"`Contentstring`form:"content"`CSRFstring`form:"csrf_token"`}funcEditHandler(whttp.ResponseWriter,r*http.Request){iferr:=r.ParseForm();err!=nil{http.Error(w,"Invalid form",http.StatusBadRequest)return}form,err:=binding.Form[EditForm](r.Form)iferr!=nil{http.Error(w,err.Error(),http.StatusBadRequest)return}// Verify CSRF tokenif!verifyCRSF(form.CSRF){http.Error(w,"Invalid CSRF token",http.StatusForbidden)return}// Process form}
Type Conversion
The binding package automatically converts string values to appropriate types:
typeRequeststruct{// String to intPageint`query:"page"`// "123" -> 123// String to boolActivebool`query:"active"`// "true" -> true// String to floatPricefloat64`query:"price"`// "19.99" -> 19.99// String to time.DurationTimeouttime.Duration`query:"timeout"`// "30s" -> 30 * time.Second// String to time.TimeCreatedAttime.Time`query:"created"`// "2025-01-01" -> time.Time// String to sliceTags[]string`query:"tags"`// "go,rust,python" -> []string}
See Type Support for complete type conversion details.
Performance Tips
Reuse request bodies: Binding consumes the body, so read it once and reuse
Use defaults: Struct tags with defaults avoid unnecessary error checking
Cache reflection: Happens automatically, but avoid dynamic struct generation
Stream large payloads: Use JSONReader for bodies > 1MB
Query parameters are automatically converted to appropriate types:
typeQueryParamsstruct{// String to integerAgeint`query:"age"`// "30" -> 30// String to booleanActivebool`query:"active"`// "true" -> true// String to floatPricefloat64`query:"price"`// "19.99" -> 19.99// String to time.DurationTimeouttime.Duration`query:"timeout"`// "30s" -> 30 * time.Second// String to time.TimeSincetime.Time`query:"since"`// "2025-01-01" -> time.Time// String sliceIDs[]int`query:"ids"`// "1&2&3" -> [1, 2, 3]}
Nested Structures
Use dot notation for nested structs:
typeSearchParamsstruct{Querystring`query:"q"`Filterstruct{Categorystring`query:"category"`MinPriceint`query:"min_price"`MaxPriceint`query:"max_price"`}`query:"filter"`// Prefix tag on parent struct}// URL: /search?q=laptop&filter.category=electronics&filter.min_price=500params,err:=binding.Query[SearchParams](r.URL.Query())
Tag Aliases
Support multiple parameter names for the same field:
typeUserParamsstruct{UserIDint`query:"user_id,id,uid"`// Accepts any of these names}// All of these work:// /users?user_id=123// /users?id=123// /users?uid=123
Optional Fields with Pointers
Use pointers to distinguish between “not provided” and “zero value”:
typeOptionalParamsstruct{Limit*int`query:"limit"`// nil if not providedOffset*int`query:"offset"`// nil if not providedFilter*string`query:"filter"`// nil if not provided}// URL: /items?limit=10params,err:=binding.Query[OptionalParams](r.URL.Query())// Result: {Limit: &10, Offset: nil, Filter: nil}ifparams.Limit!=nil{// Use *params.Limit}
typeFlagsstruct{Debugbool`query:"debug"`}// All of these parse to true:// ?debug=true// ?debug=1// ?debug=yes// ?debug=on// All of these parse to false:// ?debug=false// ?debug=0// ?debug=no// ?debug=off// (parameter not present)
The binding package focuses on type conversion. For validation (required fields, value ranges, etc.), use `rivaas.dev/validation` after binding.
params,err:=binding.Query[SearchParams](r.URL.Query())iferr!=nil{returnerr}// Validate after bindingiferr:=validation.Validate(params);err!=nil{returnerr}
Performance Tips
Use defaults: Avoids checking for zero values
Avoid reflection: Struct info is cached automatically
// For repeated params: ?tags=go&tags=rustparams,err:=binding.Query[Params](values)// Default mode// For CSV: ?tags=go,rust,pythonparams,err:=binding.Query[Params](values,binding.WithSliceMode(binding.SliceCSV),)
Bind and parse JSON request bodies with automatic type conversion and validation
Learn how to bind JSON request bodies to Go structs with proper error handling, nested objects, and integration with validators.
Basic JSON Binding
Bind JSON request bodies directly to structs:
typeCreateUserRequeststruct{Usernamestring`json:"username"`Emailstring`json:"email"`Ageint`json:"age"`}req,err:=binding.JSON[CreateUserRequest](r.Body)iferr!=nil{http.Error(w,err.Error(),http.StatusBadRequest)return}// Use req.Username, req.Email, req.Age
JSON Tags
The binding package respects standard json tags:
typeProductstruct{IDint`json:"id"`Namestring`json:"name"`Pricefloat64`json:"price"`CreatedAttime.Time`json:"created_at"`// Omit if emptyDescriptionstring`json:"description,omitempty"`// Ignore this fieldInternalstring`json:"-"`}
// Limit to 1MBreq,err:=binding.JSON[CreateUserRequest](r.Body,binding.WithMaxBytes(1024*1024),)iferr!=nil{http.Error(w,"Request too large",http.StatusRequestEntityTooLarge)return}
Strict JSON Parsing
Reject unknown fields with WithDisallowUnknownFields:
typeStrictRequeststruct{Namestring`json:"name"`Emailstring`json:"email"`}// This will error if JSON contains fields not in the structreq,err:=binding.JSON[StrictRequest](r.Body,binding.WithDisallowUnknownFields(),)
Optional Fields
Use pointers to distinguish between “not provided” and “zero value”:
The binding package provides detailed error information:
req,err:=binding.JSON[CreateUserRequest](r.Body)iferr!=nil{varbindErr*binding.BindErroriferrors.As(err,&bindErr){// Field-specific errorlog.Printf("Failed to bind field %s: %v",bindErr.Field,bindErr.Err)http.Error(w,fmt.Sprintf("Invalid field: %s",bindErr.Field),http.StatusBadRequest)return}// Generic error (malformed JSON, etc.)http.Error(w,"Invalid JSON",http.StatusBadRequest)return}
Common Error Types
// Syntax errors// {"name": "test" <- missing closing brace// Error: "unexpected end of JSON input"// Type mismatch// {"age": "not a number"} <- age is int// Error: "cannot unmarshal string into field age of type int"// Unknown fields (with WithDisallowUnknownFields)// {"name": "test", "unknown": "value"}// Error: "json: unknown field \"unknown\""// Request too large (with WithMaxBytes)// Payload > limit// Error: "http: request body too large"
Integration with Validation
Combine with rivaas.dev/validation for comprehensive validation:
import("rivaas.dev/binding""rivaas.dev/validation")typeCreateUserRequeststruct{Usernamestring`json:"username" validate:"required,min=3,max=32"`Emailstring`json:"email" validate:"required,email"`Ageint`json:"age" validate:"required,min=18,max=120"`}funcCreateUserHandler(whttp.ResponseWriter,r*http.Request){// Step 1: Bind JSON structurereq,err:=binding.JSON[CreateUserRequest](r.Body)iferr!=nil{http.Error(w,"Invalid JSON",http.StatusBadRequest)return}// Step 2: Validate business rulesiferr:=validation.Validate(req);err!=nil{http.Error(w,err.Error(),http.StatusBadRequest)return}// Proceed with valid datacreateUser(req)}
Use binding.Auto() to handle both JSON and form data:
// Works with both:// Content-Type: application/json// Content-Type: application/x-www-form-urlencodedreq,err:=binding.Auto[CreateUserRequest](r)iferr!=nil{http.Error(w,err.Error(),http.StatusBadRequest)return}
Performance Considerations
Use io.LimitReader: Always set max bytes for untrusted input
Avoid reflection: Type info is cached automatically
Reuse structs: Define request types once
Pointer fields: Only when you need to distinguish nil from zero
// CreateUserRequest represents a new user creation request.//// Example JSON://// {// "username": "johndoe",// "email": "john@example.com",// "age": 30// }typeCreateUserRequeststruct{Usernamestring`json:"username"`Emailstring`json:"email"`Ageint`json:"age"`}
Handle file uploads with form data using multipart form binding
This guide shows you how to handle file uploads and form data together using multipart form binding. You’ll learn how to bind files, work with the File type, and handle complex scenarios like JSON in form fields.
What Are Multipart Forms?
Multipart forms let you send files and regular form data in the same HTTP request. This is useful when you need to upload files along with metadata, like uploading a profile picture with user information.
Common use cases:
Uploading images with titles and descriptions
Importing CSV files with configuration options
Submitting documents with form metadata
Basic File Upload
Let’s start with a simple example. You want to upload a file with some metadata:
import"rivaas.dev/binding"typeUploadRequeststruct{File*binding.File`form:"file"`Titlestring`form:"title"`Descriptionstring`form:"description"`}// Parse the multipart formiferr:=r.ParseMultipartForm(32<<20);err!=nil{// 32MB max// Handle error}// Bind the form datareq,err:=binding.Multipart[UploadRequest](r.MultipartForm)iferr!=nil{// Handle binding error}// Now you have:// - req.File - the uploaded file// - req.Title - the title from form// - req.Description - the description from form
Working with Files
The binding.File type gives you easy access to uploaded files. Here’s what you can do:
File Properties
file:=req.Filefmt.Println(file.Name)// "photo.jpg" - sanitized filenamefmt.Println(file.Size)// 1024 - file size in bytesfmt.Println(file.ContentType)// "image/jpeg" - MIME type
Save to Disk
The easiest way to handle uploads is to save them directly:
// Save to a specific patherr:=file.Save("/uploads/photo.jpg")iferr!=nil{// Handle save error}// Save with original filenameerr:=file.Save("/uploads/"+file.Name)
The Save() method automatically creates parent directories if they don’t exist.
Read File Contents
You can read the file into memory:
// Get all bytesdata,err:=file.Bytes()iferr!=nil{// Handle error}// Process the dataprocessImage(data)
Stream File Contents
For larger files, you can stream the content:
// Open the file for readingreader,err:=file.Open()iferr!=nil{// Handle error}deferreader.Close()// Stream to another locationio.Copy(destination,reader)
Get File Extension
ext:=file.Ext()// ".jpg" for "photo.jpg"// Useful for validationifext!=".jpg"&&ext!=".png"{returnerrors.New("only JPG and PNG files allowed")}
Multiple File Uploads
You can handle multiple files using a slice:
typeGalleryUploadstruct{Photos[]*binding.File`form:"photos"`Titlestring`form:"title"`}req,err:=binding.Multipart[GalleryUpload](r.MultipartForm)iferr!=nil{// Handle error}// Process each filefori,photo:=rangereq.Photos{filename:=fmt.Sprintf("/uploads/photo_%d%s",i,photo.Ext())iferr:=photo.Save(filename);err!=nil{// Handle error}}
JSON in Form Fields
Here’s a powerful feature: Rivaas automatically parses JSON from form fields into nested structs.
typeSettingsstruct{Themestring`json:"theme"`Notificationsbool`json:"notifications"`}typeProfileUpdatestruct{Avatar*binding.File`form:"avatar"`Usernamestring`form:"username"`SettingsSettings`form:"settings"`// JSON automatically parsed!}// In your HTML form:// <input type="file" name="avatar">// <input type="text" name="username">// <input type="hidden" name="settings" value='{"theme":"dark","notifications":true}'>req,err:=binding.Multipart[ProfileUpdate](r.MultipartForm)iferr!=nil{// Handle error}// req.Settings is now populated from the JSON stringfmt.Println(req.Settings.Theme)// "dark"fmt.Println(req.Settings.Notifications)// true
packagemainimport("fmt""net/http""rivaas.dev/binding""rivaas.dev/validation")typeUploadRequeststruct{File*binding.File`form:"file" validate:"required"`Titlestring`form:"title" validate:"required,min=3,max=100"`Descriptionstring`form:"description"`Tags[]string`form:"tags"`IsPublicbool`form:"is_public"`}funcUploadHandler(whttp.ResponseWriter,r*http.Request){// Step 1: Parse multipart form (32MB limit)iferr:=r.ParseMultipartForm(32<<20);err!=nil{http.Error(w,"Failed to parse form",http.StatusBadRequest)return}// Step 2: Bind form datareq,err:=binding.Multipart[UploadRequest](r.MultipartForm)iferr!=nil{http.Error(w,err.Error(),http.StatusBadRequest)return}// Step 3: Validateiferr:=validation.Validate(req);err!=nil{http.Error(w,err.Error(),http.StatusUnprocessableEntity)return}// Step 4: Validate file typeallowedTypes:=[]string{".jpg",".jpeg",".png",".gif"}ext:=req.File.Ext()if!contains(allowedTypes,ext){http.Error(w,"Invalid file type",http.StatusBadRequest)return}// Step 5: Validate file sizeifreq.File.Size>10*1024*1024{// 10MBhttp.Error(w,"File too large",http.StatusBadRequest)return}// Step 6: Generate safe filenamefilename:=fmt.Sprintf("%s_%d%s",sanitizeFilename(req.Title),time.Now().Unix(),ext,)// Step 7: Save fileuploadPath:="/var/uploads/"+filenameiferr:=req.File.Save(uploadPath);err!=nil{http.Error(w,"Failed to save file",http.StatusInternalServerError)return}// Step 8: Save metadata to databasefile:=&FileRecord{Filename:filename,Title:req.Title,Description:req.Description,Tags:req.Tags,IsPublic:req.IsPublic,Size:req.File.Size,ContentType:req.File.ContentType,}iferr:=db.Create(file);err!=nil{http.Error(w,"Failed to save metadata",http.StatusInternalServerError)return}// Step 9: Return successw.Header().Set("Content-Type","application/json")json.NewEncoder(w).Encode(map[string]interface{}{"id":file.ID,"filename":filename,"url":"/uploads/"+filename,})}funccontains(slice[]string,itemstring)bool{for_,s:=rangeslice{ifs==item{returntrue}}returnfalse}
File Security
Always validate uploaded files to protect your application:
1. Validate File Type
Don’t trust the Content-Type header alone. Check the file extension:
allowedExtensions:=[]string{".jpg",".jpeg",".png",".gif"}ext:=strings.ToLower(file.Ext())if!slices.Contains(allowedExtensions,ext){returnerrors.New("file type not allowed")}
For better security, check the file’s magic bytes:
data,err:=file.Bytes()iferr!=nil{returnerr}// Check magic bytes for JPEGiflen(data)<2||data[0]!=0xFF||data[1]!=0xD8{returnerrors.New("not a valid JPEG file")}
2. Validate File Size
maxSize:=int64(10*1024*1024)// 10MBiffile.Size>maxSize{returnerrors.New("file too large")}
3. Sanitize Filenames
The File type automatically sanitizes filenames by:
Using only the base filename (removes paths)
Replacing dangerous characters
But you should also generate unique names:
import("crypto/rand""encoding/hex""path/filepath")funcgenerateSafeFilename(originalNamestring)string{ext:=filepath.Ext(originalName)// Generate random nameb:=make([]byte,16)rand.Read(b)name:=hex.EncodeToString(b)returnname+ext}// Use itsafeName:=generateSafeFilename(file.Name)file.Save("/uploads/"+safeName)
4. Store Outside Web Root
Never save uploads directly in your web server’s document root:
// Bad - files accessible directly via URLfile.Save("/var/www/html/uploads/file.jpg")// Good - files outside web rootfile.Save("/var/app/uploads/file.jpg")// Serve files through a handler that checks permissions
5. Scan for Malware
For production applications, scan uploaded files:
// Example with ClamAVifinfected,err:=scanFile(uploadPath);err!=nil{returnerr}elseifinfected{os.Remove(uploadPath)returnerrors.New("file contains malware")}
Integration with Rivaas App
When using rivaas.dev/app, the Context.Bind() method handles multipart forms automatically:
import"rivaas.dev/app"typeUploadRequeststruct{File*binding.File`form:"file"`Titlestring`form:"title"`}a.POST("/upload",func(c*app.Context){varreqUploadRequestiferr:=c.Bind(&req);err!=nil{c.Fail(err)return}// req.File is ready to useiferr:=req.File.Save("/uploads/"+req.File.Name);err!=nil{c.InternalError(err)return}c.JSON(http.StatusOK,map[string]string{"message":"File uploaded successfully",})})
The app context automatically:
Parses the multipart form
Binds files and form fields
Handles errors appropriately
Common Patterns
Image Processing Pipeline
typeImageUploadstruct{Image*binding.File`form:"image"`Widthint`form:"width" default:"800"`Heightint`form:"height" default:"600"`Qualityint`form:"quality" default:"85"`}funcProcessImageHandler(whttp.ResponseWriter,r*http.Request){r.ParseMultipartForm(32<<20)req,err:=binding.Multipart[ImageUpload](r.MultipartForm)iferr!=nil{http.Error(w,err.Error(),http.StatusBadRequest)return}// Read image datadata,err:=req.Image.Bytes()iferr!=nil{http.Error(w,"Failed to read image",http.StatusInternalServerError)return}// Process imageprocessed,err:=resizeImage(data,req.Width,req.Height,req.Quality)iferr!=nil{http.Error(w,"Failed to process image",http.StatusInternalServerError)return}// Save processed imageoutputPath:="/uploads/processed_"+req.Image.Nameiferr:=os.WriteFile(outputPath,processed,0644);err!=nil{http.Error(w,"Failed to save image",http.StatusInternalServerError)return}w.Header().Set("Content-Type","application/json")json.NewEncoder(w).Encode(map[string]string{"url":"/uploads/"+filepath.Base(outputPath),})}
CSV Import with Options
typeCSVImportRequeststruct{File*binding.File`form:"file"`Optionsstruct{SkipHeaderbool`json:"skip_header"`Delimiterstring`json:"delimiter"`Encodingstring`json:"encoding"`}`form:"options"`// JSON from form field}funcImportCSVHandler(whttp.ResponseWriter,r*http.Request){r.ParseMultipartForm(32<<20)req,err:=binding.Multipart[CSVImportRequest](r.MultipartForm)iferr!=nil{http.Error(w,err.Error(),http.StatusBadRequest)return}// Validate CSV fileifreq.File.Ext()!=".csv"{http.Error(w,"Only CSV files allowed",http.StatusBadRequest)return}// Open file for streamingreader,err:=req.File.Open()iferr!=nil{http.Error(w,"Failed to open file",http.StatusInternalServerError)return}deferreader.Close()// Parse CSV with optionscsvReader:=csv.NewReader(reader)csvReader.Comma=rune(req.Options.Delimiter[0])ifreq.Options.SkipHeader{csvReader.Read()// Skip first row}// Process recordsrecords,err:=csvReader.ReadAll()iferr!=nil{http.Error(w,"Failed to parse CSV",http.StatusBadRequest)return}// Import into databasefor_,record:=rangerecords{// Process each record}w.Header().Set("Content-Type","application/json")json.NewEncoder(w).Encode(map[string]interface{}{"imported":len(records),})}
Performance Tips
Set appropriate size limits - Don’t let users upload huge files:
r.ParseMultipartForm(10<<20)// 10MB limit
Stream large files - Don’t load everything into memory:
Process asynchronously - For heavy processing, use background jobs:
// Save file firstfile.Save(tempPath)// Queue processing jobqueue.Enqueue(ProcessFileJob{Path:tempPath})// Return immediatelyc.JSON(http.StatusAccepted,"Processing started")
Clean up temporary files - Remove uploaded files after processing:
deferos.Remove(tempPath)
Error Handling
The binding package provides specific errors for file operations:
req,err:=binding.Multipart[UploadRequest](r.MultipartForm)iferr!=nil{// Check for specific errorsiferrors.Is(err,binding.ErrFileNotFound){http.Error(w,"No file uploaded",http.StatusBadRequest)return}iferrors.Is(err,binding.ErrNoFilesFound){http.Error(w,"Multiple files required",http.StatusBadRequest)return}// Generic binding errorvarbindErr*binding.BindErroriferrors.As(err,&bindErr){http.Error(w,fmt.Sprintf("Field %s: %v",bindErr.Field,bindErr.Err),http.StatusBadRequest)return}// Unknown errorhttp.Error(w,"Failed to bind form data",http.StatusBadRequest)return}
Next Steps
Learn about Type Support for custom type conversion
Combine multiple data sources with precedence rules for flexible request handling
Learn how to bind data from multiple sources. This includes query parameters, JSON body, and headers. Configure precedence rules for flexible request handling.
Concept Overview
Multi-source binding allows you to populate a single struct from multiple request sources. It uses clear precedence rules:
graph LR
A[HTTP Request] --> B[Query Params]
A --> C[JSON Body]
A --> D[Headers]
A --> E[Path Params]
B --> F[Multi-Source Binder]:::info
C --> F
D --> F
E --> F
F --> G[Merged Struct]:::success
classDef default fill:#F8FAF9,stroke:#1E6F5C,color:#1F2A27
classDef info fill:#D1ECF1,stroke:#17A2B8,color:#1F2A27
classDef success fill:#D4EDDA,stroke:#28A745,color:#1F2A27
Basic Multi-Source Binding
Use binding.Auto() to bind from query, body, and headers automatically:
typeUserRequeststruct{// From query or JSON bodyUsernamestring`json:"username" query:"username"`Emailstring`json:"email" query:"email"`// From headerAPIKeystring`header:"X-API-Key"`}// Works with:// - POST /users?username=john with JSON body// - GET /users?username=john&email=john@example.com// - Headers: X-API-Key: secret123req,err:=binding.Auto[UserRequest](r)
Custom Multi-Source
Build custom multi-source binding with explicit precedence:
typeSearchRequeststruct{Querystring`query:"q" json:"query"`Pageint`query:"page" default:"1"`PageSizeint`query:"page_size" default:"20"`Filters[]string`json:"filters"`SortBystring`header:"X-Sort-By" default:"created_at"`}// Bind from multiple sourcesreq,err:=binding.Multi[SearchRequest](binding.WithQuery(r.URL.Query()),binding.WithJSON(r.Body),binding.WithHeaders(r.Header),)
Precedence Rules
By default, sources are applied in order (last wins):
typeCompleteRequeststruct{// Pagination from queryPageint`query:"page" default:"1"`PageSizeint`query:"page_size" default:"20"`// Search criteria from JSON bodyFiltersstruct{Categorystring`json:"category"`Tags[]string`json:"tags"`MinPricefloat64`json:"min_price"`MaxPricefloat64`json:"max_price"`}`json:"filters"`// Auth from headersAPIKeystring`header:"X-API-Key"`RequestIDstring`header:"X-Request-ID"`}// POST /search?page=2&page_size=50// Headers: X-API-Key: secret, X-Request-ID: req-123// Body: {"filters": {"category": "electronics", "tags": ["sale"]}}req,err:=binding.Multi[CompleteRequest](binding.WithQuery(r.URL.Query()),binding.WithJSON(r.Body),binding.WithHeaders(r.Header),)
Path Parameters
Combine with router path parameters:
typeUserUpdateRequeststruct{// From path: /users/:idUserIDint`path:"id"`// From JSON bodyUsernamestring`json:"username"`Emailstring`json:"email"`// From headerAPIKeystring`header:"X-API-Key"`}// With gorilla/mux or chireq,err:=binding.Multi[UserUpdateRequest](binding.WithPath(mux.Vars(r)),// or chi.URLParams(r)binding.WithJSON(r.Body),binding.WithHeaders(r.Header),)
Form Data and JSON
Handle both form and JSON submissions:
typeLoginRequeststruct{Usernamestring`json:"username" form:"username"`Passwordstring`json:"password" form:"password"`}// Works with both:// Content-Type: application/json// Content-Type: application/x-www-form-urlencodedreq,err:=binding.Auto[LoginRequest](r)
funcBindRequest[Tany](r*http.Request)(T,error){sources:=[]binding.Source{binding.WithQuery(r.URL.Query()),}// Add JSON source only for POST/PUT/PATCHifr.Method!="GET"&&r.Method!="DELETE"{sources=append(sources,binding.WithJSON(r.Body))}// Add auth header if presentifr.Header.Get("Authorization")!=""{sources=append(sources,binding.WithHeaders(r.Header))}returnbinding.Multi[T](sources...)}
Complex Example
Real-world multi-source scenario:
typeProductSearchRequeststruct{// Query parameters (user input)Querystring`query:"q"`Pageint`query:"page" default:"1"`PageSizeint`query:"page_size" default:"20"`SortBystring`query:"sort_by" default:"relevance"`// Advanced filters (JSON body)Filtersstruct{Categories[]string`json:"categories"`Brands[]string`json:"brands"`MinPricefloat64`json:"min_price"`MaxPricefloat64`json:"max_price"`InStock*bool`json:"in_stock"`Rating*int`json:"min_rating"`}`json:"filters"`// Request metadata (headers)Localestring`header:"Accept-Language" default:"en-US"`Currencystring`header:"X-Currency" default:"USD"`UserAgentstring`header:"User-Agent"`RequestIDstring`header:"X-Request-ID"`// Internal fields (not from request)UserIDint`binding:"-"`// Set after authRequestedAttime.Time`binding:"-"`}funcSearchProducts(whttp.ResponseWriter,r*http.Request){// Bind from multiple sourcesreq,err:=binding.Multi[ProductSearchRequest](binding.WithQuery(r.URL.Query()),binding.WithJSON(r.Body,binding.WithMaxBytes(1024*1024)),binding.WithHeaders(r.Header),)iferr!=nil{http.Error(w,err.Error(),http.StatusBadRequest)return}// Set internal fieldsreq.UserID=getUserID(r)req.RequestedAt=time.Now()// Execute searchresults:=executeSearch(req)json.NewEncoder(w).Encode(results)}
Common pattern for API versioning and backward compatibility:
typeVersionedRequeststruct{// Prefer header, fallback to queryAPIVersionstring`header:"X-API-Version" query:"api_version" default:"v1"`// Prefer body, fallback to queryUserIDint`json:"user_id" query:"user_id"`}// With first-wins strategy:req,err:=binding.Multi[VersionedRequest](binding.WithMergeStrategy(binding.MergeFirstWins),binding.WithHeaders(r.Header),// Highest prioritybinding.WithQuery(r.URL.Query()),// Fallbackbinding.WithJSON(r.Body),// Lowest priority)
Middleware Pattern
Create reusable binding middleware:
funcBindMiddleware[Tany](nexthttp.HandlerFunc)http.HandlerFunc{returnfunc(whttp.ResponseWriter,r*http.Request){req,err:=binding.Multi[T](binding.WithQuery(r.URL.Query()),binding.WithJSON(r.Body),binding.WithHeaders(r.Header),)iferr!=nil{http.Error(w,err.Error(),http.StatusBadRequest)return}// Store in contextctx:=context.WithValue(r.Context(),"request",req)next(w,r.WithContext(ctx))}}// Usagehttp.HandleFunc("/users",BindMiddleware[CreateUserRequest](CreateUserHandler))funcCreateUserHandler(whttp.ResponseWriter,r*http.Request){req:=r.Context().Value("request").(CreateUserRequest)// Use req}
Integration with Rivaas Router
Seamless integration with rivaas.dev/router:
import("rivaas.dev/binding""rivaas.dev/router")typeCreateUserRequeststruct{Usernamestring`json:"username"`Emailstring`json:"email"`APIKeystring`header:"X-API-Key"`}r:=router.New()r.POST("/users",func(c*router.Context)error{req,err:=binding.Multi[CreateUserRequest](binding.WithJSON(c.Request().Body),binding.WithHeaders(c.Request().Header),)iferr!=nil{returnc.JSON(http.StatusBadRequest,err)}// Use reqreturnc.JSON(http.StatusCreated,createUser(req))})
Performance Considerations
Source order: Most specific first (headers before query)
Lazy evaluation: Sources are processed in order
Caching: Struct info is cached across requests
Zero allocation: Primitive types use no extra memory
req,err:=binding.Multi[Request](...)iferr!=nil{returnerr}// Validate business rulesiferr:=validation.Validate(req);err!=nil{returnerr}
Troubleshooting
Values Not Merging
Check tag names match across sources:
// Wrong - different tag namestypeRequeststruct{IDint`query:"id" json:"user_id"`// Won't merge}// Correct - same semantic fieldtypeRequeststruct{IDint`query:"id" json:"id"`}
Unexpected Overwrites
Use first-wins strategy or check source order:
// Last wins (default)binding.Multi[T](binding.WithQuery(...),// Applied firstbinding.WithJSON(...),// May overwrite query)// First wins (explicit)binding.Multi[T](binding.WithMergeStrategy(binding.MergeFirstWins),binding.WithHeaders(...),// Highest prioritybinding.WithQuery(...),)
typeProductstruct{// Basic fieldIDint`json:"id"`// Custom nameNamestring`json:"product_name"`// Omit if emptyDescriptionstring`json:"description,omitempty"`// Ignore fieldInternalstring`json:"-"`// Use field name as-is (case-sensitive)SKUstring`json:"SKU"`}
JSON Tag Options
typeExamplestruct{// Omit if empty/zero valueOptionalstring`json:"optional,omitempty"`// Omit if empty AND keep formatFieldstring`json:"field,omitempty,string"`// Treat as string (for numbers)IDint64`json:"id,string"`}
Query Tags
URL query parameter binding:
typeQueryParamsstruct{// Basic parameterSearchstring`query:"q"`// With defaultPageint`query:"page" default:"1"`// Array/sliceTags[]string`query:"tags"`// Optional with pointerFilter*string`query:"filter"`}
Query Tag Aliases
Support multiple parameter names:
typeRequeststruct{// Accepts any of: user_id, id, uidUserIDint`query:"user_id,id,uid"`}
Header Tags
HTTP header binding:
typeHeaderParamsstruct{// Standard headerContentTypestring`header:"Content-Type"`// Custom headerAPIKeystring`header:"X-API-Key"`// Case-insensitiveUserAgentstring`header:"user-agent"`// Matches User-Agent// AuthorizationAuthTokenstring`header:"Authorization"`}
Header Naming Conventions
Headers are case-insensitive:
typeExamplestruct{// All match "X-API-Key", "x-api-key", "X-Api-Key"APIKeystring`header:"X-API-Key"`}
typeValidationExamplesstruct{// RequiredRequiredstring`validate:"required"`// Length constraintsUsernamestring`validate:"min=3,max=32"`// Format validationEmailstring`validate:"email"`URLstring`validate:"url"`UUIDstring`validate:"uuid"`// Numeric constraintsAgeint`validate:"min=18,max=120"`Pricefloat64`validate:"gt=0"`// Pattern matchingPhonestring`validate:"regexp=^[0-9]{10}$"`// ConditionalOptionalstring`validate:"omitempty,email"`// Validate only if present}
Tag Combinations
Complete Example
typeCompleteRequeststruct{// Multi-source with default and validationUserIDint`query:"user_id" json:"user_id" header:"X-User-ID" default:"0" validate:"min=1"`// Optional with validationEmailstring`json:"email" validate:"omitempty,email"`// Required with custom nameAPIKeystring`header:"X-API-Key" binding:"required"`// Array with defaultTags[]string`query:"tags" default:"general"`// Nested structFiltersstruct{Categorystring`json:"category" validate:"required"`MinPriceint`json:"min_price" validate:"min=0"`}`json:"filters"`}
Pointers distinguish “not provided” from “zero value”:
typeUpdateRequeststruct{// nil = not provided, &0 = set to zeroAge*int`json:"age"`// nil = not provided, &"" = set to empty stringBio*string`json:"bio"`// nil = not provided, &false = set to falseActive*bool`json:"active"`}
typeExamplestruct{// Unexported - automatically ignoredinternalstring// Explicitly ignored with json tagDebugstring`json:"-"`// Explicitly ignored with binding tagTemporarystring`binding:"-"`// Exported but not boundComputedint// No tags}
typeFlexiblestruct{// Any JSON valueDatainterface{}`json:"data"`// Strongly typed when possibleConfigmap[string]interface{}`json:"config"`}
Tag Best Practices
1. Be Consistent
// Good - consistent namingtypeUserstruct{UserIDint`json:"user_id"`FirstNamestring`json:"first_name"`LastNamestring`json:"last_name"`}// Bad - inconsistent namingtypeUserstruct{UserIDint`json:"userId"`FirstNamestring`json:"first_name"`LastNamestring`json:"LastName"`}
// Separate binding from validationtypeRequeststruct{Emailstring`json:"email" validate:"required,email"`}// Bind firstreq,err:=binding.JSON[Request](r.Body)// Then validateerr=validation.Validate(req)
4. Document Complex Tags
// UserRequest represents a user creation request.// The user_id can come from query, JSON, or X-User-ID header.// If not provided, defaults to 0 (anonymous user).typeUserRequeststruct{UserIDint`query:"user_id" json:"user_id" header:"X-User-ID" default:"0"`}
Tag Parsing Rules
Tag precedence: Last source wins (unless using first-wins strategy)
typeAuditableRequeststruct{RequestIDstring`header:"X-Request-ID"`UserAgentstring`header:"User-Agent"`ClientIPstring`header:"X-Forwarded-For"`Timestamptime.Time`binding:"-"`// Set by server}
Troubleshooting
Field Not Binding
Check that:
Field is exported (starts with uppercase)
Tag name matches source key
Tag type matches source (e.g., query for query params)
typeOptionalstruct{// Empty string is validNamestring`json:"name"`// "" is kept// Use pointer for "not provided"Bio*string`json:"bio"`// nil if not in JSON}
The binding package provides ready-made converter factories that make it easier to handle common custom type patterns.
Time Parsing with Custom Formats
import"rivaas.dev/binding"binder:=binding.MustNew(binding.WithConverter(binding.TimeConverter("01/02/2006",// US format"2006-01-02",// ISO format"02-Jan-2006",// Short month)),)typeEventstruct{Datetime.Time`query:"date"`}// Works with any of these formats:// ?date=01/28/2026// ?date=2026-01-28// ?date=28-Jan-2026event,err:=binder.Query[Event](values)
Duration with Friendly Aliases
binder:=binding.MustNew(binding.WithConverter(binding.DurationConverter(map[string]time.Duration{"quick":5*time.Minute,"normal":30*time.Minute,"long":2*time.Hour,})),)typeConfigstruct{Timeouttime.Duration`query:"timeout"`}// All of these work:// ?timeout=quick → 5 minutes// ?timeout=30m → 30 minutes (standard Go format)// ?timeout=2h30m → 2 hours 30 minutesconfig,err:=binder.Query[Config](values)
String Enums with Validation
typePrioritystringconst(PriorityLowPriority="low"PriorityMediumPriority="medium"PriorityHighPriority="high")binder:=binding.MustNew(binding.WithConverter(binding.EnumConverter(PriorityLow,PriorityMedium,PriorityHigh,)),)typeTaskstruct{PriorityPriority`query:"priority"`}// ?priority=high ✓ Works// ?priority=HIGH ✓ Works (case-insensitive)// ?priority=urgent ✗ Error: must be one of: low, medium, hightask,err:=binder.Query[Task](values)
// Protects against overflowtypeSafeIntstruct{Valueint8`json:"value"`}// JSON: {"value": 200}// Error: value overflows int8
Type Mismatches
typeTypedstruct{Ageint`json:"age"`}// JSON: {"age": "not a number"}// Error: cannot unmarshal string into int
Performance Characteristics
Type
Allocation
Speed
Notes
Primitives
Zero
Fast
Direct assignment
Strings
One
Fast
Immutable
Slices
One
Fast
Pre-allocated when possible
Maps
One
Medium
Hash allocation
Structs
Zero
Fast
Stack allocation
Pointers
One
Fast
Heap allocation
Interfaces
One
Medium
Type assertion overhead
Unsupported Types
The following types are not supported:
typeUnsupportedstruct{// ChannelChchanint// Not supported// FunctionFnfunc()// Not supported// Complex numbersCcomplex128// Not supported// Unsafe pointerPtrunsafe.Pointer// Not supported}
Best Practices
1. Use Appropriate Types
// Good - specific typestypeGoodstruct{Ageint`json:"age"`Pricefloat64`json:"price"`Createdtime.Time`json:"created"`}// Bad - generic typestypeBadstruct{Ageinterface{}`json:"age"`Priceinterface{}`json:"price"`Createdinterface{}`json:"created"`}
2. Use Pointers for Optional Fields
typeUpdatestruct{Name*string`json:"name"`// Can be nullAge*int`json:"age"`// Can be null}
3. Use Slices for Variable-Length Data
// Good - slicetypeGoodstruct{Tags[]string`json:"tags"`}// Bad - fixed arraytypeBadstruct{Tags[10]string`json:"tags"`// Rigid}
4. Document Custom Types
// UserID represents a unique user identifier.// It must be a positive integer.typeUserIDint// Validate ensures the UserID is valid.func(idUserID)Validate()error{ifid<=0{returnerrors.New("invalid user ID")}returnnil}
Troubleshooting
Type Conversion Errors
// Error: cannot unmarshal string into int// Solution: Check source data matches target type// Error: value overflows int8// Solution: Use larger type (int16, int32, int64)// Error: parsing time "invalid" as "2006-01-02"// Solution: Use correct time format
Unexpected Nil Values
// Problem: field is nil when expected// Solution: Check if source provided the value// Problem: can't distinguish nil from zero// Solution: Use pointer type
Master error handling patterns for robust request validation and debugging
Comprehensive guide to error handling in the binding package. This includes error types, validation patterns, and debugging strategies.
Error Types
The binding package provides structured error types for detailed error handling:
// BindError represents a field-specific binding errortypeBindErrorstruct{Fieldstring// Field name that failed.Sourcestring// Source like "query", "json", "header".Errerror// Underlying error.}// ValidationError represents a validation failuretypeValidationErrorstruct{Fieldstring// Field name that failed validation.Valueinterface{}// The invalid value.Rulestring// Validation rule that failed.Messagestring// Human-readable message.}
Enhanced Error Messages
The binding package now provides helpful hints when type conversion fails. These hints suggest what might have gone wrong and how to fix it.
Example error messages with hints:
typeRequeststruct{Ageint`query:"age"`Pricefloat64`query:"price"`Whentime.Time`query:"when"`Activebool`query:"active"`}// URL: ?age=10.5// Error: cannot bind field "Age" from query: strconv.ParseInt: parsing "10.5": invalid syntax// Hint: value looks like a floating-point number; use float32 or float64 instead// URL: ?price=twenty// Error: cannot bind field "Price" from query: strconv.ParseFloat: parsing "twenty": invalid syntax// Hint: value "twenty" doesn't look like a number// URL: ?when=yesterday// Error: cannot bind field "When" from query: unable to parse time "yesterday" (tried 8 layouts)// Hint: common formats: "2006-01-02T15:04:05Z07:00", "2006-01-02", "01/02/2006"// URL: ?active=maybe// Error: cannot bind field "Active" from query: strconv.ParseBool: parsing "maybe": invalid syntax// Hint: use one of: true, false, 1, 0, t, f, yes, no, y, n
These contextual hints make it easier to understand what went wrong and fix the issue quickly.
user,err:=binding.JSON[CreateUserRequest](r.Body)iferr!=nil{varbindErr*binding.BindErroriferrors.As(err,&bindErr){// Field-specific errorlog.Printf("Failed to bind field %s from %s: %v",bindErr.Field,bindErr.Source,bindErr.Err)}http.Error(w,"Invalid request",http.StatusBadRequest)return}
funcbindRequest[Tany](r*http.Request)(T,error){req,err:=binding.JSON[T](r.Body)iferr!=nil{returnreq,fmt.Errorf("binding request from %s: %w",r.RemoteAddr,err)}returnreq,nil}
Error Logging
Structured Logging
import"log/slog"funchandleRequest(whttp.ResponseWriter,r*http.Request){req,err:=binding.JSON[Request](r.Body)iferr!=nil{varbindErr*binding.BindErroriferrors.As(err,&bindErr){slog.Error("Binding error","field",bindErr.Field,"source",bindErr.Source,"error",bindErr.Err,"path",r.URL.Path,"method",r.Method,"remote",r.RemoteAddr,)}else{slog.Error("Request binding failed","error",err,"path",r.URL.Path,"method",r.Method,)}http.Error(w,"Invalid request",http.StatusBadRequest)return}// Process request}
Error Metrics
import"rivaas.dev/metrics"var(bindErrorsCounter=metrics.NewCounter("binding_errors_total","Total number of binding errors","field","source","error_type",))funchandleBindError(errerror){varbindErr*binding.BindErroriferrors.As(err,&bindErr){bindErrorsCounter.Inc(bindErr.Field,bindErr.Source,fmt.Sprintf("%T",bindErr.Err),)}}
funcloadConfig(r*http.Request)Config{cfg,err:=binding.Query[Config](r.URL.Query())iferr!=nil{// Log error but use defaultsslog.Warn("Failed to bind config, using defaults","error",err)returnDefaultConfig()}returncfg}
funcTestBindingError(t*testing.T){typeRequeststruct{Ageint`json:"age"`}// Test invalid typebody:=strings.NewReader(`{"age": "not a number"}`)_,err:=binding.JSON[Request](body)iferr==nil{t.Fatal("expected error, got nil")}varbindErr*binding.BindErrorif!errors.As(err,&bindErr){t.Fatalf("expected BindError, got %T",err)}ifbindErr.Field!="Age"{t.Errorf("expected field Age, got %s",bindErr.Field)}}
Integration Tests
funcTestErrorResponse(t*testing.T){payload:=`{"age": "invalid"}`req:=httptest.NewRequest("POST","/users",strings.NewReader(payload))req.Header.Set("Content-Type","application/json")rec:=httptest.NewRecorder()CreateUserHandler(rec,req)ifrec.Code!=http.StatusBadRequest{t.Errorf("expected status 400, got %d",rec.Code)}varresponseErrorResponseiferr:=json.NewDecoder(rec.Body).Decode(&response);err!=nil{t.Fatal(err)}ifresponse.Error==""{t.Error("expected error message")}}
Best Practices
1. Always Check Errors
// Goodreq,err:=binding.JSON[Request](r.Body)iferr!=nil{handleError(w,err)return}// Bad - ignoring errorsreq,_:=binding.JSON[Request](r.Body)
2. Use Specific Error Types
// Good - check specific error typesvarbindErr*binding.BindErroriferrors.As(err,&bindErr){// Handle binding error specifically}// Bad - generic error handlingiferr!=nil{http.Error(w,"error",500)}
3. Log for Debugging
// Good - structured loggingslog.Error("Binding failed","error",err,"path",r.URL.Path,"user",getUserID(r),)// Bad - no loggingiferr!=nil{http.Error(w,"error",400)return}
4. Return Helpful Messages
// Good - specific error messagetypeErrorResponsestruct{Errorstring`json:"error"`Fieldstring`json:"field,omitempty"`Detailstring`json:"detail,omitempty"`}// Bad - generic messagehttp.Error(w,"bad request",400)
5. Separate Binding from Validation
// Good - clear separationreq,err:=binding.JSON[Request](r.Body)iferr!=nil{returnhandleBindError(err)}iferr:=validation.Validate(req);err!=nil{returnhandleValidationError(err)}// Bad - mixing concernsiferr:=bindAndValidate(r.Body);err!=nil{// Can't tell binding from validation errors}
Error Middleware
Create reusable error handling middleware:
typeErrorHandlerfunc(http.ResponseWriter,*http.Request)errorfunc(fnErrorHandler)ServeHTTP(whttp.ResponseWriter,r*http.Request){iferr:=fn(w,r);err!=nil{handleError(w,r,err)}}funchandleError(whttp.ResponseWriter,r*http.Request,errerror){// Log errorslog.Error("Request error","error",err,"path",r.URL.Path,"method",r.Method,)// Determine status codestatus:=http.StatusInternalServerErrorvarbindErr*binding.BindErroriferrors.As(err,&bindErr){status=http.StatusBadRequest}// Send responsew.Header().Set("Content-Type","application/json")w.WriteHeader(status)json.NewEncoder(w).Encode(map[string]string{"error":err.Error(),})}// Usagehttp.Handle("/users",ErrorHandler(func(whttp.ResponseWriter,r*http.Request)error{req,err:=binding.JSON[CreateUserRequest](r.Body)iferr!=nil{returnerr}// Process requestreturnnil}))
Common Error Scenarios
Scenario 1: Type Mismatch
// Request: {"age": "twenty"}// Expected: {"age": 20}// Error: cannot unmarshal string into int
Solution: Validate input format, provide clear error message
// Save body for debuggingbody,_:=io.ReadAll(r.Body)r.Body=io.NopCloser(bytes.NewReader(body))slog.Debug("Raw request body","body",string(body))req,err:=binding.JSON[Request](r.Body)
The binding package provides ready-to-use converter factories for common patterns. These make it easier to handle dates, durations, enums, and custom boolean values.
TimeConverter
Parse time strings with custom date formats.
binder:=binding.MustNew(// US date format: 01/15/2026binding.WithConverter(binding.TimeConverter("01/02/2006")),)typeEventstruct{Datetime.Time`query:"date"`}// URL: ?date=01/15/2026event,err:=binder.Query[Event](values)
You can also provide multiple formats as fallbacks:
binder:=binding.MustNew(binding.WithConverter(binding.TimeConverter("2006-01-02",// ISO date"01/02/2006",// US format"02-Jan-2006",// Short month"2006-01-02 15:04:05",// DateTime)),)
You can use multiple converter factories together:
binder:=binding.MustNew(// Custom time formatsbinding.WithConverter(binding.TimeConverter("01/02/2006")),// Duration with aliasesbinding.WithConverter(binding.DurationConverter(map[string]time.Duration{"quick":5*time.Minute,"slow":1*time.Hour,})),// Status enumbinding.WithConverter(binding.EnumConverter("active","pending","disabled")),// Boolean with custom valuesbinding.WithConverter(binding.BoolConverter([]string{"yes","on"},[]string{"no","off"},)),// Third-party typesbinding.WithConverter[uuid.UUID](uuid.Parse),)
varAppBinder=binding.MustNew(// Type convertersbinding.WithConverter[uuid.UUID](uuid.Parse),binding.WithConverter[decimal.Decimal](decimal.NewFromString),// Time formatsbinding.WithTimeLayouts("2006-01-02","01/02/2006"),// Security limitsbinding.WithMaxDepth(16),binding.WithMaxSliceLen(1000),binding.WithMaxMapSize(500),// Error handlingbinding.WithAllErrors(),// Observabilitybinding.WithEvents(binding.Events{FieldBound:logFieldBound,UnknownField:logUnknownField,Done:logBindingStats,}),)// Use across handlersfuncCreateUserHandler(whttp.ResponseWriter,r*http.Request){user,err:=AppBinder.JSON[CreateUserRequest](r.Body)iferr!=nil{handleError(w,err)return}// ...}
Observability Hooks
Monitor binding operations:
binder:=binding.MustNew(binding.WithEvents(binding.Events{// Called when a field is successfully boundFieldBound:func(name,tagstring){metrics.Increment("binding.field.bound","field:"+name,"source:"+tag)},// Called when an unknown field is encounteredUnknownField:func(namestring){slog.Warn("Unknown field in request","field",name)metrics.Increment("binding.field.unknown","field:"+name)},// Called after binding completesDone:func(statsbinding.Stats){slog.Info("Binding completed","fields_bound",stats.FieldsBound,"errors",stats.ErrorCount,"duration",stats.Duration,)metrics.Histogram("binding.duration",stats.Duration.Milliseconds())metrics.Gauge("binding.fields.bound",stats.FieldsBound)},}),)
Binding Stats
typeStatsstruct{FieldsBoundint// Number of fields successfully boundErrorCountint// Number of errors encounteredDurationtime.Duration// Time taken for binding}
Custom Struct Tags
Extend binding with custom tag behavior:
// Example: Custom "env" tag handlertypeEnvTagHandlerstruct{prefixstring}func(h*EnvTagHandler)Get(fieldName,tagValuestring)(string,bool){envKey:=h.prefix+tagValueval,exists:=os.LookupEnv(envKey)returnval,exists}// Register custom tag handlerbinder:=binding.MustNew(binding.WithTagHandler("env",&EnvTagHandler{prefix:"APP_"}),)typeConfigstruct{APIKeystring`env:"API_KEY"`// Looks up APP_API_KEYPortint`env:"PORT"`// Looks up APP_PORT}
Streaming for Large Payloads
Use Reader variants for efficient memory usage:
// Instead of reading entire body into memory:// body, _ := io.ReadAll(r.Body) // Bad for large payloads// user, err := binding.JSON[User](body)// Stream directly from reader:user,err:=binding.JSONReader[User](r.Body)// Memory-efficient// Also available for XML, YAML:doc,err:=binding.XMLReader[Document](r.Body)config,err:=yaml.YAMLReader[Config](r.Body)
typeRequeststruct{UserIDint`query:"user_id" json:"user_id" header:"X-User-ID"`Tokenstring`header:"Authorization" query:"token"`}// Last source wins (default)req,err:=binding.Bind[Request](binding.FromQuery(r.URL.Query()),// Lowest prioritybinding.FromJSON(r.Body),// Medium prioritybinding.FromHeader(r.Header),// Highest priority)// First source wins (explicit)req,err:=binding.Bind[Request](binding.WithMergeStrategy(binding.MergeFirstWins),binding.FromHeader(r.Header),// Highest prioritybinding.FromJSON(r.Body),// Medium prioritybinding.FromQuery(r.URL.Query()),// Lowest priority)
Conditional Binding
Bind based on request properties:
funcBindRequest[Tany](r*http.Request)(T,error){sources:=[]binding.Source{}// Always include query paramssources=append(sources,binding.FromQuery(r.URL.Query()))// Include body only for certain methodsifr.Method=="POST"||r.Method=="PUT"||r.Method=="PATCH"{contentType:=r.Header.Get("Content-Type")switch{casestrings.Contains(contentType,"application/json"):sources=append(sources,binding.FromJSON(r.Body))casestrings.Contains(contentType,"application/x-www-form-urlencoded"):sources=append(sources,binding.FromForm(r.Body))casestrings.Contains(contentType,"application/xml"):sources=append(sources,binding.FromXML(r.Body))}}// Always include headerssources=append(sources,binding.FromHeader(r.Header))returnbinding.Bind[T](sources...)}
Partial Updates
Handle PATCH requests with optional fields:
typeUpdateUserRequeststruct{Name*string`json:"name"`// nil = don't updateEmail*string`json:"email"`// nil = don't updateAge*int`json:"age"`// nil = don't updateActive*bool`json:"active"`// nil = don't update}funcUpdateUser(whttp.ResponseWriter,r*http.Request){update,err:=binding.JSON[UpdateUserRequest](r.Body)iferr!=nil{http.Error(w,err.Error(),http.StatusBadRequest)return}// Only update fields that were providedifupdate.Name!=nil{user.Name=*update.Name}ifupdate.Email!=nil{user.Email=*update.Email}ifupdate.Age!=nil{user.Age=*update.Age}ifupdate.Active!=nil{user.Active=*update.Active}saveUser(user)}
Middleware Integration
Generic Binding Middleware
funcBindMiddleware[Tany](nexthttp.HandlerFunc)http.HandlerFunc{returnfunc(whttp.ResponseWriter,r*http.Request){req,err:=binding.JSON[T](r.Body)iferr!=nil{http.Error(w,err.Error(),http.StatusBadRequest)return}// Store in contextctx:=context.WithValue(r.Context(),"request",req)next(w,r.WithContext(ctx))}}// Usagehttp.HandleFunc("/users",BindMiddleware[CreateUserRequest](CreateUserHandler))funcCreateUserHandler(whttp.ResponseWriter,r*http.Request){req:=r.Context().Value("request").(CreateUserRequest)// Use req...}
// Cache binder instancevarbinder=binding.MustNew(binding.WithConverter[uuid.UUID](uuid.Parse),)// Struct info is cached automatically after first use// Subsequent bindings have minimal overhead
typeTenantRequeststruct{TenantIDstring`header:"X-Tenant-ID" validate:"required,uuid"`APIKeystring`header:"X-API-Key" validate:"required"`}typeCreateResourceRequeststruct{TenantRequestNamestring`json:"name" validate:"required"`Descriptionstring`json:"description"`Typestring`json:"type" validate:"required,oneof=typeA typeB typeC"`}funcCreateResourceHandler(whttp.ResponseWriter,r*http.Request){// Bind headers + JSONreq,err:=binding.Bind[CreateResourceRequest](binding.FromHeader(r.Header),binding.FromJSON(r.Body),)iferr!=nil{respondError(w,http.StatusBadRequest,"Invalid request",err)return}// Validateiferr:=validation.Validate(req);err!=nil{respondError(w,http.StatusUnprocessableEntity,"Validation failed",err)return}// Verify tenant and API keytenant,err:=auth.VerifyTenant(req.TenantID,req.APIKey)iferr!=nil{respondError(w,http.StatusUnauthorized,"Invalid tenant credentials",err)return}// Create resource in tenant contextresource:=&Resource{TenantID:tenant.ID,Name:req.Name,Description:req.Description,Type:req.Type,}iferr:=db.Create(resource);err!=nil{respondError(w,http.StatusInternalServerError,"Failed to create resource",err)return}respondJSON(w,http.StatusCreated,resource)}
File Upload with Metadata
Handle file uploads with form data using the new multipart binding:
typeFileUploadRequeststruct{File*binding.File`form:"file" validate:"required"`Titlestring`form:"title" validate:"required"`Descriptionstring`form:"description"`Tags[]string`form:"tags"`Publicbool`form:"public"`// JSON settings in form field (automatically parsed)Settingsstruct{Qualityint`json:"quality"`Compressionstring`json:"compression"`}`form:"settings"`}funcUploadFileHandler(whttp.ResponseWriter,r*http.Request){// Parse multipart form (32MB max)iferr:=r.ParseMultipartForm(32<<20);err!=nil{respondError(w,http.StatusBadRequest,"Failed to parse form",err)return}// Bind form fields and filereq,err:=binding.Multipart[FileUploadRequest](r.MultipartForm)iferr!=nil{respondError(w,http.StatusBadRequest,"Invalid form data",err)return}// Validateiferr:=validation.Validate(req);err!=nil{respondError(w,http.StatusUnprocessableEntity,"Validation failed",err)return}// Validate file typeallowedTypes:=[]string{".jpg",".jpeg",".png",".gif",".pdf"}ext:=req.File.Ext()if!contains(allowedTypes,ext){respondError(w,http.StatusBadRequest,"Invalid file type",nil)return}// Validate file size (10MB max)ifreq.File.Size>10*1024*1024{respondError(w,http.StatusBadRequest,"File too large (max 10MB)",nil)return}// Generate safe filenamefilename:=fmt.Sprintf("%s_%d%s",sanitizeFilename(req.Title),time.Now().Unix(),ext,)// Save fileuploadPath:="/var/uploads/"+filenameiferr:=req.File.Save(uploadPath);err!=nil{respondError(w,http.StatusInternalServerError,"Failed to save file",err)return}// Create database recordrecord:=&FileRecord{Filename:filename,Title:req.Title,Description:req.Description,Tags:req.Tags,Public:req.Public,Size:req.File.Size,ContentType:req.File.ContentType,Settings:req.Settings,}iferr:=db.Create(record);err!=nil{respondError(w,http.StatusInternalServerError,"Failed to create record",err)return}respondJSON(w,http.StatusCreated,map[string]interface{}{"id":record.ID,"filename":filename,"url":"/uploads/"+filename,})}funcsanitizeFilename(namestring)string{// Remove special charactersre:=regexp.MustCompile(`[^a-zA-Z0-9_-]`)returnre.ReplaceAllString(name,"_")}funccontains(slice[]string,itemstring)bool{for_,s:=rangeslice{ifs==item{returntrue}}returnfalse}
Multiple file uploads:
typeGalleryUploadstruct{Photos[]*binding.File`form:"photos" validate:"required,min=1,max=10"`AlbumTitlestring`form:"album_title" validate:"required"`Descriptionstring`form:"description"`}funcUploadGalleryHandler(whttp.ResponseWriter,r*http.Request){iferr:=r.ParseMultipartForm(100<<20);err!=nil{// 100MB for multiple filesrespondError(w,http.StatusBadRequest,"Failed to parse form",err)return}req,err:=binding.Multipart[GalleryUpload](r.MultipartForm)iferr!=nil{respondError(w,http.StatusBadRequest,"Invalid form data",err)return}iferr:=validation.Validate(req);err!=nil{respondError(w,http.StatusUnprocessableEntity,"Validation failed",err)return}// Process each photouploadedFiles:=make([]string,0,len(req.Photos))fori,photo:=rangereq.Photos{// Validate each fileifphoto.Size>10*1024*1024{respondError(w,http.StatusBadRequest,fmt.Sprintf("Photo %d too large",i+1),nil)return}// Generate filenamefilename:=fmt.Sprintf("%s_%d_%d%s",sanitizeFilename(req.AlbumTitle),time.Now().Unix(),i,photo.Ext(),)// Save fileiferr:=photo.Save("/var/uploads/"+filename);err!=nil{respondError(w,http.StatusInternalServerError,"Failed to save photo",err)return}uploadedFiles=append(uploadedFiles,filename)}// Create album recordalbum:=&Album{Title:req.AlbumTitle,Description:req.Description,Photos:uploadedFiles,}iferr:=db.Create(album);err!=nil{respondError(w,http.StatusInternalServerError,"Failed to create album",err)return}respondJSON(w,http.StatusCreated,album)}
API with Converter Factories
Using built-in converter factories for common patterns:
packagemainimport("net/http""time""github.com/google/uuid""rivaas.dev/binding")typeTaskStatusstringconst(TaskPendingTaskStatus="pending"TaskActiveTaskStatus="active"TaskCompletedTaskStatus="completed")typePrioritystringconst(PriorityLowPriority="low"PriorityMediumPriority="medium"PriorityHighPriority="high")// Global binder with converter factoriesvarTaskBinder=binding.MustNew(// UUID for task IDsbinding.WithConverter[uuid.UUID](uuid.Parse),// Status enum with validationbinding.WithConverter(binding.EnumConverter(TaskPending,TaskActive,TaskCompleted,)),// Priority enum with validationbinding.WithConverter(binding.EnumConverter(PriorityLow,PriorityMedium,PriorityHigh,)),// Friendly duration aliasesbinding.WithConverter(binding.DurationConverter(map[string]time.Duration{"urgent":1*time.Hour,"today":8*time.Hours,"thisweek":5*24*time.Hour,"nextweek":14*24*time.Hour,})),// US date format for deadlinesbinding.WithConverter(binding.TimeConverter("01/02/2006","2006-01-02")),// Boolean with friendly valuesbinding.WithConverter(binding.BoolConverter([]string{"yes","on","enabled"},[]string{"no","off","disabled"},)),)typeCreateTaskRequeststruct{Titlestring`json:"title" validate:"required,min=3,max=100"`Descriptionstring`json:"description"`PriorityPriority`json:"priority" validate:"required"`Deadlinetime.Time`json:"deadline"`Estimatetime.Duration`json:"estimate"`Assigneeuuid.UUID`json:"assignee"`}typeUpdateTaskRequeststruct{Title*string`json:"title,omitempty"`Description*string`json:"description,omitempty"`Status*TaskStatus`json:"status,omitempty"`Priority*Priority`json:"priority,omitempty"`Deadline*time.Time`json:"deadline,omitempty"`Completed*bool`json:"completed,omitempty"`}typeListTasksParamsstruct{StatusTaskStatus`query:"status"`PriorityPriority`query:"priority"`Assigneeuuid.UUID`query:"assignee"`DueIntime.Duration`query:"due_in"`Pageint`query:"page" default:"1"`PageSizeint`query:"page_size" default:"20"`ShowDonebool`query:"show_done"`}funcCreateTaskHandler(whttp.ResponseWriter,r*http.Request){// Bind and validatereq,err:=TaskBinder.JSON[CreateTaskRequest](r.Body)iferr!=nil{respondError(w,http.StatusBadRequest,"Invalid request",err)return}iferr:=validation.Validate(req);err!=nil{respondError(w,http.StatusUnprocessableEntity,"Validation failed",err)return}// Create tasktask:=&Task{ID:uuid.New(),Title:req.Title,Description:req.Description,Priority:req.Priority,Status:TaskPending,Deadline:req.Deadline,Estimate:req.Estimate,Assignee:req.Assignee,CreatedAt:time.Now(),}iferr:=db.Create(task);err!=nil{respondError(w,http.StatusInternalServerError,"Failed to create task",err)return}respondJSON(w,http.StatusCreated,task)}funcUpdateTaskHandler(whttp.ResponseWriter,r*http.Request){// Get task ID from pathtaskID,err:=uuid.Parse(chi.URLParam(r,"id"))iferr!=nil{respondError(w,http.StatusBadRequest,"Invalid task ID",err)return}// Bind partial updatereq,err:=TaskBinder.JSON[UpdateTaskRequest](r.Body)iferr!=nil{respondError(w,http.StatusBadRequest,"Invalid request",err)return}// Fetch existing tasktask,err:=db.GetTask(taskID)iferr!=nil{respondError(w,http.StatusNotFound,"Task not found",err)return}// Apply updates (only non-nil fields)ifreq.Title!=nil{task.Title=*req.Title}ifreq.Description!=nil{task.Description=*req.Description}ifreq.Status!=nil{task.Status=*req.Status}ifreq.Priority!=nil{task.Priority=*req.Priority}ifreq.Deadline!=nil{task.Deadline=*req.Deadline}ifreq.Completed!=nil&&*req.Completed{task.Status=TaskCompletedtask.CompletedAt=time.Now()}task.UpdatedAt=time.Now()iferr:=db.Update(task);err!=nil{respondError(w,http.StatusInternalServerError,"Failed to update task",err)return}respondJSON(w,http.StatusOK,task)}funcListTasksHandler(whttp.ResponseWriter,r*http.Request){// Bind query parameters with enum/duration validationparams,err:=TaskBinder.Query[ListTasksParams](r.URL.Query())iferr!=nil{respondError(w,http.StatusBadRequest,"Invalid query parameters",err)return}// Build queryquery:=db.NewQuery()ifparams.Status!=""{query=query.Where("status = ?",params.Status)}ifparams.Priority!=""{query=query.Where("priority = ?",params.Priority)}ifparams.Assignee!=uuid.Nil{query=query.Where("assignee = ?",params.Assignee)}ifparams.DueIn>0{dueDate:=time.Now().Add(params.DueIn)query=query.Where("deadline <= ?",dueDate)}if!params.ShowDone{query=query.Where("status != ?",TaskCompleted)}// Execute with paginationtasks,total,err:=query.Paginate(params.Page,params.PageSize).Execute()iferr!=nil{respondError(w,http.StatusInternalServerError,"Failed to list tasks",err)return}response:=map[string]interface{}{"data":tasks,"total":total,"page":params.Page,"page_size":params.PageSize,"total_pages":(total+params.PageSize-1)/params.PageSize,}respondJSON(w,http.StatusOK,response)}// Example requests that work with the converter factories:// POST /tasks// {// "title": "Fix bug #123",// "priority": "high",// "deadline": "01/31/2026",// "estimate": "urgent",// "assignee": "550e8400-e29b-41d4-a716-446655440000"// }//// GET /tasks?status=active&priority=HIGH&due_in=today&show_done=yes// Note: enums are case-insensitive, duration uses friendly aliases, bool uses "yes"
Webhook Handler with Signature Verification
Process webhooks with headers:
typeWebhookRequeststruct{Signaturestring`header:"X-Webhook-Signature" validate:"required"`Timestamptime.Time`header:"X-Webhook-Timestamp" validate:"required"`Eventstring`header:"X-Webhook-Event" validate:"required"`Payloadjson.RawMessage`json:"-"`}funcWebhookHandler(whttp.ResponseWriter,r*http.Request){// Read body for signature verificationbody,err:=io.ReadAll(r.Body)iferr!=nil{respondError(w,http.StatusBadRequest,"Failed to read body",err)return}r.Body=io.NopCloser(bytes.NewReader(body))// Bind headersreq,err:=binding.Header[WebhookRequest](r.Header)iferr!=nil{respondError(w,http.StatusBadRequest,"Invalid headers",err)return}// Validateiferr:=validation.Validate(req);err!=nil{respondError(w,http.StatusUnprocessableEntity,"Validation failed",err)return}// Verify signatureif!verifyWebhookSignature(body,req.Signature,webhookSecret){respondError(w,http.StatusUnauthorized,"Invalid signature",nil)return}// Check timestamp (prevent replay attacks)iftime.Since(req.Timestamp)>5*time.Minute{respondError(w,http.StatusBadRequest,"Request too old",nil)return}// Store raw payloadreq.Payload=body// Process eventswitchreq.Event{case"payment.success":varpaymentPaymentEventiferr:=json.Unmarshal(body,&payment);err!=nil{respondError(w,http.StatusBadRequest,"Invalid payment payload",err)return}handlePaymentSuccess(payment)case"payment.failed":varpaymentPaymentEventiferr:=json.Unmarshal(body,&payment);err!=nil{respondError(w,http.StatusBadRequest,"Invalid payment payload",err)return}handlePaymentFailed(payment)default:respondError(w,http.StatusBadRequest,"Unknown event type",nil)return}w.WriteHeader(http.StatusNoContent)}
typeBatchCreateRequest[]CreateUserRequesttypeBatchResponsestruct{Success[]User`json:"success"`Failed[]BatchError`json:"failed"`}typeBatchErrorstruct{Indexint`json:"index"`Iteminterface{}`json:"item"`Errorstring`json:"error"`}funcBatchCreateUsersHandler(whttp.ResponseWriter,r*http.Request){// Bind array of requestsbatch,err:=binding.JSON[BatchCreateRequest](r.Body)iferr!=nil{respondError(w,http.StatusBadRequest,"Invalid batch request",err)return}// Validate batch sizeiflen(batch)==0{respondError(w,http.StatusBadRequest,"Empty batch",nil)return}iflen(batch)>100{respondError(w,http.StatusBadRequest,"Batch too large (max 100)",nil)return}response:=BatchResponse{Success:make([]User,0),Failed:make([]BatchError,0),}// Process each itemfori,req:=rangebatch{// Validate itemiferr:=validation.Validate(req);err!=nil{response.Failed=append(response.Failed,BatchError{Index:i,Item:req,Error:err.Error(),})continue}// Create useruser:=&User{Username:req.Username,Email:req.Email,Age:req.Age,}iferr:=db.Create(user);err!=nil{response.Failed=append(response.Failed,BatchError{Index:i,Item:req,Error:err.Error(),})continue}response.Success=append(response.Success,*user)}// Return 207 Multi-Status if there were any failuresstatus:=http.StatusCreatediflen(response.Failed)>0{status=http.StatusMultiStatus}respondJSON(w,status,response)}
Flexible, multi-strategy validation for Go structs. Supports struct tags, JSON Schema, and custom interfaces
The Rivaas Validation package provides flexible, multi-strategy validation for Go structs. Supports struct tags, JSON Schema, and custom interfaces. Includes detailed error messages and built-in security features.
For PATCH requests where only provided fields should be validated:
// Compute which fields are present in the JSONpresence,_:=validation.ComputePresence(rawJSON)// Validate only the present fieldserr:=validator.ValidatePartial(ctx,&user,presence)
Learning Path
Follow these guides to master validation with Rivaas:
Installation - Get started with the validation package
Basic Usage - Learn the fundamentals of validation
Struct Tags - Use go-playground/validator struct tags
Implement ValidatorInterface for simple validation:
typeUserstruct{Emailstring}func(u*User)Validate()error{if!strings.Contains(u.Email,"@"){returnerrors.New("email must contain @")}returnnil}// validation.Validate will automatically call u.Validate()err:=validation.Validate(ctx,&user)
Or implement ValidatorWithContext for context-aware validation:
func(u*User)ValidateContext(ctxcontext.Context)error{// Access request-scoped data from contexttenant:=ctx.Value("tenant").(string)// Apply tenant-specific validation rulesreturnnil}
Strategy Priority
The package automatically selects the best strategy based on the type:
There are no sub-packages to import - all functionality is in the main package.
Version Management
The validation package follows semantic versioning. To use a specific version:
# Install latest versiongo get rivaas.dev/validation@latest
# Install specific versiongo get rivaas.dev/validation@v1.2.3
# Install specific commitgo get rivaas.dev/validation@abc123
Upgrading
To upgrade to the latest version:
go get -u rivaas.dev/validation
To upgrade all dependencies:
go get -u ./...
Workspace Setup
If using Go workspaces, ensure the validation module is in your workspace:
# Add to workspacego work use /path/to/rivaas/validation
# Verify workspacego work sync
Next Steps
Now that the package is installed, learn how to use it:
Learn how to validate structs using the validation package. This guide starts from simple package-level functions and progresses to customized validator instances.
Package-Level Validation
The simplest way to validate is using the package-level Validate function:
import("context""rivaas.dev/validation")typeCreateUserRequeststruct{Emailstring`json:"email" validate:"required,email"`Ageint`json:"age" validate:"min=18"`}funcHandler(ctxcontext.Context,reqCreateUserRequest)error{iferr:=validation.Validate(ctx,&req);err!=nil{returnerr}// Process valid requestreturnnil}
Handling Validation Errors
Validation errors are returned as structured *validation.Error values:
err:=validation.Validate(ctx,&req)iferr!=nil{varverr*validation.Erroriferrors.As(err,&verr){// Access structured field errorsfor_,fieldErr:=rangeverr.Fields{fmt.Printf("%s: %s\n",fieldErr.Path,fieldErr.Message)}}}
Creating a Validator Instance
For more control, create a Validator instance with custom configuration:
validator:=validation.MustNew(validation.WithMaxErrors(10),validation.WithRedactor(sensitiveFieldRedactor),)// Use in handlersiferr:=validator.Validate(ctx,&req);err!=nil{// Handle validation errors}
New vs MustNew
There are two constructors:
// New returns an error if configuration is invalidvalidator,err:=validation.New(validation.WithMaxErrors(-1),// Invalid)iferr!=nil{returnfmt.Errorf("failed to create validator: %w",err)}// MustNew panics if configuration is invalid (use in main/init)validator:=validation.MustNew(validation.WithMaxErrors(10),)
Use MustNew in main() or init() where panic on startup is acceptable. Use New when you need to handle initialization errors gracefully.
Per-Call Options
Override validator configuration on a per-call basis:
validator:=validation.MustNew(validation.WithMaxErrors(10),)// Override max errors for this callerr:=validator.Validate(ctx,&req,validation.WithMaxErrors(5),validation.WithStrategy(validation.StrategyTags),)
Per-call options don’t modify the validator instance - they create a temporary config for that call only.
// Use only struct tagserr:=validation.Validate(ctx,&req,validation.WithStrategy(validation.StrategyTags),)// Use only JSON Schemaerr:=validation.Validate(ctx,&req,validation.WithStrategy(validation.StrategyJSONSchema),)// Use only interface methodserr:=validation.Validate(ctx,&req,validation.WithStrategy(validation.StrategyInterface),)
Run All Strategies
Run all applicable strategies and aggregate errors:
Both package-level functions and Validator instances are safe for concurrent use:
validator:=validation.MustNew(validation.WithMaxErrors(10),)// Safe to use from multiple goroutinesgofunc(){validator.Validate(ctx,&user1)}()gofunc(){validator.Validate(ctx,&user2)}()
Default Validator
Package-level functions use a shared default validator:
// These both use the same default validatorvalidation.Validate(ctx,&req1)validation.Validate(ctx,&req2)
The default validator is created with zero configuration. If you need custom options, create your own Validator instance.
Working Example
Here’s a complete example showing basic usage:
packagemainimport("context""fmt""rivaas.dev/validation")typeCreateUserRequeststruct{Usernamestring`validate:"required,min=3,max=20"`Emailstring`validate:"required,email"`Ageint`validate:"min=18,max=120"`}funcmain(){ctx:=context.Background()// Invalid requestreq:=CreateUserRequest{Username:"ab",// Too shortEmail:"not-an-email",// Invalid formatAge:15,// Too young}err:=validation.Validate(ctx,&req)iferr!=nil{varverr*validation.Erroriferrors.As(err,&verr){fmt.Println("Validation errors:")for_,fieldErr:=rangeverr.Fields{fmt.Printf(" %s: %s\n",fieldErr.Path,fieldErr.Message)}}}}
Output:
Validation errors:
Username: min constraint failed
Email: must be a valid email address
Age: min constraint failed
Next Steps
Struct Tags - Learn go-playground/validator tag syntax
Validate structs using go-playground/validator tags
Use struct tags with go-playground/validator syntax to validate your structs. This is the most common validation strategy in the Rivaas validation package.
Tags are comma-separated constraints. Each constraint is evaluated, and all must pass for validation to succeed.
Common Validation Tags
Required Fields
typeUserstruct{Emailstring`validate:"required"`// Must be non-zero valueNamestring`validate:"required"`// Must be non-empty stringAgeint`validate:"required"`// Must be non-zero number}
String Constraints
typeUserstruct{// Length constraintsUsernamestring`validate:"min=3,max=20"`Biostring`validate:"max=500"`// Format constraintsEmailstring`validate:"email"`URLstring`validate:"url"`UUIDstring`validate:"uuid"`// Character constraintsAlphaOnlystring`validate:"alpha"`AlphaNumstring`validate:"alphanum"`Numericstring`validate:"numeric"`}
Number Constraints
typeProductstruct{Pricefloat64`validate:"min=0"`Quantityint`validate:"min=1,max=1000"`Ratingfloat64`validate:"gte=0,lte=5"`// Greater/less than or equal}
Comparison Operators
Tag
Description
min=N
Minimum value (numbers) or length (strings/slices)
max=N
Maximum value (numbers) or length (strings/slices)
typeConfigstruct{DataFilestring`validate:"file"`// Must be existing fileDataDirstring`validate:"dir"`// Must be existing directoryFilePathstring`validate:"filepath"`// Valid file path syntax}
typeRegistrationstruct{Passwordstring`validate:"required,min=8"`ConfirmPasswordstring`validate:"required,eqfield=Password"`// ^^^^^^^^^^^^^^^^// Must equal Password field}
Conditional Validation
typeUserstruct{Typestring`validate:"oneof=personal business"`TaxIDstring`validate:"required_if=Type business"`// ^^^^^^^^^^^^^^^^^^^^^^^^// Required when Type is "business"}
By default, validation uses JSON field names in error messages:
typeUserstruct{Emailstring`json:"email_address" validate:"required,email"`// ^^^^^^^^^^^^^^^^^^^ Used in error message}
Error message will reference email_address, not Email.
Validation Example
Complete example with various constraints:
packagemainimport("context""fmt""rivaas.dev/validation")typeCreateUserRequeststruct{// Required string with length constraintsUsernamestring`json:"username" validate:"required,min=3,max=20,alphanum"`// Valid email addressEmailstring`json:"email" validate:"required,email"`// Age rangeAgeint`json:"age" validate:"required,min=18,max=120"`// Password with confirmationPasswordstring`json:"password" validate:"required,min=8"`ConfirmPasswordstring`json:"confirm_password" validate:"required,eqfield=Password"`// Optional phone (validated if provided)Phonestring`json:"phone" validate:"omitempty,e164"`// Enum valueRolestring`json:"role" validate:"required,oneof=user admin moderator"`// Nested structAddressAddress`json:"address" validate:"required"`// Array with constraintsTags[]string`json:"tags" validate:"min=1,max=10,dive,min=2,max=20"`}typeAddressstruct{Streetstring`json:"street" validate:"required"`Citystring`json:"city" validate:"required"`Statestring`json:"state" validate:"required,len=2,alpha"`ZipCodestring`json:"zip_code" validate:"required,numeric,len=5"`}funcmain(){ctx:=context.Background()req:=CreateUserRequest{Username:"ab",// Too shortEmail:"invalid",// Invalid emailAge:15,// Too youngPassword:"pass",// Too shortConfirmPassword:"different",// Doesn't matchPhone:"123",// Invalid formatRole:"superuser",// Not in enumAddress:Address{},// Missing required fieldsTags:[]string{"a","bb"},// First tag too short}err:=validation.Validate(ctx,&req)iferr!=nil{varverr*validation.Erroriferrors.As(err,&verr){for_,fieldErr:=rangeverr.Fields{fmt.Printf("%s: %s\n",fieldErr.Path,fieldErr.Message)}}}}
Validate structs using JSON Schema. Implement the JSONSchemaProvider interface to use this feature. This provides RFC-compliant JSON Schema validation as an alternative to struct tags.
JSONSchemaProvider Interface
Implement the JSONSchemaProvider interface on your struct:
Implement custom validation methods with Validate() and ValidateContext()
Implement custom validation logic by adding Validate() or ValidateContext() methods to your structs. This provides the most flexible validation approach for complex business rules.
ValidatorInterface
Implement the ValidatorInterface for simple custom validation:
typeValidatorInterfaceinterface{Validate()error}
Basic Example
typeUserstruct{EmailstringNamestring}func(u*User)Validate()error{if!strings.Contains(u.Email,"@"){returnerrors.New("email must contain @")}iflen(u.Name)<2{returnerrors.New("name too short")}returnnil}// Validation automatically calls u.Validate()err:=validation.Validate(ctx,&user)
Returning Structured Errors
Return *validation.Error for detailed field-level errors:
func(u*User)Validate()error{varverrvalidation.Errorif!strings.Contains(u.Email,"@"){verr.Add("email","format","must contain @",nil)}iflen(u.Name)<2{verr.Add("name","length","must be at least 2 characters",nil)}ifverr.HasErrors(){return&verr}returnnil}
ValidatorWithContext
Implement ValidatorWithContext for context-aware validation:
This is preferred when you need access to request-scoped data.
Context-Aware Validation
typeUserstruct{EmailstringTenantIDstring}func(u*User)ValidateContext(ctxcontext.Context)error{// Access context valuestenant:=ctx.Value("tenant").(string)// Tenant-specific validationifu.TenantID!=tenant{returnerrors.New("user does not belong to this tenant")}// Additional validationif!strings.HasSuffix(u.Email,"@"+tenant+".com"){returnfmt.Errorf("email must be from %s.com domain",tenant)}returnnil}
Database Validation
typeUserstruct{UsernamestringEmailstring}func(u*User)ValidateContext(ctxcontext.Context)error{// Get database from contextdb:=ctx.Value("db").(*sql.DB)// Check username uniquenessvarexistsboolerr:=db.QueryRowContext(ctx,"SELECT EXISTS(SELECT 1 FROM users WHERE username = $1)",u.Username,).Scan(&exists)iferr!=nil{returnfmt.Errorf("failed to check username: %w",err)}ifexists{returnerrors.New("username already taken")}returnnil}
Interface Priority
When a type implements ValidatorInterface or ValidatorWithContext, those methods have the highest priority:
Priority Order:
ValidateContext(ctx) or Validate() (highest)
Struct tags (validate:"...")
JSON Schema (JSONSchemaProvider)
typeUserstruct{Emailstring`validate:"required,email"`// Lower priority}func(u*User)Validate()error{// This runs instead of struct tagsreturncustomEmailValidation(u.Email)}
Override this behavior by explicitly selecting a strategy:
// Skip interface method, use struct tagserr:=validation.Validate(ctx,&user,validation.WithStrategy(validation.StrategyTags),)
Combining with Other Strategies
Run interface validation along with other strategies:
typeUserstruct{Emailstring`validate:"required,email"`}func(u*User)Validate()error{// Custom business logicifisBlacklisted(u.Email){returnerrors.New("email is blacklisted")}returnnil}// Run both interface method AND struct tag validationerr:=validation.Validate(ctx,&user,validation.WithRunAll(true),)
All errors are aggregated into a single *validation.Error.
Pointer vs Value Receivers
The validation package works with both pointer and value receivers:
Pointer Receiver (Recommended)
func(u*User)Validate()error{// Can modify the struct if neededu.Email=strings.ToLower(u.Email)returnnil}
Use pointer receivers when you need to modify the struct during validation (normalization, etc.).
Complex Validation Example
typeCreateOrderRequeststruct{UserIDintItems[]OrderItemCouponCodestringTotalfloat64}typeOrderItemstruct{ProductIDintQuantityintPricefloat64}func(r*CreateOrderRequest)ValidateContext(ctxcontext.Context)error{varverrvalidation.Error// Validate user existsif!userExists(ctx,r.UserID){verr.Add("user_id","not_found","user does not exist",nil)}// Validate itemsiflen(r.Items)==0{verr.Add("items","required","at least one item required",nil)}varcalculatedTotalfloat64fori,item:=ranger.Items{// Validate product exists and price matchesproduct,err:=getProduct(ctx,item.ProductID)iferr!=nil{verr.Add(fmt.Sprintf("items.%d.product_id",i),"not_found","product does not exist",nil,)continue}ifitem.Price!=product.Price{verr.Add(fmt.Sprintf("items.%d.price",i),"mismatch","price does not match current product price",map[string]any{"expected":product.Price,"actual":item.Price,},)}ifitem.Quantity<1{verr.Add(fmt.Sprintf("items.%d.quantity",i),"invalid","quantity must be at least 1",nil,)}calculatedTotal+=item.Price*float64(item.Quantity)}// Validate coupon if providedifr.CouponCode!=""{discount,err:=validateCoupon(ctx,r.CouponCode)iferr!=nil{verr.Add("coupon_code","invalid",err.Error(),nil)}else{calculatedTotal-=discount}}// Validate total matches calculationifmath.Abs(r.Total-calculatedTotal)>0.01{verr.Add("total","mismatch","total does not match item prices",map[string]any{"expected":calculatedTotal,"actual":r.Total,},)}ifverr.HasErrors(){return&verr}returnnil}
// Good: Focused validationfunc(u*User)Validate()error{iferr:=validateEmail(u.Email);err!=nil{returnerr}iferr:=validateName(u.Name);err!=nil{returnerr}returnnil}// Bad: Too much logic in one methodfunc(u*User)Validate()error{// 200 lines of validation code...}
// Good: Dependencies from contextfunc(u*User)ValidateContext(ctxcontext.Context)error{db:=ctx.Value("db").(*sql.DB)returncheckUsernameUnique(ctx,db,u.Username)}// Bad: Global dependenciesvarglobalDB*sql.DBfunc(u*User)Validate()error{returncheckUsernameUnique(context.Background(),globalDB,u.Username)}
4. Consider Performance
// Good: Fast validation firstfunc(u*User)ValidateContext(ctxcontext.Context)error{// Quick checks firstifu.Email==""{returnerrors.New("email required")}// Expensive DB check lastreturncheckEmailUnique(ctx,u.Email)}
Error Metadata
Add metadata to errors for better debugging:
func(u*User)Validate()error{varverrvalidation.Errorverr.Add("email","blacklisted","email domain is blacklisted",map[string]any{"domain":extractDomain(u.Email),"reason":"spam","blocked_at":time.Now(),})return&verr}
Partial validation is essential for PATCH requests. Only provided fields should be validated. Absent fields are ignored even if they have “required” constraints.
With normal validation, a PATCH request like {"email": "new@example.com"} would fail. The name field is required but not provided. Partial validation solves this.
PresenceMap
A PresenceMap tracks which fields are present in the request:
typePresenceMapmap[string]bool
Keys are JSON field paths (e.g., "email", "address.city", "items.0.name").
Computing Presence
Use ComputePresence to analyze raw JSON:
rawJSON:=[]byte(`{"email": "new@example.com"}`)presence,err:=validation.ComputePresence(rawJSON)iferr!=nil{returnfmt.Errorf("failed to compute presence: %w",err)}// presence = {"email": true}
ValidatePartial
Use ValidatePartial to validate only present fields:
funcUpdateUserHandler(whttp.ResponseWriter,r*http.Request){// Read raw bodyrawJSON,_:=io.ReadAll(r.Body)// Compute presencepresence,_:=validation.ComputePresence(rawJSON)// Parse into structvarreqUpdateUserRequestjson.Unmarshal(rawJSON,&req)// Validate only present fieldserr:=validation.ValidatePartial(ctx,&req,presence)iferr!=nil{// Handle validation error}}
Complete PATCH Example
typeUpdateUserRequeststruct{Email*string`json:"email" validate:"omitempty,email"`Name*string`json:"name" validate:"omitempty,min=2"`Age*int`json:"age" validate:"omitempty,min=18"`}funcUpdateUser(whttp.ResponseWriter,r*http.Request){ctx:=r.Context()// Read raw bodyrawJSON,err:=io.ReadAll(r.Body)iferr!=nil{http.Error(w,"failed to read body",http.StatusBadRequest)return}// Compute which fields are presentpresence,err:=validation.ComputePresence(rawJSON)iferr!=nil{http.Error(w,"invalid JSON",http.StatusBadRequest)return}// Parse into structvarreqUpdateUserRequestiferr:=json.Unmarshal(rawJSON,&req);err!=nil{http.Error(w,"invalid JSON",http.StatusBadRequest)return}// Validate only present fieldsiferr:=validation.ValidatePartial(ctx,&req,presence);err!=nil{varverr*validation.Erroriferrors.As(err,&verr){// Return field errorsw.Header().Set("Content-Type","application/json")w.WriteHeader(http.StatusUnprocessableEntity)json.NewEncoder(w).Encode(verr)return}http.Error(w,err.Error(),http.StatusBadRequest)return}// Update user with provided fieldsupdateUser(ctx,req)w.WriteHeader(http.StatusOK)}
Nested Structures
Presence tracking works with nested objects and arrays:
ifpresence.Has("email"){// Email field was provided}
HasPrefix
Check if any nested path exists:
ifpresence.HasPrefix("address"){// At least one address field was provided// (e.g., "address.city" or "address.street")}
LeafPaths
Get only the deepest paths (no parent paths):
presence:=PresenceMap{"address":true,"address.city":true,"address.street":true,}leaves:=presence.LeafPaths()// returns: ["address.city", "address.street"]// "address" is excluded (it has children)
Useful for validating only actual data fields, not parent objects.
Pointer Fields for PATCH
Use pointers to distinguish between “not provided” and “zero value”:
age and active in presence map → validate even though they’re zero values
Struct Tag Strategy
For partial validation with struct tags, use omitempty instead of required:
// Good for PATCHtypeUpdateUserRequeststruct{Emailstring`json:"email" validate:"omitempty,email"`Ageint`json:"age" validate:"omitempty,min=18"`}// Bad for PATCHtypeUpdateUserRequeststruct{Emailstring`json:"email" validate:"required,email"`// Will fail if not providedAgeint`json:"age" validate:"required,min=18"`// Will fail if not provided}
Custom Interface with Partial Validation
Access the presence map in custom validation:
typeUpdateOrderRequeststruct{Items[]OrderItem}func(r*UpdateOrderRequest)ValidateContext(ctxcontext.Context)error{// Get presence from context (if available)presence:=ctx.Value("presence").(validation.PresenceMap)// Only validate items if providedifpresence.HasPrefix("items"){iflen(r.Items)==0{returnerrors.New("items cannot be empty when provided")}}returnnil}// Pass presence via contextctx=context.WithValue(ctx,"presence",presence)err:=validation.ValidatePartial(ctx,&req,presence)
Performance Considerations
ComputePresence parses JSON once (fast)
Presence map is cached per request
No reflection overhead for presence checks
Memory usage: ~100 bytes per field path
Limitations
Deep Nesting
ComputePresence has a maximum nesting depth of 100 to prevent stack overflow:
// This will stop at depth 100deeplyNested:=generateDeeplyNestedJSON(150)presence,_:=validation.ComputePresence(deeplyNested)// Only tracks first 100 levels
Maximum Fields
For security, limit the number of fields in partial validation:
Validation errors in the Rivaas validation package are structured and detailed. They provide field-level error information with codes, messages, and metadata.
Error Types
validation.Error
The main validation error type containing multiple field errors:
typeErrorstruct{Fields[]FieldError// List of field errors.Truncatedbool// True if errors were truncated due to maxErrors limit.}
FieldError
Individual field error with detailed information:
typeFieldErrorstruct{Pathstring// JSON path like "items.2.price".Codestring// Stable code like "tag.required", "schema.type".Messagestring// Human-readable message.Metamap[string]any// Additional metadata like tag, param, value.}
Checking for Validation Errors
Use errors.As to extract structured errors:
err:=validation.Validate(ctx,&req)iferr!=nil{varverr*validation.Erroriferrors.As(err,&verr){// Access structured field errorsfor_,fieldErr:=rangeverr.Fields{fmt.Printf("%s: %s\n",fieldErr.Path,fieldErr.Message)}}}
Error Codes
Error codes follow a consistent pattern for programmatic handling:
Struct Tag Errors
Format: tag.<tagname>
Code:"tag.required"// Required field missingCode:"tag.email"// Email format invalidCode:"tag.min"// Below minimum value/lengthCode:"tag.max"// Above maximum value/lengthCode:"tag.oneof"// Value not in enum
JSON Schema Errors
Format: schema.<constraint>
Code:"schema.type"// Type mismatchCode:"schema.required"// Missing required fieldCode:"schema.minimum"// Below minimum valueCode:"schema.pattern"// Pattern mismatchCode:"schema.format"// Format validation failed
Interface Method Errors
Custom codes from your validation methods:
Code:"validation_error"// Generic validation errorCode:"custom_code"// Your custom code
tag - The validation tag that failed (struct tags)
param - Tag parameter (e.g., “18” for min=18)
value - The actual value (may be redacted)
expected - Expected value for comparison errors
actual - Actual value for comparison errors
Error Messages
Default Messages
The package provides clear default messages:
"is required""must be a valid email address""must be at least 18""must be one of: pending, confirmed, shipped"
Custom Messages
Customize error messages when creating a validator:
validator:=validation.MustNew(validation.WithMessages(map[string]string{"required":"cannot be empty","email":"invalid email format","min":"too small",}),)
Dynamic Messages
Use WithMessageFunc for parameterized tags:
validator:=validation.MustNew(validation.WithMessageFunc("min",func(paramstring,kindreflect.Kind)string{ifkind==reflect.String{returnfmt.Sprintf("must be at least %s characters",param)}returnfmt.Sprintf("must be at least %s",param)}),)
Limiting Errors
Max Errors
Limit the number of errors returned:
err:=validation.Validate(ctx,&req,validation.WithMaxErrors(5),)varverr*validation.Erroriferrors.As(err,&verr){ifverr.Truncated{fmt.Println("More errors exist (showing first 5)")}}
varverr*validation.Erroriferrors.As(err,&verr){verr.Sort()// Sort by path, then by codefor_,fieldErr:=rangeverr.Fields{fmt.Printf("%s: %s\n",fieldErr.Path,fieldErr.Message)}}
HTTP Error Responses
JSON Error Response
funcHandleValidationError(whttp.ResponseWriter,errerror){varverr*validation.Erroriferrors.As(err,&verr){w.Header().Set("Content-Type","application/json")w.WriteHeader(http.StatusUnprocessableEntity)json.NewEncoder(w).Encode(map[string]any{"error":"validation_failed","fields":verr.Fields,})return}// Other error typeshttp.Error(w,"internal server error",http.StatusInternalServerError)}
Example response:
{"error":"validation_failed","fields":[{"path":"email","code":"tag.email","message":"must be a valid email address","meta":{"tag":"email","value":"[REDACTED]"}},{"path":"age","code":"tag.min","message":"must be at least 18","meta":{"tag":"min","param":"18","value":15}}]}
Problem Details (RFC 7807)
funcHandleValidationErrorProblemDetails(whttp.ResponseWriter,errerror){varverr*validation.Errorif!errors.As(err,&verr){http.Error(w,"internal server error",http.StatusInternalServerError)return}// Convert to Problem Details formatproblems:=make([]map[string]any,len(verr.Fields))fori,fieldErr:=rangeverr.Fields{problems[i]=map[string]any{"field":fieldErr.Path,"code":fieldErr.Code,"message":fieldErr.Message,}}w.Header().Set("Content-Type","application/problem+json")w.WriteHeader(http.StatusUnprocessableEntity)json.NewEncoder(w).Encode(map[string]any{"type":"https://example.com/problems/validation-error","title":"Validation Error","status":422,"detail":verr.Error(),"instance":r.URL.Path,"errors":problems,})}
Creating Custom Errors
Adding Errors Manually
varverrvalidation.Errorverr.Add("email","invalid","email is blacklisted",map[string]any{"domain":"example.com","reason":"spam",})verr.Add("password","weak","password is too weak",nil)ifverr.HasErrors(){return&verr}
Combining Errors
varallErrorsvalidation.Error// Add errors from multiple sourcesallErrors.AddError(err1)allErrors.AddError(err2)allErrors.AddError(err3)ifallErrors.HasErrors(){return&allErrors}
Error Interface Implementations
The Error type implements several interfaces:
error Interface
err:=validation.Validate(ctx,&req)fmt.Println(err.Error())// Output: "validation failed: email: must be valid email; age: must be at least 18"
errors.Is
iferrors.Is(err,validation.ErrValidation){// This is a validation error}
rivaas.dev/errors Interfaces
The Error type implements additional interfaces for the Rivaas error handling system:
Custom tag functions receive a validator.FieldLevel with methods to access field information.
typeFieldLevelinterface{Field()reflect.Value// The field being validatedFieldName()string// Field nameStructFieldName()string// Struct field nameParam()string// Tag parameterGetStructFieldOK()(reflect.Value,reflect.Kind,bool)Parent()reflect.Value// Parent struct}
// Custom tag with parameter: divisible_by=NfuncdivisibleBy(flvalidator.FieldLevel)bool{param:=fl.Param()// Get parameter valuedivisor,err:=strconv.Atoi(param)iferr!=nil{returnfalse}value:=fl.Field().Int()returnvalue%int64(divisor)==0}validator:=validation.MustNew(validation.WithCustomTag("divisible_by",divisibleBy),)typeProductstruct{Quantityint`validate:"required,divisible_by=5"`}
Cross-Field Validation
// Validate that EndDate is after StartDatefuncafterStartDate(flvalidator.FieldLevel)bool{endDate:=fl.Field().Interface().(time.Time)// Access parent structparent:=fl.Parent()startDateField:=parent.FieldByName("StartDate")if!startDateField.IsValid(){returnfalse}startDate:=startDateField.Interface().(time.Time)returnendDate.After(startDate)}validator:=validation.MustNew(validation.WithCustomTag("after_start_date",afterStartDate),)typeEventstruct{StartDatetime.Time`validate:"required"`EndDatetime.Time`validate:"required,after_start_date"`}
Use WithCustomValidator for one-off validation logic:
typeCreateOrderRequeststruct{Items[]OrderItemTotalfloat64}err:=validator.Validate(ctx,&req,validation.WithCustomValidator(func(vany)error{req:=v.(*CreateOrderRequest)// Calculate expected totalvarsumfloat64for_,item:=rangereq.Items{sum+=item.Price*float64(item.Quantity)}// Verify total matchesifmath.Abs(req.Total-sum)>0.01{returnerrors.New("total does not match item prices")}returnnil}),)
validation.WithCustomValidator(func(vany)error{req:=v.(*CreateUserRequest)varverrvalidation.ErrorifisBlacklisted(req.Email){verr.Add("email","blacklisted","email domain is blacklisted",nil)}if!isUnique(req.Username){verr.Add("username","duplicate","username already taken",nil)}ifverr.HasErrors(){return&verr}returnnil})
Field Name Mapping
Transform field names in error messages:
validator:=validation.MustNew(validation.WithFieldNameMapper(func(namestring)string{// Convert snake_case to Title Casereturnstrings.Title(strings.ReplaceAll(name,"_"," "))}),)typeUserstruct{FirstNamestring`json:"first_name" validate:"required"`}// Error message will say "First Name is required" instead of "first_name is required"
Custom Error Messages
Static Messages
validator:=validation.MustNew(validation.WithMessages(map[string]string{"required":"cannot be empty","email":"invalid email format","min":"value too small",}),)
Dynamic Messages
import"reflect"validator:=validation.MustNew(validation.WithMessageFunc("min",func(paramstring,kindreflect.Kind)string{ifkind==reflect.String{returnfmt.Sprintf("must be at least %s characters long",param)}returnfmt.Sprintf("must be at least %s",param)}),validation.WithMessageFunc("max",func(paramstring,kindreflect.Kind)string{ifkind==reflect.String{returnfmt.Sprintf("must be at most %s characters long",param)}returnfmt.Sprintf("must be at most %s",param)}),)
Combining Custom Validators
Mix custom tags, custom validators, and built-in validation:
typeCreateUserRequeststruct{Usernamestring`validate:"required,username"`// Custom tagEmailstring`validate:"required,email"`// Built-in tagAgeint`validate:"required,min=18"`// Built-in tag}validator:=validation.MustNew(validation.WithCustomTag("username",validateUsername),)err:=validator.Validate(ctx,&req,validation.WithCustomValidator(func(vany)error{req:=v.(*CreateUserRequest)// Additional custom validationifisBlacklisted(req.Email){returnerrors.New("email is blacklisted")}returnnil}),validation.WithRunAll(true),// Run all strategies)
funcvalidateUsername(flvalidator.FieldLevel)bool{username:=fl.Field().String()// Handle empty stringsifusername==""{returnfalse// Or true if username is optional}// Check lengthiflen(username)<3||len(username)>20{returnfalse}// Check formatreturnusernameRegex.MatchString(username)}
4. Use Validator Instance for Shared Tags
// Create validator once with custom tagsvarappValidator=validation.MustNew(validation.WithCustomTag("phone",validatePhone),validation.WithCustomTag("username",validateUsername),validation.WithCustomTag("slug",validateSlug),)// Reuse across handlersfuncHandler1(ctxcontext.Context,reqRequest1)error{returnappValidator.Validate(ctx,&req)}funcHandler2(ctxcontext.Context,reqRequest2)error{returnappValidator.Validate(ctx,&req)}
When a field is redacted, its value in error messages is replaced with [REDACTED]:
typeUserstruct{Emailstring`validate:"required,email"`Passwordstring`validate:"required,min=8"`}user:=User{Email:"invalid",Password:"secret123",}err:=validator.Validate(ctx,&user)// Error: email: must be valid email (value: "invalid")// Error: password: too short (value: "[REDACTED]")
Combine validation with rate limiting to prevent abuse:
import"golang.org/x/time/rate"varlimiter=rate.NewLimiter(rate.Every(time.Second),10)funcValidateWithRateLimit(ctxcontext.Context,reqany)error{// Check rate limit first (fast)if!limiter.Allow(){returnerrors.New("rate limit exceeded")}// Then validate (slower)returnvalidation.Validate(ctx,req)}
Denial of Service Prevention
Request Size Limits
funcHandler(whttp.ResponseWriter,r*http.Request){// Limit request body sizer.Body=http.MaxBytesReader(w,r.Body,1*1024*1024)// 1MB maxrawJSON,err:=io.ReadAll(r.Body)iferr!=nil{http.Error(w,"request too large",http.StatusRequestEntityTooLarge)return}// Continue with validation}
Array/Slice Limits
typeBatchRequeststruct{Items[]Item`validate:"required,min=1,max=100"`}// Prevents DoS with extremely large arrays
String Length Limits
typeRequeststruct{Descriptionstring`validate:"max=10000"`}// Prevents memory exhaustion from huge strings
// GoodfuncCreateUser(whttp.ResponseWriter,r*http.Request){varreqCreateUserRequestjson.NewDecoder(r.Body).Decode(&req)// ALWAYS validateiferr:=validation.Validate(r.Context(),&req);err!=nil{http.Error(w,err.Error(),http.StatusBadRequest)return}// Safe to use req}// BadfuncCreateUser(whttp.ResponseWriter,r*http.Request){varreqCreateUserRequestjson.NewDecoder(r.Body).Decode(&req)// Using unvalidated input - DANGEROUS!db.Exec("INSERT INTO users VALUES (?, ?)",req.Username,req.Email)}
2. Validate Before Database Operations
funcUpdateUser(ctxcontext.Context,reqUpdateUserRequest)error{// Validate firstiferr:=validation.Validate(ctx,&req);err!=nil{returnerr}// Then update databasereturndb.UpdateUser(ctx,req)}
3. Use Strict Mode for APIs
validator:=validation.MustNew(validation.WithDisallowUnknownFields(true),)// Rejects requests with unexpected fields (typo detection)
4. Redact All Sensitive Fields
funccomprehensiveRedactor(pathstring)bool{pathLower:=strings.ToLower(path)// Passwords and secretsifstrings.Contains(pathLower,"password")||strings.Contains(pathLower,"secret")||strings.Contains(pathLower,"token")||strings.Contains(pathLower,"key"){returntrue}// Payment informationifstrings.Contains(pathLower,"card")||strings.Contains(pathLower,"cvv")||strings.Contains(pathLower,"credit"){returntrue}// Personal informationifstrings.Contains(pathLower,"ssn")||strings.Contains(pathLower,"social_security")||strings.Contains(pathLower,"tax_id"){returntrue}returnfalse}
5. Log Validation Failures
err:=validation.Validate(ctx,&req)iferr!=nil{varverr*validation.Erroriferrors.As(err,&verr){// Log validation failures for security monitoringlog.With("error_count",len(verr.Fields),"fields",getFieldPaths(verr.Fields),"ip",getClientIP(r),).Warn("validation failed")}returnerr}
6. Fail Secure
// Good - fail if validation library has issuesvalidator,err:=validation.New(options...)iferr!=nil{panic("failed to create validator: "+err.Error())}// Bad - continue without validationvalidator,err:=validation.New(options...)iferr!=nil{log.Warn("validator creation failed, continuing anyway")// DANGEROUS}
Common Security Vulnerabilities
SQL Injection
// VULNERABLEtypeSearchRequeststruct{Querystring// No validation}db.Exec("SELECT * FROM users WHERE name = '"+req.Query+"'")// SAFEtypeSearchRequeststruct{Querystring`validate:"required,max=100,alphanum"`}iferr:=validation.Validate(ctx,&req);err!=nil{returnerr}db.Exec("SELECT * FROM users WHERE name = ?",req.Query)
Path Traversal
// VULNERABLEtypeFileRequeststruct{Pathstring// No validation}os.ReadFile(req.Path)// Could be "../../etc/passwd"// SAFEtypeFileRequeststruct{Pathstring`validate:"required,max=255"`}func(r*FileRequest)Validate()error{cleaned:=filepath.Clean(r.Path)ifstrings.Contains(cleaned,".."){returnerrors.New("path traversal detected")}if!strings.HasPrefix(cleaned,"/safe/directory/"){returnerrors.New("path outside allowed directory")}returnnil}
// VULNERABLEtypeUpdateUserRequeststruct{EmailstringRolestring// User shouldn't be able to set this!}// SAFE - separate request typestypeUpdateUserRequeststruct{Emailstring`validate:"required,email"`}typeAdminUpdateUserRequeststruct{Emailstring`validate:"required,email"`Rolestring`validate:"required,oneof=user admin"`}
typeUpdateUserRequeststruct{Email*string`json:"email" validate:"omitempty,email"`Age*int`json:"age" validate:"omitempty,min=18,max=120"`}funcUpdateUser(whttp.ResponseWriter,r*http.Request){ctx:=r.Context()// Read raw bodyrawJSON,err:=io.ReadAll(r.Body)iferr!=nil{http.Error(w,"failed to read body",http.StatusBadRequest)return}// Compute which fields are presentpresence,err:=validation.ComputePresence(rawJSON)iferr!=nil{http.Error(w,"invalid JSON",http.StatusBadRequest)return}// Parse into structvarreqUpdateUserRequestiferr:=json.Unmarshal(rawJSON,&req);err!=nil{http.Error(w,"invalid JSON",http.StatusBadRequest)return}// Validate only present fieldsiferr:=validation.ValidatePartial(ctx,&req,presence);err!=nil{handleValidationError(w,err)return}// Update useruserID:=r.PathValue("id")iferr:=updateUser(ctx,userID,req,presence);err!=nil{http.Error(w,err.Error(),http.StatusInternalServerError)return}w.WriteHeader(http.StatusOK)}
Custom Validation Methods
Order Validation
typeCreateOrderRequeststruct{UserIDint`json:"user_id"`Items[]OrderItem`json:"items"`CouponCodestring`json:"coupon_code"`Totalfloat64`json:"total"`}typeOrderItemstruct{ProductIDint`json:"product_id" validate:"required"`Quantityint`json:"quantity" validate:"required,min=1"`Pricefloat64`json:"price" validate:"required,min=0"`}func(r*CreateOrderRequest)ValidateContext(ctxcontext.Context)error{varverrvalidation.Error// Validate user existsif!userExists(ctx,r.UserID){verr.Add("user_id","not_found","user does not exist",nil)}// Validate itemsiflen(r.Items)==0{verr.Add("items","required","at least one item required",nil)}varcalculatedTotalfloat64fori,item:=ranger.Items{// Validate product and priceproduct,err:=getProduct(ctx,item.ProductID)iferr!=nil{verr.Add(fmt.Sprintf("items.%d.product_id",i),"not_found","product does not exist",nil,)continue}ifitem.Price!=product.Price{verr.Add(fmt.Sprintf("items.%d.price",i),"mismatch","price does not match current product price",map[string]any{"expected":product.Price,"actual":item.Price,},)}calculatedTotal+=item.Price*float64(item.Quantity)}// Validate couponifr.CouponCode!=""{discount,err:=validateCoupon(ctx,r.CouponCode)iferr!=nil{verr.Add("coupon_code","invalid",err.Error(),nil)}else{calculatedTotal-=discount}}// Validate totalifmath.Abs(r.Total-calculatedTotal)>0.01{verr.Add("total","mismatch","total does not match calculated amount",map[string]any{"expected":calculatedTotal,"actual":r.Total,},)}ifverr.HasErrors(){return&verr}returnnil}
Custom Validation Tags
Application Validator
packageappimport("regexp""unicode""github.com/go-playground/validator/v10""rivaas.dev/validation")var(phoneRegex=regexp.MustCompile(`^\+?[1-9]\d{1,14}$`)usernameRegex=regexp.MustCompile(`^[a-zA-Z0-9_]{3,20}$`))varValidator=validation.MustNew(// Custom tagsvalidation.WithCustomTag("phone",validatePhone),validation.WithCustomTag("username",validateUsername),validation.WithCustomTag("strong_password",validateStrongPassword),// Securityvalidation.WithRedactor(sensitiveFieldRedactor),validation.WithMaxErrors(20),// Custom messagesvalidation.WithMessages(map[string]string{"required":"is required","email":"must be a valid email address",}),)funcvalidatePhone(flvalidator.FieldLevel)bool{returnphoneRegex.MatchString(fl.Field().String())}funcvalidateUsername(flvalidator.FieldLevel)bool{returnusernameRegex.MatchString(fl.Field().String())}funcvalidateStrongPassword(flvalidator.FieldLevel)bool{password:=fl.Field().String()iflen(password)<8{returnfalse}varhasUpper,hasLower,hasDigit,hasSpecialboolfor_,c:=rangepassword{switch{caseunicode.IsUpper(c):hasUpper=truecaseunicode.IsLower(c):hasLower=truecaseunicode.IsDigit(c):hasDigit=truecaseunicode.IsPunct(c)||unicode.IsSymbol(c):hasSpecial=true}}returnhasUpper&&hasLower&&hasDigit&&hasSpecial}funcsensitiveFieldRedactor(pathstring)bool{pathLower:=strings.ToLower(path)returnstrings.Contains(pathLower,"password")||strings.Contains(pathLower,"token")||strings.Contains(pathLower,"secret")||strings.Contains(pathLower,"card")||strings.Contains(pathLower,"cvv")}
Using Custom Tags
typeRegistrationstruct{Usernamestring`validate:"required,username"`Phonestring`validate:"required,phone"`Passwordstring`validate:"required,strong_password"`}funcRegister(whttp.ResponseWriter,r*http.Request){varreqRegistrationjson.NewDecoder(r.Body).Decode(&req)iferr:=app.Validator.Validate(r.Context(),&req);err!=nil{handleValidationError(w,err)return}// Process registration}
import"rivaas.dev/router"funcHandler(c*router.Context)error{varreqCreateUserRequestiferr:=c.BindJSON(&req);err!=nil{returnc.JSON(http.StatusBadRequest,map[string]string{"error":"invalid JSON",})}iferr:=validator.Validate(c.Request().Context(),&req);err!=nil{varverr*validation.Erroriferrors.As(err,&verr){returnc.JSON(http.StatusUnprocessableEntity,map[string]any{"error":"validation_failed","fields":verr.Fields,})}returnerr}// Process requestreturnc.JSON(http.StatusOK,createUser(req))}
Integration with rivaas/app
import"rivaas.dev/app"funcHandler(c*app.Context)error{varreqCreateUserRequestiferr:=c.Bind(&req);err!=nil{returnerr// Automatically handled}// Validation happens automatically with app.Context// But you can also validate manually:iferr:=validator.Validate(c.Context(),&req);err!=nil{returnerr// Automatically converted to proper HTTP response}returnc.JSON(http.StatusOK,createUser(req))}
Performance Tips
Reuse Validator Instances
// Good - create oncevarappValidator=validation.MustNew(validation.WithMaxErrors(10),)funcHandler1(ctxcontext.Context,reqRequest1)error{returnappValidator.Validate(ctx,&req)}funcHandler2(ctxcontext.Context,reqRequest2)error{returnappValidator.Validate(ctx,&req)}// Bad - create every time (slow)funcHandler(ctxcontext.Context,reqRequest)error{validator:=validation.MustNew()returnvalidator.Validate(ctx,&req)}
Learn how to manage application configuration with the Rivaas config package
The Rivaas Config package provides configuration management for Go applications. It simplifies handling settings across different environments and formats. Follows the Twelve-Factor App methodology.
Features
Easy Integration: Simple and intuitive API
Flexible Sources: Load from files, environment variables (with custom prefixes), Consul, and easily extend with custom sources
Dynamic Paths: Use ${VAR} in file and Consul paths for environment-based configuration
Format Agnostic: Supports JSON, YAML, TOML, and other formats via extensible codecs
Type Casting: Built-in caster codecs for automatic type conversion (bool, int, float, time, duration, etc.)
Hierarchical Merging: Configurations from multiple sources are merged, with later sources overriding earlier ones
Struct Binding: Automatically map configuration data to Go structs
Built-in Validation: Validate configuration using struct methods, JSON Schemas, or custom functions
If you see “Config package installed successfully!”, the installation is complete!
Import Path
Always import the config package using:
import"rivaas.dev/config"
Additional Packages
Depending on your use case, you may also want to import sub-packages:
import("rivaas.dev/config""rivaas.dev/config/codec"// For custom codecs"rivaas.dev/config/dumper"// For custom dumpers"rivaas.dev/config/source"// For custom sources)
Common Issues
Go Version Too Old
If you get an error about Go version:
go: rivaas.dev/config requires go >= 1.25
Update your Go installation to version 1.25 or higher:
For complete API documentation, visit the API Reference.
2.5.2 - Basic Usage
Learn the fundamentals of loading and accessing configuration with Rivaas
This guide covers the essential operations for working with the config package. Learn how to load configuration files, access values, and handle errors.
Loading Configuration Files
The config package automatically detects file formats based on the file extension. Supported formats include JSON, YAML, and TOML.
Simple File Loading
packagemainimport("context""log""rivaas.dev/config")funcmain(){cfg:=config.MustNew(config.WithFile("config.yaml"),)iferr:=cfg.Load(context.Background());err!=nil{log.Fatalf("failed to load config: %v",err)}}
Multiple File Formats
You can load multiple configuration files of different formats:
Files are processed in order. Later files override values from earlier ones, enabling environment-specific overrides.
Environment Variables in Paths
You can use environment variables in file paths. This is useful when different environments use different directories:
// Use ${VAR} or $VAR in pathscfg:=config.MustNew(config.WithFile("${CONFIG_DIR}/app.yaml"),// Expands to actual directoryconfig.WithFile("${APP_ENV}/overrides.yaml"),// e.g., "production/overrides.yaml")
This works with all path-based options: WithFile, WithFileAs, WithConsul, WithConsulAs, WithFileDumper, and WithFileDumperAs.
Built-in Format Support
The config package includes built-in codecs for common formats:
Format
Extension
Codec Type
JSON
.json
codec.TypeJSON
YAML
.yaml, .yml
codec.TypeYAML
TOML
.toml
codec.TypeTOML
Environment Variables
-
codec.TypeEnvVar
Accessing Configuration Values
Once loaded, access configuration using dot notation and type-safe getters.
Dot Notation
Navigate nested configuration structures using dots:
The config package provides type-safe getters for common data types:
// Basic typesstringVal:=cfg.String("key")intVal:=cfg.Int("key")boolVal:=cfg.Bool("key")floatVal:=cfg.Float64("key")// Time and durationduration:=cfg.Duration("timeout")timestamp:=cfg.Time("created_at")// Collectionsslice:=cfg.StringSlice("tags")mapping:=cfg.StringMap("metadata")
This approach is ideal when you want simple access with sensible defaults.
Default Value Form (Or Methods)
Or methods provide explicit fallback values:
host:=cfg.StringOr("host","localhost")// Returns "localhost" if missingport:=cfg.IntOr("port",8080)// Returns 8080 if missingdebug:=cfg.BoolOr("debug",false)// Returns false if missingtimeout:=cfg.DurationOr("timeout",30*time.Second)// Returns 30s if missing
Error Returning Form (E Methods)
Use GetE for explicit error handling:
port,err:=config.GetE[int](cfg,"server.port")iferr!=nil{returnfmt.Errorf("invalid port configuration: %w",err)}// Errors provide context// Example: "config error: key 'server.port' not found"
ConfigError Structure
When errors occur during loading, they’re wrapped in ConfigError:
typeConfigErrorstruct{Sourcestring// Where the error occurred (e.g., "source[0]")Fieldstring// Specific field with the errorOperationstring// Operation being performed (e.g., "load")Errerror// Underlying error}
Example error handling during load:
iferr:=cfg.Load(context.Background());err!=nil{// Error message includes context:// "config error in source[0] during load: file not found: config.yaml"log.Fatalf("configuration error: %v",err)}
Nil-Safe Operations
All getter methods handle nil Config instances gracefully:
varcfg*config.Config// nil// Short methods return zero values (no panic)cfg.String("key")// Returns ""cfg.Int("key")// Returns 0cfg.Bool("key")// Returns false// Error methods return errorsport,err:=config.GetE[int](cfg,"key")// err: "config instance is nil"
Complete Example
Putting it all together:
packagemainimport("context""log""time""rivaas.dev/config")funcmain(){// Create config with file sourcecfg:=config.MustNew(config.WithFile("config.yaml"),)// Load configurationiferr:=cfg.Load(context.Background());err!=nil{log.Fatalf("failed to load config: %v",err)}// Access values with different approaches// Simple access (with zero values for missing keys)host:=cfg.String("server.host")// With defaultsport:=cfg.IntOr("server.port",8080)debug:=cfg.BoolOr("debug",false)// With error handlingtimeout,err:=config.GetE[time.Duration](cfg,"server.timeout")iferr!=nil{log.Printf("using default timeout: %v",err)timeout=30*time.Second}log.Printf("Server: %s:%d (debug: %v, timeout: %v)",host,port,debug,timeout)}
Master environment variable integration with hierarchical naming conventions
The config package provides powerful environment variable support. It automatically maps environment variables to nested configuration structures. This follows the Twelve-Factor App methodology for configuration management.
Basic Usage
Enable environment variable support with a custom prefix:
cfg:=config.MustNew(config.WithFile("config.yaml"),config.WithEnv("MYAPP_"),// Only env vars with MYAPP_ prefix)
The prefix helps avoid conflicts with system or other application variables.
Naming Convention
The config package uses a hierarchical naming convention where underscores (_) in environment variable names create nested configuration structures.
Transformation Rules
Strip prefix: Remove the configured prefix like MYAPP_.
Convert to lowercase: DATABASE_HOST → database_host.
Split by underscores: database_host → ["database", "host"].
Filter empty parts: Consecutive underscores create no extra levels.
varappConfigConfigcfg:=config.MustNew(config.WithEnv("MYAPP_"),config.WithBinding(&appConfig),)iferr:=cfg.Load(context.Background());err!=nil{log.Fatalf("config error: %v",err)}// appConfig is now populated from environment variables
Advanced Nested Structures
For complex applications with deeply nested configuration:
Always use application-specific prefixes to avoid conflicts:
# Good - Application-specificexportMYAPP_DATABASE_HOST=localhost
exportWEBAPP_DATABASE_HOST=localhost
# Avoid - Too genericexportDATABASE_HOST=localhost
Document required and optional environment variables:
# Required environment variables:# MYAPP_SERVER_HOST - Server hostname (default: localhost)# MYAPP_SERVER_PORT - Server port (default: 8080)# MYAPP_DATABASE_HOST - Database hostname (required)# MYAPP_DATABASE_PORT - Database port (default: 5432)
4. Validate Configuration
Use struct validation to ensure required variables are set:
func(c*Config)Validate()error{ifc.Server.Host==""{returnerrors.New("MYAPP_SERVER_HOST is required")}ifc.Server.Port<=0{returnerrors.New("MYAPP_SERVER_PORT must be positive")}returnnil}
Merging with Other Sources
Environment variables can override file-based configuration:
cfg:=config.MustNew(config.WithFile("config.yaml"),// Base configurationconfig.WithFile("config.prod.yaml"),// Production overridesconfig.WithEnv("MYAPP_"),// Environment overrides all files)
Source precedence: Later sources override earlier ones. Environment variables, being last, have the highest priority.
Complete Example
packagemainimport("context""log""rivaas.dev/config")typeAppConfigstruct{Serverstruct{Hoststring`config:"host"`Portint`config:"port"`}`config:"server"`Databasestruct{Hoststring`config:"host"`Portint`config:"port"`Usernamestring`config:"username"`Passwordstring`config:"password"`}`config:"database"`}func(c*AppConfig)Validate()error{ifc.Database.Host==""{returnerrors.New("database host is required")}ifc.Database.Username==""{returnerrors.New("database username is required")}returnnil}funcmain(){varappConfigAppConfigcfg:=config.MustNew(config.WithFile("config.yaml"),// Base configconfig.WithEnv("MYAPP_"),// Override with env varsconfig.WithBinding(&appConfig),)iferr:=cfg.Load(context.Background());err!=nil{log.Fatalf("failed to load config: %v",err)}log.Printf("Server: %s:%d",appConfig.Server.Host,appConfig.Server.Port)log.Printf("Database: %s:%d",appConfig.Database.Host,appConfig.Database.Port)}
See Validation to ensure configuration correctness
For technical details on the environment variable codec, see Codecs Reference.
2.5.4 - Struct Binding
Automatically map configuration data to Go structs with type safety
Struct binding allows you to automatically map configuration data to your own Go structs. This provides type safety and a clean, idiomatic way to work with configuration.
Basic Struct Binding
Define a struct and bind it during configuration initialization:
typeConfigstruct{Portint`config:"port"`Hoststring`config:"host"`}varcConfigcfg:=config.MustNew(config.WithFile("config.yaml"),config.WithBinding(&c),)iferr:=cfg.Load(context.Background());err!=nil{log.Fatalf("failed to load config: %v",err)}// c.Port and c.Host are now populatedlog.Printf("Server: %s:%d",c.Host,c.Port)
Important
Always pass a **pointer** to your struct with `WithBinding(&c)`, not the struct value itself.
Config Tags
Use the config tag to specify the configuration key for each field:
varcConfigcfg:=config.MustNew(config.WithFile("config.yaml"),// May not exist or be incompleteconfig.WithBinding(&c),)cfg.Load(context.Background())// Fields use defaults if not present in config.yaml
Nested Structs
Create hierarchical configuration by nesting structs:
The config package automatically converts between compatible types:
typeConfigstruct{Portint`config:"port"`// Converts from string "8080"Debugbool`config:"debug"`// Converts from string "true"Timeouttime.Duration`config:"timeout"`// Converts from string "30s"}
YAML (as strings):
port:"8080"# String converted to intdebug:"true"# String converted to booltimeout:"30s"# String converted to time.Duration
Common Issues and Solutions
Issue: Struct Not Populating
Problem: Fields remain at zero values after loading.
Solutions:
Pass a pointer: Use WithBinding(&c), not WithBinding(c)
Check tag names: Ensure config tags match your configuration structure
// If your YAML has "server_port", use:Portint`config:"server_port"`// Not:Portint`config:"port"`
Verify nested tags: All nested structs need the config tag
// Wrong - missing tag on Server structtypeConfigstruct{Serverstruct{Portint`config:"port"`}// Missing `config:"server"`}// CorrecttypeConfigstruct{Serverstruct{Portint`config:"port"`}`config:"server"`}
Issue: Type Mismatch Errors
Problem: Error during binding due to type incompatibility.
Solution: Ensure your struct types match the configuration data types or are compatible:
// If YAML has: port: 8080 (number)Portint`config:"port"`// Correct// If YAML has: port: "8080" (string)Portint`config:"port"`// Still works - automatic conversion
Issue: Optional Fields Always Present
Problem: Want to distinguish between “not set” and “set to zero value”.
Solution: Use pointer types:
typeConfigstruct{// Can't distinguish "not set" vs "set to 0"MaxConnectionsint`config:"max_connections"`// Can distinguish: nil = not set, &0 = set to 0MaxConnections*int`config:"max_connections"`}
Complete Example
packagemainimport("context""log""time""rivaas.dev/config")typeAppConfigstruct{Serverstruct{Hoststring`config:"host" default:"localhost"`Portint`config:"port" default:"8080"`Timeouttime.Duration`config:"timeout" default:"30s"`}`config:"server"`Databasestruct{Hoststring`config:"host"`Portint`config:"port" default:"5432"`Usernamestring`config:"username"`Passwordstring`config:"password"`MaxConns*int`config:"max_connections"`// Optional}`config:"database"`Featuresstruct{EnableCachebool`config:"enable_cache" default:"true"`EnableAuthbool`config:"enable_auth" default:"true"`}`config:"features"`}func(c*AppConfig)Validate()error{ifc.Database.Host==""{returnerrors.New("database host is required")}ifc.Database.Username==""{returnerrors.New("database username is required")}returnnil}funcmain(){varappConfigAppConfigcfg:=config.MustNew(config.WithFile("config.yaml"),config.WithEnv("MYAPP_"),config.WithBinding(&appConfig),)iferr:=cfg.Load(context.Background());err!=nil{log.Fatalf("failed to load config: %v",err)}log.Printf("Server: %s:%d (timeout: %v)",appConfig.Server.Host,appConfig.Server.Port,appConfig.Server.Timeout)log.Printf("Database: %s:%d",appConfig.Database.Host,appConfig.Database.Port)ifappConfig.Database.MaxConns!=nil{log.Printf("Max DB connections: %d",*appConfig.Database.MaxConns)}}
Validate configuration to catch errors early and ensure application correctness
The config package supports multiple validation strategies. These help catch configuration errors early. They ensure your application runs with correct settings.
Validation Strategies
The config package provides three validation approaches:
Struct-based validation - Implement Validate() error on your struct.
JSON Schema validation - Validate against a JSON Schema.
Custom validation functions - Use custom validation logic.
Struct-Based Validation
The most idiomatic approach for Go applications. Implement the Validate() method on your configuration struct:
typeValidatorinterface{Validate()error}
Basic Example
typeConfigstruct{Portint`config:"port"`Hoststring`config:"host"`}func(c*Config)Validate()error{ifc.Port<=0||c.Port>65535{returnerrors.New("port must be between 1 and 65535")}ifc.Host==""{returnerrors.New("host is required")}returnnil}varcfgConfigconfig:=config.MustNew(config.WithFile("config.yaml"),config.WithBinding(&cfg),)// Validation runs automatically during Load()iferr:=config.Load(context.Background());err!=nil{log.Fatalf("invalid configuration: %v",err)}
Complex Validation
Validate nested structures and relationships:
typeAppConfigstruct{Serverstruct{Hoststring`config:"host"`Portint`config:"port"`TLSstruct{Enabledbool`config:"enabled"`CertFilestring`config:"cert_file"`KeyFilestring`config:"key_file"`}`config:"tls"`}`config:"server"`Databasestruct{Hoststring`config:"host"`Portint`config:"port"`MaxConnsint`config:"max_connections"`IdleConnsint`config:"idle_connections"`}`config:"database"`}func(c*AppConfig)Validate()error{// Server validationifc.Server.Port<1||c.Server.Port>65535{returnfmt.Errorf("server.port must be between 1-65535, got %d",c.Server.Port)}// TLS validationifc.Server.TLS.Enabled{ifc.Server.TLS.CertFile==""{returnerrors.New("server.tls.cert_file required when TLS enabled")}ifc.Server.TLS.KeyFile==""{returnerrors.New("server.tls.key_file required when TLS enabled")}}// Database validationifc.Database.Host==""{returnerrors.New("database.host is required")}ifc.Database.MaxConns<c.Database.IdleConns{returnfmt.Errorf("database.max_connections (%d) must be >= idle_connections (%d)",c.Database.MaxConns,c.Database.IdleConns)}returnnil}
Field-Level Validation
Create reusable validation helpers:
funcvalidatePort(portint)error{ifport<1||port>65535{returnfmt.Errorf("invalid port %d: must be between 1-65535",port)}returnnil}funcvalidateHostname(hoststring)error{ifhost==""{returnerrors.New("hostname cannot be empty")}iflen(host)>253{returnerrors.New("hostname too long (max 253 characters)")}returnnil}func(c*Config)Validate()error{iferr:=validatePort(c.Server.Port);err!=nil{returnfmt.Errorf("server.port: %w",err)}iferr:=validateHostname(c.Server.Host);err!=nil{returnfmt.Errorf("server.host: %w",err)}returnnil}
JSON Schema Validation
What is JSON Schema? JSON Schema is a standard for describing the structure and validation rules of JSON data. It allows you to define required fields, data types, value constraints, and more.
Validate the merged configuration map against a JSON Schema:
schemaBytes,err:=os.ReadFile("schema.json")iferr!=nil{log.Fatalf("failed to read schema: %v",err)}cfg:=config.MustNew(config.WithFile("config.yaml"),config.WithJSONSchema(schemaBytes),)// Schema validation runs during Load()iferr:=cfg.Load(context.Background());err!=nil{log.Fatalf("configuration validation failed: %v",err)}
JSON Schema validation is applied to the merged configuration map (`map[string]any`), not directly to Go structs. It happens before struct binding.
Register custom validation functions for flexible validation logic:
cfg:=config.MustNew(config.WithFile("config.yaml"),config.WithValidator(func(datamap[string]any)error{// Validate the configuration mapport,ok:=data["port"].(int)if!ok{returnerrors.New("port must be an integer")}ifport<=0{returnerrors.New("port must be positive")}returnnil}),)
Multiple Validators
You can register multiple validators - all will be executed:
cfg:=config.MustNew(config.WithFile("config.yaml"),config.WithValidator(validatePorts),config.WithValidator(validateHosts),config.WithValidator(validateFeatures),)funcvalidatePorts(datamap[string]any)error{// Port validation logic}funcvalidateHosts(datamap[string]any)error{// Host validation logic}funcvalidateFeatures(datamap[string]any)error{// Feature flag validation logic}
typeAppConfigstruct{Serverstruct{Portint`config:"port"`Hoststring`config:"host"`}`config:"server"`}func(c*AppConfig)Validate()error{ifc.Server.Port<=0{returnerrors.New("server.port must be positive")}returnnil}varappConfigAppConfigschemaBytes,_:=os.ReadFile("schema.json")cfg:=config.MustNew(config.WithFile("config.yaml"),config.WithJSONSchema(schemaBytes),// 1. Schema validationconfig.WithValidator(customValidation),// 2. Custom validationconfig.WithBinding(&appConfig),// 3. Struct binding + validation)funccustomValidation(datamap[string]any)error{// Custom validation logicreturnnil}
All three validations will run in sequence.
Error Handling
Validation errors are wrapped in ConfigError with context:
iferr:=cfg.Load(context.Background());err!=nil{// Error format examples:// "config error in json-schema during validate: server.port: value must be >= 1"// "config error in binding during validate: port must be positive"log.Printf("Validation failed: %v",err)}
Best Practices
1. Prefer Struct Validation
For Go applications, struct-based validation is most idiomatic:
// Badreturnerrors.New("invalid value")// Goodreturnfmt.Errorf("server.port must be between 1-65535, got %d",c.Server.Port)
3. Validate Relationships
Check dependencies between fields:
func(c*Config)Validate()error{ifc.TLS.Enabled&&c.TLS.CertFile==""{returnerrors.New("tls.cert_file required when tls.enabled is true")}returnnil}
4. Use JSON Schema for APIs
When exposing configuration via APIs or accepting external config:
// Validate external configuration against schemacfg:=config.MustNew(config.WithContent(externalConfigBytes,codec.TypeJSON),config.WithJSONSchema(schemaBytes),)
5. Fail Fast
Validate during initialization, not at runtime:
funcmain(){cfg:=loadConfig()// Validates during Load()// If we reach here, config is validstartServer(cfg)}
Complete Example
packagemainimport("context""errors""fmt""log""os""rivaas.dev/config")typeAppConfigstruct{Serverstruct{Hoststring`config:"host"`Portint`config:"port"`TLSstruct{Enabledbool`config:"enabled"`CertFilestring`config:"cert_file"`KeyFilestring`config:"key_file"`}`config:"tls"`}`config:"server"`Databasestruct{Hoststring`config:"host"`Portint`config:"port"`MaxConnsint`config:"max_connections"`}`config:"database"`}func(c*AppConfig)Validate()error{// Server validationifc.Server.Port<1||c.Server.Port>65535{returnfmt.Errorf("server.port must be 1-65535, got %d",c.Server.Port)}// TLS validationifc.Server.TLS.Enabled{ifc.Server.TLS.CertFile==""{returnerrors.New("server.tls.cert_file required when TLS enabled")}if_,err:=os.Stat(c.Server.TLS.CertFile);err!=nil{returnfmt.Errorf("server.tls.cert_file not found: %w",err)}}// Database validationifc.Database.Host==""{returnerrors.New("database.host is required")}ifc.Database.MaxConns<=0{returnerrors.New("database.max_connections must be positive")}returnnil}funcmain(){varappConfigAppConfigschemaBytes,err:=os.ReadFile("schema.json")iferr!=nil{log.Fatalf("failed to read schema: %v",err)}cfg:=config.MustNew(config.WithFile("config.yaml"),config.WithEnv("MYAPP_"),config.WithJSONSchema(schemaBytes),config.WithBinding(&appConfig),)iferr:=cfg.Load(context.Background());err!=nil{log.Fatalf("configuration validation failed: %v",err)}log.Println("Configuration validated successfully!")log.Printf("Server: %s:%d",appConfig.Server.Host,appConfig.Server.Port)}
For technical details on error handling, see Troubleshooting.
2.5.6 - Multiple Sources
Combine configuration from files, environment variables, and remote sources
The config package supports loading configuration from multiple sources simultaneously. This enables powerful patterns like base configuration with environment-specific overrides.
Source Precedence
When multiple sources are configured, later sources override earlier ones:
cfg:=config.MustNew(config.WithFile("config.yaml"),// Base configurationconfig.WithFile("config.prod.yaml"),// Production overridesconfig.WithEnv("MYAPP_"),// Environment overrides all)
// Configuration from HTTP responseresp,_:=http.Get("https://config-server/config.json")configBytes,_:=io.ReadAll(resp.Body)cfg:=config.MustNew(config.WithContent(configBytes,codec.TypeJSON),)
**Works without Consul:** If `CONSUL_HTTP_ADDR` isn't set, `WithConsul` does nothing. This means you can run your app locally without Consul. When you deploy to production, just set the environment variable and Consul will be used.
typeDatabaseSourcestruct{db*sql.DB}func(s*DatabaseSource)Load(ctxcontext.Context)(map[string]any,error){rows,err:=s.db.QueryContext(ctx,"SELECT key, value FROM config")iferr!=nil{returnnil,err}deferrows.Close()config:=make(map[string]any)forrows.Next(){varkey,valuestringiferr:=rows.Scan(&key,&value);err!=nil{returnnil,err}config[key]=value}returnconfig,nil}// Usagecfg:=config.MustNew(config.WithSource(&DatabaseSource{db:db}),)
There are two ways to handle environment-specific configuration.
Using Path Expansion (Recommended)
The simplest approach is to use environment variables directly in paths:
cfg:=config.MustNew(config.WithFile("config.yaml"),// Base configconfig.WithFile("${APP_ENV}/config.yaml"),// Environment-specific (e.g., "production/config.yaml")config.WithEnv("MYAPP_"),// Environment variables)
This is cleaner and works great when your config files are in environment-named folders.
Using String Concatenation
If you need more control or want to set a default, use Go code:
packagemainimport("context""log""os""rivaas.dev/config")funcloadConfig()*config.Config{env:=os.Getenv("APP_ENV")ifenv==""{env="development"}cfg:=config.MustNew(config.WithFile("config.yaml"),// Base configconfig.WithFile("config."+env+".yaml"),// Environment-specificconfig.WithEnv("MYAPP_"),// Environment variables)iferr:=cfg.Load(context.Background());err!=nil{log.Fatalf("failed to load config: %v",err)}returncfg}funcmain(){cfg:=loadConfig()// Use configuration}
File structure:
config.yaml # Base configuration
config.development.yaml
config.staging.yaml
config.production.yaml
Dumping Configuration
Save the effective merged configuration to a file:
cfg:=config.MustNew(config.WithFile("config.yaml"),config.WithFile("config.prod.yaml"),config.WithEnv("MYAPP_"),config.WithFileDumper("effective-config.yaml"),)cfg.Load(context.Background())cfg.Dump(context.Background())// Writes merged config to effective-config.yaml
Errors from sources include context about which source failed:
iferr:=cfg.Load(context.Background());err!=nil{// Error format:// "config error in source[0] during load: file not found: config.yaml"// "config error in source[2] during load: consul key not found"log.Printf("Configuration error: %v",err)}
Complete Example
packagemainimport("context""log""os""rivaas.dev/config""rivaas.dev/config/codec")typeAppConfigstruct{Serverstruct{Hoststring`config:"host"`Portint`config:"port"`}`config:"server"`Databasestruct{Hoststring`config:"host"`Portint`config:"port"`}`config:"database"`}funcmain(){env:=os.Getenv("APP_ENV")ifenv==""{env="development"}varappConfigAppConfigcfg:=config.MustNew(// Base configurationconfig.WithFile("config.yaml"),// Environment-specific overridesconfig.WithFile("config."+env+".yaml"),// Remote configuration (production only)func()config.Option{ifenv=="production"{returnconfig.WithConsul("production/myapp.json")}returnnil}(),// Environment variables (highest priority)config.WithEnv("MYAPP_"),// Struct bindingconfig.WithBinding(&appConfig),// Dump effective config for debuggingconfig.WithFileDumper("effective-config.yaml"),)iferr:=cfg.Load(context.Background());err!=nil{log.Fatalf("failed to load configuration: %v",err)}// Save effective configurationiferr:=cfg.Dump(context.Background());err!=nil{log.Printf("warning: failed to dump config: %v",err)}log.Printf("Server: %s:%d",appConfig.Server.Host,appConfig.Server.Port)log.Printf("Database: %s:%d",appConfig.Database.Host,appConfig.Database.Port)}
Encode(v any) ([]byte, error) - Convert Go data structures to bytes.
Decode(data []byte, v any) error - Convert bytes to Go data structures.
Built-in Codecs
The config package includes several built-in codecs.
Format Codecs
Codec
Type
Capabilities
JSON
codec.TypeJSON
Encode & Decode
YAML
codec.TypeYAML
Encode & Decode
TOML
codec.TypeTOML
Encode & Decode
EnvVar
codec.TypeEnvVar
Decode only
Caster Codecs
Caster codecs handle type conversion.
Codec
Type
Converts To
Bool
codec.TypeCasterBool
bool
Int
codec.TypeCasterInt
int
Int8/16/32/64
codec.TypeCasterInt8, etc.
int8, int16, etc.
Uint
codec.TypeCasterUint
uint
Uint8/16/32/64
codec.TypeCasterUint8, etc.
uint8, uint16, etc.
Float32/64
codec.TypeCasterFloat32, codec.TypeCasterFloat64
float32, float64
String
codec.TypeCasterString
string
Time
codec.TypeCasterTime
time.Time
Duration
codec.TypeCasterDuration
time.Duration
Implementing a Custom Codec
Basic Example: INI Format
Let’s implement a simple INI file codec.
packageinicodecimport("bufio""bytes""fmt""strings""rivaas.dev/config/codec")typeINICodecstruct{}func(cINICodec)Decode(data[]byte,vany)error{result:=make(map[string]any)scanner:=bufio.NewScanner(bytes.NewReader(data))varcurrentSectionstringforscanner.Scan(){line:=strings.TrimSpace(scanner.Text())// Skip empty lines and commentsifline==""||strings.HasPrefix(line,";")||strings.HasPrefix(line,"#"){continue}// Section headerifstrings.HasPrefix(line,"[")&&strings.HasSuffix(line,"]"){currentSection=strings.Trim(line,"[]")ifresult[currentSection]==nil{result[currentSection]=make(map[string]any)}continue}// Key-value pairparts:=strings.SplitN(line,"=",2)iflen(parts)!=2{continue}key:=strings.TrimSpace(parts[0])value:=strings.TrimSpace(parts[1])ifcurrentSection!=""{section:=result[currentSection].(map[string]any)section[key]=value}else{result[key]=value}}// Type assertion to set resulttarget:=v.(*map[string]any)*target=resultreturnscanner.Err()}func(cINICodec)Encode(vany)([]byte,error){data,ok:=v.(map[string]any)if!ok{returnnil,fmt.Errorf("expected map[string]any, got %T",v)}varbufbytes.Bufferforsection,values:=rangedata{sectionMap,ok:=values.(map[string]any)if!ok{// Top-level key-valuebuf.WriteString(fmt.Sprintf("%s = %v\n",section,values))continue}// Section headerbuf.WriteString(fmt.Sprintf("[%s]\n",section))// Section key-valuesforkey,value:=rangesectionMap{buf.WriteString(fmt.Sprintf("%s = %v\n",key,value))}buf.WriteString("\n")}returnbuf.Bytes(),nil}funcinit(){codec.RegisterEncoder("ini",INICodec{})codec.RegisterDecoder("ini",INICodec{})}
Using the Custom Codec
packagemainimport("context""log""rivaas.dev/config"_"yourmodule/inicodec"// Register codec via init())funcmain(){cfg:=config.MustNew(config.WithFileAs("config.ini","ini"),)iferr:=cfg.Load(context.Background());err!=nil{log.Fatalf("failed to load config: %v",err)}host:=cfg.String("server.host")port:=cfg.Int("server.port")log.Printf("Server: %s:%d",host,port)}
RegisterEncoder(name string, encoder Codec) - Register for encoding
RegisterDecoder(name string, decoder Codec) - Register for decoding
You can register the same codec for both or different codecs for each operation.
Decode-Only Codecs
Some codecs only support decoding (like the built-in EnvVar codec):
typeEnvVarCodecstruct{}func(cEnvVarCodec)Decode(data[]byte,vany)error{// Decode environment variable format// ...}func(cEnvVarCodec)Encode(vany)([]byte,error){returnnil,errors.New("encoding to environment variables not supported")}funcinit(){codec.RegisterDecoder("envvar",EnvVarCodec{})// Note: Not registering encoder}
Advanced Example: XML Codec
A more complete example with error handling:
packagexmlcodecimport("encoding/xml""fmt""rivaas.dev/config/codec")typeXMLCodecstruct{}func(cXMLCodec)Decode(data[]byte,vany)error{target,ok:=v.(*map[string]any)if!ok{returnfmt.Errorf("expected *map[string]any, got %T",v)}// XML unmarshaling to intermediate structurevarintermediatestruct{XMLNamexml.NameContent[]byte`xml:",innerxml"`}iferr:=xml.Unmarshal(data,&intermediate);err!=nil{returnfmt.Errorf("xml decode error: %w",err)}// Convert XML to map structureresult:=make(map[string]any)// ... conversion logic ...*target=resultreturnnil}func(cXMLCodec)Encode(vany)([]byte,error){data,ok:=v.(map[string]any)if!ok{returnnil,fmt.Errorf("expected map[string]any, got %T",v)}// Convert map to XML structurexmlData,err:=xml.MarshalIndent(data,""," ")iferr!=nil{returnnil,fmt.Errorf("xml encode error: %w",err)}returnxmlData,nil}funcinit(){codec.RegisterEncoder("xml",XMLCodec{})codec.RegisterDecoder("xml",XMLCodec{})}
Caster Codecs
Caster codecs provide type conversion. You typically don’t need to implement these - use the built-in casters:
import"rivaas.dev/config/codec"// Get int value with automatic conversionport:=cfg.Int("server.port")// Uses codec.TypeCasterInt internally// Get duration with automatic conversiontimeout:=cfg.Duration("timeout")// Uses codec.TypeCasterDuration internally
A complete example demonstrating advanced features with a realistic web application configuration.
Features:
Mixed configuration sources
Complex nested structures
Validation
Comprehensive testing
Production-ready patterns
Best for: Production applications, learning advanced features, understanding best practices
Quick start:
cd config/examples/comprehensive
go test -v
go run main.go
Dynamic Paths with Environment Variables
You can use environment variables in file and Consul paths. This makes it easy to use different configurations based on your environment.
Basic Path Expansion
// Set APP_ENV=production in your environmentcfg:=config.MustNew(config.WithFile("config.yaml"),// Base configconfig.WithFile("${APP_ENV}/config.yaml"),// Becomes "production/config.yaml")
Multiple Variables
You can use several variables in one path:
// Set REGION=us-west and ENV=stagingcfg:=config.MustNew(config.WithFile("${REGION}/${ENV}/app.yaml"),// Becomes "us-west/staging/app.yaml")
Consul Paths
This also works with Consul:
// Set APP_ENV=productioncfg:=config.MustNew(config.WithFile("config.yaml"),config.WithConsul("${APP_ENV}/service.yaml"),// Fetches from Consul: "production/service.yaml")
Output Paths
You can also use variables in dumper paths:
// Set LOG_DIR=/var/log/myappcfg:=config.MustNew(config.WithFile("config.yaml"),config.WithFileDumper("${LOG_DIR}/effective-config.yaml"),// Writes to /var/log/myapp/)
**Important:** Shell-style defaults like `${VAR:-default}` are NOT supported. If a variable is not set, it expands to an empty string. Set defaults in your code before calling the config options.
Production Configuration Example
Here’s a complete production-ready configuration pattern:
packagemainimport("context""errors""fmt""log""os""time""rivaas.dev/config")typeAppConfigstruct{Serverstruct{Hoststring`config:"host" default:"localhost"`Portint`config:"port" default:"8080"`ReadTimeouttime.Duration`config:"read_timeout" default:"30s"`WriteTimeouttime.Duration`config:"write_timeout" default:"30s"`TLSstruct{Enabledbool`config:"enabled" default:"false"`CertFilestring`config:"cert_file"`KeyFilestring`config:"key_file"`}`config:"tls"`}`config:"server"`Databasestruct{Primarystruct{Hoststring`config:"host"`Portint`config:"port" default:"5432"`Databasestring`config:"database"`Usernamestring`config:"username"`Passwordstring`config:"password"`SSLModestring`config:"ssl_mode" default:"require"`}`config:"primary"`Replicastruct{Hoststring`config:"host"`Portint`config:"port" default:"5432"`Databasestring`config:"database"`}`config:"replica"`Poolstruct{MaxOpenConnsint`config:"max_open_conns" default:"25"`MaxIdleConnsint`config:"max_idle_conns" default:"5"`ConnMaxLifetimetime.Duration`config:"conn_max_lifetime" default:"5m"`}`config:"pool"`}`config:"database"`Redisstruct{Hoststring`config:"host" default:"localhost"`Portint`config:"port" default:"6379"`Databaseint`config:"database" default:"0"`Passwordstring`config:"password"`Timeouttime.Duration`config:"timeout" default:"5s"`}`config:"redis"`Authstruct{JWTSecretstring`config:"jwt_secret"`TokenDurationtime.Duration`config:"token_duration" default:"24h"`}`config:"auth"`Loggingstruct{Levelstring`config:"level" default:"info"`Formatstring`config:"format" default:"json"`Outputstring`config:"output" default:"/var/log/app.log"`}`config:"logging"`Monitoringstruct{Enabledbool`config:"enabled" default:"true"`MetricsPortint`config:"metrics_port" default:"9090"`HealthPathstring`config:"health_path" default:"/health"`}`config:"monitoring"`Featuresstruct{RateLimitbool`config:"rate_limit" default:"true"`Cachebool`config:"cache" default:"true"`DebugModebool`config:"debug_mode" default:"false"`}`config:"features"`}func(c*AppConfig)Validate()error{// Server validationifc.Server.Port<1||c.Server.Port>65535{returnfmt.Errorf("server.port must be 1-65535, got %d",c.Server.Port)}// TLS validationifc.Server.TLS.Enabled{ifc.Server.TLS.CertFile==""{returnerrors.New("server.tls.cert_file required when TLS enabled")}ifc.Server.TLS.KeyFile==""{returnerrors.New("server.tls.key_file required when TLS enabled")}}// Database validationifc.Database.Primary.Host==""{returnerrors.New("database.primary.host is required")}ifc.Database.Primary.Database==""{returnerrors.New("database.primary.database is required")}ifc.Database.Primary.Username==""{returnerrors.New("database.primary.username is required")}ifc.Database.Primary.Password==""{returnerrors.New("database.primary.password is required")}// Auth validationifc.Auth.JWTSecret==""{returnerrors.New("auth.jwt_secret is required")}iflen(c.Auth.JWTSecret)<32{returnerrors.New("auth.jwt_secret must be at least 32 characters")}returnnil}funcloadConfig()(*AppConfig,error){varappConfigAppConfig// Determine environmentenv:=os.Getenv("APP_ENV")ifenv==""{env="development"}cfg:=config.MustNew(// Base configurationconfig.WithFile("config.yaml"),// Environment-specific configurationconfig.WithFile(fmt.Sprintf("config.%s.yaml",env)),// Environment variables (highest priority)config.WithEnv("MYAPP_"),// Struct binding with validationconfig.WithBinding(&appConfig),)iferr:=cfg.Load(context.Background());err!=nil{returnnil,fmt.Errorf("failed to load configuration: %w",err)}return&appConfig,nil}funcmain(){appConfig,err:=loadConfig()iferr!=nil{log.Fatalf("Configuration error: %v",err)}log.Printf("Server: %s:%d",appConfig.Server.Host,appConfig.Server.Port)log.Printf("Database: %s:%d/%s",appConfig.Database.Primary.Host,appConfig.Database.Primary.Port,appConfig.Database.Primary.Database)log.Printf("Redis: %s:%d",appConfig.Redis.Host,appConfig.Redis.Port)log.Printf("Features: RateLimit=%v, Cache=%v, Debug=%v",appConfig.Features.RateLimit,appConfig.Features.Cache,appConfig.Features.DebugMode)}
Multi-Environment Setup
Organize configuration for different environments:
File structure:
config/
├── config.yaml # Base configuration (shared defaults)
├── config.development.yaml # Development overrides
├── config.staging.yaml # Staging overrides
├── config.production.yaml # Production overrides
└── config.test.yaml # Test overrides
# config.yaml - No secretsdatabase:primary:host:localhostport:5432database:myapp# username and password from environment
# Environment variables for secretsexportMYAPP_DATABASE_PRIMARY_USERNAME=admin
exportMYAPP_DATABASE_PRIMARY_PASSWORD=secret123
Pattern 2: Feature Flags
Use configuration for feature flags:
typeConfigstruct{Featuresstruct{NewUIbool`config:"new_ui" default:"false"`BetaFeaturesbool`config:"beta_features" default:"false"`Analyticsbool`config:"analytics" default:"true"`}`config:"features"`}// In application codeifappConfig.Features.NewUI{// Use new UI}else{// Use old UI}
Pattern 3: Dynamic Reloading
For applications that need dynamic configuration updates (advanced):
Learn how to generate OpenAPI specifications from Go code with automatic parameter discovery and schema generation
The Rivaas OpenAPI package provides automatic OpenAPI 3.0.4 and 3.1.2 specification generation from Go code. Uses struct tags and reflection with a clean, type-safe API. Minimal boilerplate required.
Features
Clean API - Builder-style API.Generate() method for specification generation
Type-Safe Version Selection - V30x and V31x constants with IDE autocomplete
Fluent HTTP Method Constructors - GET(), POST(), PUT(), etc. for clean operation definitions
Functional Options - Consistent With* pattern for all configuration
Type-Safe Warning Diagnostics - diag package for fine-grained warning control
Automatic Parameter Discovery - Extracts query, path, header, and cookie parameters from struct tags
Schema Generation - Converts Go types to OpenAPI schemas automatically
Learn the fundamentals of generating OpenAPI specifications
Learn how to generate OpenAPI specifications from Go code using the openapi package.
Creating an API Configuration
The first step is to create an API configuration using New() or MustNew():
import"rivaas.dev/openapi"// With error handlingapi,err:=openapi.New(openapi.WithTitle("My API","1.0.0"),openapi.WithInfoDescription("API description"),)iferr!=nil{log.Fatal(err)}// Without error handling (panics on error)api:=openapi.MustNew(openapi.WithTitle("My API","1.0.0"),openapi.WithInfoDescription("API description"),)
The MustNew() function is convenient for initialization code. Use it where panicking on error is acceptable.
Generating Specifications
Use api.Generate() with a context and variadic operation arguments:
The Generate() method returns a Result object containing:
JSON - The OpenAPI specification as JSON bytes.
YAML - The OpenAPI specification as YAML bytes.
Warnings - Any generation warnings. See Diagnostics for details.
// Use the JSON specificationfmt.Println(string(result.JSON))// Or use the YAML specificationfmt.Println(string(result.YAML))// Check for warningsiflen(result.Warnings)>0{fmt.Printf("Generated with %d warnings\n",len(result.Warnings))}
Defining Operations
Operations are defined using HTTP method constructors:
Each constructor takes a path and optional operation options.
Path Parameters
Use colon syntax for path parameters:
openapi.GET("/users/:id",openapi.WithSummary("Get user by ID"),openapi.WithResponse(200,User{}),)openapi.GET("/orgs/:orgId/users/:userId",openapi.WithSummary("Get user in organization"),openapi.WithResponse(200,User{}),)
Path parameters are automatically discovered and marked as required.
Request and Response Types
Define request and response types using Go structs:
typeUserstruct{IDint`json:"id"`Namestring`json:"name"`Emailstring`json:"email"`}typeCreateUserRequeststruct{Namestring`json:"name"`Emailstring`json:"email"`}// Use in operationsopenapi.POST("/users",openapi.WithRequest(CreateUserRequest{}),openapi.WithResponse(201,User{}),)
The package automatically converts Go types to OpenAPI schemas.
Multiple Response Types
Operations can have multiple response types for different status codes:
Here’s a complete example putting it all together:
packagemainimport("context""fmt""log""os""rivaas.dev/openapi")typeUserstruct{IDint`json:"id"`Namestring`json:"name"`Emailstring`json:"email"`}typeCreateUserRequeststruct{Namestring`json:"name"`Emailstring`json:"email"`}typeErrorResponsestruct{Codeint`json:"code"`Messagestring`json:"message"`}funcmain(){api:=openapi.MustNew(openapi.WithTitle("User API","1.0.0"),openapi.WithInfoDescription("API for managing users"),openapi.WithServer("http://localhost:8080","Local development"),)result,err:=api.Generate(context.Background(),openapi.GET("/users",openapi.WithSummary("List users"),openapi.WithResponse(200,[]User{}),),openapi.GET("/users/:id",openapi.WithSummary("Get user"),openapi.WithResponse(200,User{}),openapi.WithResponse(404,ErrorResponse{}),),openapi.POST("/users",openapi.WithSummary("Create user"),openapi.WithRequest(CreateUserRequest{}),openapi.WithResponse(201,User{}),openapi.WithResponse(400,ErrorResponse{}),),openapi.DELETE("/users/:id",openapi.WithSummary("Delete user"),openapi.WithResponse(204,nil),),)iferr!=nil{log.Fatal(err)}// Write to fileiferr:=os.WriteFile("openapi.json",result.JSON,0644);err!=nil{log.Fatal(err)}fmt.Println("OpenAPI specification written to openapi.json")}
Next Steps
Learn about Configuration to customize your API settings
Explore Operations for advanced operation definitions
See Auto-Discovery to learn about automatic parameter discovery
The constants V30x and V31x represent version families. Internally they map to specific versions. 3.0.4 and 3.1.2 are used in the generated specification.
Version-Specific Features
Some features are only available in OpenAPI 3.1.x:
WithInfoSummary() - Short summary for the API
WithLicenseIdentifier() - SPDX license identifier
Webhooks support
Mutual TLS authentication
When using these features with a 3.0.x target, the package will generate warnings (see Diagnostics).
Servers
Add server configurations to specify where the API is available:
Add variables to server URLs for flexible configuration:
api:=openapi.MustNew(openapi.WithTitle("My API","1.0.0"),openapi.WithServer("https://{environment}.example.com","Environment-based"),openapi.WithServerVariable("environment","api",[]string{"api","staging","dev"},"Environment to use",),)
Multiple variables can be defined for a single server:
api:=openapi.MustNew(openapi.WithTitle("My API","1.0.0"),openapi.WithExternalDocs("https://docs.example.com","Full API Documentation",),)
Complete Configuration Example
Here’s a complete example with all common configuration options:
packagemainimport("context""log""rivaas.dev/openapi")funcmain(){api:=openapi.MustNew(// Basic infoopenapi.WithTitle("User Management API","2.1.0"),openapi.WithInfoDescription("Comprehensive API for managing users and their profiles"),openapi.WithTermsOfService("https://example.com/terms"),// Contactopenapi.WithContact("API Support Team","https://example.com/support","api-support@example.com",),// Licenseopenapi.WithLicense("Apache 2.0","https://www.apache.org/licenses/LICENSE-2.0.html",),// Version selectionopenapi.WithVersion(openapi.V31x),// Serversopenapi.WithServer("https://api.example.com","Production"),openapi.WithServer("https://staging-api.example.com","Staging"),openapi.WithServer("http://localhost:8080","Local development"),// Tagsopenapi.WithTag("users","User management operations"),openapi.WithTag("profiles","User profile operations"),openapi.WithTag("auth","Authentication and authorization"),// External docsopenapi.WithExternalDocs("https://docs.example.com/api","Complete API Documentation",),// Security schemes (covered in detail in Security guide)openapi.WithBearerAuth("bearerAuth","JWT authentication"),)result,err:=api.Generate(context.Background(),// ... operations here)iferr!=nil{log.Fatal(err)}// Use result...}
api:=openapi.MustNew(openapi.WithTitle("My API","1.0.0"),openapi.WithOAuth2("oauth2","OAuth2 authentication",openapi.OAuth2Flow{Type:openapi.FlowAuthorizationCode,AuthorizationURL:"https://example.com/oauth/authorize",TokenURL:"https://example.com/oauth/token",Scopes:map[string]string{"read":"Read access to resources","write":"Write access to resources","admin":"Administrative access",},},),)
Allow multiple authentication methods for a single operation:
result,err:=api.Generate(context.Background(),openapi.GET("/users",openapi.WithSummary("List users"),openapi.WithSecurity("bearerAuth"),// Can use bearer authopenapi.WithSecurity("apiKey"),// OR can use API keyopenapi.WithResponse(200,[]User{}),),)
This means the client can authenticate using either bearer auth or an API key.
Each constructor takes a path and optional operation options.
Operation Options
All operation options follow the With* naming convention:
Function
Description
WithSummary(s)
Set operation summary
WithDescription(s)
Set operation description
WithOperationID(id)
Set custom operation ID
WithRequest(type, examples...)
Set request body type
WithResponse(status, type, examples...)
Set response type for status code
WithTags(tags...)
Add tags to operation
WithSecurity(scheme, scopes...)
Add security requirement
WithDeprecated()
Mark operation as deprecated
WithConsumes(types...)
Set accepted content types
WithProduces(types...)
Set returned content types
WithOperationExtension(key, value)
Add operation extension
Basic Operation Definition
Define a simple GET operation:
result,err:=api.Generate(context.Background(),openapi.GET("/users/:id",openapi.WithSummary("Get user by ID"),openapi.WithDescription("Retrieves a user by their unique identifier"),openapi.WithResponse(200,User{}),),)
Request Bodies
Use WithRequest() to specify the request body type:
// Single security schemeopenapi.GET("/users/:id",openapi.WithSummary("Get user"),openapi.WithSecurity("bearerAuth"),openapi.WithResponse(200,User{}),)// OAuth2 with scopesopenapi.POST("/users",openapi.WithSummary("Create user"),openapi.WithSecurity("oauth2","read","write"),openapi.WithRequest(CreateUserRequest{}),openapi.WithResponse(201,User{}),)// Multiple security schemes (OR)openapi.DELETE("/users/:id",openapi.WithSummary("Delete user"),openapi.WithSecurity("bearerAuth"),openapi.WithSecurity("apiKey"),openapi.WithResponse(204,nil),)
Deprecated Operations
Mark operations as deprecated:
openapi.GET("/users/legacy",openapi.WithSummary("Legacy user list"),openapi.WithDescription("This endpoint is deprecated. Use /users instead."),openapi.WithDeprecated(),openapi.WithResponse(200,[]User{}),)
typeGetUserRequeststruct{IDint`path:"id"`FilterUserListFilter`query:",inline"`}typeUserListFilterstruct{Active*bool`query:"active" doc:"Filter by active status"`Rolestring`query:"role" doc:"Filter by role" enum:"admin,user,guest"`Sincestring`query:"since" doc:"Filter by creation date"`}
typeUserstruct{IDint`json:"id" doc:"Unique user identifier" example:"123"`Namestring`json:"name" doc:"User's full name" example:"John Doe"`Emailstring`json:"email" doc:"User's email address" example:"john@example.com"`}
Generates:
type:objectproperties:id:type:integerdescription:Unique user identifierexample:123name:type:stringdescription:User's full nameexample:John Doeemail:type:stringdescription:User's email addressexample:john@example.com
Customize the Swagger UI interface for API documentation
Learn how to configure and customize the Swagger UI interface for your OpenAPI specification.
Overview
The package includes built-in Swagger UI support with extensive customization options. Swagger UI provides an interactive interface for exploring and testing your API.
ModelRenderingExample - Show example values. This is the default.
ModelRenderingModel - Show schema structure.
Model Expand Depth
Control how deeply nested models are expanded:
openapi.WithSwaggerUI("/docs",openapi.WithUIModelExpandDepth(1),// How deep to expand a single modelopenapi.WithUIModelsExpandDepth(1),// How deep to expand models section)
Set to -1 to disable expansion, 1 for shallow, higher numbers for deeper.
// Use local validation (recommended)openapi.WithSwaggerUI("/docs",openapi.WithUIValidator(openapi.ValidatorLocal),)// Use external validatoropenapi.WithSwaggerUI("/docs",openapi.WithUIValidator("https://validator.swagger.io/validator"),)// Disable validationopenapi.WithSwaggerUI("/docs",openapi.WithUIValidator(openapi.ValidatorNone),)
Complete Swagger UI Example
Here’s a comprehensive example with all common options:
packagemainimport("rivaas.dev/openapi")funcmain(){api:=openapi.MustNew(openapi.WithTitle("My API","1.0.0"),openapi.WithSwaggerUI("/docs",// Document expansionopenapi.WithUIExpansion(openapi.DocExpansionList),openapi.WithUIModelsExpandDepth(1),openapi.WithUIModelExpandDepth(1),// Display optionsopenapi.WithUIDisplayOperationID(true),openapi.WithUIDefaultModelRendering(openapi.ModelRenderingExample),// Try it outopenapi.WithUITryItOut(true),openapi.WithUIRequestSnippets(true,openapi.SnippetCurlBash,openapi.SnippetCurlPowerShell,openapi.SnippetCurlCmd,),openapi.WithUIRequestSnippetsExpanded(true),openapi.WithUIDisplayRequestDuration(true),// Filtering and sortingopenapi.WithUIFilter(true),openapi.WithUIMaxDisplayedTags(10),openapi.WithUIOperationsSorter(openapi.OperationsSorterAlpha),openapi.WithUITagsSorter(openapi.TagsSorterAlpha),// Syntax highlightingopenapi.WithUISyntaxHighlight(true),openapi.WithUISyntaxTheme(openapi.SyntaxThemeMonokai),// Authenticationopenapi.WithUIPersistAuth(true),openapi.WithUIWithCredentials(true),// Validationopenapi.WithUIValidator(openapi.ValidatorLocal),),)// Generate specification...}
The package generates the OpenAPI specification, but you need to integrate it with your web framework to serve Swagger UI. The typical pattern is:
// Generate the specresult,err:=api.Generate(context.Background(),operations...)iferr!=nil{log.Fatal(err)}// Serve the spec at /openapi.jsonhttp.HandleFunc("/openapi.json",func(whttp.ResponseWriter,r*http.Request){w.Header().Set("Content-Type","application/json")w.Write(result.JSON)})// Serve Swagger UI at /docs// (Framework-specific implementation)
Next Steps
Learn about Validation to validate your specifications
Validate OpenAPI specifications against official meta-schemas
Learn how to validate OpenAPI specifications using built-in validation against official meta-schemas.
Overview
The package provides built-in validation against official OpenAPI meta-schemas for both 3.0.x and 3.1.x specifications.
Enabling Validation
Validation is disabled by default for performance. Enable it during development or in CI/CD pipelines:
api:=openapi.MustNew(openapi.WithTitle("My API","1.0.0"),openapi.WithValidation(true),// Enable validation)result,err:=api.Generate(context.Background(),operations...)iferr!=nil{log.Fatal(err)// Will fail if spec is invalid}
Why Validation is Disabled by Default
Validation has a performance cost:
Schema compilation on first use.
JSON schema validation for every generation.
Not necessary for production spec generation.
When to enable:
During development.
In CI/CD pipelines.
When debugging specification issues.
When accepting external specifications.
When to disable:
Production spec generation.
When performance is critical.
After spec validation is confirmed.
Validation Errors
When validation fails, you’ll receive a detailed error:
Missing required fields like info, openapi, paths.
Invalid field types.
Invalid format values.
Schema constraint violations.
Invalid references.
Validating External Specifications
The package includes a standalone validator for external OpenAPI specifications:
import"rivaas.dev/openapi/validate"// Read external specspecJSON,err:=os.ReadFile("external-api.json")iferr!=nil{log.Fatal(err)}// Create validatorvalidator:=validate.New()// Validate against OpenAPI 3.0.xerr=validator.Validate(context.Background(),specJSON,validate.V30)iferr!=nil{log.Printf("Validation failed: %v\n",err)}// Or validate against OpenAPI 3.1.xerr=validator.Validate(context.Background(),specJSON,validate.V31)iferr!=nil{log.Printf("Validation failed: %v\n",err)}
Auto-Detection
The validator can auto-detect the OpenAPI version:
validator:=validate.New()// Auto-detects version from the specerr:=validator.ValidateAuto(context.Background(),specJSON)iferr!=nil{log.Printf("Validation failed: %v\n",err)}
packagemainimport("context""fmt""log""os""rivaas.dev/openapi""rivaas.dev/openapi/validate")typeUserstruct{IDint`json:"id"`Namestring`json:"name"`}funcmain(){// Generate with validation enabledapi:=openapi.MustNew(openapi.WithTitle("User API","1.0.0"),openapi.WithValidation(true),)result,err:=api.Generate(context.Background(),openapi.GET("/users/:id",openapi.WithSummary("Get user"),openapi.WithResponse(200,User{}),),)iferr!=nil{log.Fatalf("Generation/validation failed: %v",err)}fmt.Println("Generated valid OpenAPI 3.0.4 specification")// Write to fileiferr:=os.WriteFile("openapi.json",result.JSON,0644);err!=nil{log.Fatal(err)}// Validate external spec (e.g., from a file)externalSpec,err:=os.ReadFile("external-api.json")iferr!=nil{log.Fatal(err)}validator:=validate.New()iferr:=validator.ValidateAuto(context.Background(),externalSpec);err!=nil{log.Printf("External spec validation failed: %v\n",err)}else{fmt.Println("External spec is valid")}}
Validation vs Warnings
It’s important to distinguish between validation errors and warnings:
Validation errors: The specification violates OpenAPI schema requirements
Warnings: The specification is valid but uses version-specific features (see Diagnostics)
api:=openapi.MustNew(openapi.WithTitle("API","1.0.0"),openapi.WithVersion(openapi.V30x),openapi.WithInfoSummary("Summary"),// 3.1-only featureopenapi.WithValidation(true),)result,err:=api.Generate(context.Background(),ops...)// err is nil (spec is valid)// result.Warnings contains warning about info.summary being dropped
Learn how to work with warnings using the type-safe diagnostics package.
Overview
The package generates warnings when using version-specific features. For example, using OpenAPI 3.1 features with a 3.0 target generates warnings instead of errors.
Working with Warnings
Check for warnings in the generation result:
result,err:=api.Generate(context.Background(),operations...)iferr!=nil{log.Fatal(err)}// Basic warning checkiflen(result.Warnings)>0{fmt.Printf("Generated with %d warnings\n",len(result.Warnings))}// Iterate through warningsfor_,warn:=rangeresult.Warnings{fmt.Printf("[%s] %s\n",warn.Code(),warn.Message())}
The diag Package
Import the diag package for type-safe warning handling:
Check for specific warnings using type-safe constants:
import"rivaas.dev/openapi/diag"result,err:=api.Generate(context.Background(),ops...)iferr!=nil{log.Fatal(err)}// Check for specific warningifresult.Warnings.Has(diag.WarnDownlevelWebhooks){log.Warn("webhooks not supported in OpenAPI 3.0")}// Check for any of multiple codesifresult.Warnings.HasAny(diag.WarnDownlevelMutualTLS,diag.WarnDownlevelWebhooks,){log.Warn("Some 3.1 security features were dropped")}
Warning Categories
Warnings are organized into categories:
// Filter by categorydownlevelWarnings:=result.Warnings.FilterCategory(diag.CategoryDownlevel)fmt.Printf("Downlevel warnings: %d\n",len(downlevelWarnings))deprecationWarnings:=result.Warnings.FilterCategory(diag.CategoryDeprecation)fmt.Printf("Deprecation warnings: %d\n",len(deprecationWarnings))
Available categories:
CategoryDownlevel - 3.1 to 3.0 conversion feature losses
packagemainimport("context""fmt""log""rivaas.dev/openapi""rivaas.dev/openapi/diag")typeUserstruct{IDint`json:"id"`Namestring`json:"name"`}funcmain(){// Create API with 3.0 target but use 3.1 featuresapi:=openapi.MustNew(openapi.WithTitle("My API","1.0.0"),openapi.WithVersion(openapi.V30x),openapi.WithInfoSummary("Short summary"),// 3.1-only feature)result,err:=api.Generate(context.Background(),openapi.GET("/users/:id",openapi.WithSummary("Get user"),openapi.WithResponse(200,User{}),),)iferr!=nil{log.Fatal(err)}// Check for specific warningifresult.Warnings.Has(diag.WarnDownlevelInfoSummary){fmt.Println("info.summary was dropped (3.1 feature with 3.0 target)")}// Filter by categorydownlevelWarnings:=result.Warnings.FilterCategory(diag.CategoryDownlevel)iflen(downlevelWarnings)>0{fmt.Printf("\nDownlevel warnings (%d):\n",len(downlevelWarnings))for_,warn:=rangedownlevelWarnings{fmt.Printf(" [%s] %s at %s\n",warn.Code(),warn.Message(),warn.Path(),)}}// Check for unexpected warningsexpected:=[]diag.WarningCode{diag.WarnDownlevelInfoSummary,}unexpected:=result.Warnings.Exclude(expected...)iflen(unexpected)>0{fmt.Printf("\nUnexpected warnings (%d):\n",len(unexpected))for_,warn:=rangeunexpected{fmt.Printf(" [%s] %s\n",warn.Code(),warn.Message())}}fmt.Printf("\nGenerated %d byte specification with %d warnings\n",len(result.JSON),len(result.Warnings),)}
Warning vs Error
The package distinguishes between warnings and errors:
Warnings: The specification is valid but features were dropped or converted
Errors: The specification is invalid or generation failed
result,err:=api.Generate(context.Background(),ops...)iferr!=nil{// Hard error - generation failedlog.Fatal(err)}iflen(result.Warnings)>0{// Soft warnings - generation succeeded with caveatsfor_,warn:=rangeresult.Warnings{log.Printf("Warning: %s\n",warn.Message())}}
Strict Downlevel Mode
To treat downlevel warnings as errors, enable strict mode (see Advanced Usage):
api:=openapi.MustNew(openapi.WithTitle("API","1.0.0"),openapi.WithVersion(openapi.V30x),openapi.WithStrictDownlevel(true),// Error on 3.1 featuresopenapi.WithInfoSummary("Summary"),// This will cause an error)_,err:=api.Generate(context.Background(),ops...)// err will be non-nil due to strict mode violation
Warning Suppression
Currently, the package does not support per-warning suppression. To handle expected warnings:
Filter them out after generation
Use strict mode to error on any warnings
Log and ignore specific warning codes
// Filter out expected warningsexpected:=[]diag.WarningCode{diag.WarnDownlevelInfoSummary,diag.WarnDownlevelLicenseIdentifier,}unexpected:=result.Warnings.Exclude(expected...)iflen(unexpected)>0{log.Fatalf("Unexpected warnings: %d",len(unexpected))}
Must start with x- - Required by OpenAPI specification
Reserved prefixes - x-oai- and x-oas- are reserved in 3.1.x
Case-sensitive - x-Custom and x-custom are different
Extension Validation
Extensions are validated:
// Validopenapi.WithExtension("x-custom","value")// Invalid - doesn't start with x-openapi.WithExtension("custom","value")// Error// Invalid - reserved prefix in 3.1.xopenapi.WithExtension("x-oai-custom","value")// Filtered out in 3.1.x
By default, using 3.1 features with a 3.0 target generates warnings. Enable strict mode to error instead:
Default Behavior (Warnings)
api:=openapi.MustNew(openapi.WithTitle("API","1.0.0"),openapi.WithVersion(openapi.V30x),openapi.WithInfoSummary("Summary"),// 3.1-only feature)result,err:=api.Generate(context.Background(),ops...)// err is nil (generation succeeds)// result.Warnings contains warning about info.summary being dropped
Strict Mode (Errors)
api:=openapi.MustNew(openapi.WithTitle("API","1.0.0"),openapi.WithVersion(openapi.V30x),openapi.WithStrictDownlevel(true),// Enable strict modeopenapi.WithInfoSummary("Summary"),// This will cause an error)result,err:=api.Generate(context.Background(),ops...)// err is non-nil (generation fails)
When to Use Strict Mode
Use strict mode when:
Enforcing version compliance - Prevent accidental 3.1 feature usage
CI/CD validation - Fail builds on version violations
Team standards - Ensure consistent OpenAPI version usage
Complete examples demonstrating real-world usage patterns for the OpenAPI package.
Basic CRUD API
A simple CRUD API with all HTTP methods.
packagemainimport("context""log""os""time""rivaas.dev/openapi")typeUserstruct{IDint`json:"id" doc:"User ID" example:"123"`Namestring`json:"name" doc:"User's full name" example:"John Doe"`Emailstring`json:"email" doc:"Email address" example:"john@example.com"`CreatedAttime.Time`json:"created_at" doc:"Creation timestamp"`}typeCreateUserRequeststruct{Namestring`json:"name" doc:"User's full name" validate:"required"`Emailstring`json:"email" doc:"Email address" validate:"required,email"`}typeErrorResponsestruct{Codeint`json:"code" doc:"Error code"`Messagestring`json:"message" doc:"Error message"`}funcmain(){api:=openapi.MustNew(openapi.WithTitle("User API","1.0.0"),openapi.WithInfoDescription("Simple CRUD API for user management"),openapi.WithServer("http://localhost:8080","Local development"),openapi.WithServer("https://api.example.com","Production"),openapi.WithBearerAuth("bearerAuth","JWT authentication"),openapi.WithTag("users","User management operations"),)result,err:=api.Generate(context.Background(),openapi.GET("/users",openapi.WithSummary("List users"),openapi.WithDescription("Retrieve a list of all users"),openapi.WithTags("users"),openapi.WithSecurity("bearerAuth"),openapi.WithResponse(200,[]User{}),openapi.WithResponse(401,ErrorResponse{}),),openapi.GET("/users/:id",openapi.WithSummary("Get user"),openapi.WithDescription("Retrieve a specific user by ID"),openapi.WithTags("users"),openapi.WithSecurity("bearerAuth"),openapi.WithResponse(200,User{}),openapi.WithResponse(404,ErrorResponse{}),openapi.WithResponse(401,ErrorResponse{}),),openapi.POST("/users",openapi.WithSummary("Create user"),openapi.WithDescription("Create a new user"),openapi.WithTags("users"),openapi.WithSecurity("bearerAuth"),openapi.WithRequest(CreateUserRequest{}),openapi.WithResponse(201,User{}),openapi.WithResponse(400,ErrorResponse{}),openapi.WithResponse(401,ErrorResponse{}),),openapi.PUT("/users/:id",openapi.WithSummary("Update user"),openapi.WithDescription("Update an existing user"),openapi.WithTags("users"),openapi.WithSecurity("bearerAuth"),openapi.WithRequest(CreateUserRequest{}),openapi.WithResponse(200,User{}),openapi.WithResponse(400,ErrorResponse{}),openapi.WithResponse(404,ErrorResponse{}),openapi.WithResponse(401,ErrorResponse{}),),openapi.DELETE("/users/:id",openapi.WithSummary("Delete user"),openapi.WithDescription("Delete a user"),openapi.WithTags("users"),openapi.WithSecurity("bearerAuth"),openapi.WithResponse(204,nil),openapi.WithResponse(404,ErrorResponse{}),openapi.WithResponse(401,ErrorResponse{}),),)iferr!=nil{log.Fatal(err)}iferr:=os.WriteFile("openapi.json",result.JSON,0644);err!=nil{log.Fatal(err)}log.Println("OpenAPI specification generated: openapi.json")}
API with Query Parameters and Pagination
packagemainimport("context""log""rivaas.dev/openapi")typeListUsersRequeststruct{Pageint`query:"page" doc:"Page number" example:"1" validate:"min=1"`PerPageint`query:"per_page" doc:"Items per page" example:"20" validate:"min=1,max=100"`Sortstring`query:"sort" doc:"Sort field" enum:"name,email,created_at"`Orderstring`query:"order" doc:"Sort order" enum:"asc,desc"`Tags[]string`query:"tags" doc:"Filter by tags"`Active*bool`query:"active" doc:"Filter by active status"`}typeUserstruct{IDint`json:"id"`Namestring`json:"name"`Emailstring`json:"email"`Activebool`json:"active"`Tags[]string`json:"tags"`}typePaginatedResponsestruct{Data[]User`json:"data"`Pageint`json:"page"`PerPageint`json:"per_page"`TotalPagesint`json:"total_pages"`TotalItemsint`json:"total_items"`}funcmain(){api:=openapi.MustNew(openapi.WithTitle("Paginated API","1.0.0"),openapi.WithInfoDescription("API with pagination and filtering"),)result,err:=api.Generate(context.Background(),openapi.GET("/users",openapi.WithSummary("List users with pagination"),openapi.WithDescription("Retrieve paginated list of users with filtering"),openapi.WithResponse(200,PaginatedResponse{}),),)iferr!=nil{log.Fatal(err)}// Use result...}
Multi-Source Parameters
packagemainimport("context""log""rivaas.dev/openapi")typeCreateOrderRequeststruct{// Path parameterUserIDint`path:"user_id" doc:"User ID" example:"123"`// Query parametersCouponstring`query:"coupon" doc:"Coupon code" example:"SAVE20"`SendEmail*bool`query:"send_email" doc:"Send confirmation email"`// Header parametersIdempotencyKeystring`header:"Idempotency-Key" doc:"Idempotency key"`// Request bodyItems[]OrderItem`json:"items" validate:"required,min=1"`Totalfloat64`json:"total" validate:"required,min=0"`Notesstring`json:"notes,omitempty"`}typeOrderItemstruct{ProductIDint`json:"product_id" validate:"required"`Quantityint`json:"quantity" validate:"required,min=1"`Pricefloat64`json:"price" validate:"required,min=0"`}typeOrderstruct{IDint`json:"id"`UserIDint`json:"user_id"`Items[]OrderItem`json:"items"`Totalfloat64`json:"total"`Statusstring`json:"status" enum:"pending,processing,completed,cancelled"`}funcmain(){api:=openapi.MustNew(openapi.WithTitle("E-commerce API","1.0.0"),)result,err:=api.Generate(context.Background(),openapi.POST("/users/:user_id/orders",openapi.WithSummary("Create order"),openapi.WithDescription("Create a new order for a user"),openapi.WithRequest(CreateOrderRequest{}),openapi.WithResponse(201,Order{}),),)iferr!=nil{log.Fatal(err)}// Use result...}
Composable Options Pattern
packagemainimport("context""log""rivaas.dev/openapi")typeUserstruct{IDint`json:"id"`Namestring`json:"name"`}typeErrorResponsestruct{Codeint`json:"code"`Messagestring`json:"message"`}// Define reusable option setsvar(// Common error responsesCommonErrors=openapi.WithOptions(openapi.WithResponse(400,ErrorResponse{}),openapi.WithResponse(401,ErrorResponse{}),openapi.WithResponse(500,ErrorResponse{}),)// Authenticated user endpointsUserEndpoint=openapi.WithOptions(openapi.WithTags("users"),openapi.WithSecurity("bearerAuth"),CommonErrors,)// JSON content typeJSONContent=openapi.WithOptions(openapi.WithConsumes("application/json"),openapi.WithProduces("application/json"),)// Read operationsReadOperation=openapi.WithOptions(UserEndpoint,JSONContent,)// Write operationsWriteOperation=openapi.WithOptions(UserEndpoint,JSONContent,openapi.WithResponse(404,ErrorResponse{}),))funcmain(){api:=openapi.MustNew(openapi.WithTitle("Composable API","1.0.0"),openapi.WithBearerAuth("bearerAuth","JWT authentication"),)result,err:=api.Generate(context.Background(),openapi.GET("/users/:id",ReadOperation,openapi.WithSummary("Get user"),openapi.WithResponse(200,User{}),),openapi.POST("/users",WriteOperation,openapi.WithSummary("Create user"),openapi.WithRequest(User{}),openapi.WithResponse(201,User{}),),openapi.PUT("/users/:id",WriteOperation,openapi.WithSummary("Update user"),openapi.WithRequest(User{}),openapi.WithResponse(200,User{}),),)iferr!=nil{log.Fatal(err)}// Use result...}
OAuth2 with Multiple Flows
packagemainimport("context""log""rivaas.dev/openapi")typeUserstruct{IDint`json:"id"`Namestring`json:"name"`}funcmain(){api:=openapi.MustNew(openapi.WithTitle("OAuth2 API","1.0.0"),// Authorization code flow (for web apps)openapi.WithOAuth2("oauth2AuthCode","OAuth2 authorization code flow",openapi.OAuth2Flow{Type:openapi.FlowAuthorizationCode,AuthorizationURL:"https://auth.example.com/authorize",TokenURL:"https://auth.example.com/token",Scopes:map[string]string{"read":"Read access","write":"Write access","admin":"Admin access",},},),// Client credentials flow (for service-to-service)openapi.WithOAuth2("oauth2ClientCreds","OAuth2 client credentials flow",openapi.OAuth2Flow{Type:openapi.FlowClientCredentials,TokenURL:"https://auth.example.com/token",Scopes:map[string]string{"api":"API access",},},),)result,err:=api.Generate(context.Background(),// Public endpointopenapi.GET("/health",openapi.WithSummary("Health check"),openapi.WithResponse(200,nil),),// User-facing endpoint (auth code flow)openapi.GET("/users/:id",openapi.WithSummary("Get user"),openapi.WithSecurity("oauth2AuthCode","read"),openapi.WithResponse(200,User{}),),// Service endpoint (client credentials flow)openapi.POST("/users/sync",openapi.WithSummary("Sync users"),openapi.WithSecurity("oauth2ClientCreds","api"),openapi.WithResponse(200,nil),),)iferr!=nil{log.Fatal(err)}// Use result...}
Version-Aware API with Diagnostics
packagemainimport("context""fmt""log""rivaas.dev/openapi""rivaas.dev/openapi/diag")typeUserstruct{IDint`json:"id"`Namestring`json:"name"`}funcmain(){api:=openapi.MustNew(openapi.WithTitle("Version-Aware API","1.0.0"),openapi.WithVersion(openapi.V30x),// Target 3.0.xopenapi.WithInfoSummary("API with 3.1 features"),// 3.1-only feature)result,err:=api.Generate(context.Background(),openapi.GET("/users/:id",openapi.WithSummary("Get user"),openapi.WithResponse(200,User{}),),)iferr!=nil{log.Fatal(err)}// Handle warningsifresult.Warnings.Has(diag.WarnDownlevelInfoSummary){fmt.Println("Note: info.summary was dropped (3.1 feature with 3.0 target)")}// Filter by categorydownlevelWarnings:=result.Warnings.FilterCategory(diag.CategoryDownlevel)iflen(downlevelWarnings)>0{fmt.Printf("Downlevel warnings: %d\n",len(downlevelWarnings))for_,warn:=rangedownlevelWarnings{fmt.Printf(" [%s] %s\n",warn.Code(),warn.Message())}}// Fail on unexpected warningsexpected:=[]diag.WarningCode{diag.WarnDownlevelInfoSummary,}unexpected:=result.Warnings.Exclude(expected...)iflen(unexpected)>0{log.Fatalf("Unexpected warnings: %d",len(unexpected))}fmt.Println("Specification generated successfully")}
Complete Production Example
packagemainimport("context""fmt""log""os""time""rivaas.dev/openapi""rivaas.dev/openapi/diag")// Domain modelstypeUserstruct{IDint`json:"id" doc:"User ID"`Namestring`json:"name" doc:"User's full name"`Emailstring`json:"email" doc:"Email address"`Rolestring`json:"role" doc:"User role" enum:"admin,user,guest"`Activebool`json:"active" doc:"Whether user is active"`CreatedAttime.Time`json:"created_at" doc:"Creation timestamp"`UpdatedAttime.Time`json:"updated_at" doc:"Last update timestamp"`}typeCreateUserRequeststruct{Namestring`json:"name" validate:"required"`Emailstring`json:"email" validate:"required,email"`Rolestring`json:"role" validate:"required" enum:"admin,user,guest"`}typeErrorResponsestruct{Codeint`json:"code"`Messagestring`json:"message"`Detailsstring`json:"details,omitempty"`Timestamptime.Time`json:"timestamp"`}// Reusable option setsvar(CommonErrors=openapi.WithOptions(openapi.WithResponse(400,ErrorResponse{}),openapi.WithResponse(401,ErrorResponse{}),openapi.WithResponse(500,ErrorResponse{}),)UserEndpoint=openapi.WithOptions(openapi.WithTags("users"),openapi.WithSecurity("bearerAuth"),CommonErrors,))funcmain(){api:=openapi.MustNew(// Basic infoopenapi.WithTitle("User Management API","2.1.0"),openapi.WithInfoDescription("Production-ready API for managing users and permissions"),openapi.WithTermsOfService("https://example.com/terms"),// Contactopenapi.WithContact("API Support","https://example.com/support","api-support@example.com",),// Licenseopenapi.WithLicense("Apache 2.0","https://www.apache.org/licenses/LICENSE-2.0.html"),// Versionopenapi.WithVersion(openapi.V31x),// Serversopenapi.WithServer("https://api.example.com/v2","Production"),openapi.WithServer("https://staging-api.example.com/v2","Staging"),openapi.WithServer("http://localhost:8080/v2","Development"),// Securityopenapi.WithBearerAuth("bearerAuth","JWT token authentication"),// Tagsopenapi.WithTag("users","User management operations"),// Extensionsopenapi.WithExtension("x-api-version","2.1"),openapi.WithExtension("x-environment",os.Getenv("ENVIRONMENT")),// Enable validationopenapi.WithValidation(true),)result,err:=api.Generate(context.Background(),// Public endpointsopenapi.GET("/health",openapi.WithSummary("Health check"),openapi.WithDescription("Check API health status"),openapi.WithResponse(200,map[string]string{"status":"ok"}),),// User CRUD operationsopenapi.GET("/users",UserEndpoint,openapi.WithSummary("List users"),openapi.WithDescription("Retrieve paginated list of users"),openapi.WithResponse(200,[]User{}),),openapi.GET("/users/:id",UserEndpoint,openapi.WithSummary("Get user"),openapi.WithDescription("Retrieve a specific user by ID"),openapi.WithResponse(200,User{}),openapi.WithResponse(404,ErrorResponse{}),),openapi.POST("/users",UserEndpoint,openapi.WithSummary("Create user"),openapi.WithDescription("Create a new user"),openapi.WithRequest(CreateUserRequest{}),openapi.WithResponse(201,User{}),),openapi.PUT("/users/:id",UserEndpoint,openapi.WithSummary("Update user"),openapi.WithDescription("Update an existing user"),openapi.WithRequest(CreateUserRequest{}),openapi.WithResponse(200,User{}),openapi.WithResponse(404,ErrorResponse{}),),openapi.DELETE("/users/:id",UserEndpoint,openapi.WithSummary("Delete user"),openapi.WithDescription("Delete a user"),openapi.WithResponse(204,nil),openapi.WithResponse(404,ErrorResponse{}),),)iferr!=nil{log.Fatalf("Generation failed: %v",err)}// Handle warningsiflen(result.Warnings)>0{fmt.Printf("Generated with %d warnings:\n",len(result.Warnings))for_,warn:=rangeresult.Warnings{fmt.Printf(" [%s] %s at %s\n",warn.Code(),warn.Message(),warn.Path(),)}}// Write specification filesiferr:=os.WriteFile("openapi.json",result.JSON,0644);err!=nil{log.Fatal(err)}iferr:=os.WriteFile("openapi.yaml",result.YAML,0644);err!=nil{log.Fatal(err)}fmt.Printf("✓ Generated OpenAPI %s specification\n",api.Version())fmt.Printf("✓ JSON: openapi.json (%d bytes)\n",len(result.JSON))fmt.Printf("✓ YAML: openapi.yaml (%d bytes)\n",len(result.YAML))}
Learn how to implement structured logging with Rivaas using Go’s standard log/slog
The Rivaas Logging package provides production-ready structured logging with minimal dependencies. Uses Go’s built-in log/slog for high performance and native integration with the Go ecosystem.
Features
Multiple Output Formats: JSON, text, and human-friendly console output
Context-Aware Logging: Automatic trace correlation with OpenTelemetry
Sensitive Data Redaction: Automatic sanitization of passwords, tokens, and secrets
Log Sampling: Reduce log volume in high-traffic scenarios
Router Integration: Seamless integration following metrics/tracing patterns
Zero External Dependencies: Uses only Go standard library (except OpenTelemetry for trace correlation)
Quick Start
packagemainimport("rivaas.dev/logging")funcmain(){// Create a logger with console outputlog:=logging.MustNew(logging.WithConsoleHandler(),logging.WithDebugLevel(),)log.Info("service started","port",8080,"env","production")log.Debug("debugging information","key","value")log.Error("operation failed","error","connection timeout")}
packagemainimport("rivaas.dev/logging")funcmain(){// Create a logger with JSON outputlog:=logging.MustNew(logging.WithJSONHandler(),logging.WithServiceName("my-api"),logging.WithServiceVersion("v1.0.0"),logging.WithEnvironment("production"),)log.Info("user action","user_id","123","action","login")// Output: {"time":"2024-01-15T10:30:45.123Z","level":"INFO","msg":"user action","service":"my-api","version":"v1.0.0","env":"production","user_id":"123","action":"login"}}
packagemainimport("rivaas.dev/logging")funcmain(){// Create a logger with text outputlog:=logging.MustNew(logging.WithTextHandler(),logging.WithServiceName("my-api"),)log.Info("service started","port",8080)// Output: time=2024-01-15T10:30:45.123Z level=INFO msg="service started" service=my-api port=8080}
How It Works
Handler types determine output format (JSON, Text, Console)
Structured fields are key-value pairs, not string concatenation
Log levels control verbosity (Debug, Info, Warn, Error)
Service metadata automatically added to every log entry
Sensitive data automatically redacted (passwords, tokens, keys)
Learning Path
Follow these guides to master logging with Rivaas:
Installation - Get started with the logging package
Basic Usage - Learn handler types and output formats
Configuration - Configure loggers with all available options
How to install and set up the Rivaas logging package
This guide covers how to install the logging package and understand its dependencies.
Installation
Install the logging package using go get:
go get rivaas.dev/logging
Requirements: Go 1.25 or higher
Dependencies
The logging package has minimal external dependencies to maintain simplicity and avoid bloat.
Dependency
Purpose
Required
Go stdlib (log/slog)
Core logging
Yes
go.opentelemetry.io/otel/trace
Trace correlation in ContextLogger
Optional*
github.com/stretchr/testify
Test utilities
Test only
* The OpenTelemetry trace dependency is only used by NewContextLogger() for automatic trace/span ID extraction. If you don’t use context-aware logging with tracing, this dependency has no runtime impact.
Learn the fundamentals of structured logging with handler types and output formats
This guide covers the essential operations for working with the logging package. Learn to choose handler types, set log levels, and produce structured log output.
Handler Types
The logging package supports three output formats, each optimized for different use cases.
JSON Handler (Production)
JSON format is ideal for production environments and log aggregation systems:
Note: Console handler uses ANSI colors automatically. Colors are optimized for dark terminal themes.
Log Levels
Control log verbosity with log levels. Each level has a specific purpose.
Available Levels
// From most to least verbose:logging.LevelDebug// Detailed debugging informationlogging.LevelInfo// General informational messageslogging.LevelWarn// Warning messages (not errors)logging.LevelError// Error messages
Setting Log Level
Configure the minimum log level during initialization:
log:=logging.MustNew(logging.WithJSONHandler(),logging.WithLevel(logging.LevelInfo),// Only Info, Warn, Error)log.Debug("this won't appear")// Filtered outlog.Info("this will appear")// Loggedlog.Error("this will appear")// Logged
Debug Level Shortcut
Enable debug logging with a convenience function:
log:=logging.MustNew(logging.WithJSONHandler(),logging.WithDebugLevel(),// Same as WithLevel(logging.LevelDebug))
packagemainimport("rivaas.dev/logging")funcmain(){// Create logger for developmentlog:=logging.MustNew(logging.WithConsoleHandler(),logging.WithDebugLevel(),)// Log at different levelslog.Debug("application starting","version","v1.0.0")log.Info("server listening","port",8080,"env","development")log.Warn("high latency detected","latency_ms",250,"threshold_ms",200)log.Error("database connection failed","error","connection timeout")}
Common Patterns
Logging with Context
Add related fields that persist across multiple log calls:
// Create a logger with persistent fieldsrequestLog:=log.With("request_id","req-123","user_id","user-456",)requestLog.Info("validation started")requestLog.Info("validation completed")// Both logs include request_id and user_id
// BAD - logs thousands of timesfor_,item:=rangeitems{log.Debug("processing","item",item)process(item)}// GOOD - log once with summarylog.Info("processing batch","count",len(items))for_,item:=rangeitems{process(item)}log.Info("batch completed","processed",len(items))
Configure loggers with all available options for production readiness
This guide covers all configuration options available in the logging package. It covers handler selection to service metadata.
Handler Configuration
Choose the appropriate handler type for your environment.
Handler Types
// JSON structured logging. Default and best for production.logging.WithJSONHandler()// Text key=value logging.logging.WithTextHandler()// Human-readable colored console. Best for development.logging.WithConsoleHandler()
// Set specific levellogging.WithLevel(logging.LevelDebug)logging.WithLevel(logging.LevelInfo)logging.WithLevel(logging.LevelWarn)logging.WithLevel(logging.LevelError)// Convenience function for debuglogging.WithDebugLevel()
logging.WithReplaceAttr(func(groups[]string,aslog.Attr)slog.Attr{ifa.Key=="internal_field"{returnslog.Attr{}// Drop this field}returna})
Global Logger Registration
By default, loggers are not registered globally, allowing multiple independent logger instances.
Register as Global Default
logger:=logging.MustNew(logging.WithJSONHandler(),logging.WithServiceName("my-api"),logging.WithGlobalLogger(),// Register as slog default)deferlogger.Shutdown(context.Background())// Now third-party libraries using slog will use your loggerslog.Info("using global logger","key","value")
When to Use Global Registration
Use global registration when:
Third-party libraries use slog directly
You prefer slog.Info() over logger.Info()
Migrating from direct slog usage
Don’t use global registration when:
Running tests with isolated loggers
Creating libraries (avoid affecting global state)
Using multiple logging configurations
Default Behavior
Without WithGlobalLogger(), each logger is independent:
logger1:=logging.MustNew(logging.WithJSONHandler())logger2:=logging.MustNew(logging.WithConsoleHandler())logger1.Info("from logger1")// JSON outputlogger2.Info("from logger2")// Console outputslog.Info("from default slog")// Standard slog output (independent)
Custom Logger
Provide your own slog.Logger for advanced scenarios.
packagemainimport("os""rivaas.dev/logging""log/slog")funcmain(){// Production configurationprodLogger:=logging.MustNew(logging.WithJSONHandler(),logging.WithLevel(logging.LevelInfo),logging.WithServiceName("payment-api"),logging.WithServiceVersion("v2.1.0"),logging.WithEnvironment("production"),logging.WithOutput(os.Stdout),)deferprodLogger.Shutdown(context.Background())// Development configurationdevLogger:=logging.MustNew(logging.WithConsoleHandler(),logging.WithDebugLevel(),logging.WithSource(true),logging.WithServiceName("payment-api"),logging.WithEnvironment("development"),)deferdevLogger.Shutdown(context.Background())// Choose based on environmentvarlogger*logging.Loggerifos.Getenv("ENV")=="production"{logger=prodLogger}else{logger=devLogger}logger.Info("application started")}
Configuration Best Practices
Production Settings
logger:=logging.MustNew(logging.WithJSONHandler(),// Machine-parseablelogging.WithLevel(logging.LevelInfo),// No debug spamlogging.WithServiceName("my-api"),// Service identificationlogging.WithServiceVersion(version),// Version trackinglogging.WithEnvironment("production"),// Environment filtering)
Development Settings
logger:=logging.MustNew(logging.WithConsoleHandler(),// Human-readablelogging.WithDebugLevel(),// See everythinglogging.WithSource(true),// File:line info)
Testing Settings
buf:=&bytes.Buffer{}logger:=logging.MustNew(logging.WithJSONHandler(),logging.WithOutput(buf),logging.WithLevel(logging.LevelDebug),)// Inspect buf for assertions
Add trace correlation and contextual information to logs with ContextLogger
This guide covers context-aware logging with automatic trace correlation for distributed tracing integration.
Overview
Context-aware logging automatically extracts trace and span IDs from OpenTelemetry contexts, enabling correlation between logs and distributed traces.
Why context-aware logging:
Correlate logs with distributed traces.
Track requests across service boundaries.
Debug multi-service workflows.
Include trace IDs automatically without manual passing.
ContextLogger Basics
ContextLogger wraps a standard Logger and automatically extracts trace information from context.
Creating a ContextLogger
import("context""rivaas.dev/logging""rivaas.dev/tracing")// Create base loggerlog:=logging.MustNew(logging.WithJSONHandler())// In a request handler with traced contextfunchandler(ctxcontext.Context){// Create context loggercl:=logging.NewContextLogger(ctx,log)cl.Info("processing request","user_id","123")// Output includes: "trace_id":"abc123...", "span_id":"def456..."}
With OpenTelemetry Tracing
Full integration with OpenTelemetry:
packagemainimport("context""rivaas.dev/logging""rivaas.dev/tracing")funcmain(){// Initialize tracingtracer:=tracing.MustNew(tracing.WithOTLP("localhost:4317"),tracing.WithServiceName("my-api"),)defertracer.Shutdown(context.Background())// Initialize logginglog:=logging.MustNew(logging.WithJSONHandler(),logging.WithServiceName("my-api"),)// Start a tracectx,span:=tracer.Start(context.Background(),"operation")deferspan.End()// Create context loggercl:=logging.NewContextLogger(ctx,log)cl.Info("operation started")// Automatically includes trace_id and span_id}
All methods automatically include trace and span IDs if available.
Adding Additional Context
Use With() to add persistent fields:
// Add fields that persist across log callsrequestLogger:=cl.With("request_id","req-123","user_id","user-456",)requestLogger.Info("validation started")requestLogger.Info("validation completed")// Both logs include request_id, user_id, trace_id, span_id
Accessing Trace Information
Retrieve trace IDs programmatically:
cl:=logging.NewContextLogger(ctx,log)traceID:=cl.TraceID()// "4bf92f3577b34da6a3ce929d0e0e4736"spanID:=cl.SpanID()// "00f067aa0ba902b7"iftraceID!=""{// Context has active tracelog.Info("traced operation","trace_id",traceID)}
Use cases:
Include trace ID in API responses
Add to custom headers
Pass to external systems
Without Active Trace
If context has no active span, ContextLogger behaves like a normal logger:
ctx:=context.Background()// No spancl:=logging.NewContextLogger(ctx,log)cl.Info("message")// Output: No trace_id or span_id fields
This makes ContextLogger safe to use everywhere, whether tracing is enabled or not.
Structured Context
Combine context logging with grouped attributes for clean organization.
Grouping Related Fields
// Get underlying slog.Logger for groupinglogger:=cl.Logger()requestLogger:=logger.WithGroup("request")requestLogger.Info("received","method","POST","path","/api/users",)
func(s*Server)handleRequest(whttp.ResponseWriter,r*http.Request){// Extract or create traced contextctx:=r.Context()// Create context loggercl:=logging.NewContextLogger(ctx,s.logger)// Add request-specific fieldsrequestLog:=cl.With("request_id",generateRequestID(),"method",r.Method,"path",r.URL.Path,)requestLog.Info("request started")// Process request...requestLog.Info("request completed","status",200)}
Performance Considerations
Trace Extraction Overhead
Trace ID extraction happens once during NewContextLogger() creation:
// Trace extraction happens here (one-time cost)cl:=logging.NewContextLogger(ctx,log)// No additional overheadcl.Info("message 1")cl.Info("message 2")cl.Info("message 3")
Best practice: Create ContextLogger once per request/operation, reuse for all logging.
Pooling for High Load
For extreme high-load scenarios, consider pooling ContextLogger instances:
varcontextLoggerPool=sync.Pool{New:func()any{return&logging.ContextLogger{}},}funcgetContextLogger(ctxcontext.Context,log*logging.Logger)*logging.ContextLogger{cl:=contextLoggerPool.Get().(*logging.ContextLogger)// Reinitialize with new context*cl=*logging.NewContextLogger(ctx,log)returncl}funcputContextLogger(cl*logging.ContextLogger){contextLoggerPool.Put(cl)}
Note: Only needed for >10k requests/second with extremely tight latency requirements.
Integration with Router
The Rivaas router automatically provides traced contexts:
import("rivaas.dev/router""rivaas.dev/logging")r:=router.MustNew()logger:=logging.MustNew(logging.WithJSONHandler())r.SetLogger(logger)r.GET("/api/users",func(c*router.Context){// Context is already traced if tracing is enabledcl:=logging.NewContextLogger(c.Request.Context(),logger)cl.Info("fetching users")// Or use the router's logger directly (already context-aware)c.Logger().Info("using router logger")c.JSON(200,users)})
Use helper methods for common logging patterns like HTTP requests and errors
This guide covers convenience methods that simplify common logging patterns with pre-structured fields.
Overview
The logging package provides helper methods for frequently-used logging scenarios:
LogRequest - HTTP request logging with standard fields
LogError - Error logging with context
LogDuration - Operation timing with automatic duration calculation
ErrorWithStack - Critical error logging with stack traces
LogRequest - HTTP Request Logging
Automatically log HTTP requests with standard fields.
Basic Usage
funchandleRequest(whttp.ResponseWriter,r*http.Request){start:=time.Now()// Process request...status:=200bytesWritten:=1024logger.LogRequest(r,"status",status,"duration_ms",time.Since(start).Milliseconds(),"bytes",bytesWritten,)}
LogRequest is particularly useful in custom middleware:
funcloggingMiddleware(logger*logging.Logger)func(http.Handler)http.Handler{returnfunc(nexthttp.Handler)http.Handler{returnhttp.HandlerFunc(func(whttp.ResponseWriter,r*http.Request){start:=time.Now()// Wrap response writer to capture status/sizewrapped:=router.NewResponseWriterWrapper(w)next.ServeHTTP(wrapped,r)logger.LogRequest(r,"status",wrapped.StatusCode(),"duration_ms",time.Since(start).Milliseconds(),"bytes",wrapped.Size(),)})}}
Ensure you import rivaas.dev/router when using router.NewResponseWriterWrapper. The wrapper provides StatusCode(), Size(), and Written() for request logging.
Note: The Rivaas router includes built-in access log middleware. See Router Integration.
LogError - Error Logging with Context
Convenient error logging with automatic error field.
Recommendation: Use ErrorWithStack(includeStack: true) sparingly, only for critical errors.
Conditional Stack Traces
Include stack traces only when needed:
funchandleError(errerror,criticalbool){logger.ErrorWithStack("operation failed",err,critical,"severity",map[bool]string{true:"critical",false:"normal"}[critical],)}// Normal error - no stackhandleError(validationErr,false)// Critical error - with stackhandleError(dbCorruptionErr,true)
With Panic Recovery
funcrecoverPanic(){ifr:=recover();r!=nil{err:=fmt.Errorf("panic: %v",r)logger.ErrorWithStack("panic recovered",err,true,"panic_value",r,)}}funcriskyOperation(){deferrecoverPanic()// Operations that might panic...}
Combining Convenience Methods
Use multiple convenience methods together:
funchandleRequest(whttp.ResponseWriter,r*http.Request){start:=time.Now()// Process requestresult,err:=processRequest(r)iferr!=nil{// Log error with contextlogger.LogError(err,"request processing failed","path",r.URL.Path,)// Log request detailslogger.LogRequest(r,"status",500)http.Error(w,"Internal Server Error",500)return}// Log successful requestlogger.LogRequest(r,"status",200,"items",len(result.Items),)// Log timinglogger.LogDuration("request completed",start,"items_processed",len(result.Items),)json.NewEncoder(w).Encode(result)}
Performance Considerations
Pooled Attribute Slices
Convenience methods use pooled slices internally for efficiency:
// No allocations beyond the log entry itselflogger.LogRequest(r,"status",200,"bytes",1024)logger.LogError(err,"failed","retry",3)logger.LogDuration("done",start,"count",100)
Implementation detail: Methods use sync.Pool for attribute slices, reducing GC pressure.
Zero Allocations
Standard logging with convenience methods:
// Benchmark: 0 allocs/op for standard uselogger.LogRequest(r,"status",200)logger.LogError(err,"failed")logger.LogDuration("done",start)
Exception:ErrorWithStack allocates for stack trace capture (intentional trade-off).
logger:=logging.MustNew(logging.WithJSONHandler(),logging.WithSampling(logging.SamplingConfig{Initial:100,// Log first 100 entries unconditionallyThereafter:100,// After that, log 1 in every 100Tick:time.Minute,// Reset counter every minute}),)
How Sampling Works
The sampling algorithm has three phases:
1. Initial Phase
Log the first Initial entries unconditionally:
SamplingConfig{Initial:100,// First 100 logs always written// ...}
Purpose: Ensure you always see the beginning of operations, even if they’re short-lived.
2. Sampling Phase
After Initial entries, log 1 in every Thereafter entries:
SamplingConfig{Initial:100,Thereafter:100,// Log 1%, drop 99%// ...}
Examples:
Thereafter: 100 → 1% sampling (log 1 in 100)
Thereafter: 10 → 10% sampling (log 1 in 10)
Thereafter: 1000 → 0.1% sampling (log 1 in 1000)
3. Reset Phase
Reset counter every Tick interval:
SamplingConfig{Initial:100,Thereafter:100,Tick:time.Minute,// Reset every minute}
Purpose: Ensure recent activity is always visible. Without resets, you might miss important recent events.
logger:=logging.MustNew(logging.WithSampling(logging.SamplingConfig{Initial:100,Thereafter:100,// 1% samplingTick:time.Minute,}),)// These may be sampledlogger.Debug("processing item","id",id)// May be droppedlogger.Info("request handled","path",path)// May be dropped// These are NEVER sampledlogger.Error("database error","error",err)// Always loggedlogger.Error("payment failed","tx_id",txID)// Always logged
Rationale: Critical errors should never be lost, regardless of sampling configuration.
Configuration Examples
High-Traffic API
// Log all errors, but only 1% of info/debuglogger:=logging.MustNew(logging.WithJSONHandler(),logging.WithLevel(logging.LevelInfo),logging.WithSampling(logging.SamplingConfig{Initial:1000,// First 1000 requests fully loggedThereafter:100,// Then 1% samplingTick:5*time.Minute,// Reset every 5 minutes}),)
Result:
Startup: All logs for first 1000 requests
Steady state: 1% of logs (99% reduction)
Every 5 minutes: Full logging resumes briefly
Debug Logging in Production
// Enable debug logs with heavy samplinglogger:=logging.MustNew(logging.WithJSONHandler(),logging.WithLevel(logging.LevelDebug),logging.WithSampling(logging.SamplingConfig{Initial:50,// See first 50 debug logsThereafter:1000,// Then 0.1% samplingTick:10*time.Minute,// Reset every 10 minutes}),)
Use case: Temporarily enable debug logging in production without overwhelming logs.
Cost Optimization
// Aggressive sampling for cost reductionlogger:=logging.MustNew(logging.WithJSONHandler(),logging.WithLevel(logging.LevelInfo),logging.WithSampling(logging.SamplingConfig{Initial:500,Thereafter:1000,// 0.1% sampling (99.9% reduction)Tick:time.Hour,}),)
Result: Dramatic log volume reduction while maintaining statistical samples.
Special Configurations
No Sampling After Initial
Set Thereafter: 0 to log everything after initial:
SamplingConfig{Initial:100,// First 100 sampledThereafter:0,// Then log everythingTick:time.Minute,}
Use case: Rate limiting only during burst startup.
No Reset
Set Tick: 0 to never reset the counter:
SamplingConfig{Initial:1000,Thereafter:100,Tick:0,// Never reset}
Result: Sample continuously without periodic full logging.
varlogCountatomic.Int64logger:=logging.MustNew(logging.WithJSONHandler(),logging.WithSampling(config),)// Periodically checkticker:=time.NewTicker(time.Minute)gofunc(){forrangeticker.C{count:=logCount.Swap(0)fmt.Printf("Logs/minute: %d\n",count)// Adjust sampling if neededifcount>10000{// Consider more aggressive sampling}}}()
Troubleshooting
Missing Expected Logs
Problem: Important logs being sampled out.
Solution: Use ERROR level for critical logs:
// May be sampledlogger.Info("payment processed","tx_id",txID)// Never sampledlogger.Error("payment failed","tx_id",txID)
Too Much Log Volume
Problem: Sampling not reducing volume enough.
Solutions:
Increase Thereafter value:
SamplingConfig{Thereafter:1000,// More aggressive: 0.1% instead of 1%}
Reduce Initial value:
SamplingConfig{Initial:50,// Fewer initial logs}
Increase Tick interval:
SamplingConfig{Tick:5*time.Minute,// Reset less frequently}
Lost Debug Context
Problem: Sampling makes debugging difficult.
Solution: Temporarily disable sampling:
// Create logger without sampling for debugging sessiondebugLogger:=logging.MustNew(logging.WithJSONHandler(),logging.WithLevel(logging.LevelDebug),// No WithSampling() call)
Change log levels at runtime without restarting your application
This guide covers dynamic log level changes. You can adjust logging verbosity at runtime for troubleshooting and performance tuning.
Overview
Dynamic log levels enable changing the minimum log level without restarting your application.
Why dynamic log levels:
Enable debug logging temporarily for troubleshooting.
Reduce log volume during traffic spikes.
Runtime configuration via HTTP endpoint or signal handler.
Quick response to production issues without deployment.
Limitations:
Not supported with custom loggers.
Brief window where old and new levels may race during transition.
Basic Usage
Change log level with SetLevel:
logger:=logging.MustNew(logging.WithJSONHandler())// Initial level is Info (default)logger.Info("this appears")logger.Debug("this doesn't appear")// Enable debug loggingiferr:=logger.SetLevel(logging.LevelDebug);err!=nil{log.Printf("failed to change level: %v",err)}// Now debug logs appearlogger.Debug("this now appears")
Available Log Levels
Four log levels from least to most restrictive:
logging.LevelDebug// Most verbose: Debug, Info, Warn, Errorlogging.LevelInfo// Info, Warn, Errorlogging.LevelWarn// Warn, Errorlogging.LevelError// Error only
Setting Levels
// Enable debug logginglogger.SetLevel(logging.LevelDebug)// Reduce to warnings onlylogger.SetLevel(logging.LevelWarn)// Errors onlylogger.SetLevel(logging.LevelError)// Back to infologger.SetLevel(logging.LevelInfo)
packagemainimport("fmt""net/http""rivaas.dev/logging")funcmain(){logger:=logging.MustNew(logging.WithJSONHandler(),logging.WithLevel(logging.LevelInfo),)// Admin endpoint to change log levelhttp.HandleFunc("/admin/loglevel",func(whttp.ResponseWriter,r*http.Request){ifr.Method!=http.MethodPost{http.Error(w,"Method not allowed",http.StatusMethodNotAllowed)return}levelStr:=r.URL.Query().Get("level")varlevellogging.LevelswitchlevelStr{case"debug":level=logging.LevelDebugcase"info":level=logging.LevelInfocase"warn":level=logging.LevelWarncase"error":level=logging.LevelErrordefault:http.Error(w,"Invalid level. Use: debug, info, warn, error",http.StatusBadRequest)return}iferr:=logger.SetLevel(level);err!=nil{http.Error(w,err.Error(),http.StatusInternalServerError)return}w.WriteHeader(http.StatusOK)fmt.Fprintf(w,"Log level changed to %s\n",levelStr)})http.ListenAndServe(":8080",nil)}
Usage:
# Enable debug loggingcurl -X POST "http://localhost:8080/admin/loglevel?level=debug"# Reduce to errors onlycurl -X POST "http://localhost:8080/admin/loglevel?level=error"# Back to infocurl -X POST "http://localhost:8080/admin/loglevel?level=info"
Signal Handler for Level Changes
Use Unix signals to change log levels:
packagemainimport("os""os/signal""syscall""rivaas.dev/logging")funcmain(){logger:=logging.MustNew(logging.WithJSONHandler(),logging.WithLevel(logging.LevelInfo),)// Setup signal handlerssigChan:=make(chanos.Signal,1)signal.Notify(sigChan,syscall.SIGUSR1,syscall.SIGUSR2)gofunc(){forsig:=rangesigChan{switchsig{casesyscall.SIGUSR1:// SIGUSR1: Enable debug logginglogger.SetLevel(logging.LevelDebug)logger.Info("debug logging enabled via SIGUSR1")casesyscall.SIGUSR2:// SIGUSR2: Back to info logginglogger.SetLevel(logging.LevelInfo)logger.Info("info logging restored via SIGUSR2")}}}()// Application logic...select{}}
Usage:
# Get process IDPID=$(pgrep myapp)# Enable debug loggingkill -USR1 $PID# Restore info loggingkill -USR2 $PID
Dynamic level changes don’t work with custom loggers:
customLogger:=slog.New(slog.NewJSONHandler(os.Stdout,nil))logger:=logging.MustNew(logging.WithCustomLogger(customLogger),)// This failserr:=logger.SetLevel(logging.LevelDebug)iferrors.Is(err,logging.ErrCannotChangeLevel){fmt.Println("Cannot change level on custom logger")}
Workaround: Control level in your custom logger directly:
Enable debug logging temporarily to diagnose an issue:
# Enable debug logscurl -X POST "http://localhost:8080/admin/loglevel?level=debug"# Reproduce issue and capture logs# Restore normal levelcurl -X POST "http://localhost:8080/admin/loglevel?level=info"
Traffic Spike Response
Reduce logging during high traffic:
funcmonitorTraffic(logger*logging.Logger){ticker:=time.NewTicker(time.Minute)forrangeticker.C{rps:=getCurrentRPS()ifrps>10000{// High traffic - reduce logginglogger.SetLevel(logging.LevelWarn)logger.Warn("high traffic detected, reducing log level","rps",rps)}elseifrps<5000{// Normal traffic - restore info logginglogger.SetLevel(logging.LevelInfo)}}}
Gradual Rollout
Gradually enable debug logging across a fleet:
funcgradualDebugRollout(logger*logging.Logger,percentageint){// Only enable debug on N% of instancesifrand.Intn(100)<percentage{logger.SetLevel(logging.LevelDebug)logger.Info("debug logging enabled in rollout","percentage",percentage)}}
Environment-Based Levels
Set initial level based on environment, allow runtime changes:
Middleware - Access log and custom middleware support
Basic Router Integration
Set a logger on the router to enable request logging.
Simple Integration
import("rivaas.dev/router""rivaas.dev/logging")funcmain(){// Create loggerlogger:=logging.MustNew(logging.WithConsoleHandler(),logging.WithDebugLevel(),)// Create router and set loggerr:=router.MustNew()r.SetLogger(logger)r.GET("/",func(c*router.Context){c.Logger().Info("handling request")c.JSON(200,map[string]string{"status":"ok"})})r.Run(":8080")}
Accessing Logger in Handlers
The router context provides a logger instance:
r.GET("/api/users/:id",func(c*router.Context){userID:=c.Param("id")// Get logger from contextlog:=c.Logger()log.Info("fetching user","user_id",userID)user,err:=fetchUser(userID)iferr!=nil{log.Error("failed to fetch user","error",err,"user_id",userID)c.JSON(500,gin.H{"error":"internal server error"})return}c.JSON(200,user)})
App Package Integration
The app package provides batteries-included observability wiring.
Full Observability Setup
import("rivaas.dev/app""rivaas.dev/logging""rivaas.dev/tracing")funcmain(){a,err:=app.New(app.WithServiceName("my-api"),app.WithObservability(app.WithLogging(logging.WithJSONHandler(),logging.WithLevel(logging.LevelInfo),),app.WithMetrics(),// Prometheus is defaultapp.WithTracing(tracing.WithOTLP("localhost:4317"),),),)iferr!=nil{log.Fatal(err)}defera.Shutdown(context.Background())// Get router with logging, metrics, and tracing configuredrouter:=a.Router()router.GET("/api/users",func(c*router.Context){// Logger automatically includes trace_id and span_idc.Logger().Info("fetching users")c.JSON(200,fetchUsers())})a.Run(":8080")}
Benefits:
Automatic service metadata (name, version, environment)
Trace correlation (logs include trace_id and span_id)
a,_:=app.New(app.WithServiceName("my-api"),app.WithObservability(app.WithLogging(logging.WithJSONHandler()),app.WithMetrics(),app.WithTracing(tracing.WithOTLP("localhost:4317")),),)// Access componentslogger:=a.Logger()router:=a.Router()tracer:=a.Tracer()metrics:=a.Metrics()// Use logger directlylogger.Info("application started","port",8080)
Context-Aware Logging
Router contexts automatically support trace correlation.
Automatic Trace Correlation
r.GET("/api/process",func(c*router.Context){// Logger from context is automatically trace-awarelog:=c.Logger()log.Info("processing started")// Output includes trace_id and span_id if tracing enabledresult:=processData()log.Info("processing completed","items",result.Count)})
r.GET("/api/data",func(c*router.Context){// Get base loggerbaseLogger:=a.Logger()// Create context logger with trace infocl:=logging.NewContextLogger(c.Request.Context(),baseLogger)cl.Info("processing request")})
Access Log Middleware
The router includes built-in access log middleware.
# Service identificationexportOTEL_SERVICE_NAME=my-api
exportOTEL_SERVICE_VERSION=v1.0.0
exportRIVAAS_ENVIRONMENT=production
The app package automatically reads these:
a,_:=app.New(// Service name from OTEL_SERVICE_NAMEapp.WithObservability(app.WithLogging(logging.WithJSONHandler()),),)logger:=a.Logger()logger.Info("service started")// Automatically includes service="my-api", version="v1.0.0", env="production"
Custom Environment Configuration
funccreateLogger()*logging.Logger{varopts[]logging.Option// Handler based on environmentswitchos.Getenv("ENV"){case"development":opts=append(opts,logging.WithConsoleHandler())default:opts=append(opts,logging.WithJSONHandler())}// Level from environmentlogLevel:=os.Getenv("LOG_LEVEL")switchlogLevel{case"debug":opts=append(opts,logging.WithDebugLevel())case"warn":opts=append(opts,logging.WithLevel(logging.LevelWarn))case"error":opts=append(opts,logging.WithLevel(logging.LevelError))default:opts=append(opts,logging.WithLevel(logging.LevelInfo))}// Service metadataopts=append(opts,logging.WithServiceName(os.Getenv("SERVICE_NAME")),logging.WithServiceVersion(os.Getenv("SERVICE_VERSION")),logging.WithEnvironment(os.Getenv("ENV")),)returnlogging.MustNew(opts...)}
Custom Middleware
Create custom logging middleware for specialized needs.
Request ID Middleware
funcrequestIDMiddleware(logger*logging.Logger)router.HandlerFunc{returnfunc(c*router.Context){requestID:=c.GetHeader("X-Request-ID")ifrequestID==""{requestID=generateRequestID()}// Add request ID to request contextctx:=c.Request.Context()ctx=context.WithValue(ctx,"request_id",requestID)// Create logger with request IDreqLogger:=logger.With("request_id",requestID)ctx=context.WithValue(ctx,"logger",reqLogger)c.Request=c.Request.WithContext(ctx)c.Next()}}// Usager.Use(requestIDMiddleware(logger))
User Context Middleware
funcuserContextMiddleware()router.HandlerFunc{returnfunc(c*router.Context){userID:=extractUserID(c)ifuserID!=""{// Add user ID to loggerlog:=c.Logger().With("user_id",userID)ctx:=context.WithValue(c.Request.Context(),"logger",log)c.Request=c.Request.WithContext(ctx)}c.Next()}}
Error Logging Middleware
funcerrorLoggingMiddleware()router.HandlerFunc{returnfunc(c*router.Context){c.Next()// Log errors after handler completesifc.HasErrors(){log:=c.Logger()for_,err:=rangec.Errors(){log.Error("request error","error",err.Error(),"type",err.Type,"path",c.Request.URL.Path,)}}}}
Complete Integration Example
Putting it all together:
packagemainimport("context""os""rivaas.dev/app""rivaas.dev/logging""rivaas.dev/tracing""rivaas.dev/router/middleware/accesslog")funcmain(){// Initialize app with full observabilitya,err:=app.New(app.WithServiceName("payment-api"),app.WithServiceVersion("v2.1.0"),app.WithObservability(app.WithLogging(logging.WithJSONHandler(),logging.WithLevel(logging.LevelInfo),logging.WithEnvironment(os.Getenv("ENV")),),app.WithMetrics(),app.WithTracing(tracing.WithOTLP("localhost:4317"),),),)iferr!=nil{panic(err)}defera.Shutdown(context.Background())router:=a.Router()logger:=a.Logger()// Add middlewarerouter.Use(accesslog.New(accesslog.WithExcludePaths("/health","/ready"),))// Health endpoint (no logging)router.GET("/health",func(c*router.Context){c.JSON(200,gin.H{"status":"healthy"})})// API endpoints (with logging and tracing)api:=router.Group("/api/v1"){api.POST("/payments",func(c*router.Context){log:=c.Logger()log.Info("payment request received")varpaymentPaymentiferr:=c.BindJSON(&payment);err!=nil{log.Error("invalid payment request","error",err)c.JSON(400,gin.H{"error":"invalid request"})return}result,err:=processPayment(c.Request.Context(),payment)iferr!=nil{log.Error("payment processing failed","error",err,"payment_id",payment.ID,)c.JSON(500,gin.H{"error":"processing failed"})return}log.Info("payment processed successfully","payment_id",payment.ID,"amount",payment.Amount,"status",result.Status,)c.JSON(200,result)})}// Start serverlogger.Info("starting server","port",8080)iferr:=a.Run(":8080");err!=nil{logger.Error("server error","error",err)}}
Best Practices
Per-Request Loggers
Create request-scoped loggers with context:
r.GET("/api/data",func(c*router.Context){log:=c.Logger().With("request_id",c.GetHeader("X-Request-ID"),"user_id",extractUserID(c),)log.Info("request started")// All subsequent logs include request_id and user_idlog.Info("processing")log.Info("request completed")})
Structured Context
Add structured context early in request lifecycle:
Use access log middleware instead of manual logging:
// BAD - manual logging in every handlerr.GET("/api/users",func(c*router.Context){log:=c.Logger()log.Info("request","path",c.Request.URL.Path)// Duplicate// ... handle requestlog.Info("response","status",200)// Use access log instead})// GOOD - use access log middlewarer.Use(accesslog.New())r.GET("/api/users",func(c*router.Context){// Handle request - logging handled by middleware})
th:=logging.NewTestHelper(t,logging.WithLevel(logging.LevelWarn),// Only warnings and errorslogging.WithServiceName("test-service"),)
Parsing Log Entries
Parse JSON logs for inspection.
ParseJSONLogEntries
funcTestLogging(t*testing.T){logger,buf:=logging.NewTestLogger()logger.Info("test message","key","value")logger.Error("test error","error","something failed")entries,err:=logging.ParseJSONLogEntries(buf)require.NoError(t,err)require.Len(t,entries,2)// First entryassert.Equal(t,"INFO",entries[0].Level)assert.Equal(t,"test message",entries[0].Message)assert.Equal(t,"value",entries[0].Attrs["key"])// Second entryassert.Equal(t,"ERROR",entries[1].Level)assert.Equal(t,"something failed",entries[1].Attrs["error"])}
LogEntry Structure
typeLogEntrystruct{Timetime.Time// Log timestampLevelstring// "DEBUG", "INFO", "WARN", "ERROR"Messagestring// Log messageAttrsmap[string]any// All other fields}
Mock Writers
Test utilities for inspecting write behavior.
MockWriter
Records all writes for inspection:
funcTestWriteBehavior(t*testing.T){mw:=&logging.MockWriter{}logger:=logging.MustNew(logging.WithJSONHandler(),logging.WithOutput(mw),)logger.Info("test 1")logger.Info("test 2")logger.Info("test 3")// Verify write countassert.Equal(t,3,mw.WriteCount())// Inspect last writelastWrite:=mw.LastWrite()assert.Contains(t,string(lastWrite),"test 3")// Check total bytesassert.Greater(t,mw.BytesWritten(),0)// Reset for next testmw.Reset()}
CountingWriter
Count bytes without storing content:
funcTestLogVolume(t*testing.T){cw:=&logging.CountingWriter{}logger:=logging.MustNew(logging.WithJSONHandler(),logging.WithOutput(cw),)fori:=0;i<1000;i++{logger.Info("test message","index",i)}// Verify volumebytesLogged:=cw.Count()t.Logf("Total bytes logged: %d",bytesLogged)// Useful for volume tests without memory overhead}
funcTestErrorHandling(t*testing.T){th:=logging.NewTestHelper(t)svc:=NewService(th.Logger)err:=svc.DoSomethingThatFails()require.Error(t,err)// Verify error was loggedth.AssertLog(t,"ERROR","operation failed",map[string]any{"error":"expected failure",})}
Testing Log Levels
funcTestLogLevels(t*testing.T){th:=logging.NewTestHelper(t,logging.WithLevel(logging.LevelWarn),)th.Logger.Debug("debug message")// Won't appearth.Logger.Info("info message")// Won't appearth.Logger.Warn("warn message")// Will appearth.Logger.Error("error message")// Will appearlogs,_:=th.Logs()assert.Len(t,logs,2)assert.Equal(t,"WARN",logs[0].Level)assert.Equal(t,"ERROR",logs[1].Level)}
Testing Structured Fields
funcTestStructuredLogging(t*testing.T){th:=logging.NewTestHelper(t)th.Logger.Info("user action","user_id","123","action","login","timestamp",time.Now().Unix(),)// Verify specific attributesassert.True(t,th.ContainsAttr("user_id","123"))assert.True(t,th.ContainsAttr("action","login"))// Or use AssertLog for multiple attributesth.AssertLog(t,"INFO","user action",map[string]any{"user_id":"123","action":"login",})}
Testing Sampling
funcTestSampling(t*testing.T){th:=logging.NewTestHelper(t,logging.WithSampling(logging.SamplingConfig{Initial:10,Thereafter:100,Tick:time.Minute,}),)// Log many entriesfori:=0;i<1000;i++{th.Logger.Info("test","index",i)}logs,_:=th.Logs()// Should have ~20 logs (10 initial + ~10 sampled)assert.Less(t,len(logs),50)assert.Greater(t,len(logs),10)}
Testing Context Logging
funcTestContextLogger(t*testing.T){th:=logging.NewTestHelper(t)// Create context with trace info (mocked)ctx:=context.Background()// Add trace to context...cl:=logging.NewContextLogger(ctx,th.Logger)cl.Info("traced message")// Verify trace IDs in logslogs,_:=th.Logs()require.Len(t,logs,1)// Check for trace_id if tracing was activeiftraceID:=cl.TraceID();traceID!=""{assert.Equal(t,traceID,logs[0].Attrs["trace_id"])}}
Table-Driven Tests
Use table-driven tests for comprehensive coverage:
funcTestLogLevels(t*testing.T){tests:=[]struct{namestringlevellogging.LevellogFuncfunc(*logging.Logger)expectLoggedbool}{{name:"debug at info level",level:logging.LevelInfo,logFunc:func(l*logging.Logger){l.Debug("debug message")},expectLogged:false,},{name:"info at info level",level:logging.LevelInfo,logFunc:func(l*logging.Logger){l.Info("info message")},expectLogged:true,},{name:"error at warn level",level:logging.LevelWarn,logFunc:func(l*logging.Logger){l.Error("error message")},expectLogged:true,},}for_,tt:=rangetests{t.Run(tt.name,func(t*testing.T){th:=logging.NewTestHelper(t,logging.WithLevel(tt.level),)tt.logFunc(th.Logger)logs,_:=th.Logs()iftt.expectLogged{assert.Len(t,logs,1)}else{assert.Len(t,logs,0)}})}}
funcTestA(t*testing.T){th:=logging.NewTestHelper(t)// Independent logger// Test A logic...}funcTestB(t*testing.T){th:=logging.NewTestHelper(t)// Independent logger// Test B logic...}
Running Tests
# Run all testsgo test ./...
# Run with verbose outputgo test -v ./...
# Run specific testgo test -run TestMyFunction
# With coveragego test -cover ./...
# With race detectorgo test -race ./...
Use consistent field names across your application:
// Good - consistent naminglog.Info("request started","user_id",userID)log.Info("database query","user_id",userID)log.Info("response sent","user_id",userID)// Bad - inconsistent naminglog.Info("request started","user_id",userID)log.Info("database query","userId",userID)// Different namelog.Info("response sent","user",userID)// Different name
Recommended conventions:
Use snake_case: user_id, request_id, duration_ms
Be specific: http_status not status, db_host not host
Use consistent units: duration_ms, size_bytes, count
Always include relevant context with log messages.
Minimal Context
// Bad - no contextlog.Error("failed to save","error",err)
Better - Includes Context
// Good - includes relevant contextlog.Error("failed to save user data","error",err,"user_id",user.ID,"operation","update_profile","retry_count",retries,"elapsed_ms",elapsed.Milliseconds(),)
Context checklist:
What operation failed?
Which entity was involved?
What were the inputs?
How many times did we retry?
How long did it take?
Performance Considerations
Follow these guidelines for high-performance logging.
// Production configurationlogger:=logging.MustNew(logging.WithJSONHandler(),logging.WithLevel(logging.LevelInfo),// Skip debug logs)
Impact:
DEBUG logs have overhead even if not written
Level checks are fast but not free
Set INFO or WARN in production
Defer Expensive Operations
Only compute expensive values if the log will be written:
// Bad - always computeslog.Debug("state","expensive",expensiveComputation())// Good - only compute if debug enablediflog.Logger().Enabled(context.Background(),logging.LevelDebug){log.Debug("state","expensive",expensiveComputation())}
Personally identifiable information (PII) without consent
Production Configuration
Recommended production setup.
Production Logger
funcNewProductionLogger()*logging.Logger{returnlogging.MustNew(logging.WithJSONHandler(),// Machine-parseablelogging.WithLevel(logging.LevelInfo),// No debug spamlogging.WithServiceName(os.Getenv("SERVICE_NAME")),logging.WithServiceVersion(os.Getenv("VERSION")),logging.WithEnvironment("production"),logging.WithOutput(os.Stdout),// Stdout for container logs)}
Development Logger
funcNewDevelopmentLogger()*logging.Logger{returnlogging.MustNew(logging.WithConsoleHandler(),// Human-readablelogging.WithDebugLevel(),// See everythinglogging.WithSource(true),// File:line info)}
// Normal error - no stack traceiferr:=validation();err!=nil{logger.LogError(err,"validation failed","field",field)returnerr}// Critical error - with stack traceiferr:=criticalOperation();err!=nil{logger.ErrorWithStack("critical failure",err,true,"operation","process_payment","amount",amount,)returnerr}
// Bad - redundant with access logr.GET("/api/users",func(c*router.Context){c.Logger().Info("request received")// Don't do this// ... handle requestc.Logger().Info("request completed")// Don't do this})
funcmain(){logger:=logging.MustNew(logging.WithJSONHandler())// Setup signal handlingsigChan:=make(chanos.Signal,1)signal.Notify(sigChan,os.Interrupt,syscall.SIGTERM)gofunc(){<-sigChanlogger.Info("shutting down...")ctx,cancel:=context.WithTimeout(context.Background(),5*time.Second)defercancel()logger.Shutdown(ctx)os.Exit(0)}()// Application logic...}
Testing Considerations
Make logging testable.
Inject Loggers
// Good - logger injectedtypeServicestruct{logger*logging.Logger}funcNewService(logger*logging.Logger)*Service{return&Service{logger:logger}}// In testsfuncTestService(t*testing.T){th:=logging.NewTestHelper(t)svc:=NewService(th.Logger)// Test and verify logs}
Don’t use global loggers:
// Bad - global loggervarlog=logging.MustNew(logging.WithJSONHandler())typeServicestruct{}func(s*Service)DoSomething(){log.Info("doing something")// Can't test}
Common Anti-Patterns
Avoid these common mistakes.
String Formatting in Log Calls
// Bad - string formattinglog.Info(fmt.Sprintf("User %s did %s",user,action))// Good - structured fieldslog.Info("user action","user",user,"action",action)
Logging in Library Code
// Bad - library logging directlyfuncLibraryFunction(){log.Info("library function called")}// Good - library returns errorsfuncLibraryFunction()error{iferr:=something();err!=nil{returnfmt.Errorf("library operation failed: %w",err)}returnnil}// Caller logsiferr:=LibraryFunction();err!=nil{log.Error("library call failed","error",err)}
Ignoring Shutdown Errors
// Bad - ignoring shutdowndeferlogger.Shutdown(context.Background())// Good - handling shutdown errorsdeferfunc(){ctx,cancel:=context.WithTimeout(context.Background(),5*time.Second)defercancel()iferr:=logger.Shutdown(ctx);err!=nil{fmt.Fprintf(os.Stderr,"shutdown error: %v\n",err)}}()
Replace typed field methods (zap.String → direct values)
Update error handling (Sync → Shutdown)
Test with new logger
Update imports
Remove old logger dependency from go.mod
Update documentation and examples
Gradual Migration
Migrate gradually to minimize risk.
Phase 1: Parallel Logging
Run both loggers side-by-side:
// Keep old loggeroldLogger:=logrus.New()// Add new loggernewLogger:=logging.MustNew(logging.WithJSONHandler())// Log to bothfunclogInfo(msgstring,fieldsmap[string]any){// Old loggeroldLogger.WithFields(logrus.Fields(fields)).Info(msg)// New loggerargs:=make([]any,0,len(fields)*2)fork,v:=rangefields{args=append(args,k,v)}newLogger.Info(msg,args...)}
packagemainimport("context""net/http""time""rivaas.dev/logging""rivaas.dev/router")funcmain(){logger:=logging.MustNew(logging.WithJSONHandler(),logging.WithServiceName("api-server"),)deferlogger.Shutdown(context.Background())mux:=http.NewServeMux()// Add logging middlewaremux.HandleFunc("/",loggingMiddleware(logger,handleRoot))mux.HandleFunc("/api/users",loggingMiddleware(logger,handleUsers))logger.Info("server starting","port",8080)http.ListenAndServe(":8080",mux)}funcloggingMiddleware(logger*logging.Logger,nexthttp.HandlerFunc)http.HandlerFunc{returnfunc(whttp.ResponseWriter,r*http.Request){start:=time.Now()// Wrap response writer to capture status and size (router provides StatusCode, Size, Written)wrapped:=router.NewResponseWriterWrapper(w)next(wrapped,r)logger.LogRequest(r,"status",wrapped.StatusCode(),"duration_ms",time.Since(start).Milliseconds(),"bytes",wrapped.Size(),)}}funchandleRoot(whttp.ResponseWriter,r*http.Request){w.Write([]byte("Hello, World!"))}funchandleUsers(whttp.ResponseWriter,r*http.Request){w.Write([]byte(`{"users": []}`))}
Router Integration
Full router integration with tracing.
packagemainimport("context""rivaas.dev/app""rivaas.dev/logging""rivaas.dev/tracing""rivaas.dev/router/middleware/accesslog")funcmain(){// Create app with full observabilitya,err:=app.New(app.WithServiceName("user-api"),app.WithServiceVersion("v2.0.0"),app.WithObservability(app.WithLogging(logging.WithJSONHandler(),logging.WithLevel(logging.LevelInfo),),app.WithTracing(tracing.WithOTLP("localhost:4317"),),),)iferr!=nil{panic(err)}defera.Shutdown(context.Background())router:=a.Router()logger:=a.Logger()// Add access log middlewarerouter.Use(accesslog.New(accesslog.WithExcludePaths("/health"),))// Health endpointrouter.GET("/health",func(c*router.Context){c.JSON(200,map[string]string{"status":"healthy"})})// API endpointsapi:=router.Group("/api/v1"){api.GET("/users",getUsers(logger))api.POST("/users",createUser(logger))}logger.Info("server starting","port",8080)a.Run(":8080")}funcgetUsers(logger*logging.Logger)router.HandlerFunc{returnfunc(c*router.Context){log:=c.Logger()log.Info("fetching users")users:=fetchUsers()log.Info("users fetched","count",len(users))c.JSON(200,users)}}funccreateUser(logger*logging.Logger)router.HandlerFunc{returnfunc(c*router.Context){log:=c.Logger()varuserUseriferr:=c.BindJSON(&user);err!=nil{log.Error("invalid request","error",err)c.JSON(400,map[string]string{"error":"invalid request"})return}iferr:=saveUser(user);err!=nil{log.Error("failed to save user","error",err)c.JSON(500,map[string]string{"error":"internal error"})return}log.Info("user created","user_id",user.ID)c.JSON(201,user)}}
Multiple Loggers
Different loggers for different purposes.
packagemainimport("context""os""rivaas.dev/logging")typeApplicationstruct{appLogger*logging.LoggerdebugLogger*logging.LoggerauditLogger*logging.Logger}funcNewApplication()*Application{// Application logger - JSON for productionappLogger:=logging.MustNew(logging.WithJSONHandler(),logging.WithLevel(logging.LevelInfo),logging.WithServiceName("myapp"),)// Debug logger - Console with source infodebugLogger:=logging.MustNew(logging.WithConsoleHandler(),logging.WithDebugLevel(),logging.WithSource(true),)// Audit logger - Separate file for complianceauditFile,_:=os.OpenFile("audit.log",os.O_CREATE|os.O_WRONLY|os.O_APPEND,0666)auditLogger:=logging.MustNew(logging.WithJSONHandler(),logging.WithOutput(auditFile),logging.WithServiceName("myapp-audit"),)return&Application{appLogger:appLogger,debugLogger:debugLogger,auditLogger:auditLogger,}}func(a*Application)Run(){defera.appLogger.Shutdown(context.Background())defera.debugLogger.Shutdown(context.Background())defera.auditLogger.Shutdown(context.Background())// Normal application loga.appLogger.Info("application started")// Debug informationa.debugLogger.Debug("initialization complete","config_loaded",true,"db_connected",true,)// Audit eventa.auditLogger.Info("user action","user_id","123","action","login","success",true,)}funcmain(){app:=NewApplication()app.Run()}
Learn how to collect and export application metrics with Rivaas metrics package
The Rivaas Metrics package provides OpenTelemetry-based metrics collection. Supports multiple exporters including Prometheus, OTLP, and stdout. Enables observability best practices with minimal configuration.
Features
Multiple Providers: Prometheus, OTLP, and stdout exporters
Built-in HTTP Metrics: Request duration, count, active requests, and more
Custom Metrics: Support for counters, histograms, and gauges with error handling
Thread-Safe: All methods are safe for concurrent use
Context Support: All metrics methods accept context for cancellation
Structured Logging: Pluggable logger interface for error and warning messages
HTTP Middleware: Integration with any HTTP framework
Security: Automatic filtering of sensitive headers
Quick Start
packagemainimport("context""log""net/http""os/signal""rivaas.dev/metrics")funcmain(){ctx,cancel:=signal.NotifyContext(context.Background(),os.Interrupt)defercancel()recorder,err:=metrics.New(metrics.WithPrometheus(":9090","/metrics"),metrics.WithServiceName("my-api"),metrics.WithServiceVersion("v1.0.0"),)iferr!=nil{log.Fatal(err)}iferr:=recorder.Start(ctx);err!=nil{log.Fatal(err)}deferrecorder.Shutdown(context.Background())// Record custom metrics_=recorder.IncrementCounter(ctx,"requests_total")// Prometheus metrics available at http://localhost:9090/metrics}
packagemainimport("context""log""os/signal""rivaas.dev/metrics")funcmain(){ctx,cancel:=signal.NotifyContext(context.Background(),os.Interrupt)defercancel()recorder,err:=metrics.New(metrics.WithOTLP("http://localhost:4318"),metrics.WithServiceName("my-api"),metrics.WithServiceVersion("v1.0.0"),)iferr!=nil{log.Fatal(err)}iferr:=recorder.Start(ctx);err!=nil{log.Fatal(err)}deferrecorder.Shutdown(context.Background())// Metrics pushed to OTLP collector_=recorder.IncrementCounter(ctx,"requests_total")}
packagemainimport("context""log""rivaas.dev/metrics")funcmain(){recorder:=metrics.MustNew(metrics.WithStdout(),metrics.WithServiceName("my-api"),)ctx:=context.Background()// Metrics printed to stdout_=recorder.IncrementCounter(ctx,"requests_total")}
How It Works
Providers determine where metrics are exported (Prometheus, OTLP, stdout)
Lifecycle management ensures proper initialization and graceful shutdown
Create a simple test file to verify the installation:
packagemainimport("context""fmt""log""rivaas.dev/metrics")funcmain(){// Create a basic metrics recorderrecorder,err:=metrics.New(metrics.WithStdout(),metrics.WithServiceName("test-service"),)iferr!=nil{log.Fatalf("Failed to create recorder: %v",err)}// Start the recorder (optional for stdout, but good practice)iferr:=recorder.Start(context.Background());err!=nil{log.Fatalf("Failed to start recorder: %v",err)}deferrecorder.Shutdown(context.Background())fmt.Println("Metrics package installed successfully!")}
Run the test:
go run main.go
You should see output confirming the installation was successful.
Import Path
Import the metrics package in your code:
import"rivaas.dev/metrics"
Module Setup
If you’re starting a new project, initialize a Go module first:
go mod init your-project-name
go get rivaas.dev/metrics
Dependency Management
The metrics package uses Go modules for dependency management. After installation, your go.mod file will include:
Learn the fundamentals of metrics collection with Rivaas
This guide covers the basic patterns for using the metrics package in your Go applications.
Creating a Metrics Recorder
The core of the metrics package is the Recorder type. Create a recorder by choosing a provider and configuring it:
packagemainimport("context""log""os/signal""rivaas.dev/metrics")funcmain(){// Create context for application lifecyclectx,cancel:=signal.NotifyContext(context.Background(),os.Interrupt)defercancel()// Create recorder with error handlingrecorder,err:=metrics.New(metrics.WithPrometheus(":9090","/metrics"),metrics.WithServiceName("my-api"),metrics.WithServiceVersion("v1.0.0"),)iferr!=nil{log.Fatalf("Failed to create recorder: %v",err)}// Start metrics serveriferr:=recorder.Start(ctx);err!=nil{log.Fatalf("Failed to start metrics: %v",err)}// Your application code here...}
Using MustNew
For applications that should fail fast on configuration errors:
recorder:=metrics.MustNew(metrics.WithPrometheus(":9090","/metrics"),metrics.WithServiceName("my-api"),)// Panics if configuration is invalid
Lifecycle Management
Proper lifecycle management ensures metrics are properly initialized and flushed on shutdown.
Start and Shutdown
funcmain(){// Create lifecycle contextctx,cancel:=signal.NotifyContext(context.Background(),os.Interrupt)defercancel()recorder:=metrics.MustNew(metrics.WithPrometheus(":9090","/metrics"),metrics.WithServiceName("my-api"),)// Start with lifecycle contextiferr:=recorder.Start(ctx);err!=nil{log.Fatal(err)}// Ensure graceful shutdowndeferfunc(){shutdownCtx,shutdownCancel:=context.WithTimeout(context.Background(),5*time.Second,)defershutdownCancel()iferr:=recorder.Shutdown(shutdownCtx);err!=nil{log.Printf("Metrics shutdown error: %v",err)}}()// Your application code...}
Why Start() is Important
Different providers require Start() for different reasons:
OTLP: Requires lifecycle context for network connections and graceful shutdown
Prometheus: Starts the HTTP metrics server
Stdout: Works without Start(), but calling it is harmless
Best Practice: Always call Start(ctx) with a lifecycle context, regardless of provider.
Force Flush
For push-based providers (OTLP, stdout), you can force immediate export of pending metrics:
// Before critical operation or deploymentiferr:=recorder.ForceFlush(ctx);err!=nil{log.Printf("Failed to flush metrics: %v",err)}
This is useful for:
Ensuring metrics are exported before deployment
Checkpointing during long-running operations
Guaranteeing metrics visibility before shutdown
Note: For Prometheus (pull-based), this is typically a no-op as metrics are collected on-demand.
Standalone Usage
Use the recorder directly without HTTP middleware:
packagemainimport("context""log""os/signal""rivaas.dev/metrics""go.opentelemetry.io/otel/attribute")funcmain(){// Create context for application lifecyclectx,cancel:=signal.NotifyContext(context.Background(),os.Interrupt)defercancel()// Create metrics recorderrecorder:=metrics.MustNew(metrics.WithPrometheus(":9090","/metrics"),metrics.WithServiceName("my-service"),)// Start metrics serveriferr:=recorder.Start(ctx);err!=nil{log.Fatal(err)}deferrecorder.Shutdown(context.Background())// Record custom metrics with error handlingiferr:=recorder.RecordHistogram(ctx,"processing_duration",1.5,attribute.String("operation","create_user"),);err!=nil{log.Printf("metrics error: %v",err)}// Or fire-and-forget (ignore errors)_=recorder.IncrementCounter(ctx,"requests_total",attribute.String("status","success"),)_=recorder.SetGauge(ctx,"active_connections",42)}
HTTP Integration
Integrate metrics with your HTTP server using middleware:
packagemainimport("context""log""net/http""os/signal""time""rivaas.dev/metrics")funcmain(){ctx,cancel:=signal.NotifyContext(context.Background(),os.Interrupt)defercancel()// Create metrics recorderrecorder,err:=metrics.New(metrics.WithPrometheus(":9090","/metrics"),metrics.WithServiceName("my-api"),)iferr!=nil{log.Fatal(err)}iferr:=recorder.Start(ctx);err!=nil{log.Fatal(err)}deferfunc(){shutdownCtx,shutdownCancel:=context.WithTimeout(context.Background(),5*time.Second)defershutdownCancel()recorder.Shutdown(shutdownCtx)}()// Create HTTP handlersmux:=http.NewServeMux()mux.HandleFunc("/",func(whttp.ResponseWriter,r*http.Request){w.Header().Set("Content-Type","application/json")w.Write([]byte(`{"message": "Hello"}`))})mux.HandleFunc("/health",func(whttp.ResponseWriter,r*http.Request){w.WriteHeader(http.StatusOK)})// Wrap with metrics middlewarehandler:=metrics.Middleware(recorder,metrics.WithExcludePaths("/health","/metrics"),)(mux)// Start HTTP serverserver:=&http.Server{Addr:":8080",Handler:handler,}gofunc(){iferr:=server.ListenAndServe();err!=http.ErrServerClosed{log.Fatal(err)}}()// Wait for interrupt<-ctx.Done()shutdownCtx,shutdownCancel:=context.WithTimeout(context.Background(),5*time.Second)defershutdownCancel()server.Shutdown(shutdownCtx)}
Built-in Metrics
When using the HTTP middleware, the following metrics are automatically collected:
Metric
Type
Description
http_request_duration_seconds
Histogram
Request duration distribution
http_requests_total
Counter
Total request count by status, method, path
http_requests_active
Gauge
Current active requests
http_request_size_bytes
Histogram
Request body size distribution
http_response_size_bytes
Histogram
Response body size distribution
http_errors_total
Counter
HTTP errors by status code
Viewing Metrics
With Prometheus provider, metrics are available at the configured endpoint:
curl http://localhost:9090/metrics
Example output:
# HELP target_info Target metadata
# TYPE target_info gauge
target_info{service_name="my-api",service_version="v1.0.0"} 1
# HELP http_requests_total Total number of HTTP requests
# TYPE http_requests_total counter
http_requests_total{method="GET",http_route="/",http_status_code="200"} 42
# HELP http_request_duration_seconds HTTP request duration
# TYPE http_request_duration_seconds histogram
http_request_duration_seconds_bucket{method="GET",http_route="/",le="0.005"} 10
http_request_duration_seconds_bucket{method="GET",http_route="/",le="0.01"} 25
...
The target_info metric contains your service metadata. Individual metrics include request-specific labels like method, http_route, and http_status_code.
Error Handling
The metrics package provides two patterns for error handling:
Check Errors
For critical metrics where errors matter:
iferr:=recorder.IncrementCounter(ctx,"critical_operations",attribute.String("type","payment"),);err!=nil{log.Printf("Failed to record metric: %v",err)// Handle error appropriately}
Fire-and-Forget
For best-effort metrics where errors can be ignored:
// Ignore errors - metrics are best-effort_=recorder.IncrementCounter(ctx,"page_views")_=recorder.RecordHistogram(ctx,"query_duration",duration)
Best Practice: Use fire-and-forget for most metrics to avoid impacting application performance.
Thread Safety
All Recorder methods are thread-safe and can be called concurrently:
// Safe to call from multiple goroutinesgofunc(){_=recorder.IncrementCounter(ctx,"worker_1")}()gofunc(){_=recorder.IncrementCounter(ctx,"worker_2")}()
Context Usage
All metrics methods accept a context for cancellation and tracing:
// Use request context for tracingfunchandleRequest(whttp.ResponseWriter,r*http.Request){// Metrics will inherit trace context from request_=recorder.IncrementCounter(r.Context(),"requests_processed")}// Use timeout contextctx,cancel:=context.WithTimeout(context.Background(),5*time.Second)defercancel()_=recorder.RecordHistogram(ctx,"operation_duration",1.5)
Metrics are available immediately after Start() returns
recorder,err:=metrics.New(metrics.WithPrometheus(":9090","/metrics"),metrics.WithServiceName("my-api"),)iferr!=nil{log.Fatal(err)}// HTTP server starts hereiferr:=recorder.Start(ctx);err!=nil{log.Fatal(err)}// Metrics endpoint is now available at http://localhost:9090/metrics
Port Configuration
By default, if the requested port is unavailable, the server automatically finds the next available port (up to 100 ports searched).
Strict Port Mode
For production, use WithStrictPort() to ensure the exact port is used:
recorder:=metrics.MustNew(metrics.WithPrometheus(":9090","/metrics"),metrics.WithStrictPort(),// Fail if port 9090 is unavailablemetrics.WithServiceName("my-service"),)
Production Best Practice: Always use WithStrictPort() to avoid port conflicts.
Finding the Actual Port
If not using strict mode, check which port was actually used:
recorder:=metrics.MustNew(metrics.WithPrometheus(":9090","/metrics"),metrics.WithServiceName("my-service"),)iferr:=recorder.Start(ctx);err!=nil{log.Fatal(err)}// Get the actual address (returns port like ":9090")address:=recorder.ServerAddress()log.Printf("Metrics available at: http://localhost%s/metrics",address)
Manual Server Management
Disable automatic server startup and serve metrics on your own HTTP server:
recorder:=metrics.MustNew(metrics.WithPrometheus(":9090","/metrics"),metrics.WithServerDisabled(),metrics.WithServiceName("my-service"),)// Get the metrics handlerhandler,err:=recorder.Handler()iferr!=nil{log.Fatalf("Failed to get metrics handler: %v",err)}// Serve on your own servermux:=http.NewServeMux()mux.Handle("/metrics",handler)mux.HandleFunc("/health",healthHandler)http.ListenAndServe(":8080",mux)
Use Case: Serve metrics on the same port as your application server.
Viewing Metrics
Access metrics via HTTP:
curl http://localhost:9090/metrics
Example output:
# HELP target_info Target metadata
# TYPE target_info gauge
target_info{service_name="my-service",service_version="v1.0.0"} 1
# HELP http_requests_total Total number of HTTP requests
# TYPE http_requests_total counter
http_requests_total{method="GET",http_route="/api/users",http_status_code="200"} 1543
# HELP http_request_duration_seconds HTTP request duration
# TYPE http_request_duration_seconds histogram
http_request_duration_seconds_bucket{method="GET",http_route="/api/users",le="0.005"} 245
http_request_duration_seconds_bucket{method="GET",http_route="/api/users",le="0.01"} 892
http_request_duration_seconds_sum{method="GET",http_route="/api/users"} 15.432
http_request_duration_seconds_count{method="GET",http_route="/api/users"} 1543
Uses the lifecycle context for network connections
Enables graceful shutdown of connections
Critical: You must call Start(ctx) before recording metrics, or metrics will be silently dropped.
recorder,err:=metrics.New(metrics.WithOTLP("http://localhost:4318"),metrics.WithServiceName("my-service"),)iferr!=nil{log.Fatal(err)}// OTLP connection established hereiferr:=recorder.Start(ctx);err!=nil{log.Fatal(err)}// Metrics are now exported to collector_=recorder.IncrementCounter(ctx,"requests_total")
Why Deferred Initialization?
OTLP initialization is deferred to:
Use the application lifecycle context for network connections
Enable proper graceful shutdown
Avoid establishing connections during configuration
recorder:=metrics.MustNew(metrics.WithOTLP("http://localhost:4318"),metrics.WithExportInterval(10*time.Second),// Export every 10smetrics.WithServiceName("my-service"),)
Force Flush
Force immediate export before the next interval:
// Ensure all metrics are sent immediatelyiferr:=recorder.ForceFlush(ctx);err!=nil{log.Printf("Failed to flush metrics: %v",err)}
Works without calling Start() (but calling it is harmless)
Prints metrics to stdout periodically
recorder:=metrics.MustNew(metrics.WithStdout(),metrics.WithServiceName("my-service"),)// Optional: Start() does nothing for stdout but doesn't hurtrecorder.Start(context.Background())// Metrics are printed to stdout_=recorder.IncrementCounter(ctx,"requests_total")
Export Interval
Configure how often metrics are printed (default: 30 seconds):
recorder:=metrics.MustNew(metrics.WithStdout(),metrics.WithExportInterval(5*time.Second),// Print every 5smetrics.WithServiceName("my-service"),)
funcTestHandler(t*testing.T){recorder:=metrics.TestingRecorder(t,"test-service")// Test code...}
Multiple Recorder Instances
You can create multiple recorder instances with different providers:
// Development recorder (stdout)devRecorder:=metrics.MustNew(metrics.WithStdout(),metrics.WithServiceName("dev-metrics"),)// Production recorder (Prometheus)prodRecorder:=metrics.MustNew(metrics.WithPrometheus(":9090","/metrics"),metrics.WithServiceName("prod-metrics"),)// Both work independently without conflicts
Note: By default, recorders do NOT set the global OpenTelemetry meter provider. See Configuration for details.
Individual metrics like http_requests_total do not include service_name as a label. This keeps label cardinality low, which follows Prometheus best practices. The target_info metric is used for service discovery and correlating metrics across your infrastructure.
Best Practices:
Use lowercase with hyphens: user-service, payment-api.
Be consistent across services.
Avoid changing names in production.
Service Version
Optional version metadata for tracking deployments:
varVersion="dev"// Set by build flagsrecorder:=metrics.MustNew(metrics.WithPrometheus(":9090","/metrics"),metrics.WithServiceName("my-api"),metrics.WithServiceVersion(Version),)
Prometheus-Specific Options
Strict Port Mode
Fail immediately if the configured port is unavailable:
recorder:=metrics.MustNew(metrics.WithPrometheus(":9090","/metrics"),metrics.WithStrictPort(),// Production recommendationmetrics.WithServiceName("my-api"),)
Default Behavior: If port is unavailable, automatically searches up to 100 ports.
With Strict Mode: Fails with error if exact port is unavailable.
Production Best Practice: Always use WithStrictPort() to ensure predictable port allocation.
Without Scope Info
Remove OpenTelemetry instrumentation scope labels from metrics:
What It Does: By default, OpenTelemetry adds labels like otel_scope_name, otel_scope_version, and otel_scope_schema_url to every metric. These labels identify which instrumentation library produced each metric.
When to Use: If you only have one instrumentation scope (which is common), you can remove these labels to keep your metrics clean and reduce label cardinality.
Only Affects: Prometheus provider (OTLP and stdout ignore this option).
What It Does: By default, OpenTelemetry creates a target_info metric containing resource attributes like service_name and service_version.
When to Use: If you already identify services through Prometheus external labels or other means, you can disable this metric.
Only Affects: Prometheus provider (OTLP and stdout ignore this option).
Server Disabled
Disable automatic metrics server and manage it yourself:
recorder:=metrics.MustNew(metrics.WithPrometheus(":9090","/metrics"),metrics.WithServerDisabled(),metrics.WithServiceName("my-api"),)// Get the metrics handlerhandler,err:=recorder.Handler()iferr!=nil{log.Fatalf("Failed to get handler: %v",err)}// Serve on your own HTTP serverhttp.Handle("/metrics",handler)http.ListenAndServe(":8080",nil)
Use Cases:
Serve metrics on same port as application
Custom server configuration
Integration with existing HTTP servers
Note: Handler() only works with Prometheus provider.
Histogram Bucket Configuration
Customize histogram bucket boundaries for better resolution in specific ranges.
Duration Buckets
Configure buckets for duration metrics (in seconds):
Most requests < 100ms: Use finer buckets at low end
Slow operations (seconds): Use coarser buckets
Specific SLA requirements
Examples:
// Fast API (most requests < 100ms)metrics.WithDurationBuckets(0.001,0.005,0.01,0.025,0.05,0.1,0.5,1)// Slow batch operations (seconds to minutes)metrics.WithDurationBuckets(1,5,10,30,60,120,300,600)// Mixed workloadmetrics.WithDurationBuckets(0.01,0.1,0.5,1,5,10,30,60)
// Small JSON API (< 10KB)metrics.WithSizeBuckets(100,500,1000,5000,10000,50000)// File uploads (KB to MB)metrics.WithSizeBuckets(1024,10240,102400,1048576,10485760,104857600)// Mixed sizesmetrics.WithSizeBuckets(100,1000,10000,100000,1000000,10000000)
Impact on Cardinality
Important: More buckets = higher metric cardinality = more storage.
By default, the metrics package does NOT set the global OpenTelemetry meter provider.
Default Behavior (Recommended)
Multiple independent recorder instances work without conflicts:
// Create independent recorders (no global state!)recorder1:=metrics.MustNew(metrics.WithPrometheus(":9090","/metrics"),metrics.WithServiceName("service-1"),)recorder2:=metrics.MustNew(metrics.WithStdout(),metrics.WithServiceName("service-2"),)// Both work independently without conflicts
recorder:=metrics.MustNew(metrics.WithPrometheus(":9090","/metrics"),metrics.WithServerDisabled(),metrics.WithServiceName("api"),)handler,_:=recorder.Handler()// Serve on application portmux:=http.NewServeMux()mux.Handle("/metrics",handler)mux.HandleFunc("/",appHandler)http.ListenAndServe(":8080",mux)
Create counters, histograms, and gauges with proper naming conventions
This guide covers recording custom metrics beyond the built-in HTTP metrics.
Metric Types
The metrics package supports three metric types from OpenTelemetry:
Type
Description
Use Case
Example
Counter
Monotonically increasing value
Counts of events
Requests processed, errors occurred
Histogram
Distribution of values
Durations, sizes
Query time, response size
Gauge
Point-in-time value
Current state
Active connections, queue depth
Counters
Counters track cumulative totals that only increase.
Increment Counter
Add 1 to a counter:
// With error handlingiferr:=recorder.IncrementCounter(ctx,"orders_processed_total",attribute.String("status","success"),attribute.String("payment_method","card"),);err!=nil{log.Printf("Failed to record metric: %v",err)}// Fire-and-forget (ignore errors)_=recorder.IncrementCounter(ctx,"page_views_total")
Add to Counter
Add a specific value to a counter:
// Add multiple items (value is int64)_=recorder.AddCounter(ctx,"bytes_processed_total",1024,attribute.String("direction","inbound"),)// Batch processingitemsProcessed:=int64(50)_=recorder.AddCounter(ctx,"items_processed_total",itemsProcessed,attribute.String("batch_id",batchID),)
Important: Counter values must be non-negative integers (int64).
Counter Examples
// Simple event counting_=recorder.IncrementCounter(ctx,"user_registrations_total")// With attributes_=recorder.IncrementCounter(ctx,"api_calls_total",attribute.String("endpoint","/api/users"),attribute.String("method","POST"),attribute.Int("status_code",201),)// Tracking errors_=recorder.IncrementCounter(ctx,"errors_total",attribute.String("type","validation"),attribute.String("field","email"),)// Data volume_=recorder.AddCounter(ctx,"data_transferred_bytes",float64(len(data)),attribute.String("protocol","https"),attribute.String("direction","upload"),)
Histograms
Histograms record distributions of values, useful for durations and sizes.
Customize bucket boundaries for better resolution (see Configuration):
recorder:=metrics.MustNew(metrics.WithPrometheus(":9090","/metrics"),// Fine-grained buckets for fast operationsmetrics.WithDurationBuckets(0.001,0.005,0.01,0.025,0.05,0.1,0.5),metrics.WithServiceName("my-api"),)
Gauges
Gauges represent point-in-time values that can increase or decrease.
Set Gauge
// Current connectionsactiveConnections:=connectionPool.Active()_=recorder.SetGauge(ctx,"active_connections",float64(activeConnections),attribute.String("pool","database"),)// Queue depthqueueSize:=queue.Len()_=recorder.SetGauge(ctx,"queue_depth",float64(queueSize),attribute.String("queue","tasks"),)
Gauge Examples
// Memory usagevarmruntime.MemStatsruntime.ReadMemStats(&m)_=recorder.SetGauge(ctx,"memory_allocated_bytes",float64(m.Alloc))// Goroutine count_=recorder.SetGauge(ctx,"goroutines_active",float64(runtime.NumGoroutine()))// Cache sizecacheSize:=cache.Len()_=recorder.SetGauge(ctx,"cache_entries",float64(cacheSize),attribute.String("cache","users"),)// Connection pool_=recorder.SetGauge(ctx,"db_connections_active",float64(pool.Stats().InUse),attribute.String("database","postgres"),)// Worker pool_=recorder.SetGauge(ctx,"worker_pool_idle",float64(workerPool.IdleCount()),attribute.String("pool","background_jobs"),)// Temperature (example from IoT)_=recorder.SetGauge(ctx,"sensor_temperature_celsius",temperature,attribute.String("sensor_id",sensorID),attribute.String("location","datacenter-1"),)
Gauge Best Practices
DO:
Record current state: active connections, queue depth
Update regularly with latest values
Use for resource utilization metrics
DON’T:
Use for cumulative counts (use Counter instead)
Forget to update when value changes
Use for values that only increase (use Counter)
Metric Naming Conventions
Follow OpenTelemetry and Prometheus naming conventions for consistent metrics.
Valid Metric Names
Metric names must:
Start with a letter (a-z, A-Z)
Contain only alphanumeric, underscores, dots, hyphens
// Reserved prefix: __recorder.IncrementCounter(ctx,"__internal_metric")// Reserved prefix: http_recorder.RecordHistogram(ctx,"http_custom_duration",1.0)// Reserved prefix: router_recorder.SetGauge(ctx,"router_custom_gauge",10)// Starts with numberrecorder.IncrementCounter(ctx,"1st_metric")// Invalid charactersrecorder.IncrementCounter(ctx,"my metric!")// Space and !recorder.IncrementCounter(ctx,"metric@count")// @ symbol
Reserved Prefixes
These prefixes are reserved for built-in metrics:
__ - Prometheus internal metrics
http_ - Built-in HTTP metrics
router_ - Built-in router metrics
Naming Best Practices
Units in Name:
// Good - includes unit_=recorder.RecordHistogram(ctx,"processing_duration_seconds",1.5)_=recorder.RecordHistogram(ctx,"response_size_bytes",1024)_=recorder.SetGauge(ctx,"temperature_celsius",25.5)// Bad - no unit_=recorder.RecordHistogram(ctx,"processing_duration",1.5)_=recorder.RecordHistogram(ctx,"response_size",1024)
Counter Suffix:
// Good - ends with _total_=recorder.IncrementCounter(ctx,"requests_total")_=recorder.IncrementCounter(ctx,"errors_total")_=recorder.AddCounter(ctx,"bytes_processed_total",1024)// Acceptable - clear it's a count_=recorder.IncrementCounter(ctx,"request_count")// Bad - unclear_=recorder.IncrementCounter(ctx,"requests")
Descriptive Names:
// Good - clear and specific_=recorder.RecordHistogram(ctx,"db_query_duration_seconds",0.15)_=recorder.IncrementCounter(ctx,"payment_failures_total")_=recorder.SetGauge(ctx,"redis_connections_active",10)// Bad - too generic_=recorder.RecordHistogram(ctx,"duration",0.15)_=recorder.IncrementCounter(ctx,"failures")_=recorder.SetGauge(ctx,"connections",10)
Consistent Style:
// Good - consistent snake_case_=recorder.IncrementCounter(ctx,"user_registrations_total")_=recorder.IncrementCounter(ctx,"order_completions_total")// Avoid mixing styles_=recorder.IncrementCounter(ctx,"userRegistrations")// camelCase_=recorder.IncrementCounter(ctx,"order-completions")// kebab-case
Attributes (Labels)
Attributes add dimensions to metrics for filtering and grouping.
// Good - low cardinalityattribute.String("status","success")// success, error, timeoutattribute.String("method","GET")// GET, POST, PUT, DELETE// Bad - high cardinality (unbounded)attribute.String("user_id",userID)// Millions of unique valuesattribute.String("request_id",requestID)// Unique per requestattribute.String("timestamp",time.Now().String())// Always unique
Use Consistent Names:
// Good - consistent across metricsattribute.String("status","success")attribute.String("method","GET")attribute.String("region","us-east-1")// Bad - inconsistentattribute.String("status","success")attribute.String("http_method","GET")// Should be "method"attribute.String("aws_region","us-east-1")// Should be "region"
Limit Attribute Count:
// Good - focused attributes_=recorder.IncrementCounter(ctx,"requests_total",attribute.String("method","GET"),attribute.String("status","success"),)// Bad - too many attributes_=recorder.IncrementCounter(ctx,"requests_total",attribute.String("method","GET"),attribute.String("status","success"),attribute.String("user_agent",ua),attribute.String("ip_address",ip),attribute.String("country",country),attribute.String("device",device),// ... creates explosion of metric combinations)
Monitoring Custom Metrics
Track how many custom metrics have been created:
count:=recorder.CustomMetricCount()log.Printf("Custom metrics created: %d/%d",count,maxLimit)// Expose as a metric_=recorder.SetGauge(ctx,"custom_metrics_count",float64(count))
All metric methods return an error. Choose your handling strategy:
Check Errors (Critical Metrics)
iferr:=recorder.IncrementCounter(ctx,"payment_processed_total",attribute.String("method","credit_card"),);err!=nil{log.Printf("Failed to record payment metric: %v",err)// Alert or handle appropriately}
Fire-and-Forget (Best Effort)
// Most metrics - don't impact application performance_=recorder.IncrementCounter(ctx,"page_views_total")_=recorder.RecordHistogram(ctx,"render_time_seconds",duration)
Common Errors
Invalid name: Violates naming rules
Reserved prefix: Uses __, http_, or router_
Limit reached: Custom metric limit exceeded
Provider not started: OTLP provider not initialized
Built-in Metrics
The package automatically collects these HTTP metrics (when using middleware):
Metric
Type
Description
http_request_duration_seconds
Histogram
Request duration distribution
http_requests_total
Counter
Total requests by method, path, status
http_requests_active
Gauge
Currently active requests
http_request_size_bytes
Histogram
Request body size distribution
http_response_size_bytes
Histogram
Response body size distribution
http_errors_total
Counter
HTTP errors by status code
custom_metric_failures_total
Counter
Failed custom metric creations
Note: Built-in metrics don’t count toward the custom metrics limit.
Next Steps
Learn Middleware to automatically collect HTTP metrics
See Configuration for histogram bucket customization
handler:=metrics.Middleware(recorder,metrics.WithHeaders("X-Request-ID","X-Correlation-ID","X-Client-Version","X-API-Key",// This will be filtered out (sensitive)),)(mux)
Security
The middleware automatically protects sensitive headers.
Automatic Header Filtering
These headers are always filtered and never recorded as metrics, even if explicitly requested:
Authorization
Cookie
Set-Cookie
X-API-Key
X-Auth-Token
Proxy-Authorization
WWW-Authenticate
Example
handler:=metrics.Middleware(recorder,// Only X-Request-ID will be recorded// Authorization and Cookie are automatically filteredmetrics.WithHeaders("Authorization",// Filtered"X-Request-ID",// Recorded"Cookie",// Filtered"X-Correlation-ID",// Recorded),)(mux)
Headers are normalized to lowercase with underscores:
// Apply metrics middleware first in chainhandler:=metrics.Middleware(recorder)(loggingMiddleware(authMiddleware(mux),),)
Gorilla Mux
import"github.com/gorilla/mux"r:=mux.NewRouter()r.HandleFunc("/",homeHandler)r.HandleFunc("/api/users",usersHandler)// Wrap the routerhandler:=metrics.Middleware(recorder)(r)http.ListenAndServe(":8080",handler)
Chi Router
import"github.com/go-chi/chi/v5"r:=chi.NewRouter()r.Get("/",homeHandler)r.Get("/api/users",usersHandler)// Chi router is already http.Handlerhandler:=metrics.Middleware(recorder)(r)http.ListenAndServe(":8080",handler)
Path Cardinality
Warning: High-cardinality paths can create excessive metrics.
Problematic Paths
// DON'T: These create unique paths for each request/api/users/12345// User ID in path/api/orders/abc-123// Order ID in path/files/document-xyz// Document ID in path
Each unique path creates separate metric series, leading to:
Excessive memory usage
Slow query performance
Storage bloat
Solutions
1. Exclude High-Cardinality Paths
handler:=metrics.Middleware(recorder,// Exclude paths with IDsmetrics.WithExcludePatterns(`^/api/users/[^/]+$`,// /api/users/{id}`^/api/orders/[^/]+$`,// /api/orders/{id}`^/files/[^/]+$`,// /files/{id}),)(mux)
Check your router documentation for normalization support.
3. Record Fewer Labels
// Instead of recording full path, use endpoint name// This requires custom instrumentation
Performance Considerations
Middleware Overhead
The middleware adds minimal overhead:
~1-2 microseconds per request
Safe for production use
Thread-safe for concurrent requests
Memory Usage
Memory usage scales with:
Number of unique paths
Number of unique label combinations
Histogram bucket count
Best Practice: Exclude high-cardinality paths.
CPU Impact
Histogram recording is the most CPU-intensive operation. If needed, adjust bucket count:
recorder:=metrics.MustNew(metrics.WithPrometheus(":9090","/metrics"),// Fewer buckets = lower CPU overheadmetrics.WithDurationBuckets(0.01,0.1,1,10),metrics.WithServiceName("my-api"),)
Viewing Metrics
Access metrics via the Prometheus endpoint:
curl http://localhost:9090/metrics
Example output:
# HELP target_info Target metadata
# TYPE target_info gauge
target_info{service_name="my-api",service_version="v1.0.0"} 1
# HELP http_requests_total Total number of HTTP requests
# TYPE http_requests_total counter
http_requests_total{method="GET",http_route="/",http_status_code="200"} 42
http_requests_total{method="GET",http_route="/api/users",http_status_code="200"} 128
http_requests_total{method="POST",http_route="/api/users",http_status_code="201"} 15
# HELP http_request_duration_seconds HTTP request duration
# TYPE http_request_duration_seconds histogram
http_request_duration_seconds_bucket{method="GET",http_route="/",le="0.005"} 10
http_request_duration_seconds_bucket{method="GET",http_route="/",le="0.01"} 35
http_request_duration_seconds_bucket{method="GET",http_route="/",le="0.025"} 42
http_request_duration_seconds_sum{method="GET",http_route="/"} 0.523
http_request_duration_seconds_count{method="GET",http_route="/"} 42
# HELP http_requests_active Currently active HTTP requests
# TYPE http_requests_active gauge
http_requests_active 3
The http_requests_active gauge accurately tracks the number of requests currently being processed.
This guide covers testing utilities provided by the metrics package.
Testing Utilities
The metrics package provides utilities for testing without port conflicts or complex setup.
TestingRecorder
Create a test recorder with stdout provider. No network is required.
packagemyapp_testimport("testing""rivaas.dev/metrics")funcTestHandler(t*testing.T){t.Parallel()// Create test recorder (uses stdout, avoids port conflicts)recorder:=metrics.TestingRecorder(t,"test-service")// Use recorder in tests...handler:=NewHandler(recorder)// Test your handlerreq:=httptest.NewRequest("GET","/",nil)w:=httptest.NewRecorder()handler.ServeHTTP(w,req)// Assertions...// Cleanup is automatic via t.Cleanup()}// With additional optionsfuncTestWithOptions(t*testing.T){recorder:=metrics.TestingRecorder(t,"test-service",metrics.WithMaxCustomMetrics(100),)// ...}
No port conflicts: Uses stdout provider, no network required.
Automatic cleanup: Registers cleanup via t.Cleanup().
Parallel safe: Safe to use in parallel tests.
Simple setup: One-line initialization.
Works with benchmarks: Accepts testing.TB (both *testing.T and *testing.B).
Example
funcTestMetricsCollection(t*testing.T){t.Parallel()recorder:=metrics.TestingRecorder(t,"test-service")// Record some metricsctx:=context.Background()err:=recorder.IncrementCounter(ctx,"test_counter")iferr!=nil{t.Errorf("Failed to record counter: %v",err)}err=recorder.RecordHistogram(ctx,"test_duration",1.5)iferr!=nil{t.Errorf("Failed to record histogram: %v",err)}// Test passes if no errors}
TestingRecorderWithPrometheus
Create a test recorder with Prometheus provider (for endpoint testing):
funcTestPrometheusEndpoint(t*testing.T){t.Parallel()// Create test recorder with Prometheus (dynamic port)recorder:=metrics.TestingRecorderWithPrometheus(t,"test-service")// Wait for server to be readyerr:=metrics.WaitForMetricsServer(t,recorder.ServerAddress(),5*time.Second)iferr!=nil{t.Fatal(err)}// Test metrics endpoint (note: ServerAddress returns port like ":9090")resp,err:=http.Get("http://localhost"+recorder.ServerAddress()+"/metrics")iferr!=nil{t.Fatal(err)}deferresp.Body.Close()ifresp.StatusCode!=http.StatusOK{t.Errorf("Expected status 200, got %d",resp.StatusCode)}}
Dynamic port allocation: Automatically finds available port
Real Prometheus endpoint: Test actual HTTP metrics endpoint
Server readiness check: Use WaitForMetricsServer to wait for startup
Automatic cleanup: Shuts down server via t.Cleanup()
Works with benchmarks: Accepts testing.TB (both *testing.T and *testing.B)
WaitForMetricsServer
Wait for Prometheus metrics server to be ready:
funcTestMetricsEndpoint(t*testing.T){t.Parallel()recorder:=metrics.TestingRecorderWithPrometheus(t,"test-service")// Wait up to 5 seconds for server to starterr:=metrics.WaitForMetricsServer(t,recorder.ServerAddress(),5*time.Second)iferr!=nil{t.Fatalf("Metrics server not ready: %v",err)}// Server is ready, make requests (note: ServerAddress returns port like ":9090")resp,err:=http.Get("http://localhost"+recorder.ServerAddress()+"/metrics")// ... test response}
tb testing.TB: Test or benchmark instance for logging
address string: Server address (e.g., :9090)
timeout time.Duration: Maximum wait time
Returns
error: Returns error if server doesn’t become ready within timeout
Testing Middleware
Test HTTP middleware with metrics collection:
funcTestMiddleware(t*testing.T){t.Parallel()recorder:=metrics.TestingRecorder(t,"test-service")// Create test handlerhandler:=http.HandlerFunc(func(whttp.ResponseWriter,r*http.Request){w.WriteHeader(http.StatusOK)w.Write([]byte("OK"))})// Wrap with metrics middlewarewrappedHandler:=metrics.Middleware(recorder)(handler)// Make test requestreq:=httptest.NewRequest("GET","/test",nil)w:=httptest.NewRecorder()wrappedHandler.ServeHTTP(w,req)// Assert responseifw.Code!=http.StatusOK{t.Errorf("Expected status 200, got %d",w.Code)}ifw.Body.String()!="OK"{t.Errorf("Expected body 'OK', got %s",w.Body.String())}// Metrics are recorded (visible in test logs if verbose)}
funcTestMetricErrors(t*testing.T){t.Parallel()recorder:=metrics.TestingRecorder(t,"test-service")ctx:=context.Background()// Test invalid metric nameerr:=recorder.IncrementCounter(ctx,"http_invalid")iferr==nil{t.Error("Expected error for reserved prefix, got nil")}// Test reserved prefixerr=recorder.IncrementCounter(ctx,"__internal")iferr==nil{t.Error("Expected error for reserved prefix, got nil")}// Test valid metricerr=recorder.IncrementCounter(ctx,"valid_metric")iferr!=nil{t.Errorf("Expected no error, got %v",err)}}
Integration Testing
Test complete HTTP server with metrics:
funcTestServerWithMetrics(t*testing.T){recorder:=metrics.TestingRecorderWithPrometheus(t,"test-api")// Wait for metrics servererr:=metrics.WaitForMetricsServer(t,recorder.ServerAddress(),5*time.Second)iferr!=nil{t.Fatal(err)}// Create test HTTP servermux:=http.NewServeMux()mux.HandleFunc("/api",func(whttp.ResponseWriter,r*http.Request){w.WriteHeader(http.StatusOK)w.Write([]byte(`{"status": "ok"}`))})handler:=metrics.Middleware(recorder)(mux)server:=httptest.NewServer(handler)deferserver.Close()// Make requestsresp,err:=http.Get(server.URL+"/api")iferr!=nil{t.Fatal(err)}deferresp.Body.Close()ifresp.StatusCode!=http.StatusOK{t.Errorf("Expected status 200, got %d",resp.StatusCode)}// Check metrics endpoint (note: ServerAddress returns port like ":9090")metricsResp,err:=http.Get("http://localhost"+recorder.ServerAddress()+"/metrics")iferr!=nil{t.Fatal(err)}defermetricsResp.Body.Close()body,_:=io.ReadAll(metricsResp.Body)bodyStr:=string(body)// Verify metrics existif!strings.Contains(bodyStr,"http_requests_total"){t.Error("Expected http_requests_total metric")}}
Parallel Tests
The testing utilities support parallel test execution:
funcTestMetricsParallel(t*testing.T){tests:=[]struct{namestringpathstring}{{"endpoint1","/api/users"},{"endpoint2","/api/orders"},{"endpoint3","/api/products"},}for_,tt:=rangetests{tt:=tt// Capture range variablet.Run(tt.name,func(t*testing.T){t.Parallel()// Each test gets its own recorderrecorder:=metrics.TestingRecorder(t,"test-"+tt.name)// Test handlerhandler:=http.HandlerFunc(func(whttp.ResponseWriter,r*http.Request){w.WriteHeader(http.StatusOK)})wrapped:=metrics.Middleware(recorder)(handler)req:=httptest.NewRequest("GET",tt.path,nil)w:=httptest.NewRecorder()wrapped.ServeHTTP(w,req)ifw.Code!=http.StatusOK{t.Errorf("Expected 200, got %d",w.Code)}})}}
Benchmarking
Benchmark metrics collection performance:
funcBenchmarkMetricsMiddleware(b*testing.B){// Create recorder (use t=nil for benchmarks)recorder,err:=metrics.New(metrics.WithStdout(),metrics.WithServiceName("bench-service"),)iferr!=nil{b.Fatal(err)}deferrecorder.Shutdown(context.Background())handler:=http.HandlerFunc(func(whttp.ResponseWriter,r*http.Request){w.WriteHeader(http.StatusOK)})wrapped:=metrics.Middleware(recorder)(handler)req:=httptest.NewRequest("GET","/test",nil)b.ResetTimer()fori:=0;i<b.N;i++{w:=httptest.NewRecorder()wrapped.ServeHTTP(w,req)}}funcBenchmarkCustomMetrics(b*testing.B){recorder,err:=metrics.New(metrics.WithStdout(),metrics.WithServiceName("bench-service"),)iferr!=nil{b.Fatal(err)}deferrecorder.Shutdown(context.Background())ctx:=context.Background()b.Run("Counter",func(b*testing.B){fori:=0;i<b.N;i++{_=recorder.IncrementCounter(ctx,"bench_counter")}})b.Run("Histogram",func(b*testing.B){fori:=0;i<b.N;i++{_=recorder.RecordHistogram(ctx,"bench_duration",1.5)}})b.Run("Gauge",func(b*testing.B){fori:=0;i<b.N;i++{_=recorder.SetGauge(ctx,"bench_gauge",42)}})}
Testing Best Practices
Use Parallel Tests
Enable parallel execution to run tests faster:
funcTestSomething(t*testing.T){t.Parallel()// Always use t.Parallel() when saferecorder:=metrics.TestingRecorder(t,"test-service")// ... test code}
Prefer TestingRecorder
Use TestingRecorder (stdout) unless you specifically need to test the HTTP endpoint:
// Good - fast, no port allocationrecorder:=metrics.TestingRecorder(t,"test-service")// Only when needed - tests HTTP endpointrecorder:=metrics.TestingRecorderWithPrometheus(t,"test-service")
Wait for Server Ready
Always wait for Prometheus server before making requests:
recorder:=metrics.TestingRecorderWithPrometheus(t,"test-service")err:=metrics.WaitForMetricsServer(t,recorder.ServerAddress(),5*time.Second)iferr!=nil{t.Fatal(err)}// Now safe to make requests
// Test valid metricerr:=recorder.IncrementCounter(ctx,"valid_metric")iferr!=nil{t.Errorf("Unexpected error: %v",err)}// Test invalid metricerr=recorder.IncrementCounter(ctx,"__reserved")iferr==nil{t.Error("Expected error for reserved prefix")}
Example Test Suite
Complete example test suite:
packageapi_testimport("context""net/http""net/http/httptest""testing""time""rivaas.dev/metrics""myapp/api")funcTestAPI(t*testing.T){t.Parallel()recorder:=metrics.TestingRecorder(t,"test-api")server:=api.NewServer(recorder)tests:=[]struct{namestringmethodstringpathstringwantStatusint}{{"home","GET","/",200},{"users","GET","/api/users",200},{"not found","GET","/invalid",404},}for_,tt:=rangetests{t.Run(tt.name,func(t*testing.T){req:=httptest.NewRequest(tt.method,tt.path,nil)w:=httptest.NewRecorder()server.ServeHTTP(w,req)ifw.Code!=tt.wantStatus{t.Errorf("Expected status %d, got %d",tt.wantStatus,w.Code)}})}}funcTestMetricsEndpoint(t*testing.T){t.Parallel()recorder:=metrics.TestingRecorderWithPrometheus(t,"test-api")err:=metrics.WaitForMetricsServer(t,recorder.ServerAddress(),5*time.Second)iferr!=nil{t.Fatal(err)}resp,err:=http.Get("http://localhost"+recorder.ServerAddress()+"/metrics")iferr!=nil{t.Fatal(err)}deferresp.Body.Close()ifresp.StatusCode!=http.StatusOK{t.Errorf("Expected status 200, got %d",resp.StatusCode)}}
Real-world examples of metrics collection patterns
This guide provides complete, real-world examples of using the metrics package.
Simple HTTP Server
Basic HTTP server with Prometheus metrics.
packagemainimport("context""log""net/http""os""os/signal""time""rivaas.dev/metrics")funcmain(){// Create lifecycle contextctx,cancel:=signal.NotifyContext(context.Background(),os.Interrupt)defercancel()// Create metrics recorderrecorder,err:=metrics.New(metrics.WithPrometheus(":9090","/metrics"),metrics.WithServiceName("simple-api"),metrics.WithServiceVersion("v1.0.0"),)iferr!=nil{log.Fatal(err)}// Start metrics serveriferr:=recorder.Start(ctx);err!=nil{log.Fatal(err)}deferfunc(){shutdownCtx,cancel:=context.WithTimeout(context.Background(),5*time.Second)defercancel()iferr:=recorder.Shutdown(shutdownCtx);err!=nil{log.Printf("Metrics shutdown error: %v",err)}}()// Create HTTP handlersmux:=http.NewServeMux()mux.HandleFunc("/",func(whttp.ResponseWriter,r*http.Request){w.Header().Set("Content-Type","application/json")w.Write([]byte(`{"message": "Hello, World!"}`))})mux.HandleFunc("/health",func(whttp.ResponseWriter,r*http.Request){w.WriteHeader(http.StatusOK)})// Wrap with metrics middlewarehandler:=metrics.Middleware(recorder,metrics.WithExcludePaths("/health","/metrics"),)(mux)// Start HTTP serverserver:=&http.Server{Addr:":8080",Handler:handler,}gofunc(){log.Printf("Server listening on :8080")log.Printf("Metrics available at http://localhost:9090/metrics")iferr:=server.ListenAndServe();err!=http.ErrServerClosed{log.Fatal(err)}}()// Wait for interrupt<-ctx.Done()log.Println("Shutting down gracefully...")shutdownCtx,cancel:=context.WithTimeout(context.Background(),10*time.Second)defercancel()server.Shutdown(shutdownCtx)}
Run and test:
# Start servergo run main.go
# Make requestscurl http://localhost:8080/
# View metricscurl http://localhost:9090/metrics
Custom Metrics Example
Application with custom business metrics:
packagemainimport("context""log""math/rand""os""os/signal""time""rivaas.dev/metrics""go.opentelemetry.io/otel/attribute")typeOrderProcessorstruct{recorder*metrics.Recorder}funcNewOrderProcessor(recorder*metrics.Recorder)*OrderProcessor{return&OrderProcessor{recorder:recorder}}func(p*OrderProcessor)ProcessOrder(ctxcontext.Context,orderIDstring,amountfloat64)error{start:=time.Now()// Simulate processingtime.Sleep(time.Duration(rand.Intn(100))*time.Millisecond)// Record processing durationduration:=time.Since(start).Seconds()_=p.recorder.RecordHistogram(ctx,"order_processing_duration_seconds",duration,attribute.String("order_id",orderID),)// Record order amount_=p.recorder.RecordHistogram(ctx,"order_amount_usd",amount,attribute.String("currency","USD"),)// Increment orders processed counter_=p.recorder.IncrementCounter(ctx,"orders_processed_total",attribute.String("status","success"),)log.Printf("Processed order %s: $%.2f in %.3fs",orderID,amount,duration)returnnil}funcmain(){ctx,cancel:=signal.NotifyContext(context.Background(),os.Interrupt)defercancel()// Create metrics recorderrecorder:=metrics.MustNew(metrics.WithPrometheus(":9090","/metrics"),metrics.WithServiceName("order-processor"),metrics.WithDurationBuckets(0.01,0.05,0.1,0.5,1,5),)iferr:=recorder.Start(ctx);err!=nil{log.Fatal(err)}deferrecorder.Shutdown(context.Background())processor:=NewOrderProcessor(recorder)log.Println("Processing orders... (metrics at http://localhost:9090/metrics)")// Simulate order processingticker:=time.NewTicker(1*time.Second)deferticker.Stop()orderNum:=0for{select{case<-ctx.Done():returncase<-ticker.C:orderNum++orderID:=fmt.Sprintf("ORD-%d",orderNum)amount:=10.0+rand.Float64()*990.0iferr:=processor.ProcessOrder(ctx,orderID,amount);err!=nil{log.Printf("Error processing order: %v",err)}}}}
OTLP with OpenTelemetry Collector
Send metrics to OpenTelemetry collector:
packagemainimport("context""log""os""os/signal""time""rivaas.dev/metrics")funcmain(){ctx,cancel:=signal.NotifyContext(context.Background(),os.Interrupt)defercancel()// Get OTLP endpoint from environmentendpoint:=os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT")ifendpoint==""{endpoint="http://localhost:4318"}// Create recorder with OTLPrecorder,err:=metrics.New(metrics.WithOTLP(endpoint),metrics.WithServiceName(os.Getenv("SERVICE_NAME")),metrics.WithServiceVersion(os.Getenv("SERVICE_VERSION")),metrics.WithExportInterval(10*time.Second),)iferr!=nil{log.Fatal(err)}// Important: Start before recording metricsiferr:=recorder.Start(ctx);err!=nil{log.Fatal(err)}deferfunc(){shutdownCtx,cancel:=context.WithTimeout(context.Background(),5*time.Second)defercancel()recorder.Shutdown(shutdownCtx)}()log.Printf("Sending metrics to OTLP endpoint: %s",endpoint)// Record metrics periodicallyticker:=time.NewTicker(2*time.Second)deferticker.Stop()count:=0for{select{case<-ctx.Done():returncase<-ticker.C:count++_=recorder.IncrementCounter(ctx,"app_ticks_total")_=recorder.SetGauge(ctx,"app_counter",float64(count))log.Printf("Tick %d",count)}}}
packagemainimport("context""log""math/rand""os""os/signal""sync""time""rivaas.dev/metrics""go.opentelemetry.io/otel/attribute")typeWorkerPoolstruct{workersintactiveintidleintmusync.Mutexrecorder*metrics.Recorder}funcNewWorkerPool(sizeint,recorder*metrics.Recorder)*WorkerPool{return&WorkerPool{workers:size,idle:size,recorder:recorder,}}func(p*WorkerPool)updateMetrics(ctxcontext.Context){p.mu.Lock()active:=p.activeidle:=p.idlep.mu.Unlock()_=p.recorder.SetGauge(ctx,"worker_pool_active",float64(active))_=p.recorder.SetGauge(ctx,"worker_pool_idle",float64(idle))_=p.recorder.SetGauge(ctx,"worker_pool_total",float64(p.workers))}func(p*WorkerPool)DoWork(ctxcontext.Context,jobIDstring){p.mu.Lock()p.active++p.idle--p.mu.Unlock()p.updateMetrics(ctx)start:=time.Now()// Simulate worktime.Sleep(time.Duration(rand.Intn(1000))*time.Millisecond)duration:=time.Since(start).Seconds()_=p.recorder.RecordHistogram(ctx,"job_duration_seconds",duration,attribute.String("job_id",jobID),)_=p.recorder.IncrementCounter(ctx,"jobs_completed_total")p.mu.Lock()p.active--p.idle++p.mu.Unlock()p.updateMetrics(ctx)}funcmain(){ctx,cancel:=signal.NotifyContext(context.Background(),os.Interrupt)defercancel()recorder:=metrics.MustNew(metrics.WithPrometheus(":9090","/metrics"),metrics.WithServiceName("worker-pool"),)iferr:=recorder.Start(ctx);err!=nil{log.Fatal(err)}deferrecorder.Shutdown(context.Background())pool:=NewWorkerPool(10,recorder)log.Println("Worker pool started (metrics at http://localhost:9090/metrics)")// Submit jobsvarwgsync.WaitGroupfori:=0;i<50;i++{wg.Add(1)jobID:=fmt.Sprintf("job-%d",i)gofunc(idstring){deferwg.Done()pool.DoWork(ctx,id)}(jobID)time.Sleep(100*time.Millisecond)}wg.Wait()log.Println("All jobs completed")}
Environment-Based Configuration
Load metrics configuration from environment:
packagemainimport("context""log""os""strconv""time""rivaas.dev/metrics")funccreateRecorder()(*metrics.Recorder,error){varopts[]metrics.Option// Service metadataopts=append(opts,metrics.WithServiceName(getEnv("SERVICE_NAME","my-service")))ifversion:=os.Getenv("SERVICE_VERSION");version!=""{opts=append(opts,metrics.WithServiceVersion(version))}// Provider selectionprovider:=getEnv("METRICS_PROVIDER","prometheus")switchprovider{case"prometheus":addr:=getEnv("METRICS_ADDR",":9090")path:=getEnv("METRICS_PATH","/metrics")opts=append(opts,metrics.WithPrometheus(addr,path))ifgetBoolEnv("METRICS_STRICT_PORT",true){opts=append(opts,metrics.WithStrictPort())}// Optional: Reduce label cardinality for simple deploymentsifgetBoolEnv("METRICS_WITHOUT_SCOPE_INFO",false){opts=append(opts,metrics.WithoutScopeInfo())}ifgetBoolEnv("METRICS_WITHOUT_TARGET_INFO",false){opts=append(opts,metrics.WithoutTargetInfo())}case"otlp":endpoint:=getEnv("OTEL_EXPORTER_OTLP_ENDPOINT","http://localhost:4318")opts=append(opts,metrics.WithOTLP(endpoint))ifinterval:=getDurationEnv("METRICS_EXPORT_INTERVAL",30*time.Second);interval>0{opts=append(opts,metrics.WithExportInterval(interval))}case"stdout":opts=append(opts,metrics.WithStdout())default:log.Printf("Unknown provider %s, using stdout",provider)opts=append(opts,metrics.WithStdout())}// Custom metrics limitiflimit:=getIntEnv("METRICS_MAX_CUSTOM",1000);limit>0{opts=append(opts,metrics.WithMaxCustomMetrics(limit))}returnmetrics.New(opts...)}funcgetEnv(key,defaultValuestring)string{ifvalue:=os.Getenv(key);value!=""{returnvalue}returndefaultValue}funcgetBoolEnv(keystring,defaultValuebool)bool{ifvalue:=os.Getenv(key);value!=""{b,err:=strconv.ParseBool(value)iferr==nil{returnb}}returndefaultValue}funcgetIntEnv(keystring,defaultValueint)int{ifvalue:=os.Getenv(key);value!=""{i,err:=strconv.Atoi(value)iferr==nil{returni}}returndefaultValue}funcgetDurationEnv(keystring,defaultValuetime.Duration)time.Duration{ifvalue:=os.Getenv(key);value!=""{d,err:=time.ParseDuration(value)iferr==nil{returnd}}returndefaultValue}funcmain(){ctx,cancel:=signal.NotifyContext(context.Background(),os.Interrupt)defercancel()recorder,err:=createRecorder()iferr!=nil{log.Fatal(err)}iferr:=recorder.Start(ctx);err!=nil{log.Fatal(err)}deferrecorder.Shutdown(context.Background())log.Println("Service started with metrics")// Your application code...<-ctx.Done()}
// cmd/user-service/main.gopackagemainimport("context""log""myapp/pkg/telemetry")funcmain(){cfg:=telemetry.Config{ServiceName:"user-service",ServiceVersion:os.Getenv("VERSION"),MetricsAddr:":9090",}recorder,err:=telemetry.NewMetricsRecorder(cfg)iferr!=nil{log.Fatal(err)}iferr:=recorder.Start(context.Background());err!=nil{log.Fatal(err)}deferrecorder.Shutdown(context.Background())metrics:=telemetry.NewServiceMetrics(recorder)// Use metrics in your service// ...}
Complete Production Example
Full production-ready setup:
packagemainimport("context""log""log/slog""net/http""os""os/signal""syscall""time""rivaas.dev/metrics")funcmain(){// Setup structured logginglogger:=slog.New(slog.NewJSONHandler(os.Stdout,nil))slog.SetDefault(logger)// Create application contextctx,cancel:=signal.NotifyContext(context.Background(),os.Interrupt,syscall.SIGTERM,)defercancel()// Create metrics recorder with production settingsrecorder,err:=metrics.New(// Providermetrics.WithPrometheus(":9090","/metrics"),metrics.WithStrictPort(),// Service metadatametrics.WithServiceName("production-api"),metrics.WithServiceVersion(os.Getenv("VERSION")),// Configurationmetrics.WithDurationBuckets(0.01,0.1,0.5,1,5,10,30),metrics.WithSizeBuckets(100,1000,10000,100000,1000000),metrics.WithMaxCustomMetrics(2000),// Observabilitymetrics.WithLogger(slog.Default()),)iferr!=nil{slog.Error("Failed to create metrics recorder","error",err)os.Exit(1)}// Start metrics serveriferr:=recorder.Start(ctx);err!=nil{slog.Error("Failed to start metrics","error",err)os.Exit(1)}slog.Info("Metrics server started","address",recorder.ServerAddress())// Ensure graceful shutdowndeferfunc(){shutdownCtx,cancel:=context.WithTimeout(context.Background(),10*time.Second)defercancel()iferr:=recorder.Shutdown(shutdownCtx);err!=nil{slog.Error("Metrics shutdown error","error",err)}else{slog.Info("Metrics shut down successfully")}}()// Create HTTP servermux:=http.NewServeMux()mux.HandleFunc("/",homeHandler)mux.HandleFunc("/api/v1/users",usersHandler)mux.HandleFunc("/health",healthHandler)mux.HandleFunc("/ready",readyHandler)// Configure middlewarehandler:=metrics.Middleware(recorder,metrics.WithExcludePaths("/health","/ready","/metrics"),metrics.WithExcludePrefixes("/debug/","/_/"),metrics.WithHeaders("X-Request-ID","X-Correlation-ID"),)(mux)server:=&http.Server{Addr:":8080",Handler:handler,ReadHeaderTimeout:5*time.Second,ReadTimeout:10*time.Second,WriteTimeout:10*time.Second,IdleTimeout:60*time.Second,}// Start HTTP servergofunc(){slog.Info("HTTP server starting","address",server.Addr)iferr:=server.ListenAndServe();err!=http.ErrServerClosed{slog.Error("HTTP server error","error",err)cancel()}}()// Wait for shutdown signal<-ctx.Done()slog.Info("Shutdown signal received")// Graceful shutdownshutdownCtx,shutdownCancel:=context.WithTimeout(context.Background(),30*time.Second)defershutdownCancel()iferr:=server.Shutdown(shutdownCtx);err!=nil{slog.Error("Server shutdown error","error",err)}else{slog.Info("Server shut down successfully")}}funchomeHandler(whttp.ResponseWriter,r*http.Request){w.Header().Set("Content-Type","application/json")w.Write([]byte(`{"status": "ok"}`))}funcusersHandler(whttp.ResponseWriter,r*http.Request){w.Header().Set("Content-Type","application/json")w.Write([]byte(`{"users": []}`))}funchealthHandler(whttp.ResponseWriter,r*http.Request){w.WriteHeader(http.StatusOK)}funcreadyHandler(whttp.ResponseWriter,r*http.Request){w.WriteHeader(http.StatusOK)}
Learn how to implement distributed tracing with Rivaas tracing package
The Rivaas Tracing package provides OpenTelemetry-based distributed tracing. Supports various exporters and integrates with HTTP frameworks. Enables observability best practices with minimal configuration.
Features
OpenTelemetry Integration: Full OpenTelemetry tracing support
Context Propagation: Automatic trace context propagation across services
Span Management: Easy span creation and management with lifecycle hooks
HTTP Middleware: Standalone middleware for any HTTP framework
Multiple Providers: Stdout, OTLP (gRPC and HTTP), and Noop exporters
Path Filtering: Exclude specific paths from tracing via middleware options
Consistent API: Same design patterns as the metrics package
Thread-Safe: All operations safe for concurrent use
Quick Start
packagemainimport("context""log""os/signal""rivaas.dev/tracing")funcmain(){ctx,cancel:=signal.NotifyContext(context.Background(),os.Interrupt)defercancel()tracer,err:=tracing.New(tracing.WithServiceName("my-service"),tracing.WithServiceVersion("v1.0.0"),tracing.WithOTLP("localhost:4317"),)iferr!=nil{log.Fatal(err)}iferr:=tracer.Start(ctx);err!=nil{log.Fatal(err)}defertracer.Shutdown(context.Background())// Traces exported via OTLP gRPCctx,span:=tracer.StartSpan(ctx,"operation")defertracer.FinishSpan(span,200)}
packagemainimport("context""log""os/signal""rivaas.dev/tracing")funcmain(){ctx,cancel:=signal.NotifyContext(context.Background(),os.Interrupt)defercancel()tracer,err:=tracing.New(tracing.WithServiceName("my-service"),tracing.WithServiceVersion("v1.0.0"),tracing.WithOTLPHTTP("http://localhost:4318"),)iferr!=nil{log.Fatal(err)}iferr:=tracer.Start(ctx);err!=nil{log.Fatal(err)}defertracer.Shutdown(context.Background())// Traces exported via OTLP HTTPctx,span:=tracer.StartSpan(ctx,"operation")defertracer.FinishSpan(span,200)}
packagemainimport("context""rivaas.dev/tracing")funcmain(){tracer:=tracing.MustNew(tracing.WithServiceName("my-service"),tracing.WithStdout(),)defertracer.Shutdown(context.Background())ctx:=context.Background()// Traces printed to stdoutctx,span:=tracer.StartSpan(ctx,"operation")defertracer.FinishSpan(span,200)}
How It Works
Providers determine where traces are exported (Stdout, OTLP, Noop)
Lifecycle management ensures proper initialization and graceful shutdown
HTTP middleware creates spans for requests automatically
Custom spans can be created for detailed operation tracing
Context propagation enables distributed tracing across services
Learning Path
Follow these guides to learn distributed tracing with Rivaas:
Installation - Get started with the tracing package
Basic Usage - Learn tracer creation and span management
Providers - Understand Stdout, OTLP, and Noop exporters
Configuration - Configure service metadata, sampling, and hooks
Middleware - Integrate HTTP tracing with your application
Learn the fundamentals of creating tracers and managing spans
Learn how to create tracers, manage spans, and add tracing to your Go applications.
Creating a Tracer
The Tracer is the main entry point for distributed tracing. Create one using functional options:
With Error Handling
tracer,err:=tracing.New(tracing.WithServiceName("my-service"),tracing.WithServiceVersion("v1.0.0"),tracing.WithStdout(),)iferr!=nil{log.Fatalf("Failed to create tracer: %v",err)}defertracer.Shutdown(context.Background())
Panic on Error
For convenience, use MustNew which panics if initialization fails:
For OTLP providers (gRPC and HTTP), you must call Start() before tracing:
tracer:=tracing.MustNew(tracing.WithServiceName("my-service"),tracing.WithOTLP("localhost:4317"),)// Start is required for OTLP providersiferr:=tracer.Start(context.Background());err!=nil{log.Fatal(err)}defertracer.Shutdown(context.Background())
For Stdout and Noop providers, `Start()` is optional (they initialize immediately in `New()`).
Shutting Down
Always shut down the tracer to flush pending spans:
deferfunc(){ctx,cancel:=context.WithTimeout(context.Background(),5*time.Second)defercancel()iferr:=tracer.Shutdown(ctx);err!=nil{log.Printf("Error shutting down tracer: %v",err)}}()
Manual Span Management
Create and manage spans manually for detailed tracing:
Basic Span Creation
funcprocessData(ctxcontext.Context,tracer*tracing.Tracer){// Start a spanctx,span:=tracer.StartSpan(ctx,"process-data")defertracer.FinishSpan(span,http.StatusOK)// Your code here...}
Adding Attributes
Add attributes to provide context about the operation:
ctx,span:=tracer.StartSpan(ctx,"database-query")defertracer.FinishSpan(span,http.StatusOK)// Add attributestracer.SetSpanAttribute(span,"db.system","postgresql")tracer.SetSpanAttribute(span,"db.query","SELECT * FROM users")tracer.SetSpanAttribute(span,"db.rows_returned",42)
Supported attribute types:
string
int, int64
float64
bool
Other types (converted to string)
Adding Events
Record significant moments in a span’s lifetime:
import"go.opentelemetry.io/otel/attribute"ctx,span:=tracer.StartSpan(ctx,"cache-lookup")defertracer.FinishSpan(span,http.StatusOK)// Add an eventtracer.AddSpanEvent(span,"cache_hit",attribute.String("key","user:123"),attribute.Int("ttl_seconds",300),)
Error Handling
Use the status code to indicate span success or failure:
funcfetchUser(ctxcontext.Context,tracer*tracing.Tracer,userIDstring)error{ctx,span:=tracer.StartSpan(ctx,"fetch-user")deferfunc(){iferr!=nil{tracer.FinishSpan(span,http.StatusInternalServerError)}else{tracer.FinishSpan(span,http.StatusOK)}}()tracer.SetSpanAttribute(span,"user.id",userID)// Fetch user logic...returnnil}
Context Helpers
Work with spans through the context without direct span references:
Set Attributes from Context
funchandleRequest(ctxcontext.Context){// Add attribute to the current span in contexttracing.SetSpanAttributeFromContext(ctx,"user.role","admin")tracing.SetSpanAttributeFromContext(ctx,"user.id",12345)}
Add Events from Context
funcprocessEvent(ctxcontext.Context){// Add event to the current span in contexttracing.AddSpanEventFromContext(ctx,"event_processed",attribute.String("event_type","user_login"),attribute.String("ip_address","192.168.1.1"),)}
Use defer to ensure spans are finished even if errors occur:
ctx,span:=tracer.StartSpan(ctx,"operation")defertracer.FinishSpan(span,http.StatusOK)// Always close
Propagate Context
Always pass the context returned by StartSpan to child operations:
ctx,span:=tracer.StartSpan(ctx,"parent")defertracer.FinishSpan(span,http.StatusOK)// Pass the new context to childrenchildOperation(ctx)// ✓ CorrectchildOperation(oldCtx)// ✗ Wrong - breaks trace chain
No persistence: Traces are only printed, not stored
No visualization: Use an actual backend for trace visualization
OTLP Provider (gRPC)
The OTLP gRPC provider exports traces to an OpenTelemetry collector using the gRPC protocol.
When to Use
Production environments
OpenTelemetry collector infrastructure
Jaeger, Zipkin, or other OTLP-compatible backends
Best performance and reliability
Basic Configuration
tracer:=tracing.MustNew(tracing.WithServiceName("my-service"),tracing.WithServiceVersion("v1.0.0"),tracing.WithOTLP("localhost:4317"),)// Start is required for OTLP providersiferr:=tracer.Start(context.Background());err!=nil{log.Fatal(err)}defertracer.Shutdown(context.Background())
Secure Connection (TLS)
By default, OTLP uses TLS:
tracer:=tracing.MustNew(tracing.WithServiceName("my-service"),tracing.WithOTLP("collector.example.com:4317"),// TLS is enabled by default)
The OTLP HTTP provider exports traces to an OpenTelemetry collector using the HTTP protocol.
When to Use
Alternative to gRPC when firewalls block gRPC
Simpler infrastructure without gRPC support
HTTP-only environments
Debugging with curl/httpie
Configuration
tracer:=tracing.MustNew(tracing.WithServiceName("my-service"),tracing.WithServiceVersion("v1.0.0"),tracing.WithOTLPHTTP("http://localhost:4318"),)// Start is required for OTLP providersiferr:=tracer.Start(context.Background());err!=nil{log.Fatal(err)}defertracer.Shutdown(context.Background())
// HTTP (insecure - development only)tracing.WithOTLPHTTP("http://localhost:4318")// HTTPS (secure - production)tracing.WithOTLPHTTP("https://collector.example.com:4318")
Provider Comparison
Performance
Provider
Latency
Throughput
CPU
Memory
Noop
~10ns
Unlimited
Minimal
Minimal
Stdout
~100µs
Low
Low
Low
OTLP (gRPC)
~1-2ms
High
Low
Medium
OTLP (HTTP)
~2-3ms
Medium
Low
Medium
Use Case Matrix
// Developmenttracer:=tracing.MustNew(tracing.WithServiceName("my-api"),tracing.WithStdout(),// ← See traces in console)// Testingtracer:=tracing.MustNew(tracing.WithServiceName("my-api"),tracing.WithNoop(),// ← No tracing overhead)// Production (recommended)tracer:=tracing.MustNew(tracing.WithServiceName("my-api"),tracing.WithOTLP("collector:4317"),// ← gRPC to collector)// Production (HTTP alternative)tracer:=tracing.MustNew(tracing.WithServiceName("my-api"),tracing.WithOTLPHTTP("https://collector:4318"),// ← HTTP to collector)
Switching Providers
Only one provider can be configured at a time. Attempting to configure multiple providers results in a validation error:
Control which requests are traced to reduce overhead and costs.
Sample Rate
Set the percentage of requests to trace (0.0 to 1.0):
tracer:=tracing.MustNew(tracing.WithServiceName("my-api"),tracing.WithSampleRate(0.1),// Trace 10% of requeststracing.WithOTLP("collector:4317"),)
Sample rates:
1.0: 100% sampling. All requests traced.
0.5: 50% sampling.
0.1: 10% sampling.
0.01: 1% sampling.
0.0: 0% sampling (no traces)
Sampling Examples
// Development: trace everythingtracer:=tracing.MustNew(tracing.WithServiceName("my-api"),tracing.WithSampleRate(1.0),tracing.WithStdout(),)// Production: trace 10% of requeststracer:=tracing.MustNew(tracing.WithServiceName("my-api"),tracing.WithSampleRate(0.1),tracing.WithOTLP("collector:4317"),)// High-traffic: trace 1% of requeststracer:=tracing.MustNew(tracing.WithServiceName("my-api"),tracing.WithSampleRate(0.01),tracing.WithOTLP("collector:4317"),)
Sampling Behavior
Probabilistic: Uses deterministic hashing for consistent sampling
Request-level: Decision made once per request, all child spans included
Zero overhead: Non-sampled requests skip span creation entirely
When to Sample
Traffic Level
Recommended Sample Rate
< 100 req/s
1.0 (100%)
100-1000 req/s
0.5 (50%)
1000-10000 req/s
0.1 (10%)
> 10000 req/s
0.01 (1%)
Adjust based on:
Trace backend capacity
Storage costs
Desired trace coverage
Debug vs production needs
Span Lifecycle Hooks
Add custom logic when spans start or finish.
Span Start Hook
Execute code when a request span is created:
import("context""net/http""go.opentelemetry.io/otel/attribute""go.opentelemetry.io/otel/trace""rivaas.dev/tracing")startHook:=func(ctxcontext.Context,spantrace.Span,req*http.Request){// Add custom attributesiftenantID:=req.Header.Get("X-Tenant-ID");tenantID!=""{span.SetAttributes(attribute.String("tenant.id",tenantID))}// Add user informationifuserID:=req.Header.Get("X-User-ID");userID!=""{span.SetAttributes(attribute.String("user.id",userID))}// Record custom business contextspan.SetAttributes(attribute.String("request.region",getRegionFromIP(req)),attribute.Bool("request.is_mobile",isMobileRequest(req)),)}tracer:=tracing.MustNew(tracing.WithServiceName("my-api"),tracing.WithSpanStartHook(startHook),tracing.WithOTLP("collector:4317"),)
Use cases:
Add tenant/user identifiers
Record business context
Integrate with feature flags
Custom sampling decisions
APM tool integration
Span Finish Hook
Execute code when a request span completes:
import("go.opentelemetry.io/otel/trace""rivaas.dev/tracing")finishHook:=func(spantrace.Span,statusCodeint){// Record custom metricsifstatusCode>=500{metrics.IncrementServerErrors()}// Log slow requestsifspan.SpanContext().IsValid(){// Calculate duration and log if > threshold}// Send alerts for errorsifstatusCode>=500{alerting.SendAlert("Server error",statusCode)}}tracer:=tracing.MustNew(tracing.WithServiceName("my-api"),tracing.WithSpanFinishHook(finishHook),tracing.WithOTLP("collector:4317"),)
Use cases:
Record custom metrics
Log slow requests
Send error alerts
Update counters
Cleanup resources
Combined Hooks Example
tracer:=tracing.MustNew(tracing.WithServiceName("my-api"),tracing.WithSpanStartHook(func(ctxcontext.Context,spantrace.Span,req*http.Request){// Enrich span with business contextspan.SetAttributes(attribute.String("tenant.id",extractTenant(req)),attribute.String("feature.flags",getFeatureFlags(req)),)}),tracing.WithSpanFinishHook(func(spantrace.Span,statusCodeint){// Record completion metricsrecordRequestMetrics(statusCode)}),tracing.WithOTLP("collector:4317"),)
Logging Integration
Integrate tracing with your logging infrastructure.
import"rivaas.dev/tracing"eventHandler:=func(etracing.Event){switche.Type{casetracing.EventError:// Send to error tracking (e.g., Sentry)sentry.CaptureMessage(e.Message)myLogger.Error(e.Message,e.Args...)casetracing.EventWarning:myLogger.Warn(e.Message,e.Args...)casetracing.EventInfo:myLogger.Info(e.Message,e.Args...)casetracing.EventDebug:myLogger.Debug(e.Message,e.Args...)}}tracer:=tracing.MustNew(tracing.WithServiceName("my-api"),tracing.WithEventHandler(eventHandler),tracing.WithOTLP("collector:4317"),)
Use cases:
Integrate with non-slog loggers (zap, zerolog, logrus)
Send errors to Sentry/Rollbar
Custom alerting
Audit logging
Metrics from events
No Logging
To disable all internal logging:
tracer:=tracing.MustNew(tracing.WithServiceName("my-api"),// No WithLogger or WithEventHandler = no loggingtracing.WithOTLP("collector:4317"),)
Advanced Configuration
Custom Propagator
Use a custom trace context propagation format:
import("go.opentelemetry.io/otel/propagation""rivaas.dev/tracing")// Use B3 propagation format (Zipkin)b3Propagator:=propagation.NewCompositeTextMapPropagator(propagation.B3{},)tracer:=tracing.MustNew(tracing.WithServiceName("my-api"),tracing.WithCustomPropagator(b3Propagator),tracing.WithOTLP("collector:4317"),)
Custom Tracer Provider
Provide your own OpenTelemetry tracer provider:
import(sdktrace"go.opentelemetry.io/otel/sdk/trace""rivaas.dev/tracing")// Create custom tracer providertp:=sdktrace.NewTracerProvider(// Your custom configurationsdktrace.WithSampler(sdktrace.AlwaysSample()),)tracer:=tracing.MustNew(tracing.WithServiceName("my-api"),tracing.WithTracerProvider(tp),)// You manage tp.Shutdown() yourselfdefertp.Shutdown(context.Background())
Note: When using WithTracerProvider, you’re responsible for shutting down the provider.
Global Tracer Provider
Register as the global OpenTelemetry tracer provider:
tracer:=tracing.MustNew(tracing.WithServiceName("user-api"),tracing.WithServiceVersion(version),// From buildtracing.WithOTLP(otlpEndpoint),// From envtracing.WithSampleRate(0.1),// 10% samplingtracing.WithSpanStartHook(enrichSpan),tracing.WithSpanFinishHook(recordMetrics),)
The tracing package provides HTTP middleware for automatic request tracing with any HTTP framework.
Basic Usage
Wrap your HTTP handler with tracing middleware:
import("net/http""rivaas.dev/tracing")funcmain(){tracer:=tracing.MustNew(tracing.WithServiceName("my-api"),tracing.WithOTLP("localhost:4317"),)tracer.Start(context.Background())defertracer.Shutdown(context.Background())mux:=http.NewServeMux()mux.HandleFunc("/api/users",handleUsers)// Wrap with middlewarehandler:=tracing.Middleware(tracer)(mux)http.ListenAndServe(":8080",handler)}
Middleware Functions
Two functions are available for creating middleware:
Headers are recorded as: http.request.header.{name}
Example span attributes:
http.request.header.x-request-id: "abc123"
http.request.header.x-correlation-id: "xyz789"
Security
Sensitive headers are automatically filtered and never recorded:
Authorization
Cookie
Set-Cookie
X-API-Key
X-Auth-Token
Proxy-Authorization
WWW-Authenticate
This protects against accidental credential exposure in traces.
// This is safe - Authorization header is filteredhandler:=tracing.Middleware(tracer,tracing.WithHeaders("X-Request-ID","Authorization",// ← Automatically filtered, won't be recorded"X-Correlation-ID",),)(mux)
Header Name Normalization
Header names are case-insensitive and normalized to lowercase:
Useful when parameters may contain sensitive data.
Combined Parameter Options
// Record only safe parameters, explicitly exclude sensitive oneshandler:=tracing.Middleware(tracer,tracing.WithRecordParams("page","limit","sort"),tracing.WithExcludeParams("api_key","token"),// Takes precedence)(mux)
Behavior: Blacklist takes precedence. Even if api_key is in the whitelist, it won’t be recorded.
Complete Middleware Example
packagemainimport("context""log""net/http""os""os/signal""rivaas.dev/tracing")funcmain(){ctx,cancel:=signal.NotifyContext(context.Background(),os.Interrupt)defercancel()// Create tracertracer:=tracing.MustNew(tracing.WithServiceName("user-api"),tracing.WithServiceVersion("v1.2.3"),tracing.WithOTLP("localhost:4317"),tracing.WithSampleRate(0.1),// 10% sampling)iferr:=tracer.Start(ctx);err!=nil{log.Fatal(err)}defertracer.Shutdown(context.Background())// Create HTTP handlersmux:=http.NewServeMux()mux.HandleFunc("/api/users",handleUsers)mux.HandleFunc("/api/orders",handleOrders)mux.HandleFunc("/health",handleHealth)mux.HandleFunc("/metrics",handleMetrics)// Wrap with tracing middlewarehandler:=tracing.MustMiddleware(tracer,// Exclude health/metrics endpointstracing.WithExcludePaths("/health","/metrics","/ready","/live"),// Exclude debug and internal routestracing.WithExcludePrefixes("/debug/","/internal/"),// Record correlation headerstracing.WithHeaders("X-Request-ID","X-Correlation-ID","User-Agent"),// Whitelist safe parameterstracing.WithRecordParams("page","limit","sort","filter"),// Blacklist sensitive parameterstracing.WithExcludeParams("password","token","api_key"),)(mux)log.Println("Server starting on :8080")log.Fatal(http.ListenAndServe(":8080",handler))}funchandleUsers(whttp.ResponseWriter,r*http.Request){w.Header().Set("Content-Type","application/json")w.Write([]byte(`{"users": []}`))}funchandleOrders(whttp.ResponseWriter,r*http.Request){w.Header().Set("Content-Type","application/json")w.Write([]byte(`{"orders": []}`))}funchandleHealth(whttp.ResponseWriter,r*http.Request){w.WriteHeader(http.StatusOK)w.Write([]byte("OK"))}funchandleMetrics(whttp.ResponseWriter,r*http.Request){w.Header().Set("Content-Type","text/plain")w.Write([]byte("# Metrics"))}
Integration with Custom Context
Access the span from within your handlers:
funchandleUser(whttp.ResponseWriter,r*http.Request){ctx:=r.Context()// Add custom attributes to the current spantracing.SetSpanAttributeFromContext(ctx,"user.action","view_profile")tracing.SetSpanAttributeFromContext(ctx,"user.id",getUserID(r))// Add eventstracing.AddSpanEventFromContext(ctx,"profile_viewed",attribute.String("profile_id","123"),)// Your handler logic...}
Comparison with Metrics Middleware
The tracing middleware follows the same pattern as the metrics middleware:
Prevents accidental exposure of credentials in traces.
Combine with Span Hooks
startHook:=func(ctxcontext.Context,spantrace.Span,req*http.Request){// Add business context from requestiftenantID:=extractTenant(req);tenantID!=""{span.SetAttributes(attribute.String("tenant.id",tenantID))}}tracer:=tracing.MustNew(tracing.WithServiceName("my-api"),tracing.WithSpanStartHook(startHook),tracing.WithOTLP("collector:4317"),)
Compatible: Works with Jaeger, Zipkin, OpenTelemetry, and more.
Extracting Trace Context
Extract trace context from incoming HTTP requests.
Automatic Extraction (Middleware)
The middleware automatically extracts trace context:
handler:=tracing.Middleware(tracer)(mux)// Context extraction is automatic
No additional code needed - spans automatically become part of the parent trace.
Manual Extraction
For manual span creation or custom HTTP handlers:
funchandleRequest(whttp.ResponseWriter,r*http.Request){// Extract trace context from request headersctx:=tracer.ExtractTraceContext(r.Context(),r.Header)// Create span with propagated contextctx,span:=tracer.StartSpan(ctx,"process-request")defertracer.FinishSpan(span,http.StatusOK)// Span is now part of the distributed trace}
The ExtractTraceContext method reads these headers and links the new span to the parent trace.
Injecting Trace Context
Inject trace context into outgoing HTTP requests.
Manual Injection
When making HTTP calls to other services:
funccallDownstreamService(ctxcontext.Context,tracer*tracing.Tracer)error{// Create outgoing requestreq,err:=http.NewRequestWithContext(ctx,"GET","http://downstream/api",nil)iferr!=nil{returnerr}// Inject trace context into request headerstracer.InjectTraceContext(ctx,req.Header)// Make the requestresp,err:=http.DefaultClient.Do(req)iferr!=nil{returnerr}deferresp.Body.Close()returnnil}
What Gets Injected
The InjectTraceContext method adds headers to propagate the trace:
// Before injectionreq.Header:{}// After injectionreq.Header:{"Traceparent":["00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"],"Tracestate":["vendor1=value1"],}
Complete Distributed Tracing Example
Here’s a complete example showing service-to-service tracing:
Service A (Frontend)
packagemainimport("context""io""log""net/http""rivaas.dev/tracing")funcmain(){tracer:=tracing.MustNew(tracing.WithServiceName("frontend-api"),tracing.WithOTLP("localhost:4317"),)tracer.Start(context.Background())defertracer.Shutdown(context.Background())mux:=http.NewServeMux()// Handler that calls downstream servicemux.HandleFunc("/api/process",func(whttp.ResponseWriter,r*http.Request){ctx:=r.Context()// Create span for this service's workctx,span:=tracer.StartSpan(ctx,"frontend-process")defertracer.FinishSpan(span,http.StatusOK)// Call downstream service with trace propagationresult,err:=callBackendService(ctx,tracer)iferr!=nil{http.Error(w,err.Error(),http.StatusInternalServerError)return}w.Write([]byte(result))})handler:=tracing.Middleware(tracer)(mux)log.Fatal(http.ListenAndServe(":8080",handler))}funccallBackendService(ctxcontext.Context,tracer*tracing.Tracer)(string,error){// Create span for outgoing callctx,span:=tracer.StartSpan(ctx,"call-backend-service")defertracer.FinishSpan(span,http.StatusOK)// Create HTTP requestreq,err:=http.NewRequestWithContext(ctx,"GET","http://localhost:8081/api/data",nil)iferr!=nil{return"",err}// Inject trace context for propagationtracer.InjectTraceContext(ctx,req.Header)// Make the requestresp,err:=http.DefaultClient.Do(req)iferr!=nil{return"",err}deferresp.Body.Close()body,_:=io.ReadAll(resp.Body)returnstring(body),nil}
Service B (Backend)
packagemainimport("context""log""net/http""rivaas.dev/tracing")funcmain(){tracer:=tracing.MustNew(tracing.WithServiceName("backend-api"),tracing.WithOTLP("localhost:4317"),)tracer.Start(context.Background())defertracer.Shutdown(context.Background())mux:=http.NewServeMux()// Handler automatically receives trace context via middlewaremux.HandleFunc("/api/data",func(whttp.ResponseWriter,r*http.Request){ctx:=r.Context()// This span is automatically part of the distributed tracectx,span:=tracer.StartSpan(ctx,"fetch-data")defertracer.FinishSpan(span,http.StatusOK)tracer.SetSpanAttribute(span,"data.source","database")// Simulate workdata:=fetchFromDatabase(ctx,tracer)w.Write([]byte(data))})// Middleware automatically extracts trace contexthandler:=tracing.Middleware(tracer)(mux)log.Fatal(http.ListenAndServe(":8081",handler))}funcfetchFromDatabase(ctxcontext.Context,tracer*tracing.Tracer)string{// Nested span - all part of the same tracectx,span:=tracer.StartSpan(ctx,"database-query")defertracer.FinishSpan(span,http.StatusOK)tracer.SetSpanAttribute(span,"db.system","postgresql")tracer.SetSpanAttribute(span,"db.query","SELECT * FROM data")return"data from database"}
funcprocessOrder(ctxcontext.Context,orderIDstring){// Add attributes to current span in contexttracing.SetSpanAttributeFromContext(ctx,"order.id",orderID)tracing.SetSpanAttributeFromContext(ctx,"order.status","processing")}
No-op if no active span.
Add Events from Context
Add events to the current span:
import"go.opentelemetry.io/otel/attribute"funcvalidatePayment(ctxcontext.Context,amountfloat64){// Add event to current spantracing.AddSpanEventFromContext(ctx,"payment_validated",attribute.Float64("amount",amount),attribute.String("currency","USD"),)}
Get Trace Context
The context already contains trace information:
funcpassContextToWorker(ctxcontext.Context){// Context already has trace info - just pass itgoprocessInBackground(ctx)}funcprocessInBackground(ctxcontext.Context){// Trace context is preservedtraceID:=tracing.TraceID(ctx)log.Printf("Background work [trace=%s]",traceID)}
// ✓ Good - context propagatesfunchandler(whttp.ResponseWriter,r*http.Request){ctx:=r.Context()result:=doWork(ctx)// Pass context}funcdoWork(ctxcontext.Context)string{ctx,span:=tracer.StartSpan(ctx,"do-work")defertracer.FinishSpan(span,http.StatusOK)returndoMoreWork(ctx)// Pass context}// ✗ Bad - context lostfunchandler(whttp.ResponseWriter,r*http.Request){result:=doWork(context.Background())// Lost trace context!}
Use Context for HTTP Clients
Always use http.NewRequestWithContext:
// ✓ Goodreq,_:=http.NewRequestWithContext(ctx,"GET",url,nil)tracer.InjectTraceContext(ctx,req.Header)// ✗ Bad - no contextreq,_:=http.NewRequest("GET",url,nil)tracer.InjectTraceContext(ctx,req.Header)// Won't have span info
Inject Before Making Requests
Always inject trace context before sending requests:
req,_:=http.NewRequestWithContext(ctx,"GET",url,nil)// Inject trace contexttracer.InjectTraceContext(ctx,req.Header)// Then make requestresp,_:=http.DefaultClient.Do(req)
Extract in Custom Handlers
If not using middleware, extract context manually:
funccustomHandler(whttp.ResponseWriter,r*http.Request){// Extract trace contextctx:=tracer.ExtractTraceContext(r.Context(),r.Header)// Use propagated contextctx,span:=tracer.StartSpan(ctx,"custom-handler")defertracer.FinishSpan(span,http.StatusOK)}
Troubleshooting
Traces Not Connected Across Services
Problem: Each service shows separate traces instead of one distributed trace.
Solutions:
Ensure both services use the same propagator format (default: W3C Trace Context)
Verify InjectTraceContext is called before making requests
Verify ExtractTraceContext is called when receiving requests
Check that context is passed through the call chain
Verify both services send to the same OTLP collector
Missing Spans in Distributed Trace
Problem: Some spans appear but others are missing.
Problem: Background goroutines don’t have trace context.
Solution: Pass context explicitly to goroutines:
funchandler(ctxcontext.Context){// ✓ Good - pass contextgofunc(ctxcontext.Context){ctx,span:=tracer.StartSpan(ctx,"background-work")defertracer.FinishSpan(span,http.StatusOK)}(ctx)// ✗ Bad - lost contextgofunc(){ctx:=context.Background()// Lost trace context!ctx,span:=tracer.StartSpan(ctx,"background-work")defertracer.FinishSpan(span,http.StatusOK)}()}
Test your tracing implementation with provided utilities
The tracing package provides testing utilities to help you write tests for traced applications.
Testing Utilities
Three helper functions are provided for testing:
Function
Purpose
Provider
TestingTracer()
Create tracer for tests.
Noop
TestingTracerWithStdout()
Create tracer with output.
Stdout
TestingMiddleware()
Create test middleware.
Noop
TestingTracer
Create a tracer configured for unit tests.
Basic Usage
import("testing""rivaas.dev/tracing")funcTestSomething(t*testing.T){t.Parallel()tracer:=tracing.TestingTracer(t)// Use tracer in test...}
Features
Noop provider: No actual tracing, minimal overhead.
Automatic cleanup: Shutdown() called via t.Cleanup().
Safe for parallel tests: Each test gets its own tracer.
Default configuration:
Service name: "test-service".
Service version: "v1.0.0".
Sample rate: 1.0 (100%).
With Custom Options
Override defaults with your own options.
funcTestWithCustomConfig(t*testing.T){tracer:=tracing.TestingTracer(t,tracing.WithServiceName("my-test-service"),tracing.WithSampleRate(0.5),)// Use tracer...}
Complete Test Example
funcTestProcessOrder(t*testing.T){t.Parallel()tracer:=tracing.TestingTracer(t)ctx:=context.Background()// Test your traced functionresult,err:=processOrder(ctx,tracer,"order-123")assert.NoError(t,err)assert.Equal(t,"success",result)}funcprocessOrder(ctxcontext.Context,tracer*tracing.Tracer,orderIDstring)(string,error){ctx,span:=tracer.StartSpan(ctx,"process-order")defertracer.FinishSpan(span,200)tracer.SetSpanAttribute(span,"order.id",orderID)return"success",nil}
TestingTracerWithStdout
Create a tracer that prints traces to stdout for debugging.
funcTestDebugWithOptions(t*testing.T){tracer:=tracing.TestingTracerWithStdout(t,tracing.WithServiceName("debug-service"),tracing.WithSampleRate(1.0),)// Use tracer...}
TestingMiddleware
Create HTTP middleware for testing traced handlers.
Basic Usage
import("net/http""net/http/httptest""testing""rivaas.dev/tracing")funcTestHTTPHandler(t*testing.T){t.Parallel()// Create test middlewaremiddleware:=tracing.TestingMiddleware(t)// Wrap your handlerhandler:=middleware(http.HandlerFunc(myHandler))// Test the handlerreq:=httptest.NewRequest("GET","/api/users",nil)rec:=httptest.NewRecorder()handler.ServeHTTP(rec,req)assert.Equal(t,http.StatusOK,rec.Code)}funcmyHandler(whttp.ResponseWriter,r*http.Request){w.WriteHeader(http.StatusOK)w.Write([]byte("OK"))}
funcTestPathExclusion(t*testing.T){middleware:=tracing.TestingMiddleware(t,tracing.WithExcludePaths("/health"),)handler:=middleware(http.HandlerFunc(func(whttp.ResponseWriter,r*http.Request){// This handler should not create a span for /healthw.WriteHeader(http.StatusOK)}))// Request to excluded pathreq:=httptest.NewRequest("GET","/health",nil)rec:=httptest.NewRecorder()handler.ServeHTTP(rec,req)assert.Equal(t,http.StatusOK,rec.Code)}
TestingMiddlewareWithTracer
Use a custom tracer with test middleware.
When to Use
Need specific tracer configuration
Testing with stdout output
Custom sampling rates
Specific provider behavior
Basic Usage
funcTestWithCustomTracer(t*testing.T){// Create custom tracertracer:=tracing.TestingTracer(t,tracing.WithSampleRate(0.5),)// Create middleware with custom tracermiddleware:=tracing.TestingMiddlewareWithTracer(t,tracer,tracing.WithExcludePaths("/metrics"),)handler:=middleware(http.HandlerFunc(myHandler))// Test...}
With Stdout Output
funcTestDebugMiddleware(t*testing.T){// Create tracer with stdouttracer:=tracing.TestingTracerWithStdout(t)// Create middleware with that tracermiddleware:=tracing.TestingMiddlewareWithTracer(t,tracer)handler:=middleware(http.HandlerFunc(myHandler))// Test and see trace outputreq:=httptest.NewRequest("GET","/api/users",nil)rec:=httptest.NewRecorder()handler.ServeHTTP(rec,req)}
funcTestSpanAttributes(t*testing.T){t.Parallel()tracer:=tracing.TestingTracer(t)ctx:=context.Background()// Create span and add attributesctx,span:=tracer.StartSpan(ctx,"test-span")tracer.SetSpanAttribute(span,"user.id","123")tracer.SetSpanAttribute(span,"user.role","admin")tracer.FinishSpan(span,200)// With noop provider, this doesn't record anything,// but ensures the code doesn't panic or error}
Testing Context Propagation
funcTestContextPropagation(t*testing.T){t.Parallel()tracer:=tracing.TestingTracer(t)ctx:=context.Background()// Create parent spanctx,parentSpan:=tracer.StartSpan(ctx,"parent")defertracer.FinishSpan(parentSpan,200)// Get trace IDtraceID:=tracing.TraceID(ctx)assert.NotEmpty(t,traceID)// Create child span - should have same trace IDctx,childSpan:=tracer.StartSpan(ctx,"child")defertracer.FinishSpan(childSpan,200)childTraceID:=tracing.TraceID(ctx)assert.Equal(t,traceID,childTraceID,"child should have same trace ID")}
Testing Trace Injection/Extraction
funcTestTraceInjection(t*testing.T){t.Parallel()tracer:=tracing.TestingTracer(t)ctx:=context.Background()// Create spanctx,span:=tracer.StartSpan(ctx,"test")defertracer.FinishSpan(span,200)// Inject into headersheaders:=http.Header{}tracer.InjectTraceContext(ctx,headers)// Verify headers were setassert.NotEmpty(t,headers.Get("Traceparent"))// Extract from headersnewCtx:=context.Background()newCtx=tracer.ExtractTraceContext(newCtx,headers)// Both contexts should have the same trace IDoriginalTraceID:=tracing.TraceID(ctx)extractedTraceID:=tracing.TraceID(newCtx)assert.Equal(t,originalTraceID,extractedTraceID)}
Integration Test Example
funcTestAPIWithTracing(t*testing.T){t.Parallel()// Create tracertracer:=tracing.TestingTracer(t)// Create test server with tracingmux:=http.NewServeMux()mux.HandleFunc("/api/users",func(whttp.ResponseWriter,r*http.Request){ctx:=r.Context()// Add attributes from contexttracing.SetSpanAttributeFromContext(ctx,"handler","users")w.WriteHeader(http.StatusOK)w.Write([]byte(`{"users": []}`))})handler:=tracing.TestingMiddlewareWithTracer(t,tracer)(mux)server:=httptest.NewServer(handler)deferserver.Close()// Make requestresp,err:=http.Get(server.URL+"/api/users")require.NoError(t,err)deferresp.Body.Close()assert.Equal(t,http.StatusOK,resp.StatusCode)}
funcTestSomething(t*testing.T){t.Parallel()// Safe - each test gets its own tracertracer:=tracing.TestingTracer(t)// Test...}
Don’t Call Shutdown Manually
The test utilities handle cleanup automatically:
// ✓ Good - automatic cleanupfuncTestGood(t*testing.T){tracer:=tracing.TestingTracer(t)// No need to call Shutdown()}// ✗ Bad - redundant manual cleanupfuncTestBad(t*testing.T){tracer:=tracing.TestingTracer(t)defertracer.Shutdown(context.Background())// Unnecessary}
Use Stdout for Debugging Only
Don’t use TestingTracerWithStdout for regular tests:
// ✓ Good - stdout only when debuggingfuncTestDebug(t*testing.T){iftesting.Verbose(){tracer:=tracing.TestingTracerWithStdout(t)}else{tracer:=tracing.TestingTracer(t)}}// ✗ Bad - noisy test outputfuncTestRegular(t*testing.T){tracer:=tracing.TestingTracerWithStdout(t)// Too verbose}
Explore complete examples and best practices for production-ready tracing configurations.
Production Configuration
A production-ready tracing setup with all recommended settings.
packagemainimport("context""log""log/slog""net/http""os""os/signal""time""go.opentelemetry.io/otel/attribute""go.opentelemetry.io/otel/trace""rivaas.dev/tracing")funcmain(){// Create context for graceful shutdownctx,cancel:=signal.NotifyContext(context.Background(),os.Interrupt)defercancel()// Create logger for internal eventslogger:=slog.New(slog.NewJSONHandler(os.Stdout,&slog.HandlerOptions{Level:slog.LevelInfo,}))// Create tracer with production settingstracer,err:=tracing.New(tracing.WithServiceName("user-api"),tracing.WithServiceVersion(os.Getenv("VERSION")),tracing.WithOTLP(os.Getenv("OTLP_ENDPOINT")),tracing.WithSampleRate(0.1),// 10% samplingtracing.WithLogger(logger),tracing.WithSpanStartHook(enrichSpan),tracing.WithSpanFinishHook(recordMetrics),)iferr!=nil{log.Fatalf("Failed to initialize tracing: %v",err)}// Start tracer (required for OTLP)iferr:=tracer.Start(ctx);err!=nil{log.Fatalf("Failed to start tracer: %v",err)}// Ensure graceful shutdowndeferfunc(){shutdownCtx,shutdownCancel:=context.WithTimeout(context.Background(),5*time.Second)defershutdownCancel()iferr:=tracer.Shutdown(shutdownCtx);err!=nil{log.Printf("Error shutting down tracer: %v",err)}}()// Create HTTP handlersmux:=http.NewServeMux()mux.HandleFunc("/api/users",handleUsers)mux.HandleFunc("/api/orders",handleOrders)mux.HandleFunc("/health",handleHealth)mux.HandleFunc("/metrics",handleMetrics)// Wrap with tracing middlewarehandler:=tracing.MustMiddleware(tracer,// Exclude observability endpointstracing.WithExcludePaths("/health","/metrics","/ready","/live"),// Exclude debug endpointstracing.WithExcludePrefixes("/debug/","/internal/"),// Record correlation headerstracing.WithHeaders("X-Request-ID","X-Correlation-ID"),// Whitelist safe parameterstracing.WithRecordParams("page","limit","sort"),// Blacklist sensitive parameterstracing.WithExcludeParams("password","token","api_key"),)(mux)// Start serverlog.Printf("Server starting on :8080")iferr:=http.ListenAndServe(":8080",handler);err!=nil{log.Fatal(err)}}// enrichSpan adds custom business context to spansfuncenrichSpan(ctxcontext.Context,spantrace.Span,req*http.Request){// Add tenant identifieriftenantID:=req.Header.Get("X-Tenant-ID");tenantID!=""{span.SetAttributes(attribute.String("tenant.id",tenantID))}// Add user informationifuserID:=req.Header.Get("X-User-ID");userID!=""{span.SetAttributes(attribute.String("user.id",userID))}// Add deployment informationspan.SetAttributes(attribute.String("deployment.region",os.Getenv("REGION")),attribute.String("deployment.environment",os.Getenv("ENVIRONMENT")),)}// recordMetrics records custom metrics based on span completionfuncrecordMetrics(spantrace.Span,statusCodeint){// Record error metricsifstatusCode>=500{// metrics.IncrementServerErrors()}// Record slow request metrics// Could calculate duration and record if above threshold}funchandleUsers(whttp.ResponseWriter,r*http.Request){ctx:=r.Context()// Add custom span attributestracing.SetSpanAttributeFromContext(ctx,"handler","users")tracing.SetSpanAttributeFromContext(ctx,"operation","list")w.Header().Set("Content-Type","application/json")w.Write([]byte(`{"users": []}`))}funchandleOrders(whttp.ResponseWriter,r*http.Request){ctx:=r.Context()tracing.SetSpanAttributeFromContext(ctx,"handler","orders")w.Header().Set("Content-Type","application/json")w.Write([]byte(`{"orders": []}`))}funchandleHealth(whttp.ResponseWriter,r*http.Request){w.WriteHeader(http.StatusOK)w.Write([]byte("OK"))}funchandleMetrics(whttp.ResponseWriter,r*http.Request){w.Header().Set("Content-Type","text/plain")w.Write([]byte("# Metrics"))}
Development Configuration
A development setup with verbose output for debugging.
packagemainimport("context""log""log/slog""net/http""os""rivaas.dev/tracing")funcmain(){// Create logger with debug levellogger:=slog.New(slog.NewTextHandler(os.Stdout,&slog.HandlerOptions{Level:slog.LevelDebug,}))// Create tracer with development settingstracer:=tracing.MustNew(tracing.WithServiceName("user-api"),tracing.WithServiceVersion("dev"),tracing.WithStdout(),// Print traces to consoletracing.WithSampleRate(1.0),// Trace everythingtracing.WithLogger(logger),// Verbose logging)defertracer.Shutdown(context.Background())// Create simple handlermux:=http.NewServeMux()mux.HandleFunc("/",func(whttp.ResponseWriter,r*http.Request){w.Write([]byte("Hello, World!"))})// Minimal middleware - trace everythinghandler:=tracing.MustMiddleware(tracer)(mux)log.Println("Development server on :8080")log.Fatal(http.ListenAndServe(":8080",handler))}
Microservices Example
Complete distributed tracing across multiple services.
Service A (API Gateway)
packagemainimport("context""io""log""net/http""rivaas.dev/tracing")funcmain(){tracer:=tracing.MustNew(tracing.WithServiceName("api-gateway"),tracing.WithServiceVersion("v1.0.0"),tracing.WithOTLP("localhost:4317"),)tracer.Start(context.Background())defertracer.Shutdown(context.Background())mux:=http.NewServeMux()mux.HandleFunc("/api/users",func(whttp.ResponseWriter,r*http.Request){ctx:=r.Context()// Call user serviceusers,err:=callUserService(ctx,tracer)iferr!=nil{http.Error(w,err.Error(),http.StatusInternalServerError)return}w.Header().Set("Content-Type","application/json")w.Write([]byte(users))})handler:=tracing.MustMiddleware(tracer,tracing.WithExcludePaths("/health"),)(mux)log.Fatal(http.ListenAndServe(":8080",handler))}funccallUserService(ctxcontext.Context,tracer*tracing.Tracer)(string,error){// Create span for outgoing callctx,span:=tracer.StartSpan(ctx,"call-user-service")defertracer.FinishSpan(span,http.StatusOK)// Create requestreq,err:=http.NewRequestWithContext(ctx,"GET","http://localhost:8081/users",nil)iferr!=nil{return"",err}// Inject trace contexttracer.InjectTraceContext(ctx,req.Header)// Make requestresp,err:=http.DefaultClient.Do(req)iferr!=nil{return"",err}deferresp.Body.Close()body,_:=io.ReadAll(resp.Body)returnstring(body),nil}
Service B (User Service)
packagemainimport("context""log""net/http""rivaas.dev/tracing")funcmain(){tracer:=tracing.MustNew(tracing.WithServiceName("user-service"),tracing.WithServiceVersion("v1.0.0"),tracing.WithOTLP("localhost:4317"),)tracer.Start(context.Background())defertracer.Shutdown(context.Background())mux:=http.NewServeMux()mux.HandleFunc("/users",func(whttp.ResponseWriter,r*http.Request){ctx:=r.Context()// This span is part of the distributed tracectx,span:=tracer.StartSpan(ctx,"fetch-users")defertracer.FinishSpan(span,http.StatusOK)tracer.SetSpanAttribute(span,"db.system","postgresql")// Simulate database queryusers:=`{"users": [{"id": 1, "name": "Alice"}]}`w.Header().Set("Content-Type","application/json")w.Write([]byte(users))})// Middleware automatically extracts trace contexthandler:=tracing.MustMiddleware(tracer)(mux)log.Fatal(http.ListenAndServe(":8081",handler))}
Environment-Based Configuration
Configure tracing based on environment.
packagemainimport("context""log""log/slog""net/http""os""rivaas.dev/tracing")funcmain(){tracer:=createTracer(os.Getenv("ENVIRONMENT"))defertracer.Shutdown(context.Background())// If OTLP, start the traceriftracer.GetProvider()==tracing.OTLPProvider||tracer.GetProvider()==tracing.OTLPHTTPProvider{iferr:=tracer.Start(context.Background());err!=nil{log.Fatal(err)}}mux:=http.NewServeMux()mux.HandleFunc("/",func(whttp.ResponseWriter,r*http.Request){w.Write([]byte("Hello"))})handler:=tracing.MustMiddleware(tracer)(mux)log.Fatal(http.ListenAndServe(":8080",handler))}funccreateTracer(envstring)*tracing.Tracer{serviceName:=os.Getenv("SERVICE_NAME")ifserviceName==""{serviceName="my-api"}version:=os.Getenv("VERSION")ifversion==""{version="dev"}opts:=[]tracing.Option{tracing.WithServiceName(serviceName),tracing.WithServiceVersion(version),}switchenv{case"production":opts=append(opts,tracing.WithOTLP(os.Getenv("OTLP_ENDPOINT")),tracing.WithSampleRate(0.1),// 10% sampling)case"staging":opts=append(opts,tracing.WithOTLP(os.Getenv("OTLP_ENDPOINT")),tracing.WithSampleRate(0.5),// 50% sampling)default:// developmentlogger:=slog.New(slog.NewTextHandler(os.Stdout,nil))opts=append(opts,tracing.WithStdout(),tracing.WithSampleRate(1.0),// 100% samplingtracing.WithLogger(logger),)}returntracing.MustNew(opts...)}
Database Tracing Example
Trace database operations.
packagemainimport("context""database/sql""net/http""go.opentelemetry.io/otel/attribute""rivaas.dev/tracing")typeUserRepositorystruct{db*sql.DBtracer*tracing.Tracer}func(r*UserRepository)GetUser(ctxcontext.Context,userIDint)(*User,error){// Create span for database operationctx,span:=r.tracer.StartSpan(ctx,"db-get-user")deferr.tracer.FinishSpan(span,http.StatusOK)// Add database attributesr.tracer.SetSpanAttribute(span,"db.system","postgresql")r.tracer.SetSpanAttribute(span,"db.operation","SELECT")r.tracer.SetSpanAttribute(span,"db.table","users")r.tracer.SetSpanAttribute(span,"user.id",userID)// Execute queryquery:="SELECT id, name, email FROM users WHERE id = $1"r.tracer.SetSpanAttribute(span,"db.query",query)varuserUsererr:=r.db.QueryRowContext(ctx,query,userID).Scan(&user.ID,&user.Name,&user.Email)iferr!=nil{r.tracer.SetSpanAttribute(span,"error",true)r.tracer.SetSpanAttribute(span,"error.message",err.Error())returnnil,err}// Add event for successful queryr.tracer.AddSpanEvent(span,"user_found",attribute.Int("user.id",user.ID),)return&user,nil}typeUserstruct{IDintNamestringEmailstring}
Custom Span Events Example
Record significant events within spans.
funcprocessOrder(ctxcontext.Context,tracer*tracing.Tracer,order*Order)error{ctx,span:=tracer.StartSpan(ctx,"process-order")defertracer.FinishSpan(span,http.StatusOK)tracer.SetSpanAttribute(span,"order.id",order.ID)tracer.SetSpanAttribute(span,"order.total",order.Total)// Event: Order validation startedtracer.AddSpanEvent(span,"validation_started")iferr:=validateOrder(ctx,tracer,order);err!=nil{tracer.AddSpanEvent(span,"validation_failed",attribute.String("error",err.Error()),)returnerr}tracer.AddSpanEvent(span,"validation_passed")// Event: Payment processing startedtracer.AddSpanEvent(span,"payment_started",attribute.Float64("amount",order.Total),)iferr:=chargePayment(ctx,tracer,order);err!=nil{tracer.AddSpanEvent(span,"payment_failed",attribute.String("error",err.Error()),)returnerr}tracer.AddSpanEvent(span,"payment_succeeded",attribute.String("transaction_id","TXN123"),)// Event: Order completedtracer.AddSpanEvent(span,"order_completed")returnnil}
Performance Benchmarks
Actual performance measurements from the tracing package:
// Operation Time Memory Allocations// Request overhead (100% sampling) ~1.6 µs 2.3 KB 23// Start/Finish span ~160 ns 240 B 3// Set attribute ~3 ns 0 B 0// Path exclusion (100 paths) ~9 ns 0 B 0
Technical reference documentation for Rivaas packages and APIs
Complete API reference documentation for all Rivaas packages. Find detailed information about types, methods, options, and advanced usage.
Package Reference
Explore the complete package reference for detailed API documentation on all Rivaas packages including App, Router, Config, Binding, Validation, Logging, Metrics, Tracing, and OpenAPI.
3.1 - Package Reference
API reference documentation for Rivaas packages
Detailed API reference for all Rivaas packages. Each package reference includes complete documentation of types, methods, options, and technical details.
Available Packages
App (rivaas.dev/app)
A batteries-included web framework built on top of the Rivaas router. Includes integrated observability (metrics, tracing, logging), lifecycle management with hooks, graceful shutdown handling, health and debug endpoints, and request binding/validation.
High-performance HTTP router with radix tree routing, bloom filters, and optional compiled route tables. Sub-microsecond routing, built-in middleware, OpenTelemetry support, API versioning, and content negotiation.
Powerful configuration management for Go applications with support for multiple sources (files, environment variables, remote sources), format-agnostic with built-in JSON/YAML/TOML support, hierarchical configuration merging, and automatic struct binding with validation.
High-performance request data binding for Go web applications. Maps values from various sources (query parameters, form data, JSON bodies, headers, cookies, path parameters) into Go structs using struct tags with type-safe generic API.
Flexible, multi-strategy validation for Go structs with support for struct tags, JSON Schema, and custom interfaces. Features partial validation for PATCH requests, sensitive data redaction, and detailed field-level error reporting.
Structured logging for Go applications using Go’s standard log/slog package. Features multiple output formats (JSON, Text, Console), context-aware logging with OpenTelemetry trace correlation, automatic sensitive data redaction, and log sampling.
OpenTelemetry-based metrics collection for Go applications with support for Prometheus, OTLP, and stdout exporters. Includes built-in HTTP metrics middleware, custom metrics (counters, histograms, gauges), and automatic header filtering for security.
OpenTelemetry-based distributed tracing for Go applications with support for Stdout, OTLP (gRPC and HTTP), and Noop providers. Includes built-in HTTP middleware for request tracing, manual span management, and context propagation.
Automatic OpenAPI 3.0.4 and 3.1.2 specification generation from Go code using struct tags and reflection. Features fluent HTTP method constructors, automatic parameter discovery, schema generation, built-in validation, and Swagger UI configuration support.
Register routes for HTTP methods. Supported methods are GET, POST, PUT, DELETE, PATCH, HEAD, and OPTIONS. Registering a route with any other method (e.g. CONNECT, TRACE, or a typo) causes a panic with a clear message (fail-fast). The same applies to routes registered via Group and VersionGroup (e.g. Group.GET, VersionGroup.POST).
Middleware
func(a*App)Use(middleware...HandlerFunc)
Adds middleware to the app. Middleware executes for all routes registered after Use().
Starts the server with graceful shutdown. The server runs HTTP, HTTPS, or mTLS depending on configuration: use WithTLS or WithMTLS at construction to serve over TLS; otherwise plain HTTP is used.
Sets the service name used in observability metadata. This includes metrics, traces, and logs. If empty, validation fails.
Default:"rivaas-app"
WithServiceVersion
funcWithServiceVersion(versionstring)Option
Sets the service version used in observability and API documentation. Must be non-empty or validation fails.
Default:"1.0.0"
WithEnvironment
funcWithEnvironment(envstring)Option
Sets the environment mode. Valid values: "development", "production". Invalid values cause validation to fail. When access log scope is not set via WithAccessLogScope, production defaults to errors-only and development to full access logs.
Default:"development"
Server Configuration
WithPort
funcWithPort(portint)Option
Sets the server listen port. Default is 8080 for HTTP; when using WithTLS or WithMTLS the default is 8443. Override with WithPort(n) in all cases. Can be overridden by RIVAAS_PORT when WithEnv is used.
WithServer
funcWithServer(opts...ServerOption)Option
Configures server settings. See Server Options for sub-options.
Server Transport
At most one of WithTLS or WithMTLS may be used. Configure transport at construction; Start then runs the server. Default listen port for TLS/mTLS is 8443 unless overridden by WithPort or RIVAAS_PORT.
WithTLS
funcWithTLS(certFile,keyFilestring)Option
Configures the server to serve HTTPS using the given certificate and key files. Both certFile and keyFile must be non-empty. Default port is 8443 unless overridden. See Server guide for examples.
Configures the server to serve HTTPS with mutual TLS (mTLS). Requires a server certificate and typically WithClientCAs for client verification. Default port is 8443 unless overridden. See Server guide for mTLS options and examples.
Sets which requests are logged as access logs. Valid values are app.AccessLogScopeAll and app.AccessLogScopeErrorsOnly. Invalid values cause validation to fail at startup.
Scope values:
AccessLogScopeAll — Log every request (including 2xx). Use in production only if you need full request logs; consider log volume and cost.
AccessLogScopeErrorsOnly — Log only errors (status >= 400) and slow requests. Reduces log volume.
Access log scope and environment defaults
When you do not call WithAccessLogScope, the effective scope is determined by environment:
User choice
Production
Development
None
Errors-only (default)
Full access logs (default)
WithAccessLogScope(AccessLogScopeErrorsOnly)
Errors-only
Errors-only
WithAccessLogScope(AccessLogScopeAll)
Full access logs
Full access logs
Slow requests are always logged regardless of scope. See WithSlowThreshold.
Adds a readiness check. Readiness checks verify external dependencies.
CheckFunc
typeCheckFuncfunc(context.Context)error
Health check function that returns nil if healthy, error if unhealthy.
Example
app.WithHealthEndpoints(app.WithHealthPrefix("/_system"),app.WithHealthTimeout(800*time.Millisecond),app.WithLivenessCheck("process",func(ctxcontext.Context)error{returnnil}),app.WithReadinessCheck("database",func(ctxcontext.Context)error{returndb.PingContext(ctx)}),)// Endpoints:// GET /_system/livez - Liveness (200 if all checks pass)// GET /_system/readyz - Readiness (204 if all checks pass)
Use this after BindOnly() when you need fine-grained control.
Returns: Validation error if validation fails.
Error Handling
All error handling methods automatically format the error response and abort the handler chain. No further handlers will run after calling these methods.
Fail
func(c*Context)Fail(errerror)
Sends a formatted error response using the configured formatter. The HTTP status code is determined from the error (if it implements HTTPStatus() int) or defaults to 500.
Parameters:
err: The error to send. If nil, the method returns without doing anything.
Behavior:
Formats the error using content negotiation
Writes the HTTP response
Aborts the handler chain
FailStatus
func(c*Context)FailStatus(statusint,errerror)
Sends an error response with an explicit HTTP status code.
Parameters:
status: The HTTP status code to use
err: The error to send
Behavior:
Wraps the error with the specified status code
Formats and sends the response
Aborts the handler chain
NotFound
func(c*Context)NotFound(errerror)
Sends a 404 Not Found error response.
Parameters:
err: The error to send, or nil for a generic “Not Found” message
BadRequest
func(c*Context)BadRequest(errerror)
Sends a 400 Bad Request error response.
Parameters:
err: The error to send, or nil for a generic “Bad Request” message
Unauthorized
func(c*Context)Unauthorized(errerror)
Sends a 401 Unauthorized error response.
Parameters:
err: The error to send, or nil for a generic “Unauthorized” message
Forbidden
func(c*Context)Forbidden(errerror)
Sends a 403 Forbidden error response.
Parameters:
err: The error to send, or nil for a generic “Forbidden” message
Conflict
func(c*Context)Conflict(errerror)
Sends a 409 Conflict error response.
Parameters:
err: The error to send, or nil for a generic “Conflict” message
Gone
func(c*Context)Gone(errerror)
Sends a 410 Gone error response.
Parameters:
err: The error to send, or nil for a generic “Gone” message
UnprocessableEntity
func(c*Context)UnprocessableEntity(errerror)
Sends a 422 Unprocessable Entity error response.
Parameters:
err: The error to send, or nil for a generic “Unprocessable Entity” message
TooManyRequests
func(c*Context)TooManyRequests(errerror)
Sends a 429 Too Many Requests error response.
Parameters:
err: The error to send, or nil for a generic “Too Many Requests” message
InternalError
func(c*Context)InternalError(errerror)
Sends a 500 Internal Server Error response.
Parameters:
err: The error to send, or nil for a generic “Internal Server Error” message
ServiceUnavailable
func(c*Context)ServiceUnavailable(errerror)
Sends a 503 Service Unavailable error response.
Parameters:
err: The error to send, or nil for a generic “Service Unavailable” message
Logging
To log from a handler with trace correlation, pass the request context to the standard library’s context-aware logging functions. For example: slog.InfoContext(c.RequestContext(), "msg", ...) or slog.ErrorContext(c.RequestContext(), "msg", ...). When the app is configured with observability (logging and tracing), trace_id and span_id are injected automatically from the active OpenTelemetry span.
Presence
Presence
func(c*Context)Presence()validation.PresenceMap
Returns the presence map for the current request (tracks which fields were present in JSON).
ResetBinding
func(c*Context)ResetBinding()
Resets binding metadata (useful for testing).
Router Context
The app Context embeds router.Context, providing access to all router features:
All hook registration methods return an error when called after the router is frozen (e.g. after Start() or Router().Freeze()). Register all hooks before starting the server. Use errors.Is(err, app.ErrRouterFrozen) to detect this case.
Called when the application receives a reload signal (SIGHUP) or when Reload() is called programmatically. SIGHUP signal handling is automatically enabled when you register this hook.
If no OnReload hooks are registered, SIGHUP is ignored on Unix so the process keeps running (e.g. kill -HUP does not terminate it).
Hooks run sequentially and stop on first error. Errors are logged but don’t crash the server.
Platform: SIGHUP works on Unix/Linux/macOS. On Windows, use programmatic Reload().
Reload
func(a*App)Reload(ctxcontext.Context)error
Manually triggers all registered OnReload hooks. Useful for admin endpoints or Windows where SIGHUP isn’t available.
Returns an error if any hook fails, but the server continues running with the old configuration.
Post-freeze registration
Registering any lifecycle hook after the router is frozen (e.g. after Start() or Router().Freeze()) returns an error instead of panicking. Register all hooks before starting the server. Use errors.Is(err, app.ErrRouterFrozen) to detect this case programmatically.
Execution Flow
1. app.Start(ctx) called
2. OnStart hooks execute (sequential, stop on error)
3. Server starts listening
4. OnReady hooks execute (async, non-blocking)
5. Server handles requests...
→ OnReload hooks execute when SIGHUP received (sequential, logged on error)
6. Context canceled (SIGTERM/SIGINT)
7. OnShutdown hooks execute (LIFO order, with timeout)
8. Server shutdown complete
9. OnStop hooks execute (best-effort, no timeout)
10. Process exits
Hook Characteristics
Hook
Order
Error Handling
Timeout
Async
OnStart
Sequential
Stop on first error
No
No
OnReady
-
Panic caught and logged
No
Yes
OnReload
Sequential
Stop on first error, logged
No
No
OnShutdown
LIFO
Errors ignored
Yes (shutdown timeout)
No
OnStop
-
Panic caught and logged
No
No
OnRoute
Sequential
-
No
No
Example
a:=app.MustNew()// OnStart: Initialize (sequential, stops on error)iferr:=a.OnStart(func(ctxcontext.Context)error{returndb.Connect(ctx)});err!=nil{log.Fatal(err)}// OnReady: Post-startup (async, non-blocking)iferr:=a.OnReady(func(){consul.Register("my-service",":8080")});err!=nil{log.Fatal(err)}// OnReload: Reload configuration (sequential, logged on error)iferr:=a.OnReload(func(ctxcontext.Context)error{cfg,err:=loadConfig("config.yaml")iferr!=nil{returnerr}applyConfig(cfg)returnnil});err!=nil{log.Fatal(err)}// OnShutdown: Graceful cleanup (LIFO, with timeout)iferr:=a.OnShutdown(func(ctxcontext.Context){db.Close()});err!=nil{log.Fatal(err)}// OnStop: Final cleanup (best-effort)iferr:=a.OnStop(func(){cleanupTempFiles()});err!=nil{log.Fatal(err)}
3.1.1.9 - Troubleshooting
Common issues and solutions for the App package.
Configuration Errors
Validation Errors
Problem:app.New() returns validation errors.
Solution: Check error message for specific field. Common issues:
Empty service name or version.
Invalid environment. Must be “development” or “production”.
ReadTimeout greater than WriteTimeout.
ShutdownTimeout less than 1 second.
MaxHeaderBytes less than 1KB.
Example:
a,err:=app.New(app.WithServiceName(""),// ❌ Empty)// Error: "serviceName must not be empty"
Import Errors
Problem: Cannot import rivaas.dev/app.
Solution:
go get rivaas.dev/app
go mod tidy
Ensure Go 1.25+ is installed.
Server Issues
Port Already in Use
Problem: Server fails to start with “address already in use”.
Solution: Check if port is in use (default is 8080 for HTTP, 8443 for TLS/mTLS):
lsof -i :8080
# Or for TLS/mTLSlsof -i :8443
# Ornetstat -an | grep 8080
Kill the process or use a different port with WithPort(n).
Routes Not Registering
Problem: Routes return 404 even though registered.
Solution:
Ensure routes registered before Start().
Check paths match exactly. They are case-sensitive.
Verify HTTP method matches.
Router freezes on startup. Can’t add routes after.
Lifecycle hook registration (OnStart, OnReady, OnShutdown, etc.) after freeze returns an error instead of panicking. Check and handle the error (e.g. in main) and register all hooks before Start().
Unsupported HTTP Method Panic
Problem: Panic with message like unsupported HTTP method "…" or supported: GET, POST, ....
Solution: Use only the provided method shortcuts: a.GET, a.POST, a.PUT, a.DELETE, a.PATCH, a.HEAD, a.OPTIONS, and the same on Group and VersionGroup. If the panic appears in tests or custom code that passes a method string, ensure that string is one of: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS.
Router options are passed to router.New() or router.MustNew() to configure the router.
Router Creation
// With error handlingr,err:=router.New(opts...)iferr!=nil{log.Fatalf("Failed to create router: %v",err)}// Panics on invalid configuration. Use at startup.r:=router.MustNew(opts...)
Versioning Options
WithVersioning(opts ...version.Option)
Configures API versioning support using functional options from the version package.
Turns compiled route matching on or off. By default it’s off: the router uses tree traversal, which is fast and works well for most apps. Turn it on when you have a lot of routes (for example hundreds of static routes). Then the router can use pre-compiled lookups and bloom filters to speed things up.
Default:false (tree traversal)
// Default: tree traversal (no need to set anything)r:=router.MustNew()// Turn on compiled routes for large APIsr:=router.MustNew(router.WithRouteCompilation(true))
WithBloomFilterSize(size uint64)
Sets the bloom filter size when you use compiled routes. Larger sizes reduce false positives.
Default: 1000 Recommended: 2-3x the number of static routes
r:=router.MustNew(router.WithBloomFilterSize(2000))// For ~1000 routes
WithBloomFilterHashFunctions(numFuncs int)
Sets the number of hash functions for bloom filters.
Controls context cancellation checking in the middleware chain. When enabled (default), the router checks for canceled contexts between handlers.
// Enabled by defaultr:=router.MustNew(router.WithCancellationCheck(true))// Disable if you handle cancellation manuallyr:=router.MustNew(router.WithoutCancellationCheck())
Complete Example
packagemainimport("log/slog""net/http""os""time""rivaas.dev/router""rivaas.dev/router/version")funcmain(){logger:=slog.New(slog.NewJSONHandler(os.Stdout,nil))// Diagnostic handlerdiagHandler:=router.DiagnosticHandlerFunc(func(erouter.DiagnosticEvent){logger.Warn(e.Message,"kind",e.Kind,"fields",e.Fields)})// Create router with optionsr:=router.MustNew(// Versioningrouter.WithVersioning(version.WithHeaderDetection("API-Version"),version.WithDefault("v1"),),// Server configurationrouter.WithServerTimeouts(10*time.Second,30*time.Second,60*time.Second,120*time.Second,),// Performance tuningrouter.WithBloomFilterSize(2000),// Diagnosticsrouter.WithDiagnostics(diagHandler),)r.GET("/",func(c*router.Context){c.JSON(200,map[string]string{"message":"Hello"})})http.ListenAndServe(":8080",r)}
Observability Options
Note
For tracing, metrics, and logging configuration, use the app package which provides WithObservability(), WithTracing(), WithMetrics(), and WithLogging() options. These options configure the full observability stack and integrate with the router automatically.
err:=router.StreamJSONArray(c,func(itemUser)error{returnprocessUser(item)},10000)// Max 10k items
Response Methods
JSON Responses
c.JSON(codeint,objany)errorc.IndentedJSON(codeint,objany)errorc.PureJSON(codeint,objany)error// No HTML escapingc.SecureJSON(codeint,objany,prefix...string)errorc.ASCIIJSON(codeint,objany)error// All non-ASCII escaped
Sets a response header with automatic security sanitization (newlines stripped).
URL Information
c.Hostname()string// Host without portc.Port()string// Port numberc.Scheme()string// "http" or "https"c.BaseURL()string// scheme + hostc.FullURL()string// Complete URL with query string
Client Information
c.ClientIP()string// Real client IP (respects trusted proxies)c.ClientIPs()[]string// All IPs from X-Forwarded-For chainc.IsHTTPS()bool// Request over HTTPSc.IsLocalhost()bool// Request from localhostc.IsXHR()bool// XMLHttpRequest (AJAX)c.Subdomains(offset...int)[]string
Content Type Detection
c.IsJSON()bool// Content-Type is application/jsonc.IsXML()bool// Content-Type is application/xml or text/xmlc.AcceptsJSON()bool// Accept header includes application/jsonc.AcceptsHTML()bool// Accept header includes text/html
c.Next()// Execute next handler in chainc.Abort()// Stop handler chainc.IsAborted()bool// Check if chain was aborted
Error Collection
c.Error(errerror)// Collect error without writing responsec.Errors()[]error// Get all collected errorsc.HasErrors()bool// Check if errors were collected
Note:router.Context.Error() collects errors without writing a response or aborting the handler chain. This is useful for gathering multiple errors before deciding how to respond.
To send an error response immediately, use app.Context.Fail() which formats the error, writes the response, and stops the handler chain.
For tracing and metrics in your handlers, use the app package. The app observability guide shows how to use app.Context methods such as TraceID(), SpanID(), SetSpanAttribute(), AddSpanEvent(), RecordHistogram(), IncrementCounter(), and SetGauge().
Versioning
c.Version()string// Current API version ("v1", "v2", etc.)c.IsVersion(versionstring)boolc.RoutePattern()string// Matched route pattern ("/users/:id")
Complete Example
funchandler(c*router.Context){// Parametersid:=c.Param("id")query:=c.Query("q")// Headersauth:=c.Request.Header.Get("Authorization")c.Header("X-Custom","value")// Strict binding (for full binding, use binding package)varreqCreateRequestiferr:=c.BindStrict(&req,router.BindOptions{MaxBytes:1<<20});err!=nil{return// Error response already written}// Logging (pass request context for trace correlation)slog.InfoContext(c.RequestContext(),"processing request","user_id",id)// Responseiferr:=c.JSON(200,map[string]string{"id":id,"query":query,});err!=nil{slog.ErrorContext(c.RequestContext(),"failed to write response","error",err)}}
Binding Package: For full request binding, see binding package
3.1.2.4 - Router Performance
Comprehensive benchmark comparison between rivaas/router and other popular Go web frameworks, with methodology and reproduction instructions.
This page contains detailed performance benchmarks comparing rivaas/router against other popular Go web frameworks. The benchmarks measure pure routing dispatch overhead by using direct writes (via io.WriteString) in all handlers to eliminate string concatenation allocations.
Benchmark Methodology
Test Environment
Go Version: 1.26
CPU: AMD EPYC 7763 64-Core Processor
OS: linux/amd64
Last Updated: 2026-02-26
Frameworks Compared
The following frameworks are included in the comparison:
All frameworks are tested with the same three route patterns:
Static route:GET /
One parameter:GET /users/:id
Two parameters:GET /users/:id/posts/:post_id
Handler Implementation
To ensure fair comparison and isolate routing overhead, all handlers use direct writes rather than string concatenation:
// Instead of this (causes one string allocation):w.Write([]byte("User: "+id))// Handlers do this (zero allocations for supported frameworks):io.WriteString(w,"User: ")io.WriteString(w,id)
This eliminates the handler allocation cost, so the measured time represents:
Route tree traversal and matching
Parameter extraction
Context setup
Response writer overhead (framework-specific)
Measurement Notes
Fiber v2/v3: Measured via net/http adaptor (fiberadaptor.FiberApp) for compatibility with httptest.ResponseRecorder. The adaptor adds overhead but is necessary for the standard test harness.
Hertz: Measured using ut.PerformRequest(h.Engine, ...) (Hertz’s native test API) because Hertz does not implement http.Handler. Numbers are not directly comparable to httptest-based frameworks due to different measurement approach.
Beego: May log “init global config instance failed” when conf/app.conf is missing; this is safe to ignore in benchmarks.
Benchmark Results
Static Route (/)
This scenario measures the overhead of dispatching a request to a static route with no parameters.
Framework
ns/op
B/op
allocs/op
Notes
Rivaas
47.4
0
0
Zero alloc
Gin
61.1
0
0
Zero alloc
Echo
78.2
8
1
StdMux
80.0
0
0
Zero alloc
Chi
347.6
368
2
Beego
663.1
360
4
Hertz
1720.0
3448
24
via ut.PerformRequest
Fiber
2034.0
2066
20
via http adaptor
FiberV3
7116.0
33582
15
via http adaptor
Scenario:/ —
Lower is better. All handlers use direct writes (io.WriteString) to minimize overhead and isolate routing cost.
Key Observations:
Rivaas, Gin, and StdMux achieve zero allocations with direct writes
Echo has 1 allocation from its internal context
Chi, Fiber, Hertz, and Beego have framework-specific overhead
One Parameter (/users/:id)
This scenario measures routing + parameter extraction for a single dynamic segment.
Framework
ns/op
B/op
allocs/op
Notes
Rivaas
82.2
0
0
Zero alloc
Gin
104.4
0
0
Zero alloc
Echo
149.6
16
2
StdMux
212.2
16
1
Chi
407.2
368
2
Beego
1017.0
400
6
Hertz
2035.0
3544
27
via ut.PerformRequest
Fiber
2156.0
2060
20
via http adaptor
FiberV3
7410.0
33112
16
via http adaptor
Scenario:/users/:id —
Lower is better. All handlers use direct writes (io.WriteString) to minimize overhead and isolate routing cost.
Key Observations:
Rivaas and Gin maintain zero allocations even with parameter extraction
StdMux has 1 allocation from r.PathValue()
Echo has 2 allocations (context + param storage)
Two Parameters (/users/:id/posts/:post_id)
This scenario tests routing with multiple dynamic segments.
Framework
ns/op
B/op
allocs/op
Notes
Rivaas
130.9
0
0
Zero alloc
Gin
165.2
0
0
Zero alloc
Echo
251.3
32
4
StdMux
350.3
48
2
Chi
507.3
368
2
Beego
1362.0
448
8
Hertz
2160.0
3664
29
via ut.PerformRequest
Fiber
2346.0
2077
20
via http adaptor
FiberV3
7403.0
33128
18
via http adaptor
Scenario:/users/:id/posts/:post_id —
Lower is better. All handlers use direct writes (io.WriteString) to minimize overhead and isolate routing cost.
Key Observations:
Rivaas and Gin continue to show zero allocations
StdMux scales linearly (2 allocs for 2 params)
Echo scales with each additional parameter
How to Reproduce
The benchmarks are located in the router/benchmarks directory of the rivaas repository.
Running All Benchmarks
cd router/benchmarks
go test -bench=. -benchmem
Running a Specific Scenario
# Static route onlygo test -bench=BenchmarkStatic -benchmem
# One parameter onlygo test -bench=BenchmarkOneParam -benchmem
# Two parameters onlygo test -bench=BenchmarkTwoParams -benchmem
Running a Specific Framework
# Rivaas onlygo test -bench='/(Rivaas)$' -benchmem
# Gin onlygo test -bench='/(Gin)$' -benchmem
Multiple Runs for Statistical Analysis
Use -count to run benchmarks multiple times and benchstat to compare:
go test -bench=. -benchmem -count=5 > results.txt
go install golang.org/x/perf/cmd/benchstat@latest
benchstat results.txt
Understanding the Results
Metrics Explained
ns/op: Nanoseconds per operation (lower is better)
B/op: Bytes allocated per operation (lower is better)
allocs/op: Number of allocations per operation (lower is better)
Why Zero Allocations Matter
The router is zero allocation for the benchmarked scenarios: static route, one parameter, and two parameters.
Each allocation has a cost:
Time: Allocating memory takes time (~30-50ns for small allocations)
GC pressure: More allocations mean more garbage collection work
Scalability: At high request rates (millions/sec), eliminating allocations significantly reduces CPU and memory usage
Rivaas achieves zero allocations for routing and parameter extraction by:
Pre-allocating context pools
Using array-based parameter storage for ≤8 params
Avoiding string concatenation in hot paths
Efficient radix tree implementation with minimal allocations
Continuous Benchmarking
The rivaas repository uses continuous benchmarking to detect performance regressions:
Pull Requests: Every PR runs Rivaas-only benchmarks and compares against a baseline. If performance regresses beyond a threshold, the PR check fails.
Releases: Full framework comparison runs on every release tag and updates this page automatically.
Built-in middleware catalog with configuration options.
The router includes production-ready middleware in separate packages. Each middleware is its own Go module, so you only add the ones you need and keep your dependency footprint small. All of them use functional options for configuration.
Generates unique, time-ordered request IDs for distributed tracing and log correlation.
import"rivaas.dev/middleware/requestid"// UUID v7 by default (36 chars, time-ordered, RFC 9562)r.Use(requestid.New())// Use ULID for shorter IDs (26 chars)r.Use(requestid.New(requestid.WithULID()))// Custom header namer.Use(requestid.New(requestid.WithHeader("X-Correlation-ID")))// Get request ID in handlersfunchandler(c*router.Context){id:=requestid.Get(c)}
import"rivaas.dev/middleware/ratelimit"r.Use(ratelimit.New(ratelimit.WithRequestsPerSecond(1000),ratelimit.WithBurst(100),ratelimit.WithKeyFunc(func(c*router.Context)string{returnc.ClientIP()// Rate limit by IP}),ratelimit.WithLogger(logger),))
Common issues and solutions for the router package.
This guide helps you troubleshoot common issues with the Rivaas Router.
Quick Reference
Issue
Solution
Example
404 Route Not Found
Check route syntax and order.
r.GET("/users/:id", handler)
Middleware Not Running
Register before routes.
r.Use(middleware); r.GET("/path", handler)
Parameters Not Working
Use :param syntax.
r.GET("/users/:id", handler)
CORS Issues
Add CORS middleware.
r.Use(cors.New())
Memory Leaks
Don’t store context references.
Extract data immediately.
Slow Performance
Use route groups.
api := r.Group("/api")
Common Issues
Route Not Found (404 errors)
Problem: Routes not matching as expected.
Solutions:
// ✅ Correct: Use :param syntaxr.GET("/users/:id",handler)// ❌ Wrong: Don't use {param} syntaxr.GET("/users/{id}",handler)// ✅ Correct: Static router.GET("/users/me",currentUserHandler)// Check route registration orderr.GET("/users/me",currentUserHandler)// Register specific routes firstr.GET("/users/:id",getUserHandler)// Then parameter routes
Middleware Not Executing
Problem: Middleware doesn’t run for routes.
Solution: Register middleware before routes.
// ✅ Correct: Middleware before routesr.Use(Logger())r.GET("/api/users",handler)// ❌ Wrong: Routes before middlewarer.GET("/api/users",handler)r.Use(Logger())// Too late!// ✅ Correct: Group middlewareapi:=r.Group("/api")api.Use(Auth())api.GET("/users",handler)
// ❌ Wrong: Storing contextvarglobalContext*router.Contextfunchandler(c*router.Context){globalContext=c// Memory leak!}// ✅ Correct: Extract data immediatelyfunchandler(c*router.Context){userID:=c.Param("id")// Use userID, not cprocessUser(userID)}// ✅ Correct: Copy data for async operationsfunchandler(c*router.Context){userID:=c.Param("id")gofunc(idstring){processAsync(id)}(userID)}
// ✅ Register custom tags in init()funcinit(){router.RegisterTag("custom",validatorFunc)}// ✅ Use app.Context for binding and validationfunccreateUser(c*app.Context){varreqCreateUserRequestif!c.MustBind(&req){return}}// ✅ Partial validation for PATCHfuncupdateUser(c*app.Context){req,ok:=app.MustBindPatch[UpdateUserRequest](c)if!ok{return}}
The binding package provides a high-performance, type-safe way to bind request data from various sources (query parameters, JSON bodies, headers, etc.) into Go structs using struct tags.
import"rivaas.dev/binding"typeCreateUserRequeststruct{Usernamestring`json:"username"`Emailstring`json:"email"`Ageint`json:"age"`}// Generic API (preferred)user,err:=binding.JSON[CreateUserRequest](body)
Key Features
Type-Safe Generic API: Compile-time type safety with zero runtime overhead
Zero Allocation: Struct reflection info cached for optimal performance
Flexible Type Support: Primitives, time types, collections, nested structs, custom types
Detailed Errors: Field-level error information with context
Extensible: Custom type converters and value getters
Multi-Source Binding: Combine data from multiple sources with precedence control
Package Structure
graph TB
A[binding]:::info --> B[Core API]:::warning
A --> C[Sub-Packages]:::success
B --> B1[JSON/XML/Form]
B --> B2[Query/Header/Cookie]
B --> B3[Multi-Source]
B --> B4[Custom Binders]
C --> C1[yaml]
C --> C2[toml]
C --> C3[msgpack]
C --> C4[proto]
classDef default fill:#F8FAF9,stroke:#1E6F5C,color:#1F2A27
classDef info fill:#D1ECF1,stroke:#17A2B8,color:#1F2A27
classDef success fill:#D4EDDA,stroke:#28A745,color:#1F2A27
classDef warning fill:#FFF3CD,stroke:#FFC107,color:#1F2A27
Quick Navigation
API Reference
Core types, functions, and interfaces for request binding.
// JSON bindingfuncJSON[Tany](data[]byte,opts...Option)(T,error)// Query parameter bindingfuncQuery[Tany](valuesurl.Values,opts...Option)(T,error)// Form data bindingfuncForm[Tany](valuesurl.Values,opts...Option)(T,error)// Header bindingfuncHeader[Tany](headershttp.Header,opts...Option)(T,error)// Cookie bindingfuncCookie[Tany](cookies[]*http.Cookie,opts...Option)(T,error)// Path parameter bindingfuncPath[Tany](paramsmap[string]string,opts...Option)(T,error)// XML bindingfuncXML[Tany](data[]byte,opts...Option)(T,error)// Multi-source bindingfuncBind[Tany](sources...Source)(T,error)
Non-Generic Functions
For cases where type comes from a variable:
// JSON binding to pointerfuncJSONTo(data[]byte,targetinterface{},opts...Option)error// Query binding to pointerfuncQueryTo(valuesurl.Values,targetinterface{},opts...Option)error// ... similar for other sources
typeBindErrorstruct{Fieldstring// Field nameSourcestring// Source ("query", "json", etc.)Valuestring// Raw valueTypestring// Expected typeReasonstring// Error reasonErrerror// Underlying error}
UnknownFieldError
Unknown fields in strict mode:
typeUnknownFieldErrorstruct{Fields[]string// List of unknown fields}
MultiError
Multiple errors with WithAllErrors():
typeMultiErrorstruct{Errors[]*BindError}
Configuration Options
Common options for all binding functions:
// Security limitsbinding.WithMaxDepth(16)// Max struct nestingbinding.WithMaxSliceLen(1000)// Max slice elementsbinding.WithMaxMapSize(500)// Max map entries// Unknown fieldsbinding.WithStrictJSON()// Fail on unknown fieldsbinding.WithUnknownFields(mode)// UnknownError/UnknownWarn/UnknownIgnore// Slice parsingbinding.WithSliceMode(mode)// SliceRepeat or SliceCSV// Error collectionbinding.WithAllErrors()// Collect all errors instead of failing on first
Reusable Binders
Create configured binder instances:
binder:=binding.MustNew(binding.WithConverter[uuid.UUID](uuid.Parse),binding.WithTimeLayouts("2006-01-02","01/02/2006"),binding.WithMaxDepth(16),)// Use across handlersuser,err:=binder.JSON[User](body)params,err:=binder.Query[Params](values)
Query/Path/Form: Zero allocations for primitive types
JSON/XML: Allocations depend on encoding/json and encoding/xml
Thread-safe: All operations are safe for concurrent use
Integration
With net/http
funcHandler(whttp.ResponseWriter,r*http.Request){req,err:=binding.JSON[CreateUserRequest](r.Body)iferr!=nil{http.Error(w,err.Error(),http.StatusBadRequest)return}// Process request...}
Binds multipart form data including file uploads to a struct. Use *binding.File type for file fields.
Parameters:
form: Multipart form from r.MultipartForm after calling r.ParseMultipartForm()
opts: Optional configuration options
Returns:
Populated struct of type T with form fields and files
Error if binding fails
Example:
typeUploadRequeststruct{File*binding.File`form:"file"`Titlestring`form:"title"`Descriptionstring`form:"description"`Tags[]string`form:"tags"`}// Parse multipart form first (32MB limit)iferr:=r.ParseMultipartForm(32<<20);err!=nil{returnerr}req,err:=binding.Multipart[UploadRequest](r.MultipartForm)iferr!=nil{returnerr}// Save the uploaded fileiferr:=req.File.Save("/uploads/"+req.File.Name);err!=nil{returnerr}
Multipart binding automatically parses JSON strings from form fields into nested structs:
typeSettingsstruct{Themestring`json:"theme"`Notificationsbool`json:"notifications"`}typeProfileUpdatestruct{Avatar*binding.File`form:"avatar"`SettingsSettings`form:"settings"`// JSON automatically parsed}// Form field "settings" contains: {"theme":"dark","notifications":true}req,err:=binding.Multipart[ProfileUpdate](r.MultipartForm)// req.Settings is now populated from the JSON string
A Binder has the same methods as the package-level functions:
func(b*Binder)JSON[Tany](data[]byte,opts...Option)(T,error)func(b*Binder)Query[Tany](valuesurl.Values,opts...Option)(T,error)// ... etc for all binding functions
Error Types
BindError
Field-specific binding error with detailed context:
typeBindErrorstruct{Fieldstring// Field name that failed to bindSourcestring// Source ("query", "json", "header", etc.)Valuestring// Raw value that failed to bindTypestring// Expected Go typeReasonstring// Human-readable reasonErrerror// Underlying error}func(e*BindError)Error()stringfunc(e*BindError)Unwrap()errorfunc(e*BindError)IsType()bool// True if type conversion failedfunc(e*BindError)IsMissing()bool// True if required field missing
Example:
user,err:=binding.JSON[User](data)iferr!=nil{varbindErr*binding.BindErroriferrors.As(err,&bindErr){log.Printf("Field %s from %s failed: %v",bindErr.Field,bindErr.Source,bindErr.Err)}}
UnknownFieldError
Returned in strict mode when unknown fields are encountered:
typeUnknownFieldErrorstruct{Fields[]string// List of unknown field names}func(e*UnknownFieldError)Error()string
typeValueGetterinterface{Get(keystring)string// Get first value for keyGetAll(keystring)[]string// Get all values for keyHas(keystring)bool// Check if key exists}
Creates a converter that tries parsing time strings using the provided formats in order.
Example:
binder:=binding.MustNew(binding.WithConverter(binding.TimeConverter("2006-01-02",// ISO format"01/02/2006",// US format"02-Jan-2006",// Short month)),)typeEventstruct{Datetime.Time`query:"date"`}// Works with: ?date=2026-01-28 or ?date=01/28/2026 or ?date=28-Jan-2026event,err:=binder.Query[Event](values)
Creates a converter that parses duration strings. It supports both standard Go duration format (like "30m", "2h30m") and custom aliases you define.
Example:
binder:=binding.MustNew(binding.WithConverter(binding.DurationConverter(map[string]time.Duration{"quick":5*time.Minute,"normal":30*time.Minute,"long":2*time.Hour,})),)typeConfigstruct{Timeouttime.Duration`query:"timeout"`}// Works with: ?timeout=quick or ?timeout=30m or ?timeout=2h30mconfig,err:=binder.Query[Config](values)
binder:=binding.MustNew(binding.WithEvents(binding.Events{FieldBound:func(name,tagstring){log.Printf("Bound field %s from %s",name,tag)},UnknownField:func(namestring){log.Printf("Unknown field: %s",name)},Done:func(statsbinding.Stats){log.Printf("Binding completed: %d fields, %d errors",stats.FieldsBound,stats.ErrorCount)},}),)
Stats Type
Statistics from binding operation:
typeStatsstruct{FieldsBoundint// Number of fields successfully boundErrorCountint// Number of errors encounteredDurationtime.Duration// Time taken for binding}
The binding package provides ready-to-use converter factories for common patterns. These are functions that return converter functions you can use with WithConverter.
Creates a converter that parses time strings using the provided date formats. Tries each format in order until one succeeds.
Example:
binder:=binding.MustNew(// Try ISO format first, then US formatbinding.WithConverter(binding.TimeConverter("2006-01-02","01/02/2006",)),)typeEventstruct{Datetime.Time`query:"date"`}// Works with: ?date=2026-01-28 or ?date=01/28/2026event,err:=binder.Query[Event](values)
Creates a converter that validates string values against a set of allowed options. Matching is case-insensitive.
Example:
typeStatusstringconst(StatusActiveStatus="active"StatusPendingStatus="pending"StatusDisabledStatus="disabled")binder:=binding.MustNew(binding.WithConverter(binding.EnumConverter(StatusActive,StatusPending,StatusDisabled,)),)typeUserstruct{StatusStatus`query:"status"`}// ?status=active ✓ OK// ?status=ACTIVE ✓ OK (case-insensitive)// ?status=invalid ✗ Error: must be one of: active, pending, disableduser,err:=binder.Query[User](values)
You can use multiple converters together, including both factories and custom converters:
binder:=binding.MustNew(// Time with custom formatsbinding.WithConverter(binding.TimeConverter("01/02/2006","2006-01-02")),// Duration with friendly namesbinding.WithConverter(binding.DurationConverter(map[string]time.Duration{"short":5*time.Minute,"long":1*time.Hour,})),// Status enumbinding.WithConverter(binding.EnumConverter("active","pending","disabled")),// Boolean with custom valuesbinding.WithConverter(binding.BoolConverter([]string{"yes","on"},[]string{"no","off"},)),// Third-party typesbinding.WithConverter[uuid.UUID](uuid.Parse),binding.WithConverter[decimal.Decimal](decimal.NewFromString),)
WithTimeLayouts
funcWithTimeLayouts(layouts...string)Option
Sets custom time parsing layouts. Replaces default layouts.
Default Layouts: See binding.DefaultTimeLayouts
Example:
binder:=binding.MustNew(binding.WithTimeLayouts("2006-01-02",// Date only"01/02/2006",// US format"2006-01-02 15:04:05",// DateTime),)
funcWithMergeStrategy(strategyMergeStrategy)Option// Strategiesconst(MergeLastWinsMergeStrategy=iota// Last source wins (default)MergeFirstWins// First source wins)
Controls precedence when binding from multiple sources.
Example:
// First source winsreq,err:=binding.Bind[Request](binding.WithMergeStrategy(binding.MergeFirstWins),binding.FromHeader(r.Header),// Highest prioritybinding.FromQuery(r.URL.Query()),// Lower priority)
Strategies:
MergeLastWins: Last source overwrites (default)
MergeFirstWins: First non-empty value wins
JSON-Specific Options
WithDisallowUnknownFields
funcWithDisallowUnknownFields()Option
Equivalent to WithStrictJSON(). Provided for clarity when explicitly disallowing unknown fields.
typeEnvTagHandlerstruct{prefixstring}func(h*EnvTagHandler)Get(fieldName,tagValuestring)(string,bool){envKey:=h.prefix+tagValueval,exists:=os.LookupEnv(envKey)returnval,exists}binder:=binding.MustNew(binding.WithTagHandler("env",&EnvTagHandler{prefix:"APP_"}),)typeConfigstruct{APIKeystring`env:"API_KEY"`// Looks up APP_API_KEY}
Option Combinations
Production Configuration
varProductionBinder=binding.MustNew(// Securitybinding.WithMaxDepth(16),binding.WithMaxSliceLen(1000),binding.WithMaxMapSize(500),binding.WithMaxBytes(10*1024*1024),// 10MB// Strict validationbinding.WithStrictJSON(),// Custom typesbinding.WithConverter[uuid.UUID](uuid.Parse),binding.WithConverter[decimal.Decimal](decimal.NewFromString),// Time formatsbinding.WithTimeLayouts(append(binding.DefaultTimeLayouts,"2006-01-02","01/02/2006",)...),// Observabilitybinding.WithEvents(binding.Events{FieldBound:logFieldBound,UnknownField:logUnknownField,Done:recordMetrics,}),)
Development Configuration
varDevBinder=binding.MustNew(// Lenient limitsbinding.WithMaxDepth(32),binding.WithMaxSliceLen(10000),// Warnings instead of errorsbinding.WithUnknownFields(binding.UnknownWarn),// Collect all errors for debuggingbinding.WithAllErrors(),// Verbose loggingbinding.WithEvents(binding.Events{FieldBound:func(name,tagstring){log.Printf("[DEBUG] Bound %s from %s",name,tag)},UnknownField:func(namestring){log.Printf("[WARN] Unknown field: %s",name)},Done:func(statsbinding.Stats){log.Printf("[DEBUG] Binding: %d fields, %d errors, %v",stats.FieldsBound,stats.ErrorCount,stats.Duration)},}),)
Testing Configuration
varTestBinder=binding.MustNew(// Strict validationbinding.WithStrictJSON(),// Fail fast// (don't use WithAllErrors in tests)// Smaller limits for test databinding.WithMaxDepth(8),binding.WithMaxSliceLen(100),)
Option Precedence
When options are provided to both MustNew() and individual functions:
Options are applied in order (last wins for same option)
Example:
binder:=binding.MustNew(binding.WithMaxDepth(32),// Binder default)// This call uses maxDepth=16 (overrides binder default)user,err:=binder.JSON[User](data,binding.WithMaxDepth(16))
Best Practices
1. Use Binders for Shared Configuration
// Good - shared configurationvarAppBinder=binding.MustNew(binding.WithConverter[uuid.UUID](uuid.Parse),binding.WithMaxDepth(16),)funcHandler1(r*http.Request){user,err:=AppBinder.JSON[User](r.Body)}funcHandler2(r*http.Request){params,err:=AppBinder.Query[Params](r.URL.Query())}
2. Set Security Limits
// Good - protect against attacksuser,err:=binding.JSON[User](data,binding.WithMaxDepth(16),binding.WithMaxSliceLen(1000),binding.WithMaxBytes(1024*1024),)
3. Use Strict Mode for APIs
// Good - catch client errors earlyuser,err:=binding.JSON[User](data,binding.WithStrictJSON())
4. Collect All Errors for Forms
// Good - show all validation errors to userform,err:=binding.Form[Form](r.PostForm,binding.WithAllErrors())iferr!=nil{varmulti*binding.MultiErroriferrors.As(err,&multi){// Show all errors to userfor_,e:=rangemulti.Errors{addError(e.Field,e.Err.Error())}}}
typeMessagestruct{IDint`msgpack:"id"`Typestring`msgpack:"type"`Payload[]byte`msgpack:"payload"`Createdtime.Time`msgpack:"created"`// Omit if zeroMetadatamap[string]string`msgpack:"metadata,omitempty"`// Use as array (more compact)Points[]int`msgpack:"points,as_array"`}
Use Cases
High-performance binary serialization
Microservice communication
Event streaming
Cache serialization
Protocol Buffers Package
Import
import"rivaas.dev/binding/proto"importpb"myapp/proto"// Your generated proto files
import("rivaas.dev/binding/proto"pb"myapp/proto")funcHandleProtoRequest(whttp.ResponseWriter,r*http.Request){user,err:=proto.ProtoReader[*pb.User](r.Body)iferr!=nil{http.Error(w,err.Error(),http.StatusBadRequest)return}// Use userlog.Printf("Received user: %s",user.Username)}
funcHandleRequest(whttp.ResponseWriter,r*http.Request){contentType:=r.Header.Get("Content-Type")varreqCreateUserRequestvarerrerrorswitch{casestrings.Contains(contentType,"application/json"):req,err=binding.JSON[CreateUserRequest](r.Body)casestrings.Contains(contentType,"application/x-yaml"):req,err=yaml.YAMLReader[CreateUserRequest](r.Body)casestrings.Contains(contentType,"application/toml"):req,err=toml.TOMLReader[CreateUserRequest](r.Body)casestrings.Contains(contentType,"application/x-msgpack"):req,err=msgpack.MsgPackReader[CreateUserRequest](r.Body)default:http.Error(w,"Unsupported content type",http.StatusUnsupportedMediaType)return}iferr!=nil{http.Error(w,err.Error(),http.StatusBadRequest)return}// Process request...}
# YAMLgo get gopkg.in/yaml.v3
# TOMLgo get github.com/BurntSushi/toml
# MessagePackgo get github.com/vmihailenco/msgpack/v5
# Protocol Buffersgo get google.golang.org/protobuf
Performance Comparison
Approximate performance for a typical struct (10 fields):
Format
Speed (ns/op)
Allocs
Use Case
JSON
800
3
Web APIs, human-readable
MessagePack
500
2
High performance, binary
Protocol Buffers
400
2
Strongly typed, cross-language
YAML
1,200
5
Configuration files
TOML
1,000
4
Configuration files
Best Practices
1. Use Appropriate Format
JSON: Web APIs, JavaScript clients
YAML: Configuration files, human-readable
TOML: Configuration files, less ambiguous than YAML
MessagePack: High-performance microservices
Protocol Buffers: gRPC, schema evolution
2. Validate Input
All sub-packages support the same options as core binding:
// Good - streams from diskfile,_:=os.Open("large-config.yaml")config,err:=yaml.YAMLReader[Config](file)// Bad - loads entire file into memorydata,_:=os.ReadFile("large-config.yaml")config,err:=yaml.YAML[Config](data)
// Binding from query parameterstypeRequeststruct{Namestring`json:"name"`// Wrong - should be `query:"name"`}// CorrecttypeRequeststruct{Namestring`query:"name"`}
Source doesn’t contain the key
// URL: ?page=1typeParamsstruct{Pageint`query:"page"`Limitint`query:"limit"`// Missing in URL}// Solution: Use defaulttypeParamsstruct{Pageint`query:"page" default:"1"`Limitint`query:"limit" default:"20"`}
Type Conversion Errors
Problem: Error like “cannot unmarshal string into int”.
Solutions:
Check source data type
// JSON: {"age": "30"} <- string instead of numbertypeUserstruct{Ageint`json:"age"`}// Error: cannot unmarshal string into int
typeRequeststruct{Namestring`json:"name" validate:"required"`Email*string`json:"email" validate:"omitempty,email"`}// Name is required (validation)// Email is optional (pointer) but if provided must be valid (validation)
Q: Can I use custom JSON field names?
A: Yes, use the json tag:
typeUserstruct{IDint`json:"user_id"`// Maps to "user_id" in JSONFullNamestring`json:"full_name"`// Maps to "full_name" in JSON}
Q: How do I bind from multiple query parameters to one field?
A: Use tag aliases:
typeRequeststruct{UserIDint`query:"user_id,id,uid"`// Accepts any of these}// Works with: ?user_id=123, ?id=123, or ?uid=123
A: Not directly, but you can create a custom getter:
typeEnvGetterstruct{}func(g*EnvGetter)Get(keystring)string{returnos.Getenv(key)}func(g*EnvGetter)GetAll(keystring)[]string{ifval:=os.Getenv(key);val!=""{return[]string{val}}returnnil}func(g*EnvGetter)Has(keystring)bool{_,exists:=os.LookupEnv(key)returnexists}// Use with RawIntoconfig,err:=binding.RawInto[Config](&EnvGetter{},"env")
Q: What’s the difference between JSON and JSONReader?
A:
JSON: Takes []byte, entire data in memory
JSONReader: Takes io.Reader, streams data
Use JSONReader for large payloads (>1MB) to reduce memory usage.
Q: How do I handle API versioning?
A: Use different struct types per version:
typeCreateUserRequestV1struct{Namestring`json:"name"`}typeCreateUserRequestV2struct{FirstNamestring`json:"first_name"`LastNamestring`json:"last_name"`}// Route to appropriate handler based on version header
// Save body for debuggingbody,_:=io.ReadAll(r.Body)r.Body=io.NopCloser(bytes.NewReader(body))log.Printf("Raw body: %s",string(body))log.Printf("Content-Type: %s",r.Header.Get("Content-Type"))req,err:=binding.JSON[Request](r.Body)
3. Use Curl to Test
# Test JSON bindingcurl -X POST http://localhost:8080/users \
-H "Content-Type: application/json"\
-d '{"name":"alice","age":30}'# Test query parameterscurl "http://localhost:8080/users?page=2&limit=50"# Test headerscurl -H "X-API-Key: secret" http://localhost:8080/users
The validation package provides flexible validation for Go structs with support for multiple strategies: struct tags, JSON Schema, and custom interfaces. It’s designed for web applications with features like partial validation for PATCH requests, sensitive data redaction, and detailed error reporting.
Simple validation without creating a validator instance:
// Validate with default configurationfuncValidate(ctxcontext.Context,vany,opts...Option)error// Validate only present fields (PATCH requests)funcValidatePartial(ctxcontext.Context,vany,pmPresenceMap,opts...Option)error// Compute which fields are present in JSONfuncComputePresence(rawJSON[]byte)(PresenceMap,error)
Validator Type
Create configured validator instances for reuse:
// Create validator (returns error on invalid config)funcNew(opts...Option)(*Validator,error)// Create validator (panics on invalid config)funcMustNew(opts...Option)*Validator// Validator methodsfunc(v*Validator)Validate(ctxcontext.Context,valany,opts...Option)errorfunc(v*Validator)ValidatePartial(ctxcontext.Context,valany,pmPresenceMap,opts...Option)error
Error Types
Structured validation errors:
// Main error type with multiple field errorstypeErrorstruct{Fields[]FieldErrorTruncatedbool}// Individual field errortypeFieldErrorstruct{Pathstring// JSON path (e.g., "items.2.price")Codestring// Error code (e.g., "tag.required")Messagestring// Human-readable messageMetamap[string]any// Additional metadata}
// Automatic strategy selectionerr:=validation.Validate(ctx,&user)// Explicit strategyerr:=validation.Validate(ctx,&user,validation.WithStrategy(validation.StrategyTags),)// Run all strategieserr:=validation.Validate(ctx,&user,validation.WithRunAll(true),)
Schema caching: LRU with configurable size (default 1024)
Thread-safe: All operations safe for concurrent use
Zero allocation: Field paths cached per type
Integration
With net/http
funcHandler(whttp.ResponseWriter,r*http.Request){varreqCreateUserRequestjson.NewDecoder(r.Body).Decode(&req)iferr:=validation.Validate(r.Context(),&req);err!=nil{http.Error(w,err.Error(),http.StatusUnprocessableEntity)return}// Process request}
funcHandler(c*app.Context)error{varreqCreateUserRequestiferr:=c.Bind(&req);err!=nil{returnerr// Automatically validated and handled}returnc.JSON(http.StatusOK,processRequest(req))}
Version Compatibility
The validation package follows semantic versioning:
Creates a new Validator with the given options. Returns an error if configuration is invalid.
Parameters:
opts - Configuration options
Returns:
*Validator - Configured validator instance
error - If configuration is invalid
Example:
validator,err:=validation.New(validation.WithMaxErrors(10),validation.WithRedactor(redactor),)iferr!=nil{returnfmt.Errorf("failed to create validator: %w",err)}
MustNew
funcMustNew(opts...Option)*Validator
Creates a new Validator with the given options. Panics if configuration is invalid. Use in main() or init() where panic on startup is acceptable.
varverr*validation.Erroriferrors.As(err,&verr){fmt.Printf("Found %d errors\n",len(verr.Fields))ifverr.Truncated{fmt.Println("(more errors exist)")}ifverr.Has("email"){fmt.Println("Email field has an error")}}
presence:=PresenceMap{"email":true,"address":true,"address.city":true,}ifpresence.Has("email"){// Email was provided}ifpresence.HasPrefix("address"){// At least one address field was provided}leaves:=presence.LeafPaths()// Returns: ["email", "address.city"]// (address is excluded as it has children)
Generates dynamic error messages for parameterized validation tags. Receives the tag parameter and field’s reflect.Kind.
Example:
minMessage:=func(paramstring,kindreflect.Kind)string{ifkind==reflect.String{returnfmt.Sprintf("must be at least %s characters",param)}returnfmt.Sprintf("must be at least %s",param)}validator:=validation.MustNew(validation.WithMessageFunc("min",minMessage),)
Limits the number of errors returned. Set to 0 for unlimited errors (default).
Example:
// Return at most 5 errorserr:=validation.Validate(ctx,&req,validation.WithMaxErrors(5),)varverr*validation.Erroriferrors.As(err,&verr){ifverr.Truncated{fmt.Println("More errors exist")}}
WithMaxFields
funcWithMaxFields(maxFieldsint)Option
Sets the maximum number of fields to validate in partial mode. Prevents pathological inputs with extremely large presence maps. Set to 0 to use the default (10000).
Sets a custom validation function that runs before any other validation strategies.
Example:
err:=validation.Validate(ctx,&req,validation.WithCustomValidator(func(vany)error{req:=v.(*UserRequest)ifreq.Age<18{returnerrors.New("must be 18 or older")}returnnil}),)
Sets static error messages for validation tags. Messages override the default English messages for specified tags.
Example:
validator:=validation.MustNew(validation.WithMessages(map[string]string{"required":"cannot be empty","email":"invalid email format","min":"value too small",}),)
Sets a dynamic message generator for a parameterized tag. Use for tags like “min”, “max”, “len” that include parameters.
Example:
minMessage:=func(paramstring,kindreflect.Kind)string{ifkind==reflect.String{returnfmt.Sprintf("must be at least %s characters",param)}returnfmt.Sprintf("must be at least %s",param)}validator:=validation.MustNew(validation.WithMessageFunc("min",minMessage),)
Sets a function to transform field names in error messages. Useful for localization or custom naming conventions.
Example:
validator:=validation.MustNew(validation.WithFieldNameMapper(func(namestring)string{// Convert snake_case to Title Casereturnstrings.Title(strings.ReplaceAll(name,"_"," "))}),)
Options Summary
Validator Creation Options
Options that should be set when creating a validator (affect all validations):
Option
Purpose
WithMaxErrors
Limit total errors returned
WithMaxFields
Limit fields in partial validation
WithMaxCachedSchemas
Schema cache size
WithRedactor
Redact sensitive fields
WithCustomTag
Register custom validation tag
WithMessages
Custom error messages
WithMessageFunc
Dynamic error messages
WithFieldNameMapper
Transform field names
Per-Call Options
Options commonly used per-call (override validator config):
Option
Purpose
WithStrategy
Choose validation strategy
WithRunAll
Run all strategies
WithRequireAny
OR logic with WithRunAll
WithPartial
Enable partial validation
WithPresence
Set presence map
WithMaxErrors
Override error limit
WithCustomValidator
Add custom validator
WithCustomSchema
Override JSON Schema
WithDisallowUnknownFields
Reject unknown fields
WithContext
Override context
Usage Patterns
Creating Configured Validator
validator:=validation.MustNew(// Securityvalidation.WithRedactor(sensitiveRedactor),validation.WithMaxErrors(20),validation.WithMaxFields(5000),// Custom validationvalidation.WithCustomTag("phone",phoneValidator),validation.WithCustomTag("username",usernameValidator),// Error messagesvalidation.WithMessages(map[string]string{"required":"is required","email":"must be a valid email",}),)
Per-Call Overrides
// Use tags strategy onlyerr:=validator.Validate(ctx,&req,validation.WithStrategy(validation.StrategyTags),validation.WithMaxErrors(5),)// Partial validationerr:=validator.Validate(ctx,&req,validation.WithPartial(true),validation.WithPresence(presence),)// Custom validationerr:=validator.Validate(ctx,&req,validation.WithCustomValidator(complexBusinessLogic),)
Complete reference for validation interfaces that can be implemented for custom validation logic.
ValidatorInterface
typeValidatorInterfaceinterface{Validate()error}
Implement this interface for simple custom validation without context.
When to Use
Simple validation rules that don’t need external data
Business logic validation
Cross-field validation within the struct
Implementation
typeUserstruct{EmailstringNamestring}func(u*User)Validate()error{if!strings.Contains(u.Email,"@"){returnerrors.New("email must contain @")}iflen(u.Name)<2{returnerrors.New("name must be at least 2 characters")}returnnil}
Returning Structured Errors
Return *validation.Error for detailed field-level errors:
func(u*User)Validate()error{varverrvalidation.Errorif!strings.Contains(u.Email,"@"){verr.Add("email","format","must contain @",nil)}iflen(u.Name)<2{verr.Add("name","length","must be at least 2 characters",nil)}ifverr.HasErrors(){return&verr}returnnil}
Pointer vs Value Receivers
Both are supported:
// Pointer receiver (can modify struct)func(u*User)Validate()error{u.Email=strings.ToLower(u.Email)// NormalizereturnvalidateEmail(u.Email)}// Value receiver (read-only)func(uUser)Validate()error{returnvalidateEmail(u.Email)}
Use pointer receivers when you need to modify the struct during validation (normalization, etc.).
Implement this interface for context-aware validation that needs access to request-scoped data or external services.
When to Use
Database lookups (uniqueness checks, existence validation)
Tenant-specific validation rules
Rate limiting or quota checks
External service calls
Request-scoped data access
Implementation
typeUserstruct{UsernamestringEmailstringTenantIDstring}func(u*User)ValidateContext(ctxcontext.Context)error{// Get services from contextdb:=ctx.Value("db").(*sql.DB)tenant:=ctx.Value("tenant").(string)// Tenant validationifu.TenantID!=tenant{returnerrors.New("user does not belong to this tenant")}// Database validationvarexistsboolerr:=db.QueryRowContext(ctx,"SELECT EXISTS(SELECT 1 FROM users WHERE username = $1)",u.Username,).Scan(&exists)iferr!=nil{returnfmt.Errorf("failed to check username: %w",err)}ifexists{returnerrors.New("username already taken")}returnnil}
Context Values
Access data from context:
func(u*User)ValidateContext(ctxcontext.Context)error{// Database connectiondb:=ctx.Value("db").(*sql.DB)// Current user/tenantcurrentUser:=ctx.Value("user_id").(string)tenant:=ctx.Value("tenant").(string)// Request metadatarequestID:=ctx.Value("request_id").(string)// Use in validation logicreturnvalidateWithContext(db,u,tenant)}
Cancellation Support
Respect context cancellation for long-running validations:
func(u*User)ValidateContext(ctxcontext.Context)error{// Check cancellation before expensive operationselect{case<-ctx.Done():returnctx.Err()default:}// Expensive validationreturncheckUsernameUniqueness(ctx,u.Username)}
funcnestedRedactor(pathstring)bool{// Redact all fields under payment.*ifstrings.HasPrefix(path,"payment."){returntrue}// Redact specific nested fieldifstrings.HasPrefix(path,"user.credentials."){returntrue}returnfalse}
Interface Priority
When multiple interfaces are implemented, they have different priorities:
typeUserstruct{Emailstring`validate:"required,email"`// Priority 2}func(uUser)JSONSchema()(id,schemastring){// Priority 3 (lowest)return"user-v1",`{...}`}func(u*User)Validate()error{// Priority 1 (highest) - this runs instead of tags/schemareturncustomValidation(u.Email)}
Override priority with explicit strategy:
// Skip Validate() method, use tagserr:=validation.Validate(ctx,&user,validation.WithStrategy(validation.StrategyTags),)
Combining Interfaces
Run all strategies with WithRunAll:
typeUserstruct{Emailstring`validate:"required,email"`// Struct tags}func(uUser)JSONSchema()(id,schemastring){// JSON Schemareturn"user-v1",`{...}`}func(u*User)Validate()error{// Interface methodreturnbusinessLogic(u)}// Run all three strategieserr:=validation.Validate(ctx,&user,validation.WithRunAll(true),)
Best Practices
1. Choose the Right Interface
// Simple validation - ValidatorInterfacefunc(u*User)Validate()error{returnvalidateEmail(u.Email)}// Needs external data - ValidatorWithContextfunc(u*User)ValidateContext(ctxcontext.Context)error{db:=ctx.Value("db").(*sql.DB)returncheckUniqueness(ctx,db,u.Email)}
2. Return Structured Errors
// Goodfunc(u*User)Validate()error{varverrvalidation.Errorverr.Add("email","invalid","must be valid email",nil)return&verr}// Badfunc(u*User)Validate()error{returnerrors.New("email invalid")}
3. Use Context Safely
func(u*User)ValidateContext(ctxcontext.Context)error{db,ok:=ctx.Value("db").(*sql.DB)if!ok{returnerrors.New("database not available in context")}returnvalidateWithDB(ctx,db,u)}
4. Document Custom Validation
// ValidateContext validates the user against business rules:// - Username must be unique within tenant// - Email domain must be allowed for tenant// - User must not exceed account limitsfunc(u*User)ValidateContext(ctxcontext.Context)error{// Implementation}
Type implements ValidatorInterface or ValidatorWithContext
Checks both value and pointer receivers
Tags Strategy:
Type is a struct
At least one field has a validate tag
JSON Schema Strategy:
Type implements JSONSchemaProvider, OR
Custom schema provided with WithCustomSchema
Priority Examples
// Example 1: Only interface methodtypeUserstruct{Emailstring}func(u*User)Validate()error{returnvalidateEmail(u.Email)}// Uses: StrategyInterface (highest priority)validation.Validate(ctx,&user)
// Example 2: Both interface and tagstypeUserstruct{Emailstring`validate:"required,email"`}func(u*User)Validate()error{returncustomLogic(u.Email)}// Uses: StrategyInterface (interface has priority over tags)validation.Validate(ctx,&user)
// Example 3: All three strategiestypeUserstruct{Emailstring`validate:"required,email"`}func(uUser)JSONSchema()(id,schemastring){return"user-v1",`{...}`}func(u*User)Validate()error{returncustomLogic(u.Email)}// Uses: StrategyInterface (highest priority)validation.Validate(ctx,&user)
Explicit Strategy Selection
Override automatic selection with WithStrategy:
typeUserstruct{Emailstring`validate:"required,email"`// Has tags}func(u*User)Validate()error{returncustomLogic(u.Email)// Has interface method}// Force use of tags (skip interface method)err:=validation.Validate(ctx,&user,validation.WithStrategy(validation.StrategyTags),)
Running Multiple Strategies
WithRunAll
Run all applicable strategies and aggregate errors:
typeUserstruct{Emailstring`validate:"required,email"`// Tags}func(uUser)JSONSchema()(id,schemastring){return"user-v1",`{...}`// JSON Schema}func(u*User)Validate()error{returncustomLogic(u.Email)// Interface}// Run all three strategieserr:=validation.Validate(ctx,&user,validation.WithRunAll(true),)// All errors from all strategies are collectedvarverr*validation.Erroriferrors.As(err,&verr){// verr.Fields contains errors from all strategies}
WithRequireAny
With WithRunAll, succeed if any one strategy passes (OR logic):
// Pass if ANY strategy succeedserr:=validation.Validate(ctx,&user,validation.WithRunAll(true),validation.WithRequireAny(true),)
Use Cases:
Multiple validation approaches, any one is sufficient
Fallback validation strategies
Gradual migration between strategies
Strategy Comparison
Strategy
Advantages
Disadvantages
Best For
Interface
Most flexible, full programmatic control
More code, not declarative
Complex business logic, database checks
Tags
Concise, declarative, well-documented
Limited to supported tags
Standard validation, simple rules
JSON Schema
Portable, language-independent
Verbose, learning curve
Shared validation with frontend
Strategy Patterns
Pattern 1: Tags for Simple, Interface for Complex
typeUserstruct{Emailstring`validate:"required,email"`Usernamestring`validate:"required,min=3,max=20"`Ageint`validate:"required,min=18"`}func(u*User)ValidateContext(ctxcontext.Context)error{// Complex validation (database checks, etc.)db:=ctx.Value("db").(*sql.DB)returncheckUsernameUnique(ctx,db,u.Username)}// Tags validate format, interface validates business rulesvalidation.Validate(ctx,&user)
Pattern 2: Schema for API, Interface for Internal
func(uUser)JSONSchema()(id,schemastring){// For external API documentation/validationreturn"user-v1",apiSchema}func(u*User)ValidateContext(ctxcontext.Context)error{// Internal business rulesreturnvalidateInternal(ctx,u)}// External API: use schemavalidation.Validate(ctx,&user,validation.WithStrategy(validation.StrategyJSONSchema),)// Internal: use interfacevalidation.Validate(ctx,&user,validation.WithStrategy(validation.StrategyInterface),)
Pattern 3: Progressive Enhancement
// Start with tagstypeUserstruct{Emailstring`validate:"required,email"`}// Add interface for complex validation laterfunc(u*User)ValidateContext(ctxcontext.Context)error{// Complex validation added over timereturnadditionalValidation(ctx,u)}// Automatically uses interface (higher priority)validation.Validate(ctx,&user)
Performance Considerations
Strategy Performance
Fastest to Slowest:
Tags - Cached reflection, zero allocation after first use
Interface - Direct method call, user code performance
// Fast: Use tags for simple validationtypeUserstruct{Emailstring`validate:"required,email"`}// Slower: JSON Schema (first time, then cached)func(uUser)JSONSchema()(id,schemastring){return"user-v1",complexSchema}// Variable: Depends on your implementationfunc(u*User)ValidateContext(ctxcontext.Context)error{// Keep this fast - runs every timereturnquickValidation(u)}
Caching
Tags: Struct reflection cached per type
JSON Schema: Schemas cached by ID (LRU eviction)
Interface: No caching (direct method call)
Error Aggregation
When running multiple strategies:
err:=validation.Validate(ctx,&user,validation.WithRunAll(true),)varverr*validation.Erroriferrors.As(err,&verr){// Errors are aggregated from all strategiesfor_,fieldErr:=rangeverr.Fields{fmt.Printf("%s: %s (from %s strategy)\n",fieldErr.Path,fieldErr.Message,inferStrategy(fieldErr.Code),// tag.*, schema.*, etc.)}// Sort for consistent outputverr.Sort()}
Error codes indicate strategy:
tag.* - From struct tags
schema.* - From JSON Schema
Custom codes - From interface methods
Best Practices
1. Use Automatic Selection
// Good - let validator choosevalidation.Validate(ctx,&user)// Only override when necessaryvalidation.Validate(ctx,&user,validation.WithStrategy(validation.StrategyTags),)
2. Single Strategy Per Type
// Good - clear which strategy is usedtypeUserstruct{Emailstring`validate:"required,email"`}// Confusing - multiple strategies competetypeUserstruct{Emailstring`validate:"required,email"`}func(uUser)JSONSchema()(id,schemastring){...}func(u*User)Validate()error{...}
3. Document Strategy Choice
// User validation uses struct tags for simplicity and performance.// Email format and length are validated declaratively.typeUserstruct{Emailstring`validate:"required,email,max=255"`}
4. Use WithRunAll Sparingly
// Most cases: automatic selection is sufficientvalidation.Validate(ctx,&user)// Only when you need to validate with multiple strategiesvalidation.Validate(ctx,&user,validation.WithRunAll(true),)
// WrongEmailstring`validation:"required"`// Should be "validate"// CorrectEmailstring`validate:"required"`
Validating value instead of pointer
// May not work with pointer receiversuser:=User{}// valueuser.Validate()// Method might not be found// Use pointeruser:=&User{}// pointervalidation.Validate(ctx,user)
Partial Validation Issues
Issue: Required fields failing in PATCH requests
Symptom:
typeUpdateUserstruct{Emailstring`validate:"required,email"`}// PATCH with only ageerr:=validation.ValidatePartial(ctx,&req,presence)// Error: email is required (but it wasn't provided)
Solution: Use omitempty instead of required for PATCH:
typeUpdateUserstruct{Emailstring`validate:"omitempty,email"`// Not "required"}
validator:=validation.MustNew(validation.WithCustomTag("phone",phoneValidator),)typeUserstruct{Phonestring`validate:"phone"`// Not recognized}
Possible Causes:
Tag registered on wrong validator
// Registered on custom validatorvalidator:=validation.MustNew(validation.WithCustomTag("phone",phoneValidator),)// But using package-level function (different validator)validation.Validate(ctx,&user)// Doesn't have custom tag
Solution: Use the same validator:
validator.Validate(ctx,&user)// Use custom validator
// Method defined on valuefunc(uUser)ValidateContext(ctxcontext.Context)error{...}// But validating pointeruser:=&User{}validation.Validate(ctx,user)// Method not found
// Bad - creates validator every timefuncHandler(whttp.ResponseWriter,r*http.Request){validator:=validation.MustNew(...)// Slowvalidator.Validate(ctx,&req)}
// Temporarily force each strategy to see which worksstrategies:=[]validation.Strategy{validation.StrategyInterface,validation.StrategyTags,validation.StrategyJSONSchema,}for_,strategy:=rangestrategies{err:=validation.Validate(ctx,&user,validation.WithStrategy(strategy),)fmt.Printf("%v: %v\n",strategy,err)}
The config package provides powerful configuration management for Go applications with support for multiple sources, formats, and validation strategies.
cfg,err:=config.New(options...)// With error handlingcfg:=config.MustNew(options...)// Panics on error
Loading Configuration
err:=cfg.Load(ctxcontext.Context)
Accessing Values
// Direct access (returns zero values for missing keys)value:=cfg.String("key")value:=cfg.Int("key")value:=cfg.Bool("key")// With defaultsvalue:=cfg.StringOr("key","default")value:=cfg.IntOr("key",8080)// With error handlingvalue,err:=config.GetE[Type](cfg,"key")
typeConfigstruct{// contains filtered or unexported fields}
Main configuration container. Thread-safe for concurrent access.
ConfigError
typeConfigErrorstruct{Sourcestring// Where the error occurredFieldstring// Specific field with errorOperationstring// Operation being performedErrerror// Underlying error}
Error type for configuration operations with detailed context.
Option
typeOptionfunc(*Config)error
Configuration option function type used with New() and MustNew().
func(c*AppConfig)Validate()error{ifc.Server.Port<=0{returnerrors.New("port must be positive")}returnnil}cfg:=config.MustNew(config.WithFile("config.yaml"),config.WithBinding(&appConfig),// Validation runs after binding)
Thread Safety
The Config type is thread-safe for:
Concurrent Load() operations
Concurrent getter operations
Mixed Load() and getter operations
Not thread-safe for:
Concurrent modification of the same configuration instance during initialization
Performance Notes
Getter methods are O(1) for simple keys, O(n) for nested dot notation paths
Load performance depends on source count and data size
Struct binding uses reflection, minimal overhead for most applications
Validation overhead depends on validation complexity
Version Compatibility
The config package follows semantic versioning. The API is stable for the v1 series.
Minimum Go version: 1.25
Next Steps
Read the API Reference for detailed method documentation
Explore Options for all available configuration options
Complete API documentation for the Config type and methods
Complete API reference for the Config struct and all its methods.
Types
Config
typeConfigstruct{// contains filtered or unexported fields}
Main configuration container. Thread-safe for concurrent read operations and loading.
Key properties:
Thread-safe for concurrent Load() and getter operations.
Nil-safe. All getter methods handle nil instances gracefully.
Hierarchical data storage with dot notation support.
ConfigError
typeConfigErrorstruct{Sourcestring// Where the error occurred (e.g., "source[0]", "json-schema")Fieldstring// Specific field with the error (optional)Operationstring// Operation being performed (e.g., "load", "validate")Errerror// Underlying error}
Error type providing detailed context about configuration errors.
Example error messages:
config error in source[0] during load: file not found: config.yaml
config error in json-schema during validate: server.port: must be >= 1
config error in binding during bind: failed to decode configuration
Initialization Functions
New
funcNew(options...Option)(*Config,error)
Creates a new Config instance with the given options. Returns an error if any option fails.
Parameters:
options - Variable number of Option functions.
Returns:
*Config - Initialized configuration instance.
error - Error if initialization fails.
Example:
cfg,err:=config.New(config.WithFile("config.yaml"),config.WithEnv("APP_"),)iferr!=nil{log.Fatalf("failed to create config: %v",err)}
Use when: You need explicit error handling. Recommended for libraries.
MustNew
funcMustNew(options...Option)*Config
Creates a new Config instance with the given options. Panics if any option fails.
Use when: In main() or initialization code where panic is acceptable.
Lifecycle Methods
Load
func(c*Config)Load(ctxcontext.Context)error
Loads configuration from all configured sources, merges them, and runs validation.
Parameters:
ctx - Context for cancellation and deadlines (must not be nil)
Returns:
error - ConfigError if loading, merging, or validation fails
Behavior:
Loads data from all sources sequentially
Merges data hierarchically (later sources override earlier ones)
Runs JSON Schema validation (if configured)
Runs custom validation functions (if configured)
Binds to struct (if configured)
Runs struct Validate() method (if implemented)
Example:
iferr:=cfg.Load(context.Background());err!=nil{log.Fatalf("failed to load config: %v",err)}
Thread-safety: Safe for concurrent calls (uses internal locking).
Dump
func(c*Config)Dump(ctxcontext.Context)error
Writes the current configuration state to all configured dumpers.
Parameters:
ctx - Context for cancellation and deadlines (must not be nil)
Returns:
error - Error if any dumper fails
Example:
cfg:=config.MustNew(config.WithFile("config.yaml"),config.WithFileDumper("output.yaml"),)cfg.Load(context.Background())cfg.Dump(context.Background())// Writes to output.yaml
Use cases: Debugging, configuration snapshots, generating configuration files.
Getter Methods
Get
func(c*Config)Get(keystring)any
Retrieves the value at the given key path. Returns nil for missing keys.
iferr:=cfg.Load(context.Background());err!=nil{varconfigErr*config.ConfigErroriferrors.As(err,&configErr){log.Printf("Config error in %s during %s: %v",configErr.Source,configErr.Operation,configErr.Err)}returnerr}
Complete reference for all configuration option functions
Comprehensive documentation of all option functions used to configure Config instances.
Option Type
typeOptionfunc(*Config)error
Options are functions that configure a Config instance during initialization. They are passed to New() or MustNew().
Environment Variable Expansion
All path-based options (WithFile, WithFileAs, WithConsul, WithConsulAs, WithFileDumper, WithFileDumperAs) support environment variable expansion in paths. This makes it easy to use different paths based on your environment.
Supported syntax:
${VAR} - Braced variable name
$VAR - Simple variable name
Note: Shell-style defaults like ${VAR:-default} are NOT supported. Set defaults in your code before calling the option.
Examples:
// Environment-based Consul pathconfig.WithConsul("${APP_ENV}/service.yaml")// When APP_ENV=production, expands to: "production/service.yaml"// Config directory from environmentconfig.WithFile("${CONFIG_DIR}/app.yaml")// When CONFIG_DIR=/etc/myapp, expands to: "/etc/myapp/app.yaml"// Multiple variablesconfig.WithFile("${REGION}/${ENV}/settings.yaml")// When REGION=us-west and ENV=staging, expands to: "us-west/staging/settings.yaml"// Output directoryconfig.WithFileDumper("${LOG_DIR}/effective-config.yaml")// When LOG_DIR=/var/log, expands to: "/var/log/effective-config.yaml"
Handling unset variables:
If an environment variable is not set, it expands to an empty string:
// If APP_ENV is not set:config.WithConsul("${APP_ENV}/service.yaml")// Expands to: "/service.yaml"
To provide defaults, set them in your code:
ifos.Getenv("APP_ENV")==""{os.Setenv("APP_ENV","development")}config.WithConsul("${APP_ENV}/service.yaml")// Uses "development" if not set
Source Options
Source options specify where configuration data comes from.
WithFile
funcWithFile(pathstring)Option
Loads configuration from a file with automatic format detection based on extension.
Loads configuration from HashiCorp Consul. The format is detected from the file extension.
Works without Consul: If CONSUL_HTTP_ADDR isn’t set, this option does nothing. This means you can run your app locally without Consul. When you deploy to production, just set the environment variable and Consul will be used.
Parameters:
path - Consul key path (format detected from extension)
Example:
cfg:=config.MustNew(config.WithFile("config.yaml"),config.WithConsul("production/service.json"),// Skipped in dev, used in prod)
Environment variables:
CONSUL_HTTP_ADDR - Consul server address (required for Consul to work)
CONSUL_HTTP_TOKEN - Access token for authentication (optional)
Registers a custom validation function for the configuration map.
Parameters:
fn - Validation function that receives the merged configuration
Example:
cfg:=config.MustNew(config.WithFile("config.yaml"),config.WithValidator(func(datamap[string]any)error{port,ok:=data["port"].(int)if!ok||port<=0{returnerrors.New("port must be a positive integer")}returnnil}),)
Timing: Validation runs after sources are merged, before struct binding.
Multiple validators: You can register multiple validators; all will be executed.
Dumper options specify where to write configuration.
WithFileDumper
funcWithFileDumper(pathstring)Option
Writes configuration to a file with automatic format detection.
Parameters:
path - Output file path (format detected from extension)
Example:
cfg:=config.MustNew(config.WithFile("config.yaml"),config.WithEnv("APP_"),config.WithFileDumper("effective-config.yaml"),)cfg.Load(context.Background())cfg.Dump(context.Background())// Writes to effective-config.yaml
typeCustomDumperstruct{}func(d*CustomDumper)Dump(ctxcontext.Context,datamap[string]any)error{// Write data somewherereturnnil}cfg:=config.MustNew(config.WithFile("config.yaml"),config.WithDumper(&CustomDumper{}),)
Option Composition
Options are applied in the order they are passed to New() or MustNew():
cfg:=config.MustNew(// 1. Load base configconfig.WithFile("config.yaml"),// 2. Load environment-specific configconfig.WithFile("config.prod.yaml"),// 3. Override with environment variables (highest priority)config.WithEnv("APP_"),// 4. Set up validationconfig.WithJSONSchema(schemaBytes),config.WithValidator(customValidation),// 5. Bind to structconfig.WithBinding(&appConfig),// 6. Set up dumperconfig.WithFileDumper("effective-config.yaml"),)
Source Precedence
When multiple sources are configured, later sources override earlier ones:
typeMyCodecstruct{}func(cMyCodec)Encode(vany)([]byte,error){data,ok:=v.(map[string]any)if!ok{returnnil,fmt.Errorf("expected map[string]any, got %T",v)}// Your encoding logicvarbufbytes.Buffer// ... write to buf ...returnbuf.Bytes(),nil}func(cMyCodec)Decode(data[]byte,vany)error{target,ok:=v.(*map[string]any)if!ok{returnfmt.Errorf("expected *map[string]any, got %T",v)}// Your decoding logicresult:=make(map[string]any)// ... parse data into result ...*target=resultreturnnil}funcinit(){codec.RegisterEncoder("myformat",MyCodec{})codec.RegisterDecoder("myformat",MyCodec{})}
iferr:=cfg.Load(context.Background());err!=nil{// Error format:// "config error in source[0] during load: yaml: unmarshal error"log.Printf("Failed to decode: %v",err)}
Encode Errors
iferr:=cfg.Dump(context.Background());err!=nil{// Error format:// "config error in dumper[0] during dump: json: unsupported type"log.Printf("Failed to encode: %v",err)}
Type Conversion Errors
// For error-returning methodsport,err:=config.GetE[int](cfg,"server.port")iferr!=nil{log.Printf("Invalid port: %v",err)}
Performance Notes
JSON: Fast, minimal overhead
YAML: Moderate overhead (parsing complexity)
TOML: Fast, strict typing
Casters: Minimal overhead, optimized for common cases
Next Steps
Learn Custom Codecs for implementing your own formats
typeConfigstruct{Serverstruct{Hoststring`config:"host"`// Must match "host" in YAMLPortint`config:"port"`// Must match "port" in YAML}`config:"server"`// Must match "server" in YAML}
Export struct fields: Fields must be exported (start with uppercase)
config.WithValidator(func(datamap[string]any)error{port,ok:=data["port"].(int)if!ok{returnfmt.Errorf("port must be an integer, got %T",data["port"])}ifport<1||port>65535{returnfmt.Errorf("port must be 1-65535, got %d",port)}returnnil})
Check data types: Values in map might not be expected type
// Type assertion with checkifport,ok:=data["port"].(int);ok{// Use port}
Performance Issues
Slow Configuration Loading
Problem: Configuration loading takes too long.
Solutions:
Reduce source count: Combine configuration files when possible
Avoid remote sources in hot paths: Cache remote configuration
api:=openapi.MustNew(openapi.WithTitle("My API","1.0.0"),openapi.WithValidation(true),)result,err:=api.Generate(context.Background(),operations...)// Fails if spec is invalid
With Diagnostics
import"rivaas.dev/openapi/diag"result,err:=api.Generate(context.Background(),operations...)iferr!=nil{log.Fatal(err)}ifresult.Warnings.Has(diag.WarnDownlevelInfoSummary){log.Warn("info.summary was dropped")}
Thread Safety
The API type is safe for concurrent use:
Multiple goroutines can call Generate() simultaneously
Configuration is immutable after creation
Not thread-safe:
Modifying API configuration during initialization
Performance Notes
Schema generation: First use per type ~500ns (reflection), subsequent uses ~50ns (cached)
Validation: Adds 10-20ms on first validation (schema compilation), 1-5ms subsequent
Generation: Depends on operation count and complexity
Version Compatibility
The package follows semantic versioning. The API is stable for the v1 series.
Minimum Go version: 1.25
Next Steps
Read the API Reference for detailed method documentation
Explore Options for all available configuration options
Generates an OpenAPI specification from the configured API and operations.
Parameters:
ctx - Context for cancellation
operations - Variable number of Operation instances
Returns:
*Result - Generation result with JSON, YAML, and warnings
error - Generation or validation error if any
Errors:
Returns error if context is nil
Returns error if generation fails
Returns error if validation is enabled and spec is invalid
Example:
result,err:=api.Generate(context.Background(),openapi.GET("/users/:id",openapi.WithSummary("Get user"),openapi.WithResponse(200,User{}),),openapi.POST("/users",openapi.WithSummary("Create user"),openapi.WithRequest(CreateUserRequest{}),openapi.WithResponse(201,User{}),),)iferr!=nil{log.Fatal(err)}// Use result.JSON or result.YAMLfmt.Println(string(result.JSON))
openapi.WithServer("https://{environment}.example.com","Environment-based"),openapi.WithServerVariable("environment","api",[]string{"api","staging","dev"},"Environment to use",)
Security Scheme Options
WithBearerAuth
funcWithBearerAuth(name,descriptionstring)Option
Adds Bearer (JWT) authentication scheme.
Parameters:
name - Security scheme name (used in WithSecurity())
Multiple calls create alternative security requirements (OR logic):
openapi.GET("/users/:id",openapi.WithSecurity("bearerAuth"),// Can use bearer authopenapi.WithSecurity("apiKey"),// OR can use API keyopenapi.WithResponse(200,User{}),)
Combines multiple operation options into a single reusable option.
Parameters:
opts - Operation options to combine
Example:
CommonErrors:=openapi.WithOptions(openapi.WithResponse(400,ErrorResponse{}),openapi.WithResponse(500,ErrorResponse{}),)UserEndpoint:=openapi.WithOptions(openapi.WithTags("users"),openapi.WithSecurity("bearerAuth"),CommonErrors,)// Use in operationsopenapi.GET("/users/:id",UserEndpoint,openapi.WithSummary("Get user"),openapi.WithResponse(200,User{}),)
import"rivaas.dev/openapi/diag"result,err:=api.Generate(context.Background(),ops...)iferr!=nil{log.Fatal(err)}ifresult.Warnings.Has(diag.WarnDownlevelInfoSummary){log.Warn("info.summary was dropped (3.1 feature with 3.0 target)")}
Filter by Category
downlevelWarnings:=result.Warnings.FilterCategory(diag.CategoryDownlevel)iflen(downlevelWarnings)>0{fmt.Printf("Downlevel warnings: %d\n",len(downlevelWarnings))for_,warn:=rangedownlevelWarnings{fmt.Printf(" [%s] %s at %s\n",warn.Code(),warn.Message(),warn.Path(),)}}
// Missing x- prefixopenapi.WithExtension("custom","value")// Error// Reserved prefix in 3.1.xopenapi.WithExtension("x-oai-custom","value")// Filtered out in 3.1.xopenapi.WithExtension("x-oas-custom","value")// Filtered out in 3.1.x
Version Compatibility
Problem
Using OpenAPI 3.1 features with a 3.0 target generates warnings or errors.
Solution
When using OpenAPI 3.0.x target, some 3.1.x features are automatically down-leveled:
api:=openapi.MustNew(openapi.WithVersion(openapi.V30x),openapi.WithStrictDownlevel(true),// Error on 3.1 featuresopenapi.WithInfoSummary("Summary"),// Causes error)
Use 3.1 target:
api:=openapi.MustNew(openapi.WithVersion(openapi.V31x),// All features availableopenapi.WithInfoSummary("Summary"),// No warning)
Parameters Not Discovered
Problem
Parameters are not appearing in the generated specification.
Solution
Ensure struct tags are correct:
Common Issues:
// Wrong tag nametypeRequeststruct{IDint`params:"id"`// Should be "path", "query", "header", or "cookie"}// Missing tagtypeRequeststruct{IDint// No tag - won't be discovered}// Wrong locationtypeRequeststruct{IDint`query:"id"`// Should be "path" for path parameters}
The logging package provides structured logging for Rivaas applications using Go’s standard log/slog package, with additional features for production environments.
Core Features
Multiple output formats (JSON, Text, Console)
Context-aware logging with OpenTelemetry trace correlation
Automatic sensitive data redaction
Log sampling for high-traffic scenarios
Dynamic log level changes at runtime
Convenience methods for common patterns
Comprehensive testing utilities
Zero external dependencies (except OpenTelemetry for tracing)
Architecture
The package is organized around key components:
Main Types
Logger - Main logging type with structured logging methods
typeLoggerstruct{// contains filtered or unexported fields}
ContextLogger - Context-aware logger with automatic trace correlation
typeContextLoggerstruct{// contains filtered or unexported fields}
Option - Functional option for logger configuration
typeOptionfunc(*Logger)
Quick API Index
Logger Creation
logger,err:=logging.New(options...)// With error handlinglogger:=logging.MustNew(options...)// Panics on error
typeSamplingConfigstruct{Initialint// Log first N entries unconditionallyThereafterint// After Initial, log 1 of every M entriesTicktime.Duration// Reset sampling counter every interval}
Configuration for log sampling.
Error Types
The package defines sentinel errors for better error handling:
var(ErrNilLogger=errors.New("custom logger is nil")ErrInvalidHandler=errors.New("invalid handler type")ErrLoggerShutdown=errors.New("logger is shut down")ErrInvalidLevel=errors.New("invalid log level")ErrCannotChangeLevel=errors.New("cannot change level on custom logger"))
When you create a logger with this package (and optionally set it as the global logger with WithGlobalLogger()), trace correlation is automatic. You do not need a special logger type.
Any call to the standard library’s context-aware methods — slog.InfoContext(ctx, ...), slog.ErrorContext(ctx, ...), and so on — will automatically get trace_id and span_id added to the log record if the context contains an active OpenTelemetry span. The logging package wraps the handler with a context-aware layer that reads the span from the context and injects these fields.
Example (in an HTTP handler):
// Pass the request context when you logslog.InfoContext(c.RequestContext(),"processing request","order_id",orderID)// Output includes trace_id and span_id when tracing is enabled
Use the same pattern with slog.DebugContext, slog.WarnContext, and slog.ErrorContext. No wrapper type or extra API is required.
Logger Type
Logging Methods
Debug
func(l*Logger)Debug(msgstring,args...any)
Logs a debug message with structured attributes.
Parameters:
msg - Log message
args - Key-value pairs (must be even number of arguments)
logger:=logging.MustNew(logging.WithServiceName("payment-api"),logging.WithServiceVersion("v2.1.0"),logging.WithEnvironment("production"),)// All logs include: "service":"payment-api","version":"v2.1.0","env":"production"
Feature Options
Enable additional logging features.
WithSource
funcWithSource(enabledbool)Option
Enables source code location (file and line number) in logs.
Registers this logger as the global slog default logger. Allows third-party libraries using slog to use your configured logger.
Example:
logger:=logging.MustNew(logging.WithJSONHandler(),logging.WithGlobalLogger(),)// Now slog.Info() uses this logger
Default: Not registered globally (allows multiple independent loggers)
WithSampling
funcWithSampling(cfgSamplingConfig)Option
Enables log sampling to reduce volume in high-traffic scenarios.
Parameters:
cfg - Sampling configuration
Example:
logger:=logging.MustNew(logging.WithSampling(logging.SamplingConfig{Initial:1000,// First 1000 logsThereafter:100,// Then 1% samplingTick:time.Minute,// Reset every minute}),)
SamplingConfig fields:
Initial (int) - Log first N entries unconditionally
Thereafter (int) - After Initial, log 1 of every M entries (0 = log all)
Tick (time.Duration) - Reset counter every interval (0 = never reset)
logger:=logging.MustNew(logging.WithReplaceAttr(func(groups[]string,aslog.Attr)slog.Attr{ifa.Key=="internal_field"{returnslog.Attr{}// Drop this field}returna}),)
funcTestLogLevels(t*testing.T){tests:=[]struct{namestringlevellogging.LevelexpectLoggedbool}{{"debug at info",logging.LevelInfo,false},{"info at info",logging.LevelInfo,true},{"error at warn",logging.LevelWarn,true},}for_,tt:=rangetests{t.Run(tt.name,func(t*testing.T){th:=logging.NewTestHelper(t,logging.WithLevel(tt.level),)th.Logger.Debug("test")logs,_:=th.Logs()iftt.expectLogged{assert.Len(t,logs,1)}else{assert.Len(t,logs,0)}})}}
// Less aggressive samplinglogger:=logging.MustNew(logging.WithSampling(logging.SamplingConfig{Initial:1000,Thereafter:10,// 10% instead of 1%Tick:time.Minute,}),)// Or disable samplinglogger:=logging.MustNew(logging.WithJSONHandler(),// No WithSampling() call)
Sensitive Data Issues
Sensitive Data Not Redacted
Problem: Custom sensitive fields not being redacted.
Cause: Only built-in fields are automatically redacted.
// Instead of "token" (redacted)log.Info("processing","request_token_id",tokenID)// Instead of "secret" (redacted)log.Info("config","shared_secret_name",secretName)
// Wrong - no context, so no trace_id/span_idslog.Info("message")// Right - pass context so trace_id and span_id are injected automaticallyslog.InfoContext(ctx,"message")
Context has no active span:
// Start a span so the context carries trace infoctx,span:=tracer.Start(context.Background(),"operation")deferspan.End()slog.InfoContext(ctx,"message")// Now includes trace_id and span_id
Wrong Trace IDs
Problem: Trace IDs don’t match distributed trace.
Cause: Context not properly propagated.
Solution: Ensure context flows through the call chain and pass it when you log:
funchandler(whttp.ResponseWriter,r*http.Request){ctx:=r.Context()// Context carries trace from middlewareresult:=processRequest(ctx)w.Write(result)}funcprocessRequest(ctxcontext.Context)[]byte{slog.InfoContext(ctx,"processing")// Uses same context, so same tracereturndata}
Performance Issues
High CPU Usage
Problem: Logging causes high CPU usage.
Possible causes:
Logging in tight loops:
// Bad - logs thousands of timesfor_,item:=rangeitems{logger.Debug("processing","item",item)}// Good - log summarylogger.Info("processing batch","count",len(items))
Source location enabled in production:
// Bad for productionlogger:=logging.MustNew(logging.WithSource(true),// Adds overhead)// Good for productionlogger:=logging.MustNew(logging.WithJSONHandler(),// No source location)
Debug level in production:
// Bad - debug logs have overhead even if filteredlogger:=logging.MustNew(logging.WithDebugLevel(),)// Good - appropriate levellogger:=logging.MustNew(logging.WithLevel(logging.LevelInfo),)
High Memory Usage
Problem: Memory usage grows over time.
Possible causes:
No log rotation: Logs written to file without rotation.
Solution: Use external log rotation (logrotate) or rotate in code:
// Use external tool like logrotate// Or implement rotation
Buffered output not flushed: Buffers growing without flush.
The metrics package provides OpenTelemetry-based metrics collection for Go applications with support for multiple exporters including Prometheus, OTLP, and stdout.
address:=recorder.ServerAddress()// Prometheus: actual addresshandler,err:=recorder.Handler()// Prometheus: metrics handlercount:=recorder.CustomMetricCount()// Number of custom metrics
recorder:=metrics.MustNew(metrics.WithOTLP("http://localhost:4318"),metrics.WithServiceName("my-service"),)recorder.Start(ctx)// Required before recording metricsdeferrecorder.Shutdown(context.Background())
Thread Safety
The Recorder type is thread-safe for:
All metric recording methods
Concurrent Start() and Shutdown() operations
Mixed recording and lifecycle operations
Not thread-safe for:
Concurrent modification during initialization
Performance Notes
Metric recording: ~1-2 microseconds per operation
HTTP middleware: ~1-2 microseconds overhead per request
Memory usage: Scales with number of unique metric names and label combinations
Histogram overhead: Proportional to bucket count
Best Practices:
Use fire-and-forget pattern for most metrics (ignore errors)
count:=recorder.CustomMetricCount()log.Printf("Custom metrics: %d/%d",count,maxLimit)// Expose as a metric_=recorder.SetGauge(ctx,"custom_metrics_count",float64(count))
Use Cases:
Monitoring metric cardinality
Debugging metric limit issues
Capacity planning
Note: Built-in HTTP metrics do not count toward this total.
funcTestHandler(t*testing.T){t.Parallel()recorder:=metrics.TestingRecorder(t,"test-service")// Use recorder in tests...// Cleanup is automatic}// With additional optionsfuncTestWithOptions(t*testing.T){recorder:=metrics.TestingRecorder(t,"test-service",metrics.WithMaxCustomMetrics(100),)}
funcTestMetricsEndpoint(t*testing.T){t.Parallel()recorder:=metrics.TestingRecorderWithPrometheus(t,"test-service")// Wait for servererr:=metrics.WaitForMetricsServer(t,recorder.ServerAddress(),5*time.Second)iferr!=nil{t.Fatal(err)}// Test metrics endpoint...}
tb testing.TB - Test or benchmark instance for logging
address string - Server address (e.g., :9090)
timeout time.Duration - Maximum wait time
Returns:
error - Error if server not ready within timeout
Example:
recorder:=metrics.TestingRecorderWithPrometheus(t,"test-service")err:=metrics.WaitForMetricsServer(t,recorder.ServerAddress(),5*time.Second)iferr!=nil{t.Fatalf("Server not ready: %v",err)}// Server is ready, make requests
recorder:=metrics.MustNew(metrics.WithOTLP("http://localhost:4318"))err:=recorder.IncrementCounter(ctx,"metric")// Error: OTLP provider not started (call Start first)
Thread Safety
All methods are thread-safe and can be called concurrently:
// Safe to call from multiple goroutinesgofunc(){_=recorder.IncrementCounter(ctx,"worker_1")}()gofunc(){_=recorder.IncrementCounter(ctx,"worker_2")}()
Individual metrics like http_requests_total do not include service_name as a label. This keeps label cardinality low, following Prometheus best practices.
Best Practices:
Use lowercase with hyphens: user-service, payment-api
Requires the metrics server to use the exact port specified. Fails if port is unavailable.
Example:
recorder:=metrics.MustNew(metrics.WithPrometheus(":9090","/metrics"),metrics.WithStrictPort(),// Fail if 9090 unavailablemetrics.WithServiceName("my-api"),)
Default Behavior: Automatically searches up to 100 ports if requested port is unavailable.
With Strict Mode: Returns error if exact port is not available.
Production Recommendation: Always use WithStrictPort() for predictable behavior.
WithServerDisabled
funcWithServerDisabled()Option
Disables automatic metrics server startup. Use Handler() to get metrics handler for manual serving.
Example:
recorder:=metrics.MustNew(metrics.WithPrometheus(":9090","/metrics"),metrics.WithServerDisabled(),metrics.WithServiceName("my-api"),)handler,err:=recorder.Handler()iferr!=nil{log.Fatal(err)}// Serve on your own serverhttp.Handle("/metrics",handler)http.ListenAndServe(":8080",nil)
Use Cases:
Serve metrics on same port as application
Custom server configuration
Integration with existing HTTP servers
WithoutScopeInfo
funcWithoutScopeInfo()Option
Removes OpenTelemetry instrumentation scope labels from Prometheus metrics output.
By default, OpenTelemetry adds labels like otel_scope_name, otel_scope_version, and otel_scope_schema_url to every metric point. These labels identify which instrumentation library produced each metric.
When to Use:
You only have one instrumentation scope (common case)
You want to reduce label cardinality
The scope information is not useful for your use case
Only Affects: Prometheus provider (OTLP and stdout ignore this option)
Default Behavior: Scope labels are included on all metrics
WithoutTargetInfo
funcWithoutTargetInfo()Option
Disables the target_info metric in Prometheus output.
By default, OpenTelemetry creates a target_info metric containing resource attributes like service_name and service_version. This metric helps identify and correlate metrics across your infrastructure.
When to Use:
You manage service identification through Prometheus external labels
You have your own service discovery mechanism
You don’t need the resource-level metadata
Only Affects: Prometheus provider (OTLP and stdout ignore this option)
Default Behavior: The target_info metric is created with service metadata
Histogram Bucket Options
WithDurationBuckets
funcWithDurationBuckets(buckets...float64)Option
Sets custom histogram bucket boundaries for duration metrics (in seconds).
// Fast API (most requests < 100ms)recorder:=metrics.MustNew(metrics.WithPrometheus(":9090","/metrics"),metrics.WithDurationBuckets(0.001,0.005,0.01,0.025,0.05,0.1,0.5,1),metrics.WithServiceName("fast-api"),)// Slow operations (seconds to minutes)recorder:=metrics.MustNew(metrics.WithPrometheus(":9090","/metrics"),metrics.WithDurationBuckets(1,5,10,30,60,120,300,600),metrics.WithServiceName("batch-processor"),)
Trade-offs:
More buckets = better resolution, higher memory/storage
// Small JSON API (< 10KB)recorder:=metrics.MustNew(metrics.WithPrometheus(":9090","/metrics"),metrics.WithSizeBuckets(100,500,1000,5000,10000,50000),metrics.WithServiceName("json-api"),)// File uploads (KB to MB)recorder:=metrics.MustNew(metrics.WithPrometheus(":9090","/metrics"),metrics.WithSizeBuckets(1024,10240,102400,1048576,10485760,104857600),metrics.WithServiceName("file-service"),)
recorder:=metrics.MustNew(metrics.WithPrometheus(":9090","/metrics"),metrics.WithServerDisabled(),metrics.WithServiceName("api"),)handler,_:=recorder.Handler()// Serve on application portmux:=http.NewServeMux()mux.Handle("/metrics",handler)mux.HandleFunc("/",appHandler)http.ListenAndServe(":8080",mux)
Option Validation
The following validation occurs during New() or MustNew():
Provider Conflicts: Only one provider option (WithPrometheus, WithOTLP, WithStdout) can be used
Service Name: Cannot be empty (default: "rivaas-service")
Service Version: Cannot be empty (default: "1.0.0")
Port Format: Must be valid address format for Prometheus
Custom Metrics Limit: Must be at least 1
Defaults: If no provider is specified, defaults to Prometheus on :9090/metrics.
Validation Errors:
// Multiple providers - ERRORrecorder,err:=metrics.New(metrics.WithPrometheus(":9090","/metrics"),metrics.WithOTLP("http://localhost:4318"),// Error: conflicting providers)// Empty service name - ERRORrecorder,err:=metrics.New(metrics.WithPrometheus(":9090","/metrics"),metrics.WithServiceName(""),// Error: service name cannot be empty)// No options - OK (uses defaults)recorder,err:=metrics.New()// Uses default Prometheus on :9090/metrics
// With prefix "/debug/"/debug/pprof/heap✓excluded/debug/vars✓excluded/debug/✓excluded// Not excluded/debuginfo✗notexcluded(noslash)/api/debug✗notexcluded(doesn'tstartwithprefix)
// Pattern: `^/v[0-9]+/internal/.*`/v1/internal/metrics✓excluded/v2/internal/debug✓excluded// Not excluded/internal/api✗notexcluded(noversion)/api/v1/internal✗notexcluded(doesn'tstartwith/v)// Pattern: `^/api/[0-9]+$`/api/123✓excluded/api/456✓excluded// Not excluded/api/users✗notexcluded(notnumeric)/api/123/details✗notexcluded(hassuffix)
Pattern Tips:
// Anchors^// Start of path$// End of path// Character classes[0-9]// Any digit[a-z]// Any lowercase letter.// Any character\d// Any digit// Quantifiers*// Zero or more+// One or more?// Zero or one{n}// Exactly n// Grouping(...)// Group// Case-insensitive(?i)pattern// Case-insensitive match
Combining Exclusions
Use multiple exclusion options together:
handler:=metrics.Middleware(recorder,// Exact pathsmetrics.WithExcludePaths("/health","/metrics","/ready"),// Prefixesmetrics.WithExcludePrefixes("/debug/","/internal/","/_/"),// Patternsmetrics.WithExcludePatterns(`^/v[0-9]+/internal/.*`,`^/api/users/[0-9]+$`,// User IDs in path),)(mux)
Evaluation Order:
Exact paths (WithExcludePaths)
Prefixes (WithExcludePrefixes)
Patterns (WithExcludePatterns)
If any exclusion matches, the path is excluded.
Header Recording Options
WithHeaders
funcWithHeaders(headers...string)MiddlewareOption
Records specific HTTP headers as metric attributes.
The middleware automatically filters sensitive headers, even if explicitly requested.
Always Filtered Headers:
Authorization
Cookie
Set-Cookie
X-API-Key
X-Auth-Token
Proxy-Authorization
WWW-Authenticate
Example:
// Only X-Request-ID will be recorded// Authorization and Cookie are automatically filteredhandler:=metrics.Middleware(recorder,metrics.WithHeaders("Authorization",// ✗ Filtered (sensitive)"X-Request-ID",// ✓ Recorded"Cookie",// ✗ Filtered (sensitive)"X-Correlation-ID",// ✓ Recorded),)(mux)
handler:=metrics.Middleware(recorder,// Exclude paths with IDs to avoid high cardinalitymetrics.WithExcludePatterns(`^/api/users/[0-9]+$`,// /api/users/123`^/api/orders/[a-z0-9-]+$`,// /api/orders/abc-123`^/files/[^/]+$`,// /files/{id}),)(mux)
Record high-cardinality headers (user IDs, timestamps)
Record excessive headers (increases metric cardinality)
Cardinality Management
High cardinality leads to:
Excessive memory usage
Slow query performance
Storage bloat
Low Cardinality (Good):
// Headers with limited valuesX-Client-Version:v1.0,v1.1,v2.0(3values)X-Region:us-east-1,eu-west-1(2values)
High Cardinality (Bad):
// Headers with unbounded valuesX-Request-ID:abc123,def456,...(millionsofvalues)X-Timestamp:2025-01-18T10:30:00Z(alwaysunique)X-User-ID:user123,user456,...(millionsofvalues)
Performance Considerations
Path Evaluation Overhead
Exact paths: O(1) hash lookup
Prefixes: O(n) prefix checks (n = number of prefixes)
Patterns: O(n) regex matches (n = number of patterns)
Recommendation: Use exact paths when possible for best performance.
Header Recording Impact
Each header adds:
Additional metric attribute
Increased metric cardinality
Higher memory usage
Recommendation: Only record necessary headers.
Troubleshooting
Path Not Excluded
Check:
Path is exact match (use WithExcludePaths)
Prefix includes trailing slash
Pattern uses correct regex syntax
Pattern is anchored (^ and $)
Header Not Recorded
Check:
Header name is correct (case-insensitive)
Header is not in sensitive list
Header is present in request
High Memory Usage
Check:
Too many unique paths (exclude high-cardinality routes)
If you see unknown_service:main, make sure you’re using the latest version of the metrics package.
3. Verify Configuration Order
Options can be passed in any order. The service name will be applied correctly:
// Both work the samerecorder:=metrics.MustNew(metrics.WithServiceName("my-service"),metrics.WithPrometheus(":9090","/metrics"),)recorder:=metrics.MustNew(metrics.WithPrometheus(":9090","/metrics"),metrics.WithServiceName("my-service"),)
4. Check Where Service Name Appears
The service name shows up in two places:
Metric labels: Every metric has a service_name label
The OTLP provider requires Start() to be called before recording metrics:
recorder:=metrics.MustNew(metrics.WithOTLP("http://localhost:4318"),metrics.WithServiceName("my-service"),)// IMPORTANT: Call Start() before recordingiferr:=recorder.Start(ctx);err!=nil{log.Fatal(err)}// Now recording works_=recorder.IncrementCounter(ctx,"requests_total")
2. Check OTLP Collector Reachability
Verify the collector is accessible:
# Test connectivitycurl http://localhost:4318/v1/metrics
# Check collector logsdocker logs otel-collector
3. Wait for Export Interval
OTLP exports metrics periodically (default: 30s):
// Reduce interval for testingrecorder:=metrics.MustNew(metrics.WithOTLP("http://localhost:4318"),metrics.WithExportInterval(5*time.Second),metrics.WithServiceName("my-service"),)
Or force immediate export:
iferr:=recorder.ForceFlush(ctx);err!=nil{log.Printf("Failed to flush: %v",err)}
recorder:=metrics.MustNew(metrics.WithPrometheus(":9090","/metrics"),metrics.WithServiceName("my-service"),)// Start the HTTP serveriferr:=recorder.Start(ctx);err!=nil{log.Fatal(err)}
2. Check Actual Address
If not using strict mode, server may use different port:
iferr:=recorder.ForceFlush(ctx);err!=nil{log.Printf("Failed to flush: %v",err)}
Port Conflicts
Symptoms
Error: address already in use
Metrics server fails to start
Different port than expected
Solutions
1. Use Strict Port Mode (Production)
Fail explicitly if port unavailable:
recorder:=metrics.MustNew(metrics.WithPrometheus(":9090","/metrics"),metrics.WithStrictPort(),// Fail if 9090 unavailablemetrics.WithServiceName("my-service"),)
recorder:=metrics.MustNew(metrics.WithPrometheus(":0","/metrics"),// :0 = any available portmetrics.WithServiceName("test-service"),)recorder.Start(ctx)// Get actual portaddress:=recorder.ServerAddress()log.Printf("Using port: %s",address)
4. Use Testing Utilities
For tests, use the testing utilities with automatic port allocation:
funcTestMetrics(t*testing.T){t.Parallel()recorder:=metrics.TestingRecorderWithPrometheus(t,"test-service")// Automatically finds available port}
Custom Metric Limit Reached
Symptoms
Error: custom metric limit reached
New metrics not created
Warning in logs
Solutions
1. Increase Limit
recorder:=metrics.MustNew(metrics.WithPrometheus(":9090","/metrics"),metrics.WithMaxCustomMetrics(5000),// Increase from default 1000metrics.WithServiceName("my-service"),)
2. Monitor Usage
Track how many custom metrics are created:
count:=recorder.CustomMetricCount()log.Printf("Custom metrics: %d/%d",count,maxLimit)// Expose as a metric_=recorder.SetGauge(ctx,"custom_metrics_count",float64(count))
3. Review Metric Cardinality
Check if you’re creating too many unique metrics:
// BAD: High cardinality (unique per user)_=recorder.IncrementCounter(ctx,"user_"+userID+"_requests")// GOOD: Low cardinality (use labels)_=recorder.IncrementCounter(ctx,"user_requests_total",attribute.String("user_type",userType),)
4. Consolidate Metrics
Combine similar metrics:
// BAD: Many separate metrics_=recorder.IncrementCounter(ctx,"get_requests_total")_=recorder.IncrementCounter(ctx,"post_requests_total")_=recorder.IncrementCounter(ctx,"put_requests_total")// GOOD: One metric with label_=recorder.IncrementCounter(ctx,"requests_total",attribute.String("method","GET"),)
What Counts as Custom Metric?
Counts:
Each unique metric name created with IncrementCounter, AddCounter, RecordHistogram, SetGauge
Does NOT count:
Built-in HTTP metrics
Different label combinations of same metric
Re-recording same metric name
Metrics Server Not Starting
Symptoms
Start() returns error
Server not accessible
No metrics endpoint
Solutions
1. Check Context
Ensure context is not canceled:
ctx,cancel:=signal.NotifyContext(context.Background(),os.Interrupt)defercancel()// Use context with Startiferr:=recorder.Start(ctx);err!=nil{log.Fatal(err)}
iferr:=recorder.IncrementCounter(ctx,metricName);err!=nil{log.Printf("Invalid metric name %q: %v",metricName,err)}
High Memory Usage
Symptoms
Excessive memory consumption
Out of memory errors
Slow performance
Solutions
1. Reduce Metric Cardinality
Limit unique label combinations:
// BAD: High cardinality_=recorder.IncrementCounter(ctx,"requests_total",attribute.String("user_id",userID),// Millions of valuesattribute.String("request_id",requestID),// Always unique)// GOOD: Low cardinality_=recorder.IncrementCounter(ctx,"requests_total",attribute.String("user_type",userType),// Few valuesattribute.String("region",region),// Few values)
2. Exclude High-Cardinality Paths
handler:=metrics.Middleware(recorder,metrics.WithExcludePatterns(`^/api/users/[0-9]+$`,// User IDs`^/api/orders/[a-z0-9-]+$`,// Order IDs),)(mux)
3. Reduce Histogram Buckets
// BAD: Too many buckets (15)metrics.WithDurationBuckets(0.001,0.005,0.01,0.025,0.05,0.1,0.25,0.5,1,2.5,5,10,30,60,120,)// GOOD: Fewer buckets (7)metrics.WithDurationBuckets(0.01,0.1,0.5,1,5,10)
4. Monitor Custom Metrics
count:=recorder.CustomMetricCount()ifcount>500{log.Printf("WARNING: High custom metric count: %d",count)}
Performance Issues
HTTP Middleware Overhead
Symptom: Slow request handling
Solution: Exclude high-traffic paths:
handler:=metrics.Middleware(recorder,metrics.WithExcludePaths("/health"),// Called frequentlymetrics.WithExcludePrefixes("/static/"),// Static assets)(mux)
By default, recorders do NOT set global meter provider:
// These work independentlyrecorder1:=metrics.MustNew(metrics.WithPrometheus(":9090","/metrics"),metrics.WithServiceName("service-1"),)recorder2:=metrics.MustNew(metrics.WithStdout(),metrics.WithServiceName("service-2"),)
2. Avoid WithGlobalMeterProvider
Only use WithGlobalMeterProvider() if you need:
OpenTelemetry instrumentation libraries to use your provider
otel.GetMeterProvider() to return your provider
// Only if neededrecorder:=metrics.MustNew(metrics.WithPrometheus(":9090","/metrics"),metrics.WithGlobalMeterProvider(),// Explicit opt-inmetrics.WithServiceName("my-service"),)
Thread Safety
All Recorder methods are thread-safe. No special handling needed for concurrent access:
// Safe to call from multiple goroutinesgofunc(){_=recorder.IncrementCounter(ctx,"worker_1")}()gofunc(){_=recorder.IncrementCounter(ctx,"worker_2")}()
Solution: Use testing utilities with dynamic ports:
funcTestHandler(t*testing.T){t.Parallel()// Safe with TestingRecorder// Uses stdout, no port neededrecorder:=metrics.TestingRecorder(t,"test-service")// Or with Prometheus (dynamic port)recorder:=metrics.TestingRecorderWithPrometheus(t,"test-service")}
The tracing package provides OpenTelemetry-based distributed tracing for Go applications with support for multiple exporters including Stdout, OTLP (gRPC and HTTP), and Noop.
Core Features
Multiple tracing providers (Stdout, OTLP, Noop)
Built-in HTTP middleware for request tracing
Manual span management with attributes and events
Context propagation for distributed tracing
Thread-safe operations
Span lifecycle hooks
Testing utilities
Architecture
The package is built on OpenTelemetry and provides a simplified interface for distributed tracing.
// Extract from incoming requestsctx:=tracer.ExtractTraceContext(ctx,req.Header)// Inject into outgoing requeststracer.InjectTraceContext(ctx,req.Header)
// Service A - inject trace contextreq,_:=http.NewRequestWithContext(ctx,"GET",url,nil)tracer.InjectTraceContext(ctx,req.Header)resp,_:=http.DefaultClient.Do(req)// Service B - extract trace contextctx=tracer.ExtractTraceContext(r.Context(),r.Header)ctx,span:=tracer.StartSpan(ctx,"operation")defertracer.FinishSpan(span,http.StatusOK)
Creates a new Tracer with the given options. Panics if the tracing provider fails to initialize. Use this when you want to panic on initialization errors.
Initializes OTLP providers that require network connections. The context is used for the OTLP connection establishment. This method is idempotent; calling it multiple times is safe.
Required for: OTLP (gRPC and HTTP) providers Optional for: Noop and Stdout providers (they initialize immediately in New())
Gracefully shuts down the tracing system, flushing any pending spans. This should be called before the application exits to ensure all spans are exported. This method is idempotent - calling it multiple times is safe and will only perform shutdown once.
Example:
deferfunc(){ctx,cancel:=context.WithTimeout(context.Background(),5*time.Second)defercancel()iferr:=tracer.Shutdown(ctx);err!=nil{log.Printf("Error shutting down tracer: %v",err)}}()
Starts a new span with the given name and options. Returns a new context with the span attached and the span itself.
If tracing is disabled, returns the original context and a non-recording span. The returned span should always be ended, even if tracing is disabled.
Parameters:
ctx: Parent context
name: Span name (should be descriptive)
opts: Optional OpenTelemetry span start options
Returns:
New context with span attached
The created span
Example:
ctx,span:=tracer.StartSpan(ctx,"database-query")defertracer.FinishSpan(span,http.StatusOK)tracer.SetSpanAttribute(span,"db.query","SELECT * FROM users")
Internal operational event from the tracing package. Events are used to report errors, warnings, and informational messages about the tracing system’s operation.
EventHandler
typeEventHandlerfunc(Event)
Processes internal operational events from the tracing package. Implementations can log events, send them to monitoring systems, or take custom actions based on event type.
Called when a request span is started. It receives the context, span, and HTTP request. This can be used for custom attribute injection, dynamic sampling, or integration with APM tools.
Called when a request span is finished. It receives the span and the HTTP status code. This can be used for custom metrics, logging, or post-processing.
funchandleRequest(whttp.ResponseWriter,r*http.Request,tracer*tracing.Tracer){ctx:=r.Context()span:=trace.SpanFromContext(ctx)// Create context tracing helperct:=tracing.NewContextTracing(ctx,tracer,span)// Use helper methodsct.SetSpanAttribute("user.id","123")ct.AddSpanEvent("processing_started")// Get trace info for logginglog.Printf("Processing [trace=%s, span=%s]",ct.TraceID(),ct.SpanID())}
Sets the sampling rate (0.0 to 1.0). Values outside this range are clamped to valid bounds.
A rate of 1.0 samples all requests, 0.5 samples 50%, and 0.0 samples none. Sampling decisions are made per-request based on the configured rate.
Parameters:
rate: Sampling rate between 0.0 and 1.0
Default:1.0 (100% sampling)
Example:
// Sample 10% of requeststracer:=tracing.MustNew(tracing.WithServiceName("my-service"),tracing.WithSampleRate(0.1),)
Hook Options
WithSpanStartHook
funcWithSpanStartHook(hookSpanStartHook)Option
Sets a callback that is invoked when a request span is started. The hook receives the context, span, and HTTP request, allowing custom attribute injection, dynamic sampling decisions, or integration with APM tools.
Sets a callback that is invoked when a request span is finished. The hook receives the span and HTTP status code, allowing custom metrics recording, logging, or post-processing.
Sets the logger for internal operational events using the default event handler. This is a convenience wrapper around WithEventHandler that logs events to the provided slog.Logger.
Sets a custom event handler for internal operational events. Use this for advanced use cases like sending errors to Sentry, custom alerting, or integrating with non-slog logging systems.
Allows you to provide a custom OpenTelemetry TracerProvider. When using this option, the package will NOT set the global otel.SetTracerProvider() by default. Use WithGlobalTracerProvider() if you want global registration.
Use cases:
Manage tracer provider lifecycle yourself
Need multiple independent tracing configurations
Want to avoid global state in your application
Important: When using WithTracerProvider, provider options (WithOTLP, WithStdout, etc.) are ignored since you’re managing the provider yourself. You are also responsible for calling Shutdown() on your provider.
Example:
importsdktrace"go.opentelemetry.io/otel/sdk/trace"tp:=sdktrace.NewTracerProvider(// Your custom configurationsdktrace.WithSampler(sdktrace.AlwaysSample()),)tracer:=tracing.MustNew(tracing.WithServiceName("my-service"),tracing.WithTracerProvider(tp),)// You manage tp.Shutdown() yourselfdefertp.Shutdown(context.Background())
WithCustomTracer
funcWithCustomTracer(tracertrace.Tracer)Option
Allows using a custom OpenTelemetry tracer. This is useful when you need specific tracer configuration or want to use a tracer from an existing OpenTelemetry setup.
Allows using a custom OpenTelemetry propagator. This is useful for custom trace context propagation formats. By default, uses the global propagator from otel.GetTextMapPropagator() (W3C Trace Context).
Example:
import"go.opentelemetry.io/otel/propagation"// Use W3C Trace Context explicitlyprop:=propagation.TraceContext{}tracer:=tracing.MustNew(tracing.WithServiceName("my-service"),tracing.WithCustomPropagator(prop),)
Registers the tracer provider as the global OpenTelemetry tracer provider via otel.SetTracerProvider(). By default, tracer providers are not registered globally to allow multiple tracing configurations to coexist in the same process.
Use when:
You want otel.GetTracerProvider() to return your tracer
Integrating with libraries that use the global tracer
// ✗ Error: service name cannot be emptytracer,err:=tracing.New(tracing.WithServiceName(""),)// Returns: "invalid configuration: serviceName: cannot be empty"
Solution: Always provide a service name.
Invalid Sample Rate
// Values are automatically clampedtracer:=tracing.MustNew(tracing.WithServiceName("my-service"),tracing.WithSampleRate(1.5),// Clamped to 1.0)
Sample rates outside 0.0-1.0 are automatically clamped to valid bounds.
Excludes specific paths from tracing. Excluded paths will not create spans or record any tracing data. This is useful for health checks, metrics endpoints, etc.
Maximum of 1000 paths can be excluded to prevent unbounded growth.
Parameters:
paths: Exact paths to exclude (e.g., "/health", "/metrics")
Excludes paths matching the given regex patterns from tracing. The patterns are compiled once during configuration. Returns a validation error if any pattern fails to compile.
Validation: Invalid regex patterns cause the middleware to panic during initialization.
Example:
handler:=tracing.Middleware(tracer,tracing.WithExcludePatterns(`^/v[0-9]+/internal/.*`,// Version-prefixed internal routes`^/api/health.*`,// Any health-related endpoint`^/debug/.*`,// All debug routes),)(mux)
Matches:
/v1/internal/status
/v2/internal/debug
/api/health
/api/health/db
/debug/pprof/heap
Header Recording Options
WithHeaders
funcWithHeaders(headers...string)MiddlewareOption
Records specific request headers as span attributes. Header names are case-insensitive. Recorded as http.request.header.{name}.
Security: Sensitive headers (Authorization, Cookie, etc.) are automatically filtered out to prevent accidental exposure of credentials in traces.
Parameters:
headers: Header names to record (case-insensitive)
Recorded as: Lowercase header names (http.request.header.x-request-id)
The following headers are automatically filtered and will never be recorded, even if explicitly included:
Authorization
Cookie
Set-Cookie
X-API-Key
X-Auth-Token
Proxy-Authorization
WWW-Authenticate
Example:
// Authorization is automatically filteredhandler:=tracing.Middleware(tracer,tracing.WithHeaders("X-Request-ID","Authorization",// ← Filtered, won't be recorded"X-Correlation-ID",),)(mux)
Query Parameter Recording Options
Default Behavior
By default, all query parameters are recorded as span attributes.
Specifies which URL query parameters to record as span attributes. Only parameters in this list will be recorded. This provides fine-grained control over which parameters are traced.
If this option is not used, all query parameters are recorded by default (unless WithoutParams is used).
Disables recording URL query parameters as span attributes. By default, all query parameters are recorded. Use this option if parameters may contain sensitive data.
Creates a middleware function for standalone HTTP integration. It panics if any middleware option is invalid (e.g., invalid regex pattern). This is a convenience wrapper around Middleware for consistency with MustNew.
Behavior: Identical to Middleware() - both panic on invalid options.
handler:=tracing.Middleware(tracer,// Only exclude metricstracing.WithExcludePaths("/metrics"),// Record all headers (except sensitive ones)tracing.WithHeaders("X-Request-ID","X-Correlation-ID","User-Agent"),)(mux)
High-Security Middleware
handler:=tracing.Middleware(tracer,// Exclude health checkstracing.WithExcludePaths("/health"),// No headers recorded// No query parameters recordedtracing.WithoutParams(),)(mux)
Performance Considerations
Path Exclusion Performance
Method
Complexity
Performance
WithExcludePaths()
O(1)
~9ns per request (hash lookup)
WithExcludePrefixes()
O(n)
~9ns per request (n prefixes)
WithExcludePatterns()
O(p)
~20ns per request (p patterns)
Recommendation: Use exact paths when possible for best performance.
Memory Usage
Path exclusion: ~100 bytes per path
Header recording: ~50 bytes per header
Parameter recording: ~30 bytes per parameter name
Limits
Maximum excluded paths: 1000 (enforced by WithExcludePaths)
No limit on: Prefixes, patterns, headers, parameters
Validation Errors
Configuration is validated when calling Middleware() or MustMiddleware(). Invalid options cause a panic.
Common issues and solutions for the tracing package
Common issues and solutions when using the tracing package.
Traces Not Appearing
Symptom
No traces appear in your tracing backend (Jaeger, Zipkin, etc.) even though tracing is configured.
Possible Causes & Solutions
1. OTLP Provider Not Started
Problem: OTLP providers require calling Start(ctx) before tracing.
Solution:
tracer:=tracing.MustNew(tracing.WithServiceName("my-service"),tracing.WithOTLP("localhost:4317"),)// ✓ Required for OTLP providersiferr:=tracer.Start(context.Background());err!=nil{log.Fatal(err)}
2. Sampling Rate Too Low
Problem: Sample rate is set too low. For example, 1% sampling means 99% of requests aren’t traced.
Solution: Increase sample rate or remove sampling for testing.
// Development - trace everythingtracer:=tracing.MustNew(tracing.WithServiceName("my-service"),tracing.WithSampleRate(1.0),// 100% sampling)
3. Wrong Provider Configured
Problem: Using Noop provider (no traces exported).
Solution: Verify provider configuration:
// ✗ Bad - no traces exportedtracer:=tracing.MustNew(tracing.WithServiceName("my-service"),tracing.WithNoop(),// No traces!)// ✓ Good - traces exportedtracer:=tracing.MustNew(tracing.WithServiceName("my-service"),tracing.WithOTLP("localhost:4317"),)
4. Paths Excluded from Tracing
Problem: Paths are excluded via middleware options.
Solution: Check middleware exclusions.
// Check if your paths are excludedhandler:=tracing.Middleware(tracer,tracing.WithExcludePaths("/health","/api/users"),// ← Is this excluding your endpoint?)(mux)
5. Shutdown Called Too Early
Problem: Application exits before spans are exported.
Solution: Ensure proper shutdown with timeout:
deferfunc(){ctx,cancel:=context.WithTimeout(context.Background(),5*time.Second)defercancel()iferr:=tracer.Shutdown(ctx);err!=nil{log.Printf("Error shutting down tracer: %v",err)}}()
6. OTLP Endpoint Unreachable
Problem: OTLP collector is not running or unreachable.
Solution: Verify collector is running:
# Check if collector is listeningnc -zv localhost 4317# OTLP gRPCnc -zv localhost 4318# OTLP HTTP
Check logs for connection errors:
logger:=slog.New(slog.NewTextHandler(os.Stdout,&slog.HandlerOptions{Level:slog.LevelDebug,}))tracer:=tracing.MustNew(tracing.WithServiceName("my-service"),tracing.WithOTLP("localhost:4317"),tracing.WithLogger(logger),// See connection errors)
Context Propagation Issues
Symptom
Services create separate traces instead of one distributed trace.
Possible Causes & Solutions
1. Context Not Propagated
Problem: Context is not passed through the call chain.
Solution: Always pass context:
// ✓ Good - context propagatesfunchandler(ctxcontext.Context){result:=doWork(ctx)// Pass context}// ✗ Bad - context lostfunchandler(ctxcontext.Context){result:=doWork(context.Background())// Lost!}
2. Trace Context Not Injected
Problem: Trace context not injected into outgoing requests.
Solution: Middleware automatically extracts, or do it manually:
// Automatic (with middleware)handler:=tracing.Middleware(tracer)(mux)// Manual (without middleware)funcmyHandler(whttp.ResponseWriter,r*http.Request){ctx:=tracer.ExtractTraceContext(r.Context(),r.Header)// Use extracted context...}
4. Different Propagators
Problem: Services use different propagation formats.
Solution: Ensure all services use the same propagator (default is W3C Trace Context):
// All services should use default (W3C) or same custom propagatortracer:=tracing.MustNew(tracing.WithServiceName("my-service"),// Default propagator is W3C Trace Context)
Performance Issues
Symptom
High CPU usage, increased latency, or memory consumption.
Possible Causes & Solutions
1. Too Much Sampling
Problem: Sampling 100% of high-traffic endpoints.
Solution: Reduce sample rate:
// For high-traffic services (> 1000 req/s)tracer:=tracing.MustNew(tracing.WithServiceName("my-service"),tracing.WithSampleRate(0.1),// 10% sampling)
2. Not Excluding High-Frequency Endpoints
Problem: Tracing health checks and metrics endpoints.
Problem: Adding excessive attributes to every span.
Solution: Only add essential attributes:
// ✓ Good - essential attributestracer.SetSpanAttribute(span,"user.id",userID)tracer.SetSpanAttribute(span,"request.id",requestID)// ✗ Bad - too many attributesfork,v:=rangereq.Header{tracer.SetSpanAttribute(span,k,v)// Don't do this!}
4. Using Regex for Path Exclusion
Problem: Regex patterns are slower than exact paths.
Problem: Middleware doesn’t create spans for requests.
Solution: Ensure middleware is applied:
mux:=http.NewServeMux()mux.HandleFunc("/api/users",handleUsers)// ✓ Middleware appliedhandler:=tracing.Middleware(tracer)(mux)http.ListenAndServe(":8080",handler)// ✗ Middleware not appliedhttp.ListenAndServe(":8080",mux)// No tracing!
funchandleUsers(whttp.ResponseWriter,r*http.Request){// ✓ Good - use request contextctx:=r.Context()traceID:=tracing.TraceID(ctx)// ✗ Bad - creates new contextctx:=context.Background()// Lost trace context!}
Testing Issues
Tests Fail to Clean Up
Problem: Tests hang or don’t complete cleanup.
Solution: Use testing utilities:
funcTestSomething(t*testing.T){// ✓ Good - automatic cleanuptracer:=tracing.TestingTracer(t)// ✗ Bad - manual cleanup requiredtracer,_:=tracing.New(tracing.WithNoop())defertracer.Shutdown(context.Background())}
Race Conditions in Tests
Problem: Race detector reports issues in parallel tests.
Solution: Use t.Parallel() correctly:
funcTestParallel(t*testing.T){t.Parallel()// Each test gets its own tracertracer:=tracing.TestingTracer(t)// Use tracer...}
Learn about the philosophy and principles behind Rivaas
Welcome to the About section! Here you can learn about the ideas and principles that guide Rivaas development.
What is Rivaas?
Rivaas is a web framework for Go. We built it to make creating web APIs easier and more enjoyable. The name comes from ریواس (Rivās), a wild rhubarb plant that grows in the mountains of Iran.
Just like this tough mountain plant, Rivaas is:
Strong — Built to handle production workloads
Light — Fast and uses little memory
Flexible — Works in many different environments
Independent — Each piece works on its own
Our Goals
We want Rivaas to be:
Easy to use — You should understand it quickly
Hard to misuse — Good defaults keep you safe
Fun to work with — Clear APIs and helpful errors
Ready for production — Works well from day one
Design Philosophy
Every decision we make follows a few key ideas:
Developer experience comes first — Your time is valuable
Simple things stay simple — Basic tasks need simple code
Advanced features are available — But they don’t get in your way
Each package stands alone — Use only what you need
Learn More
Want to understand how we built Rivaas? Read about our design principles:
This page explains the core ideas behind Rivaas. Understanding these principles helps you use the framework better. If you want to contribute code, these principles guide your work.
Core Philosophy
Developer Experience First
We put your experience as a developer first. Every choice we make thinks about how it affects you.
What this means:
When you use Rivaas, you should feel like the framework helps you, not fights you. Good defaults mean you can start quickly. Clear errors help you fix problems fast. The API should feel natural.
In practice:
Everything works without configuration
Simple tasks use simple code
Error messages tell you what went wrong and how to fix it
Your IDE can show you all available options
Example: Sensible Defaults
// This works right away - no setup neededapp:=app.MustNew()// Add configuration when you need itapp:=app.MustNew(app.WithServiceName("my-api"),app.WithEnvironment("production"),)
The first example works perfectly for getting started. The second example shows how to customize when you need to.
Progressive Disclosure
Simple use cases stay simple. Advanced features exist but don’t make basic tasks harder.
Three levels:
Basic — Works immediately with good defaults
Intermediate — Common changes are easy
Advanced — Full control when you need it
Example:
// Level 1: Basic - just workslogger:=logging.MustNew()// Level 2: Common customizationlogger:=logging.MustNew(logging.WithJSONHandler(),logging.WithLevel(logging.LevelDebug),)// Level 3: Advanced - full controllogger:=logging.MustNew(logging.WithCustomLogger(myCustomLogger),logging.WithSampling(logging.SamplingConfig{Initial:100,Thereafter:100,Tick:time.Minute,}),)
Discoverable APIs
Your IDE should help you find what you need. When you type metrics.With..., your IDE shows all options.
metrics.MustNew(metrics.With...// IDE shows: WithProvider, WithPort, WithPath, etc.)
Fail Fast with Clear Errors
Configuration errors happen at startup, not during requests. This helps you catch problems early.
// Returns a clear error immediatelyapp,err:=app.New(app.WithServerTimeout(-1*time.Second),// Invalid)// Error: "server.readTimeout: must be positive"
Convenience Without Sacrificing Control
We provide two ways to create things:
MustNew() — Panics on error (good for main function)
New() — Returns error (good for tests and libraries)
// In main() - panic is fineapp:=app.MustNew(...)// In tests or libraries - handle errorsapp,err:=app.New(...)iferr!=nil{returnfmt.Errorf("failed to create app: %w",err)}
Architectural Patterns
Functional Options Pattern
All Rivaas packages use the same configuration pattern. This keeps the API consistent across packages.
Benefits:
Backward compatible — Adding new options doesn’t break existing code
Good defaults — You only specify what you want to change
Self-documenting — Option names tell you what they do
Easy to combine — Options work together naturally
IDE-friendly — Autocomplete shows all options
How it works:
Every package follows this structure:
// Step 1: Define an Option typetypeOptionfunc(*Config)// Step 2: Create constructor that accepts optionsfuncNew(opts...Option)(*Config,error){cfg:=defaultConfig()// Start with defaultsfor_,opt:=rangeopts{opt(cfg)// Apply each option}iferr:=cfg.validate();err!=nil{returnnil,err}returncfg,nil}// Step 3: Convenience constructor that panics on errorfuncMustNew(opts...Option)*Config{cfg,err:=New(opts...)iferr!=nil{panic(err)}returncfg}
Naming conventions:
With<Feature> — Enable or configure something
Without<Feature> — Disable something (when default is enabled)
Each package does one thing well. This makes the code easier to:
Test — Test each package alone
Maintain — Changes to one package don’t affect others
Use — Pick only what you need
Understand — Clear boundaries make the code clearer
Package responsibilities:
Package
What it does
router
Routes HTTP requests to handlers
metrics
Collects and exports metrics
tracing
Tracks requests across services
logging
Writes structured log messages
binding
Converts request data to Go structs
validation
Checks if data is valid
errors
Formats error messages
openapi
Generates API documentation
app
Connects everything together
Clear boundaries:
Packages talk through clean interfaces. They don’t know about each other’s internal details.
// metrics package has a clean interfacetypeRecorderstruct{...}func(r*Recorder)RecordRequest(method,pathstring,statusint,durationtime.Duration)// app package uses the interface without knowing how it works insideapp.metrics.RecordRequest(method,path,status,duration)
Package Architecture
Standalone Packages
Every Rivaas package works on its own. You can use any package without the full framework.
Benefits:
No lock-in — Use Rivaas packages with any Go framework
Gradual adoption — Start with one package, add more later
Easy testing — Test with minimal dependencies
Flexible — Different services can use different packages
Requirements for standalone packages:
Each package must:
Work without the app package
Have its own go.mod file
Provide New() and MustNew() constructors
Use functional options
Have good defaults
Include documentation and examples
Example: Using metrics with standard library
packagemainimport("net/http""rivaas.dev/metrics")funcmain(){// Use metrics without the app frameworkrecorder:=metrics.MustNew(metrics.WithPrometheus(":9090","/metrics"),metrics.WithServiceName("my-api"),)deferrecorder.Shutdown(context.Background())// Create middleware for standard http.Handlerhandler:=metrics.Middleware(recorder)(myHandler)http.ListenAndServe(":8080",handler)}
Example: Using logging standalone
packagemainimport"rivaas.dev/logging"funcmain(){// Use logging anywhere - no framework neededlogger:=logging.MustNew(logging.WithJSONHandler(),logging.WithServiceName("background-worker"),)logger.Info("worker started","queue","emails")}
Example: Using binding with any framework
packagemainimport"rivaas.dev/binding"typeCreateUserRequeststruct{Namestring`json:"name" validate:"required"`Emailstring`json:"email" validate:"required,email"`}funchandler(whttp.ResponseWriter,r*http.Request){// Use binding standalonevarreqCreateUserRequestiferr:=binding.JSON(r,&req);err!=nil{// Handle error}}
All standalone packages:
Package
Import Path
What it does
router
rivaas.dev/router
HTTP routing
metrics
rivaas.dev/metrics
Prometheus/OTLP metrics
tracing
rivaas.dev/tracing
OpenTelemetry tracing
logging
rivaas.dev/logging
Structured logging
binding
rivaas.dev/binding
Request binding
validation
rivaas.dev/validation
Input validation
errors
rivaas.dev/errors
Error formatting
openapi
rivaas.dev/openapi
API documentation
The App Package: Integration Layer
The app package is the glue that connects standalone packages into a complete framework.
What app does:
Connects packages — Wires standalone packages together
Manages lifecycle — Handles startup, shutdown, and cleanup
Shares configuration — Passes service name and version to all packages
Provides defaults — Sets up everything for production use
Makes it easy — One entry point for common use cases
Configures server transport — HTTP, HTTPS, or mTLS via WithTLS / WithMTLS at construction; a single Start(ctx) runs the server. Default port is 8080 for HTTP and 8443 for TLS/mTLS, overridable with WithPort.
How app connects packages:
// app/app.go imports and connects standalone packagesimport("rivaas.dev/errors""rivaas.dev/logging""rivaas.dev/metrics""rivaas.dev/openapi""rivaas.dev/router""rivaas.dev/tracing")typeAppstruct{router*router.Routermetrics*metrics.Recordertracing*tracing.Configlogging*logging.Configopenapi*openapi.Manager// ...}
Automatic wiring:
When you use app, packages connect automatically:
app:=app.MustNew(app.WithServiceName("my-api"),app.WithObservability(app.WithLogging(logging.WithJSONHandler()),app.WithMetrics(),// Prometheus is defaultapp.WithTracing(tracing.WithOTLP("localhost:4317")),),)// Behind the scenes, app:// 1. Creates logging with service name "my-api"// 2. Creates metrics with service name "my-api"// 3. Connects logger to metrics (for error reporting)// 4. Connects logger to tracing (for error reporting)// 5. Sets up unified observability// 6. Configures graceful shutdown for all components
Choose your level:
Full framework (recommended for most):
// Use app for batteries-included experienceapp:=app.MustNew(app.WithServiceName("my-api"),app.WithObservability(app.WithLogging(),app.WithMetrics(),app.WithTracing(),),)app.GET("/users",handlers.ListUsers)app.Start(ctx)
Standalone packages (for advanced use):
// Use packages individually for maximum controlr:=router.MustNew()logger:=logging.MustNew()recorder:=metrics.MustNew()// Wire them yourselfr.Use(loggingMiddleware(logger))r.Use(metricsMiddleware(recorder))r.GET("/users",listUsers)http.ListenAndServe(":8080",r)
Design Decisions
This section explains why we made certain choices.
Why functional options over config structs?
Decision: Use functional options instead of configuration structs.
Reason:
New options don’t break existing code
Defaults are built in, not set by you
Option names tell you what they do
Your IDE can show all options
Options can check values when you use them
Example of the benefit:
// With config struct: Adding new fields breaks codetypeConfigstruct{ServiceNamestringPortint// New field added - all code must be checkedNewFeaturebool}// With functional options: Adding options doesn't break anythingmetrics.MustNew(metrics.WithServiceName("api"),// New option added - old code still works)
Why standalone packages?
Decision: Every package works independently.
Reason:
You can try packages one at a time
No vendor lock-in to the framework
Testing is easier with fewer dependencies
Library authors can use specific features
Follows Go’s philosophy of composition
Why a separate app package?
Decision: Provide an app package that connects standalone packages.
Automated checks run — Tests, linting, and coverage checks
Maintainer reviews — A maintainer looks at your code
Feedback loop — You address any comments
Approval — Maintainer approves when ready
Merge — Your code becomes part of Rivaas!
Pull Request Guidelines
Good pull requests:
Focus on one thing — Don’t mix unrelated changes
Include tests — Test your changes
Update documentation — Keep docs current
Follow style guides — Match existing code style
Write clear commit messages — Explain what and why
Commit messages:
Use clear, descriptive commit messages:
Add user authentication middleware
This adds JWT authentication middleware for protecting routes.
It validates tokens and adds user info to the context.
Fixes #123
Format:
First line: Brief summary (under 72 characters)
Blank line
Detailed description (if needed)
Reference issues with Fixes #123 or Closes #456
Code of Conduct
We want Rivaas to be welcoming to everyone. Please:
By contributing to Rivaas, you agree that your contributions will be licensed under the Apache License 2.0.
Thank You!
Your contributions make Rivaas better for everyone. Thank you for helping!
5.1 - Documentation Standards
How to write clear documentation for Go code
This page explains how to write documentation for Rivaas code. Good documentation helps everyone understand how the code works.
Main Goal
Write clear documentation that explains:
What the code does
How to use it
What inputs it needs and outputs it gives
What NOT to Write
Don’t mention these things in documentation:
Performance Details
Don’t use words like:
“fast”, “slow”, “efficient”
“optimized”, “quick”
“high-performance”
Any speed comparisons
Algorithm Details
Don’t include:
Big-O notation (like O(1), O(n))
Time or space complexity
Algorithm names used to show speed
Benchmark Results
Don’t mention:
“zero allocations”
“optimized for speed”
“50% faster”
Any performance numbers
Memory Usage
Don’t talk about:
“low memory usage”
“minimal allocations”
“memory-efficient”
Specific memory amounts
Visual Decorations
Don’t use:
Lines of equals signs or dashes
ASCII art
Empty comment lines for spacing
Comments that add no information
TODO Comments About Moving Code
Don’t write:
“TODO: move this to…”
“FIXME: this should be in…”
“NOTE: consider moving to…”
Why not? If code needs to move, move it now. Don’t leave a comment about it. Use version control (git) to track changes.
File History Comments
Don’t write:
“merged from…”
“moved from…”
“originally in…”
“Benchmarks from X file”
Why not? Git tracks file history. Comments should explain what code does now, not where it came from.
What You SHOULD Write
Your documentation must focus on:
Purpose
What the function, type, or method does
Why it exists
When to use it
Functionality
What it does in simple words
How it changes inputs to outputs
Step-by-step behavior (when helpful)
Usage
How to use it (with brief examples)
Common use cases
How to integrate it
Code Examples in Documentation
Public functions should include examples:
Use tab-indented code blocks with // Example: header
Show typical usage patterns
Keep examples short and focused
Use valid Go code that compiles
Put examples after main description
Important: GoDoc needs tab indentation (not spaces) for code blocks.
Inline example format:
// FunctionName does something useful.// It processes the input and returns a result.//// Example://// result := FunctionName("input")// fmt.Println(result)//// Parameters:// - input: descriptionfuncFunctionName(inputstring)string{...}
Runnable Example functions (preferred):
For public APIs, create Example functions in *_test.go files:
// In example_test.gofuncExampleFunctionName(){result:=FunctionName("input")fmt.Println(result)// Output: expected output}
Parameters and Return Values
What each parameter means
What values are returned
Error conditions and their meanings
Behavior and Edge Cases
Expected behavior normally
Edge cases and how they’re handled
Side effects (if any)
Thread safety (if relevant)
Constraints and Requirements
Requirements to use it
Limitations or known issues
Dependencies
Error Documentation
Document when errors happen:
// Parse parses the input string into a Result.// It returns an error if parsing fails.//// Errors:// - [ErrInvalidFormat]: input string is malformed// - [ErrEmpty]: input is an empty string// - [ErrTooLong]: input exceeds maximum lengthfuncParse(inputstring)(Result,error){...}
Deprecation
Mark deprecated APIs clearly:
// Deprecated: Use [NewRouter] instead. This function will be removed in v2.0.funcOldRouter()*Router{...}// Deprecated: Use [Context.Value] with [RequestIDKey] instead.func(c*Context)RequestID()string{...}
Interface vs Implementation
Interfaces document the contract:
// Handler handles HTTP requests.// Implementations must be safe for concurrent use.// Handle should not modify the request after returning.typeHandlerinterface{Handle(ctx*Context)error}
Implementations reference the interface:
// JSONHandler implements [Handler] for JSON request/response handling.// It automatically parses JSON request bodies and encodes JSON responses.typeJSONHandlerstruct{...}
Generic Types
Document type parameter requirements:
// BindInto binds values from a ValueGetter into a struct of type T.// T must be a struct type; using non-struct types results in an error.// T should have exported fields with appropriate struct tags.//// Example://// result, err := BindInto[UserRequest](getter, "query")funcBindInto[Tany](getterValueGetter,tagstring)(T,error){...}
Thread Safety
Document concurrency behavior when relevant:
// Router is safe for concurrent use by multiple goroutines.// Routes should be registered before calling [Router.ServeHTTP].typeRouterstruct{...}// Counter provides a thread-safe counter.// All methods may be called concurrently from multiple goroutines.typeCounterstruct{...}// Builder is NOT safe for concurrent use.// Create separate Builder instances for each goroutine.typeBuilderstruct{...}
Cross-References
Use bracket syntax [Symbol] to link to other symbols (Go 1.19+):
// Handle processes the request using the provided [Context].// It returns a [Response] or an error.// See [Router.Register] for how to register handlers.funcHandle(ctx*Context)(*Response,error){...}
Link targets:
[FunctionName] — links to function in same package
[TypeName] — links to type in same package
[TypeName.MethodName] — links to method
[pkg.Symbol] — links to symbol in other package (e.g., [http.Handler])
Style Rules
GoDoc Standards
Start with the name — Begin function comments with the function/type name
✅ // Register adds a new route...
❌ // This function registers...
Use third-person — Write “Handler creates…” not “I create…”
Prefer runnable Example functions in *_test.go files
Keep examples minimal and focused
Package Documentation Files (doc.go)
When package documentation is long (more than a few lines), use a doc.go file:
File name: Must be exactly doc.go (lowercase)
Location: In the package root directory
Content: Only package comment and package declaration
Purpose: Keeps package overview separate from code
Format requirements:
Start with // Package [name] and clear description
First sentence is summary (shown in listings)
Use markdown headers (#) for sections
Include code examples when helpful
Cover: purpose, main concepts, usage patterns
What to include:
Package overview and purpose
Key features
Architecture (when relevant)
Quick start examples
Common usage patterns
Links to examples or related packages
What NOT to include:
Performance details
Algorithm complexity
File organization history
Individual function documentation (put those in their files)
Example structure:
// Package router provides an HTTP router for Go.//// The router implements a routing system for cloud-native applications.// It features path matching, parameter extraction, and comprehensive middleware support.//// # Key Features//// - Path matching for static and parameterized routes// - Parameter extraction from URL paths// - Context pooling for request handling//// # Quick Start//// package main//// import "rivaas.dev/router"//// func main() {// r := router.New()// r.GET("/", handler)// r.Run(":8080")// }//// # Examples//// See the examples directory for complete working examples.packagerouter
When to use doc.go:
Use doc.go: Package documentation is long (multiple paragraphs, sections)
Use inline comments: Package documentation is brief (1-3 sentences)
Examples
Good Documentation
// Register adds a new route to the [Router] using the given method and pattern.// It returns the created [Route], which can be further configured.// Register should be called during application setup before the server starts.func(r*Router)Register(method,patternstring)*Route{...}// Context represents an HTTP request context.// It provides access to the request, response writer, and route parameters.// Context instances are pooled and reused across requests.// Context is NOT safe for use after the handler returns.typeContextstruct{...}// Param returns the value of the named route parameter.// It returns an empty string if the parameter is not found.// Parameters are extracted from the URL path during route matching.//// Example://// userID := c.Param("id")// fmt.Println(userID)func(c*Context)Param(namestring)string{...}
Bad Documentation
// Register is a highly optimized router method with zero allocations.// Uses O(1) lookup for fast routing.// Extremely efficient performance characteristics.func(r*Router)Register(method,patternstring)*Route{...}// Context is a fast, memory-efficient request context.// Uses minimal allocations and provides high-performance access.// Benchmarks show 50% faster than alternatives.typeContextstruct{...}// Param returns the value with O(1) lookup time.// Optimized for speed with zero allocations.func(c*Context)Param(namestring)string{...}// ========================================// HTTP Context Methods// ========================================func(c*Context)Param(namestring)string{...}// TODO: move this to a separate file// Param returns the value of the named route parameter.func(c*Context)Param(namestring)string{...}
Review Checklist
When writing or reviewing documentation, check:
Content Rules
No performance words (fast, efficient, optimized)
No algorithm complexity (Big-O, O(1))
No benchmark claims
No memory usage details
No marketing language
No decorative comment lines
No TODO/FIXME about moving code
No file history comments (merged from, moved from)
Remember: Documentation explains what code does and how to use it, not how well it performs. Focus on functionality, behavior, and usage patterns. If performance is implied by code, don’t mention it in documentation.
5.2 - Testing Standards
How to write tests for Rivaas code
This page explains how to write tests for Rivaas. Good tests help us keep the code working correctly.
Test File Structure
All packages must have these test files:
*_test.go — Unit tests (same package)
example_test.go — Examples for documentation (external package)
For table-driven tests, use names that explain the scenario:
tests:=[]struct{namestring// ...}{{name:"valid email address"},// ✅ Good - descriptive{name:"empty string returns error"},// ✅ Good - explains behavior{name:"test1"},// ❌ Bad - not descriptive{name:"case 1"},// ❌ Bad - not helpful}
Grouping with Subtests
Use nested t.Run() for related tests:
funcTestUser(t*testing.T){t.Parallel()t.Run("Create",func(t*testing.T){t.Parallel()t.Run("valid input succeeds",func(t*testing.T){t.Parallel()// test code})t.Run("invalid email returns error",func(t*testing.T){t.Parallel()// test code})})t.Run("Delete",func(t*testing.T){t.Parallel()t.Run("existing user succeeds",func(t*testing.T){t.Parallel()// test code})})}
Package Organization
Unit Tests
Package: Same as source (package router)
Access: Can test public and internal APIs
Use for: Testing individual functions, internal details, edge cases
Framework: Standard testing with testify/assert or testify/require
Integration Tests
Package: External (package router_test)
Access: Only public APIs (black-box testing)
Use for: Testing full request/response cycles, component interactions
Framework:
Standard testing for simple tests
Ginkgo/Gomega for complex scenarios
Example Tests
Package: External (package router_test)
Access: Only public APIs
Use for: Showing how to use public APIs in documentation
Test Data Management
The testdata Directory
Go has special handling for testdata/ directories:
funcTestHandler(t*testing.T){t.Parallel()// Load test fixtureinput,err:=os.ReadFile("testdata/fixtures/valid_request.json")require.NoError(t,err)// Use in testresult,err:=ProcessRequest(input)require.NoError(t,err)// Compare with golden fileexpected,err:=os.ReadFile("testdata/golden/expected_output.json")require.NoError(t,err)assert.JSONEq(t,string(expected),string(result))}
Golden File Testing
Golden files store expected output. Use -update flag to regenerate:
varupdateGolden=flag.Bool("update",false,"update golden files")funcTestOutput_Golden(t*testing.T){result:=GenerateOutput()goldenPath:="testdata/golden/output.txt"if*updateGolden{err:=os.WriteFile(goldenPath,[]byte(result),0644)require.NoError(t,err)return}expected,err:=os.ReadFile(goldenPath)require.NoError(t,err)assert.Equal(t,string(expected),result)}
Update golden files:
go test -update ./...
Assertions
Important: Always use assertion libraries. Don’t use manual if statements with t.Errorf().
testify/assert vs testify/require
assert: Continues test after failure (checks multiple things)
require: Stops test after failure (when later checks depend on it)
// Use require when later code needs the valueresult,err:=FunctionThatShouldSucceed()require.NoError(t,err)// Must succeed to continueassert.Equal(t,expected,result)// Use assert for independent checksassert.NoError(t,err)assert.Equal(t,expected,result)assert.Contains(t,message,"success")// All run even if first fails
Error Checking
Always use testify error functions, not manual error checks.
Available Functions
assert.NoError(t, err) — Verify no error occurred
assert.Error(t, err) — Verify an error occurred
assert.ErrorIs(t, err, target) — Verify error wraps specific error
assert.ErrorAs(t, err, target) — Verify error is specific type
assert.ErrorContains(t, err, substring) — Verify error message contains text
When to Use Each
NoError / require.NoError:
result,err:=FunctionThatShouldSucceed()require.NoError(t,err)// Use require if result is neededassert.Equal(t,expected,result)
Error / assert.Error:
_,err:=FunctionThatShouldFail()assert.Error(t,err)// Any error is fine
ErrorIs / assert.ErrorIs:
varErrNotFound=errors.New("not found")_,err:=FunctionThatReturnsWrappedError()assert.ErrorIs(t,err,ErrNotFound)// Check for specific error
ErrorAs / require.ErrorAs:
typeValidationErrorstruct{Fieldstring}_,err:=FunctionThatReturnsTypedError()varvalidationErr*ValidationErrorrequire.ErrorAs(t,err,&validationErr)// Use require if you need validationErrassert.Equal(t,"email",validationErr.Field)
tmpfile,err:=os.CreateTemp("","test-*.txt")require.NoError(t,err)// Must succeed to continuedeferos.Remove(tmpfile.Name())
Need non-nil value:
db,err:=sql.Open("postgres",dsn)require.NoError(t,err)// Must succeedrequire.NotNil(t,db)// Must not be nilrows,err:=db.Query("SELECT ...")// Safe to use db
Later assertions depend on it:
err:=c.Format(200,data)require.NoError(t,err)// Must succeed for rest of test// These assume Format succeededassert.Contains(t,w.Header().Get("Content-Type"),"application/xml")assert.Contains(t,w.Body.String(),"<?xml")
Use assert when:
Independent validations:
assert.NoError(t,err)assert.Equal(t,expected,result)assert.Contains(t,message,"success")// All checked even if first fails
Non-critical checks:
err:=optionalOperation()assert.NoError(t,err)// Nice to have, but test can continueassert.Equal(t,http.StatusOK,w.Code)
Table-Driven Tests
All tests with multiple cases should use table-driven pattern:
Include // Output: comments for deterministic examples
Use log.Fatal(err) for error handling (acceptable in examples)
Benchmarks
Critical paths must have benchmarks in *_bench_test.go:
funcBenchmarkFunctionName(b*testing.B){setup:=prepareTestData()b.ResetTimer()b.ReportAllocs()// Preferred: Go 1.23+ syntaxforb.Loop(){FunctionName(setup)}}funcBenchmarkFunctionName_Parallel(b*testing.B){setup:=prepareTestData()b.ResetTimer()b.ReportAllocs()b.RunParallel(func(pb*testing.PB){forpb.Next(){FunctionName(setup)}})}
Benchmark Guidelines
Use b.ResetTimer() after setup
Use b.ReportAllocs() to track memory
Prefer b.Loop() for Go 1.23+
Test both sequential and parallel execution
Use b.Context() instead of context.Background() (Go 1.24+)
Use b.Fatal(err) for setup failures (acceptable in benchmarks)
Integration Tests
Integration tests use the integration build tag:
//go:build integrationpackagepackage_testimport("net/http""net/http/httptest""testing""rivaas.dev/package")funcTestIntegration(t*testing.T){r:=package.MustNew()// Integration test code}
Build Tags for Test Separation
Test Type
Build Tag
Run Command
Unit tests
//go:build !integration
go test ./...
Integration tests
//go:build integration
go test -tags=integration ./...
Why build tags?
Tests excluded at compile time, not skipped at runtime
Cleaner coverage reports
Faster unit test runs
Easy to run different suites in parallel
Ginkgo Integration Tests
For complex scenarios, use Ginkgo. Important: Only one RunSpecs call per package.
var_=Describe("Router Stress Tests",Label("stress","slow"),func(){It("should handle high concurrent load",Label("stress"),func(){// Stress test})})
Run with labels:
# Run only stress testsginkgo -label-filter=stress ./package
# Run everything except stress testsginkgo -label-filter='!stress' ./package
# Run tests with multiple labels (AND)ginkgo -label-filter='integration && versioning' ./package
Test Helpers
Common utilities go in testing.go:
packagepackageimport("testing""github.com/stretchr/testify/assert")// testHelper creates a test instance with default configuration.functestHelper(t*testing.T)*Config{t.Helper()returnMustNew(WithTestDefaults())}// assertError checks if error matches expected.funcassertError(t*testing.T,errerror,wantErrbool,msgstring){t.Helper()ifwantErr{assert.Error(t,err,msg)}else{assert.NoError(t,err,msg)}}
// Define interfacetypeUserRepositoryinterface{FindByID(ctxcontext.Context,idstring)(*User,error)Save(ctxcontext.Context,user*User)error}// Test implementation (fake)typefakeUserRepositorystruct{usersmap[string]*Usererrerror}func(f*fakeUserRepository)FindByID(ctxcontext.Context,idstring)(*User,error){iff.err!=nil{returnnil,f.err}returnf.users[id],nil}// Test using the fakefuncTestUserService_GetUser(t*testing.T){t.Parallel()repo:=&fakeUserRepository{users:map[string]*User{"123":{ID:"123",Name:"Test User"},},}service:=NewUserService(repo)user,err:=service.GetUser(context.Background(),"123")require.NoError(t,err)assert.Equal(t,"Test User",user.Name)}
# Package coveragego test -cover ./package
# Detailed reportgo test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
# Coverage by functiongo tool cover -func=coverage.out
Best Practices
Parallel Execution: Use t.Parallel() for all tests (except testing.AllocsPerRun)
Assertions: Always use testify/assert or testify/require
Error Messages: Include descriptive messages
Test Isolation: Each test should be independent
Cleanup: Use t.Cleanup() instead of defer:
funcTestWithResource(t*testing.T){t.Parallel()resource:=createResource()t.Cleanup(func(){resource.Close()})// Use resource...}
Descriptive Names: Use clear test and subtest names
Documentation: Document complex test scenarios
Race Detection: Always run with -race in CI
Deterministic Tests: Avoid depending on:
Current time (use clock injection)
Random values (use fixed seeds)
Network availability (use mocks)
Filesystem state (use temp directories)
Running Tests
# Run unit tests (excludes integration)go test ./...
# Run unit tests with verbose outputgo test -v ./...
# Run unit tests with race detection (REQUIRED in CI)go test -race ./...
# Run integration tests with race detectiongo test -tags=integration -race ./...
# Run unit tests with coveragego test -cover ./...
# Run benchmarksgo test -bench=. -benchmem ./...
# Run specific test by namego test -run TestFunctionName ./...
# Run tests with timeoutgo test -timeout 5m ./...
CI Commands
# Unit tests with race and coverage (CI)go test -race -coverprofile=coverage.out -timeout 10m ./...
# Integration tests with race and coverage (CI)go test -tags=integration -race -coverprofile=coverage-integration.out -timeout 10m ./...
Summary
Good tests:
Use clear, descriptive names
Use table-driven patterns for multiple cases
Always use assertion libraries
Run in parallel when possible
Include examples for public APIs
Test both success and error cases
Use proper build tags for integration tests
Have good coverage (80%+)
Remember: Tests are documentation too. Write them clearly!