introspection

package
v0.1.0-alpha.11 Latest Latest
Warning

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

Go to latest
Published: Jan 2, 2026 License: Apache-2.0 Imports: 13 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 "haptic/pkg/introspection"

Quick Start

package main

import (
    "context"
    "time"
    "haptic/pkg/introspection"
)

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

    // Publish variables
    counter := &introspection.IntVar{}
    registry.Publish("requests", counter)

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

    // Start HTTP server
    server := introspection.NewServer(":6060", registry)
    ctx := context.Background()
    go server.Start(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) (interface{}, error)

Retrieves a variable's value by path.

func (r *Registry) GetWithField(path string, field string) (interface{}, error)

Retrieves a variable and extracts a specific field using JSONPath.

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

Returns all registered variable paths.

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

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

Built-in Variable Types
IntVar
type IntVar struct {
    value atomic.Int64
}

func (v *IntVar) Add(delta int64)
func (v *IntVar) Set(value int64)
func (v *IntVar) Get() (interface{}, error)

Thread-safe integer variable.

StringVar
type StringVar struct {
    value atomic.Value  // stores string
}

func (v *StringVar) Set(value string)
func (v *StringVar) Get() (interface{}, error)

Thread-safe string variable.

FloatVar
type FloatVar struct {
    mu    sync.RWMutex
    value float64
}

func (v *FloatVar) Set(value float64)
func (v *FloatVar) Get() (interface{}, error)

Thread-safe float64 variable.

MapVar
type MapVar struct {
    mu   sync.RWMutex
    data map[string]interface{}
}

func (v *MapVar) Set(key string, value interface{})
func (v *MapVar) Get() (interface{}, error)

Thread-safe map variable.

Func
type Func func() (interface{}, error)

func (f Func) Get() (interface{}, error)

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

Example:

registry.Publish("uptime", introspection.Func(func() (interface{}, error) {
    return map[string]interface{}{
        "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) Start(ctx context.Context) error

Starts the HTTP server. Blocks until context is cancelled. Performs graceful shutdown with 30s 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 interface{})

Writes JSON response with proper content-type.

func WriteJSONField(w http.ResponseWriter, data interface{}, field string)

Extracts field using JSONPath and writes JSON response.

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

Writes error response as JSON.

JSONPath
func ExtractField(data interface{}, jsonPathExpr string) (interface{}, 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:

{
  "vars": ["config", "uptime", "metrics"]
}
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() (interface{}, error) {
    v.mu.RLock()
    defer v.mu.RUnlock()

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

    return map[string]interface{}{
        "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]interface{}{
        "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 (Stage 6)
  • 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() (interface{}, error) {
    return time.Since(startTime), nil
}))

// Start HTTP server
server := introspection.NewServer(":6060", registry)
server.Start(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 interface{}, jsonPathExpr string) (interface{}, 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]interface{}{
    "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 interface{})

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]interface{}{
    "status": "ok",
    "count": 42,
})

func WriteJSONField

func WriteJSONField(w http.ResponseWriter, data interface{}, field string)

WriteJSONField writes a specific field from data as JSON using JSONPath.

The field parameter should use kubectl-style JSONPath syntax (e.g., "{.version}"). If field is empty, writes the entire data object.

Example:

config := map[string]interface{}{
    "version": "1.2.3",
    "templates": []string{"main", "secondary"},
}

// Get full object
WriteJSONField(w, config, "")

// Get specific field
WriteJSONField(w, config, "{.version}")  // Returns: "1.2.3"

func WriteJSONWithStatus

func WriteJSONWithStatus(w http.ResponseWriter, statusCode int, data interface{})

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]interface{}{
    "status": "degraded",
    "components": components,
})

func WriteText

func WriteText(w http.ResponseWriter, text string)

WriteText writes a plain text response.

Useful for simple string responses or formatted text.

Example:

WriteText(w, "OK\n")

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 FloatVar

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

FloatVar is a thread-safe float64 variable.

Example:

temperature := NewFloat(0.0)
registry.Publish("cpu_temperature", temperature)
temperature.Set(45.2)

func NewFloat

func NewFloat(initial float64) *FloatVar

NewFloat creates a new FloatVar with the specified initial value.

func (*FloatVar) Add

func (v *FloatVar) Add(delta float64)

Add adds delta to the value.

func (*FloatVar) Get

func (v *FloatVar) Get() (interface{}, error)

Get implements the Var interface.

func (*FloatVar) Set

func (v *FloatVar) Set(value float64)

Set sets the value to the specified float.

func (*FloatVar) Value

func (v *FloatVar) Value() float64

Value returns the current value.

type Func

type Func func() (interface{}, 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() (interface{}, error) {
    return time.Since(startTime).String(), nil
}))

