Add logger package with performance benchmarks

This commit is contained in:
loveuer
2026-01-17 17:27:33 +08:00
parent f7160ce416
commit 507a67e455
7 changed files with 1105 additions and 0 deletions

121
README.md
View File

@@ -96,6 +96,127 @@ fm.CloseManager()
- `x-amz-meta-complete`: Upload completion status
- `x-amz-meta-code`: Unique file code
## logger
A lightweight, structured logging package for Go.
### Features
- **Simple API**: Format strings and structured fields
- **Global & Instance**: Use default global logger or create custom instances
- **Context Support**: Extract trace_id from context automatically
- **Multiple Formats**: Text (default) and JSON
- **Level Filtering**: DEBUG, INFO, WARN, ERROR, FATAL, PANIC
- **No Dependencies**: Standard library only
- **High Performance**: ~727 ns/op for text format, zero-allocation when filtered
### Quick Start
```go
import "gitea.loveuer.com/loveuer/upkg/logger"
// Global logger (default)
logger.Info("hello %s", "world")
logger.Error("failed: %v", err)
// With fields
logger.InfoField("user logged in",
"user_id", 123,
"action", "login",
)
// Context with trace_id
ctx := context.WithValue(ctx, "trace_id", "req-123")
logger.InfoCtx(ctx, "request processed")
```
### Custom Logger
```go
log := logger.New(
logger.WithLevel(logger.DEBUG),
logger.WithFormat(logger.JSON),
logger.WithOutput(logger.Stdout),
logger.WithCaller(true),
)
log.Info("custom logger")
log.InfoField("event", "key", "value")
```
### API
#### Level Methods
```go
log.Debug(format string, args ...)
log.Info(format string, args ...)
log.Warn(format string, args ...)
log.Error(format string, args ...)
log.Fatal(format string, args ...)
log.Panic(format string, args ...)
```
#### Context Methods
```go
log.InfoCtx(ctx, format string, args ...)
// Automatically adds trace_id from ctx as a field
```
#### Field Methods
```go
log.InfoField(message string, keyValues ...interface{})
// Example: log.InfoField("user created", "id", 123, "name", "john")
```
### Configuration Options
```go
logger.New(
WithLevel(INFO), // Minimum log level
WithFormat(TEXT), // TEXT or JSON
WithOutput(Stdout), // Stdout, Stderr, or use WithOutputFile
WithOutputFile("/path/to.log"), // Write to file
WithCaller(true), // Include file:line in output
WithFieldKey("trace_id"), // Context field key name
WithPrefix("myapp"), // Text format prefix
WithTimeFormat("2006-01-02"), // Custom time format
)
```
### Performance
| Benchmark | ns/op | B/op | allocs |
|-----------|-------|------|--------|
| Info (Text) | 727 | 319 | 6 |
| Info (JSON) | 1932 | 1026 | 18 |
| Info (Filtered) | 2 | 0 | 0 |
| InfoField | 1889 | 1014 | 14 |
| InfoWithCaller | 1772 | 856 | 11 |
### Text Format Output
```
2026-01-17 15:30:45 INFO hello world
2026-01-17 15:30:45 INFO user logged in user_id=123 action=login
```
### JSON Format Output
```json
{"time":"2026-01-17T15:30:45Z","level":"INFO","message":"hello world"}
{"time":"2026-01-17T15:30:45Z","level":"INFO","message":"user logged in","user_id":123,"action":"login"}
```
## Packages
| Package | Description |
|---------|-------------|
| `controller/file_manager` | File storage abstraction (local/S3) |
| `logger` | Structured logging package |
## License
MIT

99
logger/default.go Normal file
View File

