log

package module
v0.0.0-...-3b89e3b Latest Latest
Warning

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

Go to latest
Published: Apr 25, 2024 License: MIT Imports: 12 Imported by: 18

README

mlog

mlog (multi-logger) is a fork of github.com/go-ozzo/ozzo-log (MIT-licensed).

It has these modifications and enhancements:

  • A default logger, named L, so that a simple app need not pass around logger arguments
  • Each logging level has both a distinct color, and a distinct emoji added at the start of each log message (because emoji are easily to scan for, and are used everywhere)
  • A variation on RFC5424, such that there are four related message statuses, in increasing order of severity: Info (grey), Okay (green), Warning (yellow), and Error (red); these should be used so that they are about the same level of importance but with distinct stats indications, much like traffic lights
  • (work in progress) A new logger target HtmlSimple, which logs to an HTML element ID, and ends every log message with (not newline but) <br/>
  • (work in progress) A new enhanced logger target interface DetailsTarget that can generate lists of nested log messages, which lists should be collapsible
    This     RFC5424
0    -       Emergency (system is unusable)
1    -       Alert (take action ASAP)
2   Panic    Critical
3   Error    Error
4   Warning  Warning
5   Okay     Notice (normal but significant condition)
6   Info     Informational
7   Progress Debug
8   Dbg      (none)

(resume old README)

ozzo-log is a Go package providing enhanced logging support for Go programs. It has the following features:

  • High performance through asynchronous logging;
  • Recording message severity levels;
  • Recording message categories;
  • Recording message call stacks;
  • Filtering via severity levels and categories;
  • Customizable message format;
  • Configurable and pluggable message handling through log targets;
  • Included console, file, network, and email log targets.

Requirements

Go 1.2 or above.

Installation

Run the following command to install the package:

go get github.com/go-ozzo/ozzo-log

Getting Started

The following code snippet shows how you can use this package.

package main

import (
	"github.com/go-ozzo/ozzo-log"
)

func main() {
    // creates the root logger
	logger := log.NewLogger()

	// adds a console target and a file target
	t1 := log.NewConsoleTarget()
	t2 := log.NewFileTarget()
	t2.FileName = "app.log"
	t2.MaxLevel = log.LevelError
	logger.Targets = append(logger.Targets, t1, t2)

	logger.Open()
	defer logger.Close()

	// calls log methods to log various log messages
	logger.Error("plain text error")
	logger.Error("error with format: %v", true)
	logger.Debug("some debug info")

	// customizes log category
	l := logger.GetLogger("app.services")
	l.Info("some info")
	l.Warning("some warning")

	...
}

Loggers and Targets

A logger provides various log methods that can be called by application code to record messages of various severity levels.

A target filters log messages by their severity levels and message categories and processes the filtered messages in various ways, such as saving them in files, sending them in emails, etc.

A logger can be equipped with multiple targets each with different filtering conditions.

The following targets are included in the ozzo-log package.

  • ConsoleTarget: displays filtered messages to console window
  • FileTarget: saves filtered messages in a file (supporting file rotating)
  • NetworkTarget: sends filtered messages to an address on a network
  • MailTarget: sends filtered messages in emails

You can create a logger, configure its targets, and start to use logger with the following code:

// creates the root logger
logger := log.NewLogger()
logger.Targets = append(logger.Targets, target1, target2, ...)
logger.Open()
...calling log methods...
logger.Close()

Severity Levels

You can log a message of a particular severity level (following the RFC5424 standard) by calling one of the following methods of the Logger struct:

  • Emergency(): the system is unusable.
  • Alert(): action must be taken immediately.
  • Critical(): critical conditions.
  • Error(): error conditions.
  • Warning(): warning conditions.
  • Notice(): normal but significant conditions.
  • Info(): informational purpose.
  • Debug(): debugging purpose.

Message Categories

Each log message is associated with a category which can be used to group messages. For example, you may use the same category for messages logged by the same Go package. This will allow you to selectively send messages to different targets.

When you call log.NewLogger(), a root logger is returned which logs messages using the category named as app. To log messages with a different category, call the GetLogger() method of the root logger or a parent logger to get a child logger and then call its log methods:

logger := log.NewLogger()
// the message is of category "app"
logger.Error("...")

l1 := logger.GetLogger("system")
// the message is of category "system"
l1.Error("...")

