| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349 |
- 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)
- }
|