@@ -0,0 +1,99 @@
package logger
import (
"context"
"fmt"
"os"
)
var defaultLogger *Logger
func init() {
defaultLogger = New()
}
func Default() *Logger {
return defaultLogger
}
func SetDefault(l *Logger) {
defaultLogger = l
}
func Debug(format string, args ...interface{}) {
defaultLogger.log(DEBUG, format, args...)
}
func Info(format string, args ...interface{}) {
defaultLogger.log(INFO, format, args...)
}
func Warn(format string, args ...interface{}) {
defaultLogger.log(WARN, format, args...)
}
func Error(format string, args ...interface{}) {
defaultLogger.log(ERROR, format, args...)
}
func Fatal(format string, args ...interface{}) {
defaultLogger.log(FATAL, format, args...)
os.Exit(1)
}
func Panic(format string, args ...interface{}) {
defaultLogger.log(PANIC, format, args...)
panic(fmt.Sprintf(format, args...))
}
func DebugCtx(ctx context.Context, format string, args ...interface{}) {
defaultLogger.logCtx(DEBUG, ctx, format, args...)
}
func InfoCtx(ctx context.Context, format string, args ...interface{}) {
defaultLogger.logCtx(INFO, ctx, format, args...)
}
func WarnCtx(ctx context.Context, format string, args ...interface{}) {
defaultLogger.logCtx(WARN, ctx, format, args...)
}
func ErrorCtx(ctx context.Context, format string, args ...interface{}) {
defaultLogger.logCtx(ERROR, ctx, format, args...)
}
func FatalCtx(ctx context.Context, format string, args ...interface{}) {
defaultLogger.logCtx(FATAL, ctx, format, args...)
os.Exit(1)
}
func PanicCtx(ctx context.Context, format string, args ...interface{}) {
defaultLogger.logCtx(PANIC, ctx, format, args...)
panic(fmt.Sprintf(format, args...))
}
func DebugField(message string, keyValues ...interface{}) {
defaultLogger.logField(DEBUG, message, keyValues...)
}
func InfoField(message string, keyValues ...interface{}) {
defaultLogger.logField(INFO, message, keyValues...)
}
func WarnField(message string, keyValues ...interface{}) {
defaultLogger.logField(WARN, message, keyValues...)
}
func ErrorField(message string, keyValues ...interface{}) {
defaultLogger.logField(ERROR, message, keyValues...)
}
func FatalField(message string, keyValues ...interface{}) {
defaultLogger.logField(FATAL, message, keyValues...)
os.Exit(1)
}
func PanicField(message string, keyValues ...interface{}) {
defaultLogger.logField(PANIC, message, keyValues...)
panic(message)
}

77
logger/formatter.go Normal file
View File

@@ -0,0 +1,77 @@
package logger
import (
"encoding/json"
"fmt"
"io"
"strings"
"time"
)
type Formatter interface {
Format(writer io.Writer, entry *Entry)
}
type textFormatter struct {
timeFormat string
prefix string
}
type jsonFormatter struct{}
func newFormatter(format Format) Formatter {
switch format {
case JSON:
return &jsonFormatter{}
default:
return &textFormatter{
timeFormat: "2006-01-02 15:04:05",
}
}
}
func (f *textFormatter) Format(writer io.Writer, entry *Entry) {
parts := make([]string, 0, 8)
parts = append(parts, entry.Time.Format(f.timeFormat))
parts = append(parts, entry.Level.String())
if f.prefix != "" {
parts = append(parts, "["+f.prefix+"]")
}
if entry.Caller != "" {
parts = append(parts, entry.Caller)
}
parts = append(parts, entry.Message)
if len(entry.Fields) > 0 {
fieldStrs := make([]string, 0, len(entry.Fields))
for k, v := range entry.Fields {
fieldStrs = append(fieldStrs, fmt.Sprintf("%s=%v", k, v))
}
parts = append(parts, strings.Join(fieldStrs, " "))
}
fmt.Fprintln(writer, strings.Join(parts, " "))
}
func (f *jsonFormatter) Format(writer io.Writer, entry *Entry) {
m := map[string]interface{}{
"time": entry.Time.Format(time.RFC3339),
"level": entry.Level.String(),
"message": entry.Message,
}
if entry.Caller != "" {
m["caller"] = entry.Caller
}
for k, v := range entry.Fields {
m[k] = v
}
data, _ := json.Marshal(m)
writer.Write(data)
writer.Write([]byte("\n"))
}

278
logger/logger.go Normal file
View File