l2 := l1.GetLogger("app.models")
// the message is of category "app.models"
l2.Error("...")

Message Formatting

By default, each log message takes this format when being sent to different targets:

2015-10-22T08:39:28-04:00 [Error][app.models] something is wrong
...call stack (if enabled)...

You may customize the message format by specifying your own message formatter when calling Logger.GetLogger(). For example,

logger := log.NewLogger()
logger = logger.GetLogger("app", func (l *Logger, e *Entry) string {
    return fmt.Sprintf("%v [%v][%v] %v%v", e.Time.Format(time.RFC822Z), e.Level, e.Category, e.Message, e.CallStack)
})

Logging Call Stacks

By setting Logger.CallStackDepth as a positive number, it is possible to record call stack information for each log method call. You may further configure Logger.CallStackFilter so that only call stack frames containing the specified substring will be recorded. For example,

logger := log.NewLogger()
// record call stacks containing "myapp/src" up to 5 frames per log message
logger.CallStackDepth = 5
logger.CallStackFilter = "myapp/src"

Message Filtering

By default, messages of all severity levels will be recorded. You may customize Logger.MaxLevel to change this behavior. For example,

logger := log.NewLogger()
// only record messages between Emergency and Warning levels
logger.MaxLevel = log.LevelWarning

Besides filtering messages at the logger level, a finer grained message filtering can be done at target level. For each target, you can specify its MaxLevel similar to that with the logger; you can also specify which categories of the messages the target should handle. For example,

target := log.NewConsoleTarget()
// handle messages between Emergency and Info levels
target.MaxLevel = log.LevelInfo
// handle messages of categories which start with "system.db." or "app."
target.Categories = []string{"system.db.*", "app.*"}

Configuring Logger

When an application is deployed for production, a common need is to allow changing the logging configuration of the application without recompiling its source code. ozzo-log is designed with this in mind.

For example, you can use a JSON file to specify how the application and its logger should be configured:

{
    "Logger": {
        "Targets": [
            {
                "type": "ConsoleTarget",
            },
            {
                "type": "FileTarget",
                "FileName": "app.log",
                "MaxLevel": 4   // Warning or above
            }
        ]
    }
}

Assuming the JSON file is app.json, in your application code you can use the ozzo-config package to load the JSON file and configure the logger used by the application:

package main

import (
	"github.com/go-ozzo/ozzo-config"
    "github.com/go-ozzo/ozzo-log"
)

func main() {
    c := config.New()
    c.Load("app.json")
    // register the target types to allow configuring Logger.Targets.
    c.Register("ConsoleTarget", log.NewConsoleTarget)
    c.Register("FileTarget", log.NewFileTarget)

    logger := log.NewLogger()
    if err := c.Configure(logger, "Logger"); err != nil {
        panic(err)
    }
}

To change the logger configuration, simply modify the JSON file without recompiling the Go source files.

Documentation

Overview

package mlog is labelled as package log.

Package log implements logging with severity levels and message categories.

Example
package main

import (
	L "github.com/fbaube/mlog"
)

func main() {
	// MAYBE not necessary! Does running an Example func invoke L.init() ?
	LL := L.NewLogger()
	// Add two loggers that write to Stdout
	LL.Targets = append(L.L.Targets, L.NewConsoleTarget())
	LL.Targets = append(L.L.Targets, L.NewConsoleTarget())
	LL.Open()
	LL.Info("%d", len(L.L.Targets))
	LL.Dbg("Debug message")
	LL.Error("Error message")
}
Output:

2
Debug message
Error message

Index

Examples

Constants

This section is empty.

Variables

View Source
var CtlSeqTextBrushes = map[LU.Level]ControlSequenceTextBrush{
	LU.LevelDebug: newControlSequenceTextBrush("30;2"),

	LU.LevelInfo:    newControlSequenceTextBrush("36"),
	LU.LevelOkay:    newControlSequenceTextBrush("32"),
	LU.LevelWarning: newControlSequenceTextBrush("31"),
	LU.LevelError:   newControlSequenceTextBrush("31;1"),
	LU.LevelPanic:   newControlSequenceTextBrush("1;95"),
	LU.GreenBG:      newControlSequenceTextBrush("42;2;4"),
}

Functions

func DefaultDetailsFormatter