func (Func) Get

func (f Func) Get() (interface{}, 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 IntVar

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

IntVar is a thread-safe 64-bit integer variable.

Provides atomic operations for reading and modifying the value. Similar to expvar.Int but instance-based rather than global.

Example:

counter := NewInt(0)
registry.Publish("request_count", counter)
counter.Add(1)  // Increment counter

func NewInt

func NewInt(initial int64) *IntVar

NewInt creates a new IntVar with the specified initial value.

func (*IntVar) Add

func (v *IntVar) Add(delta int64)

Add adds delta to the value.

func (*IntVar) Get

func (v *IntVar) Get() (interface{}, error)

Get implements the Var interface.

func (*IntVar) Set

func (v *IntVar) Set(value int64)

Set sets the value to the specified integer.

func (*IntVar) Value

func (v *IntVar) Value() int64

Value returns the current value.

type MapVar

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

MapVar is a thread-safe map variable.

Useful for publishing structured data that changes over time.

Example:

stats := NewMap()
registry.Publish("stats", stats)
stats.Set("requests", 100)
stats.Set("errors", 5)

func NewMap

func NewMap() *MapVar

NewMap creates a new MapVar.

func (*MapVar) Delete

func (v *MapVar) Delete(key string)

Delete removes a key from the map.

func (*MapVar) Get

func (v *MapVar) Get() (interface{}, error)

Get implements the Var interface.

func (*MapVar) Len

func (v *MapVar) Len() int

Len returns the number of entries in the map.

func (*MapVar) Set

func (v *MapVar) Set(key string, value interface{})

Set sets a key to the specified value.

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]interface{}, 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) (interface{}, 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) (interface{}, 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) Len

func (r *Registry) Len() int

Len returns the number of registered variables.

Example:

count := registry.Len()  // Returns number of published variables

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 supports two initialization patterns:

1. Simple (combined): Use Start() which calls Setup() and Serve() internally:

go server.Start(ctx)

2. Two-phase (for early health checks): Call Setup() first, then Serve() later:

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)
go server.Start(ctx)

func (*Server) Addr

func (s *Server) Addr() string

Addr returns the address the server is configured to listen on.

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() (or Start(), which calls Setup() internally).

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 Start().

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)

func (*Server) Start

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

Start is a convenience method that calls Setup() then Serve(). For more control over timing (e.g., registering handlers after server creation but before routes are finalized), call Setup() and Serve() separately.

This method should typically be run in a goroutine:

go server.Start(ctx)

Example:

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

go server.Start(ctx)

// Later: cancel context to shutdown
cancel()

type StringVar

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

StringVar is a thread-safe string variable.

Example:

status := NewString("initializing")
registry.Publish("status", status)
status.Set("running")

func NewString

func NewString(initial string) *StringVar

NewString creates a new StringVar with the specified initial value.

func (*StringVar) Get

func (v *StringVar) Get() (interface{}, error)

Get implements the Var interface.

func (*StringVar) Set

func (v *StringVar) Set(value string)

Set sets the value to the specified string.

func (*StringVar) Value

func (v *StringVar) Value() string

Value returns the current value.

type TypedFunc

type TypedFunc[T any] func() (T, error)

TypedFunc is a generic adapter that wraps a typed function as a Var.

This allows creating type-safe debug variables while still implementing the Var interface. The type parameter T specifies the return type of the underlying function.

Example:

// Create a typed var that returns a specific type
getStats := func() (Stats, error) {
    return Stats{Count: 42, LastUpdate: time.Now()}, nil
}
statsVar := TypedFunc[Stats](getStats)
registry.Publish("stats", statsVar)

func (TypedFunc[T]) Get

func (f TypedFunc[T]) Get() (interface{}, error)

Get implements the Var interface by calling the underlying typed function. The typed result is returned as interface{} to satisfy the Var interface.

type TypedGetter

type TypedGetter[T any] interface {
	GetTyped() (T, error)
}

TypedGetter is a generic interface for typed variable access.

Unlike Var which returns interface{}, TypedGetter returns a specific type. This is useful for internal code that knows the expected type.

type TypedVar

type TypedVar[T any] struct {
	// contains filtered or unexported fields
}

TypedVar wraps a TypedGetter to implement both Var and TypedGetter interfaces.

func NewTypedVar

func NewTypedVar[T any](getter func() (T, error)) *TypedVar[T]

NewTypedVar creates a new TypedVar from a getter function.

func (*TypedVar[T]) Get

func (v *TypedVar[T]) Get() (interface{}, error)

Get implements the Var interface.

func (*TypedVar[T]) GetTyped

func (v *TypedVar[T]) GetTyped() (T, error)

GetTyped returns the value with its concrete type.

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() (interface{}, 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() (interface{}, 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