This commit is contained in:
glidea
2025-04-19 15:50:26 +08:00
commit 8b33df8a05
109 changed files with 24407 additions and 0 deletions

196
pkg/telemetry/log/log.go Normal file
View File

@@ -0,0 +1,196 @@
// Copyright (C) 2025 wangyusong
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package log
import (
"context"
"log/slog"
"os"
"runtime"
"strconv"
"strings"
"sync"
"sync/atomic"
"github.com/pkg/errors"
slogdedup "github.com/veqryn/slog-dedup"
)
type Level string
const (
LevelDebug Level = "debug"
LevelInfo Level = "info"
LevelWarn Level = "warn"
LevelError Level = "error"
)
func SetLevel(level Level) error {
if level == "" {
level = LevelInfo
}
var logLevel slog.Level
switch level {
case LevelDebug:
logLevel = slog.LevelDebug
case LevelInfo:
logLevel = slog.LevelInfo
case LevelWarn:
logLevel = slog.LevelWarn
case LevelError:
logLevel = slog.LevelError
default:
return errors.Errorf("invalid log level, valid values are: %v", []Level{LevelDebug, LevelInfo, LevelWarn, LevelError})
}
newLogger := slog.New(slogdedup.NewOverwriteHandler(
slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: logLevel}),
nil,
))
mu.Lock()
defaultLogger = newLogger
mu.Unlock()
return nil
}
// With returns a new context with additional labels added to the logger.
func With(ctx context.Context, keyvals ...any) context.Context {
logger := from(ctx)
return with(ctx, logger.With(keyvals...))
}
// Debug logs a debug message with stack trace.
func Debug(ctx context.Context, msg string, args ...any) {
logWithStack(ctx, slog.LevelDebug, msg, args...)
}
// Info logs an informational message with stack trace.
func Info(ctx context.Context, msg string, args ...any) {
logWithStack(ctx, slog.LevelInfo, msg, args...)
}
// Warn logs a warning message with stack trace.
func Warn(ctx context.Context, err error, args ...any) {
logWithStack(ctx, slog.LevelWarn, err.Error(), args...)
}
// Error logs an error message with call stack trace.
func Error(ctx context.Context, err error, args ...any) {
logWithStack(ctx, slog.LevelError, err.Error(), args...)
}
// Fatal logs a fatal message with call stack trace.
// It will call os.Exit(1) after logging.
func Fatal(ctx context.Context, err error, args ...any) {
logWithStack(ctx, slog.LevelError, err.Error(), args...)
os.Exit(1)
}
type ctxKey uint8
var (
loggerCtxKey = ctxKey(0)
defaultLogger = slog.New(slogdedup.NewOverwriteHandler(slog.NewTextHandler(os.Stdout, nil), nil))
mu sync.RWMutex
// withStackLevel controls which log level and above will include stack traces.
withStackLevel atomic.Int32
)
func init() {
// Default to include stack traces for Warn and above.
SetWithStackLevel(slog.LevelWarn)
}
// SetWithStackLevel sets the minimum log level that will include stack traces.
// It should not be called in init().
func SetWithStackLevel(level slog.Level) {
withStackLevel.Store(int32(level))
}
// with returns a new context with the given logger.
func with(ctx context.Context, logger *slog.Logger) context.Context {
return context.WithValue(ctx, loggerCtxKey, logger)
}
// from retrieves the logger from context.
// Returns default logger if context has no logger.
func from(ctx context.Context) *slog.Logger {
mu.RLock()
defer mu.RUnlock()
if ctx == nil {
return defaultLogger
}
if logger, ok := ctx.Value(loggerCtxKey).(*slog.Logger); ok {
return logger
}
return defaultLogger
}
const (
stackSkip = 2 // Skip ERROR../logWithStack.
stackDepth = 5 // Maximum number of stack frames to capture.
avgFrameLen = 64
)
func logWithStack(ctx context.Context, level slog.Level, msg string, args ...any) {
logger := from(ctx)
if !logger.Enabled(ctx, level) {
// avoid to get stack trace if logging is disabled for this level
return
}
// Only include stack trace if level is >= withStackLevel
newArgs := make([]any, 0, len(args)+2)
newArgs = append(newArgs, args...)
if level >= slog.Level(withStackLevel.Load()) {
newArgs = append(newArgs, "stack", getStack(stackSkip, stackDepth))
}
logger.Log(ctx, level, msg, newArgs...)
}
// getStack returns a formatted call stack trace.
func getStack(skip, depth int) string {
pc := make([]uintptr, depth)
n := runtime.Callers(skip+2, pc) // skip itself and runtime.Callers
if n == 0 {
return ""
}
var b strings.Builder
b.Grow(n * avgFrameLen)
frames := runtime.CallersFrames(pc[:n])
first := true
for frame, more := frames.Next(); more; frame, more = frames.Next() {
if !first {
b.WriteString(" <- ")
}
first = false
b.WriteString(frame.Function)
b.WriteByte(':')
b.WriteString(strconv.Itoa(frame.Line))
}
return b.String()
}

View File