func DefaultDetailsFormatter(l *Logger, e *Entry, spcl []string) string

DefaultDetailsFormatter is the default formatter used to format every log message when the Target is details-capable. In this formatter, we assume that the Logger IS a Details Logger.

Note that this only really works with single threading, or else the log messages of different Details sets get all mised up.

func DefaultFormatter

func DefaultFormatter(l *Logger, e *Entry) string

DefaultFormatter is the default formatter used to format every log message. This formatter assumes no Target is a DetailsTarget.

func GetCallStack

func GetCallStack(skip int, frames int, filter string) string

GetCallStack returns the current call stack information as a string. The skip parameter specifies how many top frames should be skipped, while the frames parameter specifies at most how many frames should be returned.

func LogTextQuote

func LogTextQuote(*Entry, string)

func SetMaxLevel

func SetMaxLevel(lvl LU.Level)

Types

type ConsoleTarget

type ConsoleTarget struct {
	*Filter
	ColorMode bool      // whether to use colors to differentiate log levels
	Writer    io.Writer // the writer to write log messages

	DetailsInfo // NEW
	// contains filtered or unexported fields
}

ConsoleTarget writes filtered log messages to console window.

func NewConsoleTarget

func NewConsoleTarget() *ConsoleTarget

NewConsoleTarget creates a ConsoleTarget (i.e. Stdout). The new ConsoleTarget takes these default options: MaxLevel: LU,LevelDebug, ColorMode: true, Writer: os.Stdout .

Example
package main

import (
	log "github.com/fbaube/mlog"
)

func main() {
	logger := log.NewLogger()

	// creates a ConsoleTarget with color mode being disabled
	target := log.NewConsoleTarget()
	target.ColorMode = false

	logger.Targets = append(logger.Targets, target)

	logger.Open()

	// ... logger is ready to use ...
}
Output:

func (*ConsoleTarget) Close

func (t *ConsoleTarget) Close()

Close closes the console target.

func (*ConsoleTarget) CloseDetailsBlock

func (t *ConsoleTarget) CloseDetailsBlock(string)

func (*ConsoleTarget) CloseLogDetailsBlock

func (t *ConsoleTarget) CloseLogDetailsBlock(s string)

func (*ConsoleTarget) DoesDetails

func (t *ConsoleTarget) DoesDetails() bool

func (*ConsoleTarget) Flush

func (t *ConsoleTarget) Flush()

Flush is a no-op.

func (*ConsoleTarget) LogTextQuote

func (t *ConsoleTarget) LogTextQuote(E *Entry, s string)

func (*ConsoleTarget) Open

func (t *ConsoleTarget) Open(io.Writer) error

Open prepares ConsoleTarget for processing log messages.

func (*ConsoleTarget) Process

func (t *ConsoleTarget) Process(e *Entry)

Process writes a log message using Writer.

func (*ConsoleTarget) SetCategory

func (t *ConsoleTarget) SetCategory(s string)

func (*ConsoleTarget) SetSubcategory

func (t *ConsoleTarget) SetSubcategory(s string)

func (*ConsoleTarget) StartDetailsBlock

func (t *ConsoleTarget) StartDetailsBlock(*Entry)

func (*ConsoleTarget) StartLogDetailsBlock

func (t *ConsoleTarget) StartLogDetailsBlock(sCatg string, E *Entry)

type ControlSequenceTextBrush

type ControlSequenceTextBrush StrInStrOut

type DetailsFormatter

type DetailsFormatter func(*Logger, *Entry, []string) string

DetailsFormatter formats a log message into an appropriate string, but also handles Details nicely (in a manner TBD), and accepts a third argument of a string that may be specified per-message.

type DetailsInfo

type DetailsInfo struct {
	DoingDetails     bool
	MinLogLevel      LU.Level
	Category         string
	Subcategory      string
	DetailsFormatter // message formatter
}

DetailsInfo is embedded in details-capable Target's. It applies only to "log details", which logging is stateful, not to "text quotes", which logging is an atomic operation.

type DetailsTarget

type DetailsTarget interface {
	Target
	StartLogDetailsBlock(string, *Entry) // s = Category e.g. "[01]" and clear Subcat
	CloseLogDetailsBlock(string)
	LogTextQuote(*Entry, string)
	// SetCategory is used per-Contentity, e.g. "[00]", "[01]", ...
	SetCategory(string)
	// SetSubcategory is used per- Contentity processing stage, e.g. "[st1b]"
	SetSubcategory(string)
}

