475 lines
9.8 KiB
Go
475 lines
9.8 KiB
Go
// Package observability provides logging and metrics instrumentation.
|
|
package observability
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"runtime"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
ansiReset = "\x1b[0m"
|
|
ansiBlue = "\x1b[36m"
|
|
ansiStatusBlue = "\x1b[34m"
|
|
ansiGreen = "\x1b[32m"
|
|
ansiYellow = "\x1b[33m"
|
|
ansiOrange = "\x1b[38;5;208m"
|
|
ansiRed = "\x1b[31m"
|
|
)
|
|
|
|
var colorLogs = shouldColorLogs()
|
|
|
|
type LogLevel string
|
|
|
|
const (
|
|
LogLevelInfo LogLevel = "info"
|
|
LogLevelWarn LogLevel = "warn"
|
|
LogLevelError LogLevel = "error"
|
|
)
|
|
|
|
type LogEvent struct {
|
|
TS string `json:"ts"`
|
|
Level LogLevel `json:"level"`
|
|
Event string `json:"event"`
|
|
Message string `json:"message,omitempty"`
|
|
Fields map[string]any `json:"fields,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
Component string `json:"component,omitempty"`
|
|
}
|
|
|
|
func init() {
|
|
log.SetFlags(0)
|
|
}
|
|
|
|
func LogJSON(level LogLevel, event string, component string, message string, fields map[string]any, err error) {
|
|
LogContext(context.TODO(), level, event, component, message, fields, err)
|
|
}
|
|
|
|
func LogContext(ctx context.Context, level LogLevel, event string, component string, message string, fields map[string]any, err error) {
|
|
fields = enrichFields(level, fields, err)
|
|
fields = enrichRequestFields(ctx, fields)
|
|
|
|
entry := LogEvent{
|
|
TS: time.Now().UTC().Format(time.RFC3339Nano),
|
|
Level: level,
|
|
Event: event,
|
|
Message: message,
|
|
Fields: fields,
|
|
Component: component,
|
|
}
|
|
|
|
if err != nil {
|
|
entry.Error = err.Error()
|
|
}
|
|
|
|
log.Print(formatLogEntry(entry))
|
|
}
|
|
|
|
func enrichRequestFields(ctx context.Context, fields map[string]any) map[string]any {
|
|
requestContext, ok := RequestContextFromContext(ctx)
|
|
if !ok {
|
|
return fields
|
|
}
|
|
|
|
enriched := cloneFields(fields)
|
|
if enriched == nil {
|
|
enriched = make(map[string]any, 3)
|
|
}
|
|
|
|
if requestContext.ID != "" {
|
|
if _, exists := enriched["request_id"]; !exists {
|
|
enriched["request_id"] = requestContext.ID
|
|
}
|
|
}
|
|
if requestContext.Path != "" {
|
|
if _, exists := enriched["request_path"]; !exists {
|
|
enriched["request_path"] = requestContext.Path
|
|
}
|
|
}
|
|
if requestContext.Route != "" && requestContext.Route != requestContext.Path {
|
|
if _, exists := enriched["request_route"]; !exists {
|
|
enriched["request_route"] = requestContext.Route
|
|
}
|
|
}
|
|
|
|
return enriched
|
|
}
|
|
|
|
func enrichFields(level LogLevel, fields map[string]any, err error) map[string]any {
|
|
if level == LogLevelInfo {
|
|
return fields
|
|
}
|
|
|
|
enriched := cloneFields(fields)
|
|
if enriched == nil {
|
|
enriched = make(map[string]any, 3)
|
|
}
|
|
|
|
if _, exists := enriched["source"]; !exists {
|
|
if source := callerSource(); source != "" {
|
|
enriched["source"] = source
|
|
}
|
|
}
|
|
|
|
if err != nil {
|
|
if _, exists := enriched["error_type"]; !exists {
|
|
if errorType := formatErrorType(err); errorType != "" {
|
|
enriched["error_type"] = errorType
|
|
}
|
|
}
|
|
if _, exists := enriched["error_chain"]; !exists {
|
|
if chain := formatErrorChain(err); chain != "" {
|
|
enriched["error_chain"] = chain
|
|
}
|
|
}
|
|
}
|
|
|
|
return enriched
|
|
}
|
|
|
|
func callerSource() string {
|
|
pcs := make([]uintptr, 8)
|
|
n := runtime.Callers(3, pcs)
|
|
frames := runtime.CallersFrames(pcs[:n])
|
|
|
|
for {
|
|
frame, more := frames.Next()
|
|
if !strings.Contains(frame.File, "/internal/observability/") {
|
|
return filepath.Base(frame.File) + ":" + strconv.Itoa(frame.Line)
|
|
}
|
|
if !more {
|
|
return ""
|
|
}
|
|
}
|
|
}
|
|
|
|
func formatErrorType(err error) string {
|
|
errType := reflect.TypeOf(err)
|
|
if errType == nil {
|
|
return ""
|
|
}
|
|
|
|
return errType.String()
|
|
}
|
|
|
|
func formatErrorChain(err error) string {
|
|
parts := make([]string, 0, 4)
|
|
for current := err; current != nil; current = errors.Unwrap(current) {
|
|
parts = append(parts, current.Error())
|
|
if len(parts) == 4 {
|
|
break
|
|
}
|
|
}
|
|
|
|
if len(parts) <= 1 {
|
|
return ""
|
|
}
|
|
|
|
return strings.Join(parts, " -> ")
|
|
}
|
|
|
|
func formatLogEntry(entry LogEvent) string {
|
|
if entry.Event == "http_request" {
|
|
return formatHTTPRequestLog(entry)
|
|
}
|
|
|
|
parts := []string{entry.TS, formatLogLevel(entry.Level), entry.Event}
|
|
|
|
if entry.Component != "" {
|
|
parts = append(parts, "component="+entry.Component)
|
|
}
|
|
|
|
if entry.Message != "" {
|
|
parts = append(parts, quoteIfNeeded(entry.Message))
|
|
}
|
|
|
|
if len(entry.Fields) > 0 {
|
|
keys := make([]string, 0, len(entry.Fields))
|
|
for key := range entry.Fields {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
|
|
for _, key := range keys {
|
|
parts = append(parts, key+"="+formatFieldValue(entry.Fields[key]))
|
|
}
|
|
}
|
|
|
|
if entry.Error != "" {
|
|
parts = append(parts, "error="+quoteIfNeeded(entry.Error))
|
|
}
|
|
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
func formatHTTPRequestLog(entry LogEvent) string {
|
|
fields := cloneFields(entry.Fields)
|
|
status := popField(fields, "status")
|
|
method := popField(fields, "method")
|
|
path := popField(fields, "path")
|
|
duration := popField(fields, "duration_ms")
|
|
bytes := popField(fields, "bytes")
|
|
route := popField(fields, "route")
|
|
query := popField(fields, "query")
|
|
clientIP := popField(fields, "client_ip")
|
|
|
|
parts := []string{entry.TS, formatLogLevel(entry.Level), "http"}
|
|
appendNonEmpty(&parts, status)
|
|
appendNonEmpty(&parts, strings.TrimSpace(method+" "+path))
|
|
appendNonEmpty(&parts, duration)
|
|
appendNonEmpty(&parts, bytes)
|
|
appendKeyValue(&parts, "route", route)
|
|
appendKeyValueQuoted(&parts, "query", query)
|
|
appendClientIP(&parts, clientIP)
|
|
appendSortedFields(&parts, fields)
|
|
|
|
if entry.Error != "" {
|
|
parts = append(parts, "error="+quoteIfNeeded(entry.Error))
|
|
}
|
|
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
func appendNonEmpty(parts *[]string, value string) {
|
|
if value == "" {
|
|
return
|
|
}
|
|
|
|
*parts = append(*parts, value)
|
|
}
|
|
|
|
func appendKeyValue(parts *[]string, key string, value string) {
|
|
if value == "" {
|
|
return
|
|
}
|
|
|
|
*parts = append(*parts, key+"="+value)
|
|
}
|
|
|
|
func appendKeyValueQuoted(parts *[]string, key string, value string) {
|
|
if value == "" {
|
|
return
|
|
}
|
|
|
|
*parts = append(*parts, key+"="+quoteIfNeeded(value))
|
|
}
|
|
|
|
func appendClientIP(parts *[]string, clientIP string) {
|
|
if clientIP == "" || isLocalClientIP(clientIP) {
|
|
return
|
|
}
|
|
|
|
*parts = append(*parts, "ip="+clientIP)
|
|
}
|
|
|
|
func appendSortedFields(parts *[]string, fields map[string]any) {
|
|
if len(fields) == 0 {
|
|
return
|
|
}
|
|
|
|
keys := make([]string, 0, len(fields))
|
|
for key := range fields {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
|
|
for _, key := range keys {
|
|
*parts = append(*parts, key+"="+formatFieldValue(fields[key]))
|
|
}
|
|
}
|
|
|
|
func cloneFields(fields map[string]any) map[string]any {
|
|
if len(fields) == 0 {
|
|
return nil
|
|
}
|
|
|
|
copyFields := make(map[string]any, len(fields))
|
|
for key, value := range fields {
|
|
copyFields[key] = value
|
|
}
|
|
|
|
return copyFields
|
|
}
|
|
|
|
func popField(fields map[string]any, key string) string {
|
|
if len(fields) == 0 {
|
|
return ""
|
|
}
|
|
|
|
value, ok := fields[key]
|
|
if !ok {
|
|
return ""
|
|
}
|
|
delete(fields, key)
|
|
|
|
return formatInlineField(key, value)
|
|
}
|
|
|
|
func formatInlineField(key string, value any) string {
|
|
switch key {
|
|
case "status":
|
|
return formatHTTPStatus(value)
|
|
case "duration_ms":
|
|
return formatDurationMillis(value)
|
|
case "bytes":
|
|
return formatBytes(value)
|
|
default:
|
|
if text, ok := value.(string); ok {
|
|
return text
|
|
}
|
|
return fmt.Sprint(value)
|
|
}
|
|
}
|
|
|
|
func formatHTTPStatus(value any) string {
|
|
status := fmt.Sprint(value)
|
|
if !colorLogs || status == "" {
|
|
return status
|
|
}
|
|
|
|
switch status[0] {
|
|
case '1':
|
|
return ansiStatusBlue + status + ansiReset
|
|
case '2':
|
|
return ansiGreen + status + ansiReset
|
|
case '3':
|
|
return ansiYellow + status + ansiReset
|
|
case '4':
|
|
return ansiOrange + status + ansiReset
|
|
case '5':
|
|
return ansiRed + status + ansiReset
|
|
default:
|
|
return status
|
|
}
|
|
}
|
|
|
|
func formatDurationMillis(value any) string {
|
|
ms, ok := toFloat64(value)
|
|
if !ok {
|
|
return fmt.Sprint(value)
|
|
}
|
|
|
|
return strconv.FormatFloat(ms, 'f', -1, 64) + "ms"
|
|
}
|
|
|
|
func formatBytes(value any) string {
|
|
bytesValue, ok := toFloat64(value)
|
|
if !ok {
|
|
return fmt.Sprint(value)
|
|
}
|
|
|
|
if bytesValue < 1024 {
|
|
return strconv.FormatFloat(bytesValue, 'f', -1, 64) + "B"
|
|
}
|
|
if bytesValue < 1024*1024 {
|
|
return strconv.FormatFloat(bytesValue/1024, 'f', 1, 64) + "KB"
|
|
}
|
|
|
|
return strconv.FormatFloat(bytesValue/(1024*1024), 'f', 1, 64) + "MB"
|
|
}
|
|
|
|
func toFloat64(value any) (float64, bool) {
|
|
switch v := value.(type) {
|
|
case int:
|
|
return float64(v), true
|
|
case int32:
|
|
return float64(v), true
|
|
case int64:
|
|
return float64(v), true
|
|
case float32:
|
|
return float64(v), true
|
|
case float64:
|
|
return v, true
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|
|
|
|
func isLocalClientIP(value string) bool {
|
|
parsed := net.ParseIP(value)
|
|
if parsed == nil {
|
|
return false
|
|
}
|
|
|
|
return parsed.IsLoopback()
|
|
}
|
|
|
|
func formatLogLevel(level LogLevel) string {
|
|
if colorLogs {
|
|
switch level {
|
|
case LogLevelWarn:
|
|
return ansiYellow + "WARN" + ansiReset
|
|
case LogLevelError:
|
|
return ansiRed + "ERROR" + ansiReset
|
|
default:
|
|
return ansiBlue + "INFO" + ansiReset
|
|
}
|
|
}
|
|
|
|
switch level {
|
|
case LogLevelWarn:
|
|
return "WARN"
|
|
case LogLevelError:
|
|
return "ERROR"
|
|
default:
|
|
return "INFO"
|
|
}
|
|
}
|
|
|
|
func shouldColorLogs() bool {
|
|
if strings.TrimSpace(os.Getenv("NO_COLOR")) != "" {
|
|
return false
|
|
}
|
|
if strings.EqualFold(strings.TrimSpace(os.Getenv("TERM")), "dumb") {
|
|
return false
|
|
}
|
|
|
|
info, err := os.Stderr.Stat()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
return info.Mode()&os.ModeCharDevice != 0
|
|
}
|
|
|
|
func formatFieldValue(value any) string {
|
|
switch v := value.(type) {
|
|
case string:
|
|
return quoteIfNeeded(v)
|
|
case time.Duration:
|
|
return v.String()
|
|
case float32:
|
|
return strconv.FormatFloat(float64(v), 'f', -1, 32)
|
|
case float64:
|
|
return strconv.FormatFloat(v, 'f', -1, 64)
|
|
case fmt.Stringer:
|
|
return quoteIfNeeded(v.String())
|
|
default:
|
|
return quoteIfNeeded(fmt.Sprint(value))
|
|
}
|
|
}
|
|
|
|
func quoteIfNeeded(value string) string {
|
|
if value == "" {
|
|
return `""`
|
|
}
|
|
|
|
for _, r := range value {
|
|
if r == '=' || r == ' ' || r == '\t' || r == '\n' || r == '"' {
|
|
return strconv.Quote(value)
|
|
}
|
|
}
|
|
|
|
return value
|
|
}
|