introspection

package
v0.2.0-alpha.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 5, 2026 License: Apache-2.0 Imports: 14 Imported by: 0

README

pkg/introspection

Generic HTTP server infrastructure for exposing internal application state via debug endpoints.

Overview

The introspection package provides a reusable framework for creating debug HTTP servers with:

  • Instance-based variable registry
  • JSONPath field selection
  • Built-in Go profiling (pprof)
  • Graceful shutdown

This is a pure infrastructure package with no domain dependencies - it can be used in any Go application.

Installation

import "gitlab.com/haproxy-haptic/haptic/pkg/introspection"

Quick Start

package main

import (
    "context"
    "time"
    "gitlab.com/haproxy-haptic/haptic/pkg/introspection"
)

func main() {
    // Create registry
    registry := introspection.NewRegistry()

    // Publish a computed variable
    startTime := time.Now()
    registry.Publish("uptime", introspection.Func(func() (any, error) {
        return time.Since(startTime).Seconds(), nil
    }))

    // Start HTTP server
    server := introspection.NewServer(":6060", registry)
    ctx := context.Background()
    server.Setup()
    go server.Serve(ctx)

    // Access via:
    // curl http://localhost:6060/debug/vars
    // curl http://localhost:6060/debug/vars/uptime
    // curl http://localhost:6060/debug/pprof/
}

API Reference

Registry
type Registry struct {
    // Thread-safe variable registry
}

func NewRegistry() *Registry

Creates a new instance-based registry. Each application iteration should create its own registry to avoid stale references.

func (r *Registry) Publish(path string, v Var)

Registers a variable at the given path (e.g., "config", "metrics/requests").

func (r *Registry) Get(path string) (any, error)

Retrieves a variable's value by path.

func (r *Registry) GetWithField(path, field string) (any, error)

Retrieves a variable and extracts a specific field using JSONPath.

func (r *Registry) Paths() []string
func (r *Registry) All() (map[string]any, error)
func (r *Registry) Clear()

Paths() returns the sorted list of registered variable paths (used by the /debug/vars index handler). All() returns a path → value map by calling Get() on every registered variable; the first failure aborts and bubbles up. Clear() empties the registry without tearing down the HTTP server — used between controller iterations so stale Vars from a previous iteration get garbage-collected.

Var Interface
type Var interface {
    Get() (any, error)
}

Interface for debug variables. Implementations should be thread-safe and return JSON-serializable values.

Built-in Variable Types
Func
type Func func() (any, error)

func (f Func) Get() (any, error)

Computed variable - value is calculated on-demand when requested.

Example:

registry.Publish("uptime", introspection.Func(func() (any, error) {
    return map[string]any{
        "seconds": time.Since(startTime).Seconds(),
        "started": startTime,
    }, nil
}))
Server
type Server struct {
    addr     string
    registry *Registry
}

func NewServer(addr string, registry *Registry) *Server

Creates a new HTTP server bound to addr (e.g., ":6060"). Server binds to 0.0.0.0 for compatibility with kubectl port-forward.

func (s *Server) Setup()
func (s *Server) Serve(ctx context.Context) error

Setup() finalises the routes (call it after registering custom handlers and the health checker). Serve() starts the HTTP server and blocks until context is cancelled. Performs graceful shutdown with 10s timeout.