DetailsTarget is a target where the logger can both (1) Open a collapsible, ignorable set of log detail messages, and (2) Quote a collapsible, ignorable block of text (atomic operation!).

In a Console target, do this by omitting the first three (or six) characters of the timestamp, providing visual indenting. For (1) use " - " or " * ", so that it resembles a list. For (2) use " " " or " ' ", so that it is obv a quote.

In an HTML target, do this by opening a "<details> block" and in the very same log message, opening the <summary> element. Then subsequent log messages (or the accompanying text block) can be written to the body of the <details> element (separated by <br/> tags, rather than by newlines as in most log targets) until the <details> element is closed.

As an enhancement, a set of log detail messages tracks its minimum (i.e. most severe) logging level, with a summary line at the end.

The five function calls could be ignored as no-ops by targets that do not implemement the interface. However it is simpler and clearer, and follows the existing processing architecture, to make the call for every Target in a Logger, but then in the Logger method call, check each Target for being a DetailsTarget.

type Entry

type Entry struct {
	Level            LU.Level
	Category         string
	Message          string
	Time             time.Time
	CallStack        string
	FormattedMessage string
}

Entry represents a log entry.

func (*Entry) String

func (e *Entry) String() string

String returns the string representation of the log entry

type FileTarget

type FileTarget struct {
	*Filter
	// the log file name. When Rotate is true, log file name will be suffixed
	// to differentiate different backup copies (e.g. app.log.1)
	FileName string
	// whether to enable file rotating at specific time interval or when maximum file size is reached.
	Rotate bool
	// how many log files should be kept when Rotate is true (the current log file is not included).
	// This field is ignored when Rotate is false.
	BackupCount int
	// maximum number of bytes allowed for a log file. Zero means no limit.
	// This field is ignored when Rotate is false.
	MaxBytes int64

	Category    string
	Subcategory string
	// contains filtered or unexported fields
}

FileTarget writes filtered log messages to a file. FileTarget supports file rotation by keeping certain number of backup log files.

func NewFileTarget

func NewFileTarget() *FileTarget

NewFileTarget creates a FileTarget. The new FileTarget takes these default options: MaxLevel: LevelNotice, Rotate: true, BackupCount: 10, MaxBytes: 1 << 20 After calling this, you must fill in the FileName field.

Example
package main

import (
	log "github.com/fbaube/mlog"
)

func main() {
	logger := log.NewLogger()

	// creates a FileTarget which keeps log messages in the app.log file
	target := log.NewFileTarget()
	target.FileName = "app.log"

	logger.Targets = append(logger.Targets, target)

	logger.Open()

	// ... logger is ready to use ...
}
Output:

func (*FileTarget) Close

func (t *FileTarget) Close()

Close closes the file target.

func (*FileTarget) CloseDetailsBlock

func (t *FileTarget) CloseDetailsBlock(string)

func (*FileTarget) DoesDetails

func (t *FileTarget) DoesDetails() bool

func (*FileTarget) Flush

func (t *FileTarget) Flush()

Flush is a no-op but SHOULD have a call to flush the file.

func (*FileTarget) Open

func (t *FileTarget) Open(errWriter io.Writer) error

Open prepares FileTarget for processing log messages.

func (*FileTarget) Process

func (t *FileTarget) Process(e *Entry)

Process saves an allowed log message into the log file.

func (*FileTarget) SetCategory

func (t *FileTarget) SetCategory(s string)

func (*FileTarget) SetSubcategory

func (t *FileTarget) SetSubcategory(s string)

func (*FileTarget) StartDetailsBlock

func (t *FileTarget) StartDetailsBlock(*Entry)

type Filter

type Filter struct {
	MaxLevel   LU.Level // the maximum severity level that is allowed
	Categories []string // the allowed message categories. Categories can use "*" as a suffix for wildcard matching.
	// contains filtered or unexported fields
}

Filter checks if a log message meets the level and category requirements.

func (*Filter) Allow

func (t *Filter) Allow(e *Entry) bool

Allow checks if a message meets the severity level and category requirements.

func (*Filter) Init

func (t *Filter) Init()