@@ -0,0 +1,278 @@
package logger
import (
"context"
"fmt"
"io"
"os"
"runtime"
"sync"
"time"
)
type Level int8
const (
DEBUG Level = iota
INFO
WARN
ERROR
FATAL
PANIC
)
func (l Level) String() string {
switch l {
case DEBUG:
return "DEBUG"
case INFO:
return "INFO"
case WARN:
return "WARN"
case ERROR:
return "ERROR"
case FATAL:
return "FATAL"
case PANIC:
return "PANIC"
default:
return "UNKNOWN"
}
}
type Format int8
const (
TEXT Format = iota
JSON
)
type Output int8
const (
Stdout Output = iota
Stderr
)
type Entry struct {
Time time.Time
Level Level
Message string
Fields map[string]interface{}
Caller string
}
type Logger struct {
opts options
mu sync.Mutex
writer io.Writer
formatter Formatter
}
func New(opts ...Option) *Logger {
o := defaultOptions()
for _, opt := range opts {
opt(&o)
}
var writer io.Writer
switch o.output {
case Stdout:
writer = os.Stdout
case Stderr:
writer = os.Stderr
default:
writer = os.Stdout
}
if o.outputFile != "" {
f, err := os.OpenFile(o.outputFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err == nil {
writer = f
}
}
return &Logger{
opts: o,
writer: writer,
formatter: newFormatter(o.format),
}
}
func (l *Logger) log(level Level, msg string, args ...interface{}) {
if level < l.opts.level {
return
}
entry := &Entry{
Time: time.Now(),
Level: level,
Message: fmt.Sprintf(msg, args...),
Fields: make(map[string]interface{}),
}
if l.opts.caller {
entry.Caller = getCaller(3)
}
l.mu.Lock()
defer l.mu.Unlock()
l.formatter.Format(l.writer, entry)
}
func (l *Logger) logCtx(level Level, ctx context.Context, msg string, args ...interface{}) {
if level < l.opts.level {
return
}
entry := &Entry{
Time: time.Now(),
Level: level,
Message: fmt.Sprintf(msg, args...),
Fields: make(map[string]interface{}),
}
if ctx != nil {
if traceID := ctx.Value(l.opts.fieldKey); traceID != nil {
entry.Fields[l.opts.fieldKey] = traceID
}
}
if l.opts.caller {
entry.Caller = getCaller(3)
}
l.mu.Lock()
defer l.mu.Unlock()
l.formatter.Format(l.writer, entry)
}
func (l *Logger) logField(level Level, message string, keyValues ...interface{}) {
if level < l.opts.level {
return
}
entry := &Entry{
Time: time.Now(),
Level: level,
Message: message,
Fields: parseFields(keyValues),
}
if l.opts.caller {
entry.Caller = getCaller(3)
}
l.mu.Lock()
defer l.mu.Unlock()
l.formatter.Format(l.writer, entry)
}
func (l *Logger) Debug(format string, args ...interface{}) {
l.log(DEBUG, format, args...)
}
func (l *Logger) Info(format string, args ...interface{}) {
l.log(INFO, format, args...)
}
func (l *Logger) Warn(format string, args ...interface{}) {
l.log(WARN, format, args...)
}
func (l *Logger) Error(format string, args ...interface{}) {
l.log(ERROR, format, args...)
}
func (l *Logger) Fatal(format string, args ...interface{}) {
l.log(FATAL, format, args...)
os.Exit(1)
}
func (l *Logger) Panic(format string, args ...interface{}) {
l.log(PANIC, format, args...)
l.mu.Lock()
defer l.mu.Unlock()
panic(fmt.Sprintf(format, args...))
}
func (l *Logger) DebugCtx(ctx context.Context, format string, args ...interface{}) {
l.logCtx(DEBUG, ctx, format, args...)
}
func (l *Logger) InfoCtx(ctx context.Context, format string, args ...interface{}) {
l.logCtx(INFO, ctx, format, args...)
}
func (l *Logger) WarnCtx(ctx context.Context, format string, args ...interface{}) {
l.logCtx(WARN, ctx, format, args...)
}
func (l *Logger) ErrorCtx(ctx context.Context, format string, args ...interface{}) {
l.logCtx(ERROR, ctx, format, args...)
}
func (l *Logger) FatalCtx(ctx context.Context, format string, args ...interface{}) {
l.logCtx(FATAL, ctx, format, args...)
os.Exit(1)
}
func (l *Logger) PanicCtx(ctx context.Context, format string, args ...interface{}) {
l.logCtx(PANIC, ctx, format, args...)
l.mu.Lock()
defer l.mu.Unlock()
panic(fmt.Sprintf(format, args...))
}
func (l *Logger) DebugField(message string, keyValues ...interface{}) {
l.logField(DEBUG, message, keyValues...)
}
func (l *Logger) InfoField(message string, keyValues ...interface{}) {
l.logField(INFO, message, keyValues...)
}
func (l *Logger) WarnField(message string, keyValues ...interface{}) {
l.logField(WARN, message, keyValues...)
}
func (l *Logger) ErrorField(message string, keyValues ...interface{}) {
l.logField(ERROR, message, keyValues...)
}
func (l *Logger) FatalField(message string, keyValues ...interface{}) {
l.logField(FATAL, message, keyValues...)
os.Exit(1)
}
func (l *Logger) PanicField(message string, keyValues ...interface{}) {
l.logField(PANIC, message, keyValues...)
panic(message)
}
func (l *Logger) Close() error {
if f, ok := l.writer.(*os.File); ok {
return f.Close()
}
return nil
}
func getCaller(skip int) string {
_, file, line, ok := runtime.Caller(skip)
if !ok {
return "?:?"
}
return fmt.Sprintf("%s:%d", file, line)
}
func parseFields(keyValues []interface{}) map[string]interface{} {
fields := make(map[string]interface{})
for i := 0; i+1 < len(keyValues); i += 2 {
key, ok := keyValues[i].(string)
if !ok {
continue
}
fields[key] = keyValues[i+1]
}
return fields
}

326
logger/logger_test.go Normal file
View File

@@ -0,0 +1,326 @@
package logger
import (
"bytes"
"context"
"strings"
"testing"
)
func TestTextFormat(t *testing.T) {
buf := &bytes.Buffer{}
l := New(
WithOutputFile(""),
WithFormat(TEXT),
WithTimeFormat("2006-01-02"),
)
l.mu.Lock()
l.writer = buf
l.mu.Unlock()
l.Info("hello %s", "world")
output := buf.String()
if !strings.Contains(output, "hello world") {
t.Errorf("expected 'hello world' in output, got: %s", output)
}
}
func TestJSONFormat(t *testing.T) {
buf := &bytes.Buffer{}
l := New(
WithOutputFile(""),
WithFormat(JSON),
)
l.mu.Lock()
l.writer = buf
l.mu.Unlock()
l.Info("test message")
output := buf.String()
if !strings.Contains(output, `"message":"test message"`) {
t.Errorf("expected JSON message in output, got: %s", output)
}
if !strings.Contains(output, `"level":"INFO"`) {
t.Errorf("expected level INFO in output, got: %s", output)
}
}
func TestWithFields(t *testing.T) {
buf := &bytes.Buffer{}
l := New(
WithOutputFile(""),
WithFormat(TEXT),
)
l.mu.Lock()
l.writer = buf
l.mu.Unlock()
l.InfoField("user logged in",
"user_id", 123,
"action", "login",
)
output := buf.String()
if !strings.Contains(output, "user_id=123") {
t.Errorf("expected user_id=123 in output, got: %s", output)
}
if !strings.Contains(output, "action=login") {
t.Errorf("expected action=login in output, got: %s", output)
}
}
func TestContextTraceID(t *testing.T) {
buf := &bytes.Buffer{}
l := New(
WithOutputFile(""),
WithFormat(JSON),
)
l.mu.Lock()
l.writer = buf
l.mu.Unlock()
ctx := context.WithValue(context.Background(), "trace_id", "req-123")
l.InfoCtx(ctx, "request processed")
output := buf.String()
if !strings.Contains(output, `"trace_id":"req-123"`) {
t.Errorf("expected trace_id in output, got: %s", output)
}
}
func TestCustomFieldKey(t *testing.T) {
buf := &bytes.Buffer{}
l := New(
WithOutputFile(""),
WithFormat(JSON),
WithFieldKey("request_id"),
)
l.mu.Lock()
l.writer = buf
l.mu.Unlock()
ctx := context.WithValue(context.Background(), "request_id", "req-456")
l.InfoCtx(ctx, "request processed")
output := buf.String()
if !strings.Contains(output, `"request_id":"req-456"`) {
t.Errorf("expected request_id in output, got: %s", output)
}
}
func TestCaller(t *testing.T) {
buf := &bytes.Buffer{}
l := New(
WithOutputFile(""),
WithFormat(TEXT),
WithCaller(true),
)
l.mu.Lock()
l.writer = buf
l.mu.Unlock()
l.Info("test caller")
output := buf.String()
if !strings.Contains(output, "logger_test.go:") {
t.Errorf("expected caller info in output, got: %s", output)
}
}
func TestLevelFiltering(t *testing.T) {
buf := &bytes.Buffer{}
l := New(
WithOutputFile(""),
WithLevel(ERROR),
)
l.mu.Lock()
l.writer = buf
l.mu.Unlock()
l.Debug("debug message")
l.Info("info message")
l.Error("error message")
output := buf.String()
if strings.Contains(output, "debug message") {
t.Errorf("debug message should be filtered out, got: %s", output)
}
if strings.Contains(output, "info message") {
t.Errorf("info message should be filtered out, got: %s", output)
}
if !strings.Contains(output, "error message") {
t.Errorf("error message should be present, got: %s", output)
}
}
func TestGlobalLogger(t *testing.T) {
buf := &bytes.Buffer{}
original := defaultLogger
defer func() { defaultLogger = original }()
defaultLogger = New(
WithOutputFile(""),
WithFormat(TEXT),
)
defaultLogger.mu.Lock()
defaultLogger.writer = buf
defaultLogger.mu.Unlock()
Info("global logger test")
output := buf.String()
if !strings.Contains(output, "global logger test") {
t.Errorf("expected global logger output, got: %s", output)
}
}
// Benchmarks
func benchmarkLogger(format Format, caller bool) *Logger {
buf := &bytes.Buffer{}
l := New(
WithOutputFile(""),
WithFormat(format),
WithCaller(caller),
)
l.mu.Lock()
l.writer = buf
l.mu.Unlock()
return l
}
func BenchmarkInfoText(b *testing.B) {
l := benchmarkLogger(TEXT, false)
msg := "hello world"
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
l.Info("hello world")
_ = msg
}
}
func BenchmarkInfoTextWithArgs(b *testing.B) {
l := benchmarkLogger(TEXT, false)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
l.Info("user %s logged in from %s", "john", "192.168.1.1")
}
}
func BenchmarkInfoJSON(b *testing.B) {
l := benchmarkLogger(JSON, false)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
l.Info("hello world")
}
}
func BenchmarkInfoField(b *testing.B) {
l := benchmarkLogger(TEXT, false)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
l.InfoField("user logged in",
"user_id", 123,
"action", "login",
"ip", "192.168.1.1",
)
}
}
func BenchmarkInfoFieldJSON(b *testing.B) {
l := benchmarkLogger(JSON, false)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
l.InfoField("user logged in",
"user_id", 123,
"action", "login",
"ip", "192.168.1.1",
)
}
}
func BenchmarkInfoWithCaller(b *testing.B) {
l := benchmarkLogger(TEXT, true)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
l.Info("hello world")
}
}
func BenchmarkInfoCtx(b *testing.B) {
l := benchmarkLogger(JSON, false)
ctx := context.WithValue(context.Background(), "trace_id", "req-123456789")
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
l.InfoCtx(ctx, "request processed")
}
}
func BenchmarkInfoFiltered(b *testing.B) {
l := New(
WithOutputFile(""),
WithLevel(ERROR),
)
l.mu.Lock()
l.writer = &bytes.Buffer{}
l.mu.Unlock()
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
l.Info("this should be filtered")
}
}
func BenchmarkManyFields(b *testing.B) {
l := benchmarkLogger(JSON, false)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
l.InfoField("event",
"field1", "value1",
"field2", "value2",
"field3", "value3",
"field4", "value4",
"field5", "value5",
"field6", "value6",
"field7", "value7",
"field8", "value8",
)
}
}
func BenchmarkGlobalInfo(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
Info("hello world")
}
}
func BenchmarkGlobalInfoField(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
InfoField("event",
"key1", "value1",
"key2", "value2",
)
}
}

75
logger/options.go Normal file
View File

@@ -0,0 +1,75 @@
package logger
type options struct {
level Level
format Format
output Output
outputFile string
caller bool
fieldKey string
prefix string
timeFormat string
}
type Option func(*options)
func WithLevel(level Level) Option {
return func(o *options) {
o.level = level
}
}
func WithFormat(format Format) Option {
return func(o *options) {
o.format = format
}
}
func WithOutput(output Output) Option {
return func(o *options) {
o.output = output
}
}
func WithOutputFile(file string) Option {
return func(o *options) {
o.outputFile = file
}
}
func WithCaller(caller bool) Option {
return func(o *options) {
o.caller = caller
}
}
func WithFieldKey(key string) Option {
return func(o *options) {
o.fieldKey = key
}
}
func WithPrefix(prefix string) Option {
return func(o *options) {
o.prefix = prefix
}
}
func WithTimeFormat(format string) Option {
return func(o *options) {
o.timeFormat = format
}
}
func defaultOptions() options {
return options{
level: INFO,
format: TEXT,
output: Stdout,
outputFile: "",
caller: false,
fieldKey: "trace_id",
prefix: "",
timeFormat: "2006-01-02 15:04:05",
}
}

129
logger/readme.md Normal file
View File

@@ -0,0 +1,129 @@
# Logger
A lightweight, structured logging package for Go.
## Features
- **Simple API**: Format strings and structured fields
- **Global & Instance**: Use default global logger or create custom instances
- **Context Support**: Extract trace_id from context automatically
- **Multiple Formats**: Text (default) and JSON
- **Level Filtering**: DEBUG, INFO, WARN, ERROR, FATAL, PANIC
- **No Dependencies**: Standard library only
## Quick Start
```go
import "gitea.loveuer.com/loveuer/upkg/logger"
// Global logger (default)
logger.Info("hello %s", "world")
logger.Error("failed: %v", err)
// With fields
logger.InfoField("user logged in",
"user_id", 123,
"action", "login",
)
// Context with trace_id
ctx := context.WithValue(ctx, "trace_id", "req-123")
logger.InfoCtx(ctx, "request processed")
```
## Custom Logger
```go
log := logger.New(
logger.WithLevel(logger.DEBUG),
logger.WithFormat(logger.JSON),
logger.WithOutput(logger.Stdout),
logger.WithCaller(true),
)
log.Info("custom logger")
log.InfoField("event", "key", "value")
```
## API
### Level Methods
```go
log.Debug(format string, args ...)
log.Info(format string, args ...)
log.Warn(format string, args ...)
log.Error(format string, args ...)
log.Fatal(format string, args ...)
log.Panic(format string, args ...)
```
### Context Methods
```go
log.InfoCtx(ctx, format string, args ...)
// Automatically adds trace_id from ctx as a field
```
### Field Methods
```go
log.InfoField(message string, keyValues ...interface{})
// Example: log.InfoField("user created", "id", 123, "name", "john")
```
## Configuration Options
```go
logger.New(
WithLevel(INFO), // Minimum log level
WithFormat(TEXT), // TEXT or JSON
WithOutput(Stdout), // Stdout, Stderr, or use WithOutputFile
WithOutputFile("/path/to.log"), // Write to file
WithCaller(true), // Include file:line in output
WithFieldKey("trace_id"), // Context field key name
WithPrefix("myapp"), // Text format prefix
WithTimeFormat("2006-01-02"), // Custom time format
)
```
## Text Format Output
```
2026-01-17 15:30:45 INFO hello world
2026-01-17 15:30:45 INFO user logged in user_id=123 action=login
```
## JSON Format Output
```json
{"time":"2026-01-17T15:30:45Z","level":"INFO","message":"hello world"}
{"time":"2026-01-17T15:30:45Z","level":"INFO","message":"user logged in","user_id":123,"action":"login"}
```
## Global Functions
```go
import "gitea.loveuer.com/loveuer/upkg/logger"
func Debug(format string, args ...)
func Info(format string, args ...)
func Warn(format string, args ...)
func Error(format string, args ...)
func Fatal(format string, args ...)
func Panic(format string, args ...)
func DebugCtx(ctx context.Context, format string, args ...)
func InfoCtx(ctx context.Context, format string, args ...)
func WarnCtx(ctx context.Context, format string, args ...)
func ErrorCtx(ctx context.Context, format string, args ...)
func FatalCtx(ctx context.Context, format string, args ...)
func PanicCtx(ctx context.Context, format string, args ...)
func DebugField(message string, keyValues ...interface{})
func InfoField(message string, keyValues ...interface{})
func WarnField(message string, keyValues ...interface{})
func ErrorField(message string, keyValues ...interface{})
func FatalField(message string, keyValues ...interface{})
func PanicField(message string, keyValues ...interface{})
```