Exposes endpoints:

  • GET /debug/vars - List all variables
  • GET /debug/vars/{path} - Get variable value
  • GET /debug/vars/{path}?field={.jsonpath} - Extract specific field
  • GET /debug/pprof/* - Go profiling (heap, goroutine, CPU, etc.)
HTTP Helpers
func WriteJSON(w http.ResponseWriter, data any)

Writes JSON response with proper content-type.

func WriteJSONWithStatus(w http.ResponseWriter, statusCode int, data any)

Writes JSON response with an explicit HTTP status code (for handlers that need 4xx responses).

func WriteError(w http.ResponseWriter, code int, message string)

Writes error response as JSON.

To narrow a payload to a single field, call ExtractField (below) and then WriteJSON. There is no combined WriteJSONField helper.

JSONPath
func ExtractField(data any, jsonPathExpr string) (any, error)

Extracts a field from data using JSONPath expression (kubectl syntax).

func ParseFieldQuery(r *http.Request) string

Parses ?field={.path} query parameter from HTTP request.

HTTP Endpoints

GET /debug/vars

Lists all registered variable paths.

Response:

{
  "paths": ["config", "uptime", "metrics"],
  "count": 3
}
GET /debug/vars/{path}

Retrieves variable value.

Examples:

curl http://localhost:6060/debug/vars/uptime

Response:

{
  "seconds": 123.45,
  "started": "2025-01-15T10:30:00Z"
}
GET /debug/vars/{path}?field={.jsonpath}

Extracts specific field using JSONPath.

Examples:

# Get just the seconds
curl 'http://localhost:6060/debug/vars/uptime?field={.seconds}'
# Response: 123.45

# Get nested field
curl 'http://localhost:6060/debug/vars/config?field={.templates.main}'

JSONPath Syntax:

  • {.field} - Top-level field
  • {.nested.field} - Nested field
  • {.array[0]} - Array element
  • {.array[*]} - All array elements

See: https://kubernetes.io/docs/reference/kubectl/jsonpath/

GET /debug/pprof/

Go profiling endpoints (automatically included):

  • /debug/pprof/ - Index
  • /debug/pprof/heap - Memory allocations
  • /debug/pprof/goroutine - Goroutine stacks
  • /debug/pprof/profile?seconds=30 - CPU profile
  • /debug/pprof/trace?seconds=5 - Execution trace

Usage:

# Interactive profiling
go tool pprof http://localhost:6060/debug/pprof/heap

# Save profile
curl http://localhost:6060/debug/pprof/profile?seconds=30 > cpu.prof
go tool pprof cpu.prof

Custom Variable Implementation

Implement the Var interface for custom debug variables:

type MyVar struct {
    data *MyData
    mu   sync.RWMutex
}

func (v *MyVar) Get() (any, error) {
    v.mu.RLock()
    defer v.mu.RUnlock()

    if v.data == nil {
        return nil, fmt.Errorf("data not loaded")
    }

    return map[string]any{
        "field1": v.data.Field1,
        "field2": v.data.Field2,
    }, nil
}

// Register
registry.Publish("myvar", &MyVar{data: myData})

Security Considerations

  1. Bind Address: Server binds to 0.0.0.0 by default. In Kubernetes pods, this is safe (private network). For other deployments, consider firewall rules.

  2. Sensitive Data: Do NOT expose passwords, keys, or tokens. Return metadata only:

    // Good
    return map[string]any{
        "has_password": creds.Password != "",
        "username": creds.Username,
    }
    
    // Bad
    return creds  // Exposes password!
    
  3. Access Control: No built-in authentication. Use kubectl port-forward or reverse proxy with auth for production access.

  4. Performance: /debug/pprof/profile can impact performance. Use with caution in production.

Access via kubectl

For Kubernetes deployments:

# Forward debug port from pod
kubectl port-forward pod/my-app-xxx 6060:6060

# Access endpoints
curl http://localhost:6060/debug/vars
curl http://localhost:6060/debug/pprof/heap

Examples

See:

  • Controller integration: pkg/controller/controller.go (persistentInfra.IntrospectionServer — the registry and server are created once before the iteration loop and reused; Registry.Clear() is called at the top of each iteration so stale references from the previous run drop out)
  • Debug variables: pkg/controller/debug/
  • Acceptance tests: tests/acceptance/debug_client.go

License

See main repository for license information.

Documentation

Overview

Package introspection provides a generic debug variable registry and HTTP server for exposing application internal state over HTTP.

This package is inspired by the standard library's expvar package but extends it with:

  • Instance-based registry (not global) for proper lifecycle management
  • JSONPath field selection for querying specific fields
  • Custom HTTP routing with path-based variable access

The core abstraction is the Var interface, which represents any debug variable that can be queried. Implementations provide their current value via the Get() method.

Example usage:

// Create an instance-scoped registry
registry := introspection.NewRegistry()

// Publish variables
registry.Publish("config", &ConfigVar{provider})
registry.Publish("uptime", introspection.Func(func() (any, error) {
    return time.Since(startTime), nil
}))

// Start HTTP server
server := introspection.NewServer(":6060", registry)
server.Setup()
go server.Serve(ctx)

// Query variables:
// GET /debug/vars - list all variables
// GET /debug/vars/config - get config variable
// GET /debug/vars/config?field={.version} - get specific field

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func ExtractField

func ExtractField(data any, jsonPathExpr string) (any, error)

ExtractField extracts a specific field from data using JSONPath syntax.

Uses k8s.io/client-go/util/jsonpath (kubectl-style JSONPath) for field extraction. This provides familiar syntax for Kubernetes users.

JSONPath syntax examples:

  • {.version} - simple field
  • {.config.templates} - nested field
  • {.items[0]} - array index
  • {.items[*].name} - all names from array

The data parameter should be JSON-serializable. The jsonPathExpr parameter should include braces (e.g., "{.version}").

Example:

config := map[string]any{
    "version": "1.2.3",
    "templates": map[string]string{"main": "..."},
}

version, err := ExtractField(config, "{.version}")
// Returns: "1.2.3"

templates, err := ExtractField(config, "{.templates}")
// Returns: map[string]string{"main": "..."}

func ParseFieldQuery

func ParseFieldQuery(r *http.Request) string

ParseFieldQuery extracts the "field" query parameter from an HTTP request.

Returns the field parameter value if present, or empty string otherwise.

Example:

// GET /debug/vars/config?field={.version}
field := ParseFieldQuery(r)  // Returns: "{.version}"

// GET /debug/vars/config
field := ParseFieldQuery(r)  // Returns: ""

func WriteError

func WriteError(w http.ResponseWriter, code int, message string)

WriteError writes an error response with the specified HTTP status code.

The error message is wrapped in a JSON object with an "error" field.

Example:

WriteError(w, http.StatusNotFound, "variable not found")
// Response: {"error": "variable not found"}

func WriteJSON

func WriteJSON(w http.ResponseWriter, data any)

WriteJSON writes data as JSON to the HTTP response with status 200 OK.

Sets appropriate Content-Type header and handles JSON marshaling. If marshaling fails, writes an error response instead.

Example:

WriteJSON(w, map[string]any{
    "status": "ok",
    "count": 42,
})

func WriteJSONWithStatus

func WriteJSONWithStatus(w http.ResponseWriter, statusCode int, data any)

WriteJSONWithStatus writes data as JSON with a custom HTTP status code.

Use this when you need to return JSON with a non-200 status code.

Example:

WriteJSONWithStatus(w, http.StatusServiceUnavailable, map[string]any{
    "status": "degraded",
    "components": components,
})

Types

type ComponentHealth

type ComponentHealth struct {
	Healthy bool   `json:"healthy"`
	Error   string `json:"error,omitempty"`
}

ComponentHealth represents the health status of a single component.

type Func

type Func func() (any, error)

Func is a Var that computes its value on-demand by calling a function.

This is useful for values that are expensive to compute or change frequently, as they are only calculated when actually queried.

Example:

startTime := time.Now()
registry.Publish("uptime", Func(func() (any, error) {
    return time.Since(startTime).String(), nil
}))

func (Func) Get

func (f Func) Get() (any, error)

Get implements the Var interface by calling the function.

type HealthCheckFunc

type HealthCheckFunc func() map[string]ComponentHealth

HealthCheckFunc is a function that returns component health status. Used to integrate with lifecycle registry or other health monitoring.

type Registry

type Registry struct {
	// contains filtered or unexported fields
}

Registry manages a collection of debug variables.

Unlike expvar which uses a global registry, this implementation is instance-based, allowing for proper lifecycle management. Each Registry instance is independent and can be garbage collected when no longer needed.

This is especially important for applications that reinitialize components (like a Kubernetes controller that reloads configuration), as it prevents stale references to old component instances.

Registry is thread-safe and can be accessed from multiple goroutines.

func NewRegistry

func NewRegistry() *Registry

NewRegistry creates a new empty registry.

Each registry instance is independent and manages its own set of variables.

Example:

registry := introspection.NewRegistry()

func (*Registry) All

func (r *Registry) All() (map[string]any, error)

All returns all variables as a map of path → value.

If any variable's Get() method fails, the error is returned and the map may be incomplete.

This is used by the /debug/vars endpoint to list all variables.

Example:

all, err := registry.All()
for path, value := range all {
    fmt.Printf("%s = %v\n", path, value)
}

func (*Registry) Clear

func (r *Registry) Clear()

Clear removes all published variables from the registry.

This is used between controller iterations to prevent stale references to components from previous iterations. The registry can then be reused with new variables without restarting the HTTP server.

Example:

// Between iterations
registry.Clear()
registry.Publish("config", newConfigVar)

func (*Registry) Get

func (r *Registry) Get(path string) (any, error)

Get retrieves the value of the variable at the specified path.

Returns an error if the path does not exist or if the variable's Get() method fails.

Example:

value, err := registry.Get("config")
if err != nil {
    slog.Error("Failed to get config", "error", err)
}

func (*Registry) GetWithField

func (r *Registry) GetWithField(path, field string) (any, error)

GetWithField retrieves a specific field from the variable at the specified path using JSONPath syntax.

The field parameter should use kubectl-style JSONPath syntax (e.g., "{.version}"). If field is empty, the full variable value is returned.

This method is used internally by the HTTP handlers to support field selection via query parameters.

Example:

// Get full config
value, err := registry.GetWithField("config", "")

// Get just the version field
version, err := registry.GetWithField("config", "{.version}")

func (*Registry) Paths

func (r *Registry) Paths() []string

Paths returns a sorted list of all registered variable paths.

This is used by the /debug/vars endpoint to provide an index of available variables.

Example:

paths := registry.Paths()
// Returns: ["config", "resources/ingresses", "uptime"]

func (*Registry) Publish

func (r *Registry) Publish(path string, v Var)

Publish registers a variable at the specified path.

The path is used to access the variable via HTTP (e.g., /debug/vars/{path}). If a variable already exists at the given path, it is replaced.

Path format:

  • Use simple names: "config", "uptime", "stats"
  • Or hierarchical paths: "resources/ingresses", "cache/hits"

Example:

registry.Publish("config", &ConfigVar{provider})
registry.Publish("resources/ingresses", &IngressVar{store})

type Server

type Server struct {
	// contains filtered or unexported fields
}

Server serves debug variables over HTTP.

The server provides HTTP endpoints for accessing variables registered in a Registry. It supports JSONPath field selection for querying specific fields from variables.

Standard endpoints:

  • GET /debug/vars - list all variable paths
  • GET /debug/vars/all - get all variables
  • GET /debug/vars/{path} - get specific variable
  • GET /debug/vars/{path}?field={.jsonpath} - get field from variable
  • GET /health - health check
  • GET /debug/pprof/* - Go profiling endpoints (via import side-effect)

Custom handlers can be registered using RegisterHandler() before Setup() is called.

The server uses two-phase initialization: call Setup() first, then Serve():

server.RegisterHandler("/debug/events", eventsHandler)
server.SetHealthChecker(checker)
server.Setup()
go server.Serve(ctx)

The two-phase pattern allows registering handlers and health checkers before routes are finalized, while enabling the server to start serving immediately.

func NewServer

func NewServer(addr string, registry *Registry) *Server

NewServer creates a new HTTP server for serving debug variables.

Parameters:

  • addr: TCP address to listen on (e.g., ":6060" or "localhost:6060")
  • registry: The variable registry to serve

Example:

registry := introspection.NewRegistry()
registry.Publish("config", &ConfigVar{provider})

server := introspection.NewServer(":6060", registry)
server.Setup()
go server.Serve(ctx)

func (*Server) RegisterHandler

func (s *Server) RegisterHandler(pattern string, handler http.HandlerFunc)

RegisterHandler registers a custom HTTP handler for the given pattern. This must be called before Setup().

Parameters:

  • pattern: URL pattern (e.g., "/debug/events")
  • handler: HTTP handler function

Example:

server.RegisterHandler("/debug/events", func(w http.ResponseWriter, r *http.Request) {
    correlationID := r.URL.Query().Get("correlation_id")
    events := commentator.FindByCorrelationID(correlationID, 100)
    introspection.WriteJSON(w, events)
})

func (*Server) Serve

func (s *Server) Serve(ctx context.Context) error

Serve starts the HTTP server and blocks until the context is cancelled. Setup() must be called before Serve().

This method should typically be run in a goroutine:

server.Setup()
go server.Serve(ctx)

The server performs graceful shutdown when the context is cancelled, waiting for active connections to complete (up to a timeout).

Example (two-phase initialization for early health checks):

server.RegisterHandler("/debug/events", eventsHandler)
server.SetHealthChecker(checker)
server.Setup()
go server.Serve(ctx)

func (*Server) SetHealthChecker

func (s *Server) SetHealthChecker(checker HealthCheckFunc)

SetHealthChecker sets a function to check component health. This must be called before Setup().

The health checker function is called by the /health endpoint to get the health status of all components. If not set, /health just returns "ok".

Example integration with lifecycle registry:

server.SetHealthChecker(func() map[string]introspection.ComponentHealth {
    status := registry.Status()
    result := make(map[string]introspection.ComponentHealth)
    for name, info := range status {
        healthy := info.Status == lifecycle.StatusRunning
        if info.Healthy != nil {
            healthy = *info.Healthy
        }
        result[name] = introspection.ComponentHealth{
            Healthy: healthy,
            Error:   info.Error,
        }
    }
    return result
})

func (*Server) Setup

func (s *Server) Setup()

Setup initializes routes and prepares the server for serving. This must be called before Serve(). Custom handlers and health checker can be registered after NewServer() but before Setup().

After Setup() is called, no new handlers can be registered.

Example:

server.RegisterHandler("/debug/events", eventsHandler)
server.SetHealthChecker(checker)
server.Setup()
go server.Serve(ctx)

type Var

type Var interface {
	// Get returns the current value of this variable.
	//
	// The returned value should be JSON-serializable.
	// If an error occurs while retrieving the value, it should be returned.
	//
	// Implementations must be thread-safe, as Get() may be called concurrently
	// from multiple HTTP requests.
	Get() (any, error)
}

Var represents a debug variable that can be queried for its current value.

Implementations should return their current state when Get() is called. The returned value must be JSON-serializable.

Example implementation:

type ConfigVar struct {
    mu     sync.RWMutex
    config *Config
}

func (v *ConfigVar) Get() (any, error) {
    v.mu.RLock()
    defer v.mu.RUnlock()
    return v.config, nil
}

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL