init
This commit is contained in:
196
pkg/telemetry/log/log.go
Normal file
196
pkg/telemetry/log/log.go
Normal 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()
|
||||
}
|
||||
159
pkg/telemetry/metric/metric.go
Normal file
159
pkg/telemetry/metric/metric.go
Normal 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
|
||||
}
|
||||
29
pkg/telemetry/model/model.go
Normal file
29
pkg/telemetry/model/model.go
Normal 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"
|
||||
)
|
||||
56
pkg/telemetry/telemetry.go
Normal file
56
pkg/telemetry/telemetry.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user