Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
350 lines
8.8 KiB
Go
350 lines
8.8 KiB
Go
package servertiming
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
HeaderName = "Server-Timing"
|
|
AdminUIHeader = "X-Admin-UI-Request"
|
|
UserUIHeader = "X-User-UI-Request"
|
|
MetricDatabase = "db"
|
|
MetricRedis = "redis"
|
|
dependencyPrefix = "dep_"
|
|
|
|
maxMetricNameLength = 48
|
|
maxIntervals = 2048
|
|
maxHeaderLength = 4096
|
|
)
|
|
|
|
type contextKey struct{}
|
|
|
|
type interval struct {
|
|
start time.Time
|
|
end time.Time
|
|
}
|
|
|
|
type metric struct {
|
|
count int64
|
|
intervals []interval
|
|
}
|
|
|
|
// Collector stores request-scoped timing samples. It is safe for concurrent use.
|
|
type Collector struct {
|
|
startedAt time.Time
|
|
|
|
mu sync.Mutex
|
|
metrics map[string]*metric
|
|
cacheStatus string
|
|
}
|
|
|
|
// New creates a collector whose total duration starts at startedAt.
|
|
func New(startedAt time.Time) *Collector {
|
|
if startedAt.IsZero() {
|
|
startedAt = time.Now()
|
|
}
|
|
return &Collector{
|
|
startedAt: startedAt,
|
|
metrics: make(map[string]*metric),
|
|
}
|
|
}
|
|
|
|
// WithCollector attaches a collector to a context.
|
|
func WithCollector(ctx context.Context, collector *Collector) context.Context {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
if collector == nil {
|
|
return ctx
|
|
}
|
|
return context.WithValue(ctx, contextKey{}, collector)
|
|
}
|
|
|
|
// FromContext returns the request timing collector, when one is active.
|
|
func FromContext(ctx context.Context) (*Collector, bool) {
|
|
if ctx == nil {
|
|
return nil, false
|
|
}
|
|
collector, ok := ctx.Value(contextKey{}).(*Collector)
|
|
return collector, ok && collector != nil
|
|
}
|
|
|
|
// Active reports whether timing collection is enabled for this request.
|
|
func Active(ctx context.Context) bool {
|
|
_, ok := FromContext(ctx)
|
|
return ok
|
|
}
|
|
|
|
// Record adds a completed interval and operation count to a metric.
|
|
func Record(ctx context.Context, name string, startedAt, endedAt time.Time, count int) {
|
|
collector, ok := FromContext(ctx)
|
|
if !ok {
|
|
return
|
|
}
|
|
collector.Record(name, startedAt, endedAt, count)
|
|
}
|
|
|
|
// RecordInterval adds timing without incrementing the operation count. It is
|
|
// useful when one logical operation has multiple blocking driver calls.
|
|
func RecordInterval(ctx context.Context, name string, startedAt, endedAt time.Time) {
|
|
collector, ok := FromContext(ctx)
|
|
if !ok {
|
|
return
|
|
}
|
|
collector.record(name, startedAt, endedAt, 0)
|
|
}
|
|
|
|
// Record adds a completed interval directly to the collector.
|
|
func (c *Collector) Record(name string, startedAt, endedAt time.Time, count int) {
|
|
if count <= 0 {
|
|
count = 1
|
|
}
|
|
c.record(name, startedAt, endedAt, count)
|
|
}
|
|
|
|
func (c *Collector) record(name string, startedAt, endedAt time.Time, count int) {
|
|
name = normalizeMetricName(name)
|
|
if c == nil || name == "" || startedAt.IsZero() || endedAt.Before(startedAt) {
|
|
return
|
|
}
|
|
if count < 0 {
|
|
count = 0
|
|
}
|
|
|
|
c.mu.Lock()
|
|
m := c.metrics[name]
|
|
if m == nil {
|
|
m = &metric{}
|
|
c.metrics[name] = m
|
|
}
|
|
m.count += int64(count)
|
|
if len(m.intervals) < maxIntervals {
|
|
m.intervals = append(m.intervals, interval{start: startedAt, end: endedAt})
|
|
}
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
// Observe starts a metric span and returns an idempotent completion function.
|
|
func Observe(ctx context.Context, name string) func() {
|
|
collector, ok := FromContext(ctx)
|
|
name = normalizeMetricName(name)
|
|
if !ok || name == "" {
|
|
return func() {}
|
|
}
|
|
startedAt := time.Now()
|
|
var once sync.Once
|
|
return func() {
|
|
once.Do(func() {
|
|
collector.Record(name, startedAt, time.Now(), 1)
|
|
})
|
|
}
|
|
}
|
|
|
|
// ObserveDependency starts a named external dependency span.
|
|
func ObserveDependency(ctx context.Context, module string) func() {
|
|
return Observe(ctx, dependencyMetricName(module))
|
|
}
|
|
|
|
// RecordDependency records a completed external dependency interval.
|
|
func RecordDependency(ctx context.Context, module string, startedAt, endedAt time.Time) {
|
|
Record(ctx, dependencyMetricName(module), startedAt, endedAt, 1)
|
|
}
|
|
|
|
// SetCacheStatus records the response-cache outcome for the request.
|
|
func SetCacheStatus(ctx context.Context, status string) {
|
|
collector, ok := FromContext(ctx)
|
|
if !ok {
|
|
return
|
|
}
|
|
status = normalizeCacheStatus(status)
|
|
if status == "" {
|
|
return
|
|
}
|
|
collector.mu.Lock()
|
|
collector.cacheStatus = status
|
|
collector.mu.Unlock()
|
|
}
|
|
|
|
// HeaderValue renders a bounded, deterministic Server-Timing header.
|
|
func HeaderValue(ctx context.Context, endedAt time.Time, cacheStatus string) string {
|
|
collector, ok := FromContext(ctx)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return collector.HeaderValue(endedAt, cacheStatus)
|
|
}
|
|
|
|
// HeaderValue renders a bounded, deterministic Server-Timing header.
|
|
func (c *Collector) HeaderValue(endedAt time.Time, cacheStatus string) string {
|
|
if c == nil {
|
|
return ""
|
|
}
|
|
if endedAt.IsZero() {
|
|
endedAt = time.Now()
|
|
}
|
|
if endedAt.Before(c.startedAt) {
|
|
endedAt = c.startedAt
|
|
}
|
|
|
|
c.mu.Lock()
|
|
metrics := make(map[string]metric, len(c.metrics))
|
|
allIntervals := make([]interval, 0)
|
|
dependencyIntervals := make([]interval, 0)
|
|
var dependencyCount int64
|
|
for name, source := range c.metrics {
|
|
copied := metric{count: source.count, intervals: append([]interval(nil), source.intervals...)}
|
|
metrics[name] = copied
|
|
allIntervals = append(allIntervals, copied.intervals...)
|
|
if strings.HasPrefix(name, dependencyPrefix) {
|
|
dependencyIntervals = append(dependencyIntervals, copied.intervals...)
|
|
dependencyCount += copied.count
|
|
}
|
|
}
|
|
storedCacheStatus := c.cacheStatus
|
|
c.mu.Unlock()
|
|
|
|
total := endedAt.Sub(c.startedAt)
|
|
blocked := unionDuration(allIntervals, c.startedAt, endedAt)
|
|
app := total - blocked
|
|
if app < 0 {
|
|
app = 0
|
|
}
|
|
|
|
cacheStatus = normalizeCacheStatus(cacheStatus)
|
|
if cacheStatus == "" {
|
|
cacheStatus = normalizeCacheStatus(storedCacheStatus)
|
|
}
|
|
if cacheStatus == "" {
|
|
cacheStatus = "bypass"
|
|
}
|
|
|
|
database := metrics[MetricDatabase]
|
|
redisMetric := metrics[MetricRedis]
|
|
parts := []string{
|
|
"total;dur=" + formatDuration(total),
|
|
"app;dur=" + formatDuration(app),
|
|
fmt.Sprintf("db;dur=%s;desc=\"queries=%d\"", formatDuration(unionDuration(database.intervals, c.startedAt, endedAt)), database.count),
|
|
fmt.Sprintf("redis;dur=%s;desc=\"commands=%d\"", formatDuration(unionDuration(redisMetric.intervals, c.startedAt, endedAt)), redisMetric.count),
|
|
"cache;desc=\"" + cacheStatus + "\"",
|
|
fmt.Sprintf("deps;dur=%s;desc=\"calls=%d\"", formatDuration(unionDuration(dependencyIntervals, c.startedAt, endedAt)), dependencyCount),
|
|
}
|
|
|
|
dependencyNames := make([]string, 0)
|
|
for name := range metrics {
|
|
if strings.HasPrefix(name, dependencyPrefix) {
|
|
dependencyNames = append(dependencyNames, name)
|
|
}
|
|
}
|
|
sort.Strings(dependencyNames)
|
|
for _, name := range dependencyNames {
|
|
m := metrics[name]
|
|
part := fmt.Sprintf("%s;dur=%s;desc=\"calls=%d\"", name, formatDuration(unionDuration(m.intervals, c.startedAt, endedAt)), m.count)
|
|
candidate := strings.Join(append(parts, part), ", ")
|
|
if len(candidate) > maxHeaderLength {
|
|
break
|
|
}
|
|
parts = append(parts, part)
|
|
}
|
|
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
func dependencyMetricName(module string) string {
|
|
module = normalizeMetricName(module)
|
|
module = strings.TrimPrefix(module, dependencyPrefix)
|
|
if module == "" {
|
|
module = "http"
|
|
}
|
|
return dependencyPrefix + module
|
|
}
|
|
|
|
func normalizeMetricName(name string) string {
|
|
name = strings.ToLower(strings.TrimSpace(name))
|
|
if name == "" {
|
|
return ""
|
|
}
|
|
var b strings.Builder
|
|
b.Grow(min(len(name), maxMetricNameLength))
|
|
for _, r := range name {
|
|
if b.Len() >= maxMetricNameLength {
|
|
break
|
|
}
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
|
_, _ = b.WriteRune(r)
|
|
case r == '_' || r == '-':
|
|
_ = b.WriteByte('_')
|
|
}
|
|
}
|
|
return strings.Trim(b.String(), "_")
|
|
}
|
|
|
|
func normalizeCacheStatus(status string) string {
|
|
switch strings.ToLower(strings.TrimSpace(status)) {
|
|
case "hit":
|
|
return "hit"
|
|
case "miss":
|
|
return "miss"
|
|
case "bypass":
|
|
return "bypass"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func unionDuration(intervals []interval, lowerBound, upperBound time.Time) time.Duration {
|
|
if len(intervals) == 0 || !upperBound.After(lowerBound) {
|
|
return 0
|
|
}
|
|
normalized := make([]interval, 0, len(intervals))
|
|
for _, item := range intervals {
|
|
start := item.start
|
|
end := item.end
|
|
if start.Before(lowerBound) {
|
|
start = lowerBound
|
|
}
|
|
if end.After(upperBound) {
|
|
end = upperBound
|
|
}
|
|
if end.After(start) {
|
|
normalized = append(normalized, interval{start: start, end: end})
|
|
}
|
|
}
|
|
if len(normalized) == 0 {
|
|
return 0
|
|
}
|
|
sort.Slice(normalized, func(i, j int) bool {
|
|
return normalized[i].start.Before(normalized[j].start)
|
|
})
|
|
|
|
currentStart := normalized[0].start
|
|
currentEnd := normalized[0].end
|
|
var total time.Duration
|
|
for _, item := range normalized[1:] {
|
|
if !item.start.After(currentEnd) {
|
|
if item.end.After(currentEnd) {
|
|
currentEnd = item.end
|
|
}
|
|
continue
|
|
}
|
|
total += currentEnd.Sub(currentStart)
|
|
currentStart = item.start
|
|
currentEnd = item.end
|
|
}
|
|
total += currentEnd.Sub(currentStart)
|
|
return total
|
|
}
|
|
|
|
func formatDuration(value time.Duration) string {
|
|
if value < 0 {
|
|
value = 0
|
|
}
|
|
return strconv.FormatFloat(float64(value)/float64(time.Millisecond), 'f', 1, 64)
|
|
}
|