Init initializes the filter. Init must be called before Allow is called.

type Formatter

type Formatter func(*Logger, *Entry) string

Formatter formats a log message into an appropriate string.

type HtmlTarget

type HtmlTarget struct {
	*Filter
	// the target HTML element's ID attribute.
	FieldID string
	Writer  io.Writer // the writer to write log messages

	DetailsInfo
	// contains filtered or unexported fields
}

type Logger

type Logger struct {
	Category  string    // the category associated with this logger
	Formatter Formatter // message formatter
	// contains filtered or unexported fields
}

Logger records log messages and dispatches them to various targets for further processing.

var L *Logger

L is the predefined default global logger.

func NewLogger

func NewLogger() *Logger

NewLogger creates a root logger. The new logger takes these default options: ErrorWriter: os.Stderr, BufferSize: 1024, MaxLevel: LU.LevelDebug, Category: app, Formatter: DefaultFormatter

func NewNullLogger

func NewNullLogger() *Logger

NewNullLogger creates a no-op logger. .

func (Logger) Close

func (l Logger) Close()

Close closes the logger and the targets. Existing messages will be processed before the targets are closed. New incoming messages will be discarded after calling this method.

func (*Logger) Debug

func (l *Logger) Debug(format string, a ...interface{})

Debug logs a message for debugging purpose. Please refer to Error() for how to use this method.

func (*Logger) Error

func (l *Logger) Error(format string, a ...interface{})

Error logs a message indicating an error condition. This method takes one or multiple parameters. If a single parameter is provided, it IS the log message. If multiple parameters are provided, they are passed to fmt.Sprintf() to generate the log message.

Example
package main

import (
	log "github.com/fbaube/mlog"
)

func main() {
	logger := log.NewLogger()

	logger.Targets = append(logger.Targets, log.NewConsoleTarget())

	logger.Open()

	// log without formatting
	logger.Error("a plain message")
	// log with formatting
	logger.Error("the value is: %v", 100)
}
Output:

func (Logger) Flush

func (l Logger) Flush()

Flush flushes the logger and the targets.

func (*Logger) GetLogger

func (l *Logger) GetLogger(category string, formatter ...Formatter) *Logger

GetLogger creates a logger with the specified category and log formatter. Messages logged thru this logger will carry the same category name. The formatter, if not specified, will inherit from the calling logger. It will be used to format all messages logged thru this logger.

func (*Logger) Info

func (l *Logger) Info(format string, a ...interface{})

Info logs a message for a normal but meaningful condition.

func (*Logger) Log

func (l *Logger) Log(level LU.Level, format string, a ...interface{})

Log logs a message of a specified severity level.

func (*Logger) LogWithString

func (l *Logger) LogWithString(level LU.Level, format string, special string, a ...interface{})

func (*Logger) Okay

func (l *Logger) Okay(format string, a ...interface{})

Okay logs a message indicating an okay condition.

func (Logger) Open

func (l Logger) Open() error

Open prepares the logger and the targets for logging purpose. Open must be called before any message can be logged.

func (*Logger) Panic

func (l *Logger) Panic(format string, a ...interface{})

Panic logs a message indicating the system is dying, but does NOT actually execute a call to panic(..)

func (Logger) SetCategory

func (l Logger) SetCategory(s string)

SetCategory is for DetailsTarget's.

func (Logger) SetSubcategory

func (l Logger) SetSubcategory(s string)

SetSubcategory is for DetailsTarget's.

func (*Logger) Warning

func (l *Logger) Warning(format string, a ...interface{})

Warning logs a message indicating a warning condition.

type MailTarget

type MailTarget struct {
	*Filter
	Host       string   // SMTP server address
	Username   string   // SMTP server login username
	Password   string   // SMTP server login password
	Subject    string   // the mail subject
	Sender     string   // the mail sender
	Recipients []string // the mail recipients
	BufferSize int      // the size of the message channel.
	// contains filtered or unexported fields
}

MailTarget sends log messages in emails via an SMTP server.

func NewMailTarget

func NewMailTarget() *MailTarget

NewMailTarget creates a MailTarget. The new MailTarget takes these default options: MaxLevel: LevelDbg, BufferSize: 1024. You must specify these fields: Host, Username, Subject, Sender, and Recipients.

Example
package main

import (
	log "github.com/fbaube/mlog"
)