@@ -0,0 +1,159 @@
// Copyright (C) 2025 wangyusong
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package metric
import (
"context"
"net/http"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/glidea/zenfeed/pkg/model"
telemetrymodel "github.com/glidea/zenfeed/pkg/telemetry/model"
)
func Handler() http.Handler {
return promhttp.Handler()
}
var (
operationInFlight = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Namespace: model.AppName,
Name: "operation_in_flight",
Help: "Number of operations in flight.",
},
[]string{
telemetrymodel.KeyComponent,
telemetrymodel.KeyComponentInstance,
telemetrymodel.KeyOperation,
},
)
operationTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Namespace: model.AppName,
Name: "operation_total",
Help: "Total number of operations.",
},
[]string{
telemetrymodel.KeyComponent,
telemetrymodel.KeyComponentInstance,
telemetrymodel.KeyOperation,
telemetrymodel.KeyResult,
},
)
operationDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Namespace: model.AppName,
Name: "operation_duration_seconds",
Help: "Histogram of operation latencies in seconds.",
Buckets: []float64{.001, .005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10, 20},
},
[]string{
telemetrymodel.KeyComponent,
telemetrymodel.KeyComponentInstance,
telemetrymodel.KeyOperation,
telemetrymodel.KeyResult,
},
)
)
type ctxKey uint8
const (
ctxKeyComponent ctxKey = iota
ctxKeyInstance
ctxKeyOperation
ctxKeyStartTime
)
func StartWith(ctx context.Context, keyvals ...any) context.Context {
// Extend from parent context.
component, instance, operation, _ := parseFrom(ctx)
// Parse component and operation... from keyvals.
for i := 0; i < len(keyvals); i += 2 {
if i+1 < len(keyvals) {
switch keyvals[i] {
case telemetrymodel.KeyComponent:
component = keyvals[i+1].(string)
case telemetrymodel.KeyComponentInstance:
instance = keyvals[i+1].(string)
case telemetrymodel.KeyOperation:
operation = keyvals[i+1].(string)
}
}
}
if component == "" || operation == "" {
panic("missing required keyvals")
}
// Record operation in flight.
operationInFlight.WithLabelValues(component, instance, operation).Inc()
// Add to context.
ctx = context.WithValue(ctx, ctxKeyComponent, component)
ctx = context.WithValue(ctx, ctxKeyInstance, instance)
ctx = context.WithValue(ctx, ctxKeyOperation, operation)
ctx = context.WithValue(ctx, ctxKeyStartTime, time.Now())
return ctx
}
func RecordRED(ctx context.Context, err error) {
// Parse component, instance, operation, and start time from context.
component, instance, operation, startTime := parseFrom(ctx)
duration := time.Since(startTime)
// Determine result.
result := telemetrymodel.ValResultSuccess
if err != nil {
result = telemetrymodel.ValResultError
}
// Record metrics.
operationTotal.WithLabelValues(component, instance, operation, result).Inc()
operationDuration.WithLabelValues(component, instance, operation, result).Observe(duration.Seconds())
operationInFlight.WithLabelValues(component, instance, operation).Dec()
}
func Close(id prometheus.Labels) {
operationInFlight.DeletePartialMatch(id)
operationTotal.DeletePartialMatch(id)
operationDuration.DeletePartialMatch(id)
}
func parseFrom(ctx context.Context) (component, instance, operation string, startTime time.Time) {
if v := ctx.Value(ctxKeyComponent); v != nil {
component = v.(string)
}
if v := ctx.Value(ctxKeyInstance); v != nil {
instance = v.(string)
}
if v := ctx.Value(ctxKeyOperation); v != nil {
operation = v.(string)
}
if v := ctx.Value(ctxKeyStartTime); v != nil {
startTime = v.(time.Time)
}
return
}

View File

@@ -0,0 +1,29 @@
// Copyright (C) 2025 wangyusong
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package model
const (
// KeyComponent is the label for the component name.
KeyComponent = "component"
// KeyComponentInstance is the label for the component instance name.
KeyComponentInstance = "component_instance"
// KeyOperation is the label for the operation name.
KeyOperation = "operation"
// KeyResult is the label for the result of the operation.
KeyResult = "result"
ValResultSuccess = "success"
ValResultError = "error"
)

View File

@@ -0,0 +1,56 @@
// Copyright (C) 2025 wangyusong
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package telemetry
import (
"context"
"github.com/prometheus/client_golang/prometheus"
"github.com/glidea/zenfeed/pkg/telemetry/log"
"github.com/glidea/zenfeed/pkg/telemetry/metric"
)
type Labels []any
func (l Labels) Get(key any) any {
for i := 0; i < len(l); i += 2 {
if l[i] == key {
return l[i+1]
}
}
return nil
}
// StartWith starts a new operation with the given key-value pairs.
// MUST call End() to finalize the operation.
func StartWith(ctx context.Context, keyvals ...any) context.Context {
ctx = log.With(ctx, keyvals...)
ctx = metric.StartWith(ctx, keyvals...)
return ctx
}
// End records and finalizes the operation.
func End(ctx context.Context, err error) {
metric.RecordRED(ctx, err)
}
// CloseMetrics closes the metrics for the given id.
func CloseMetrics(id prometheus.Labels) {
metric.Close(id)
}