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 router directly. Latency goes from 119ns to about 121-122ns. 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)}}
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
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.
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 onlyapp.WithLogOnlyErrors(),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
1.5 - Context
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...}
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:
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/router/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/router/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/router/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/router/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/router/middleware/requestid""rivaas.dev/router/middleware/cors""rivaas.dev/router/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
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:
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()a.OnStart(func(ctxcontext.Context)error{log.Println("Connecting to database...")returndb.Connect(ctx)})a.OnStart(func(ctxcontext.Context)error{log.Println("Running migrations...")returndb.Migrate(ctx)})// 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 resourcesa.OnStart(func(ctxcontext.Context)error{log.Println("Connecting to database...")varerrerrordb,err=ConnectDB(ctx)iferr!=nil{returnfmt.Errorf("database connection failed: %w",err)}returnnil})a.OnStart(func(ctxcontext.Context)error{log.Println("Running migrations...")returndb.Migrate(ctx)})// OnRoute: Log route registrationa.OnRoute(func(rt*route.Route){log.Printf("Route registered: %s %s",rt.Method(),rt.Path())})// OnReady: Post-startup tasksa.OnReady(func(){log.Println("Server is ready!")log.Println("Registering with service discovery...")consul.Register("api",":8080")})// OnShutdown: Graceful cleanupa.OnShutdown(func(ctxcontext.Context){log.Println("Deregistering from service discovery...")consul.Deregister("api")})a.OnShutdown(func(ctxcontext.Context){log.Println("Closing database connection...")iferr:=db.Close();err!=nil{log.Printf("Error closing database: %v",err)}})// OnStop: Final cleanupa.OnStop(func(){log.Println("Cleanup complete")})// 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
// 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)// Start mTLS serverctx,cancel:=signal.NotifyContext(context.Background(),os.Interrupt,syscall.SIGTERM)defercancel()err=a.StartMTLS(ctx,serverCert,app.WithClientCAs(caCertPool),app.WithMinVersion(tls.VersionTLS13),)
Client Authorization
Authorize clients based on certificate:
err=a.StartMTLS(ctx,serverCert,app.WithClientCAs(caCertPool),app.WithAuthorize(func(cert*x509.Certificate)(string,bool){// Extract principal from certificateprincipal:=cert.Subject.CommonName// Check if authorizedifprincipal==""{return"",false}returnprincipal,true}),)
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(){a:=app.MustNew(app.WithServiceName("secure-api"))// Load certificatesserverCert,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)// Register routesa.GET("/",homeHandler)// Start mTLS serverctx,cancel:=signal.NotifyContext(context.Background(),os.Interrupt,syscall.SIGTERM)defercancel()log.Println("mTLS server starting on :8443")err=a.StartMTLS(ctx,serverCert,app.WithClientCAs(caCertPool),app.WithMinVersion(tls.VersionTLS13),)iferr!=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.WithLogOnlyErrors(),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 delivers 8.4M+ requests per second at 119ns per operation. It includes automatic request binding, validation, content negotiation, API versioning, and native OpenTelemetry tracing.
Key Features
Core Routing & Request Handling
Radix tree routing - Path matching with bloom filters for static route lookups.
Compiled route tables - Pre-compiled routes for static and dynamic path matching.
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: Native OpenTelemetry support with zero overhead when disabled
Diagnostics: Optional diagnostic events for security concerns
Performance
Sub-microsecond routing: 119ns per operation
High throughput: 8.4M+ requests/second
Memory efficient: 16 bytes per request, 1 allocation
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: ~119ns/op, 0 allocations
>8 params: ~119ns/op, 1 allocation (~24 bytes)
Real-world impact: Negligible for most applications (<1% overhead)
// 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 sub-packages. See the Middleware Reference for complete options.
import"rivaas.dev/router/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/router/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/router/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/router/middleware/accesslog""rivaas.dev/router/middleware/cors""rivaas.dev/router/middleware/recovery""rivaas.dev/router/middleware/requestid""rivaas.dev/router/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.12 - Observability
Native OpenTelemetry tracing support with zero overhead when disabled, plus diagnostic events.
The router includes native OpenTelemetry tracing support and optional diagnostic events.
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.
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.
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.
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.
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,)}
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""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