func main() {
	logger := log.NewLogger()

	// creates a MailTarget which sends emails to admin@example.com
	target := log.NewMailTarget()
	target.Host = "smtp.example.com"
	target.Username = "foo"
	target.Password = "bar"
	target.Subject = "log messages for foobar"
	target.Sender = "admin@example.com"
	target.Recipients = []string{"admin@example.com"}

	logger.Targets = append(logger.Targets, target)

	logger.Open()

	// ... logger is ready to use ...
}
Output:

func (*MailTarget) Close

func (t *MailTarget) Close()

Close closes the mail target.

func (*MailTarget) DoesDetails

func (t *MailTarget) DoesDetails() bool

func (*MailTarget) Flush

func (t *MailTarget) Flush()

Flush is a no-op.

func (*MailTarget) Open

func (t *MailTarget) Open(errWriter io.Writer) error

Open prepares MailTarget for processing log messages.

func (*MailTarget) Process

func (t *MailTarget) Process(e *Entry)

Process puts filtered log messages into a channel for sending in emails.

type NetworkTarget

type NetworkTarget struct {
	*Filter
	// the network to connect to. Valid networks include
	// tcp", "tcp4" (IPv4-only), "tcp6" (IPv6-only),
	// "udp", "udp4" (IPv4-only), "udp6" (IPv6-only), "ip", "ip4"
	// (IPv4-only), "ip6" (IPv6-only), "unix", "unixgram" and
	// "unixpacket".
	Network string
	// the address on the network to connect to.
	// For TCP and UDP networks, addresses have the form host:port.
	// If host is a literal IPv6 address it must be enclosed
	// in square brackets as in "[::1]:80" or "[ipv6-host%zone]:80".
	Address string
	// whether to use a persistent network connection.
	// If this is false, for every message to be sent, a network
	// connection will be open and closed.
	Persistent bool
	// the size of the message channel.
	BufferSize int
	// contains filtered or unexported fields
}

NetworkTarget sends log messages over a network connection.

func NewNetworkTarget

func NewNetworkTarget() *NetworkTarget

NewNetworkTarget creates a NetworkTarget. The new NetworkTarget takes these default options: MaxLevel: LevelDbg, Persistent: true, BufferSize: 1024. You must specify the Network and Address fields.

Example
package main

import (
	log "github.com/fbaube/mlog"
)

func main() {
	logger := log.NewLogger()

	// creates a NetworkTarget which uses tcp network and address :10234
	target := log.NewNetworkTarget()
	target.Network = "tcp"
	target.Address = ":10234"

	logger.Targets = append(logger.Targets, target)

	logger.Open()

	// ... logger is ready to use ...
}
Output:

func (*NetworkTarget) Close

func (t *NetworkTarget) Close()

Close closes the network target.

func (*NetworkTarget) DoesDetails

func (t *NetworkTarget) DoesDetails() bool

func (*NetworkTarget) Flush

func (t *NetworkTarget) Flush()

Flush is a no-op.

func (*NetworkTarget) Open

func (t *NetworkTarget) Open(errWriter io.Writer) error

Open prepares NetworkTarget for processing log messages.

func (*NetworkTarget) Process

func (t *NetworkTarget) Process(e *Entry)

Process puts filtered log messages into a channel for sending over network.

type StrInStrOut

type StrInStrOut func(string) string

StrInStrOut is String In, String Out. In this app, a ControlSequenceTextBrush StrInStrOut wraps simple text in console control characters to apply color and effects, and then resets them at the end of the text.

type Target

type Target interface {
	// Open prepares the target for processing log messages.
	// Called when Logger.Open() is called.
	// If an error is returned, the target will be removed from the
	// logger. errWriter should be used to write errors found while
	// processing log messages, and should probably default to Stderr.
	Open(errWriter io.Writer) error
	// Process processes an incoming log message.
	Process(*Entry)
	// Close closes a target.
	// Called when Logger.Close() is called. Each target gets
	// a chance to flush its log messages to its destination.
	Close()
	// Flush is NEW and added so that logging plays nicely with
	// other sources of text.
	Flush()
	// DoesDetails is NEW and has a value per-struct, not per-instance.
	DoesDetails() bool
}

Target represents a target where the logger can send log messages to for further processing.

Jump to

Keyboard shortcuts

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