fix: expose workbench cache observability

This commit is contained in:
lyon
2026-06-22 15:10:26 +08:00
parent 19b6db4254
commit 52182aa0a5
11 changed files with 813 additions and 23 deletions
+275 -15
View File
@@ -21,6 +21,9 @@ import (
const cacheKeySchemaVersion = "wb-derived-v1"
var cacheOperationDurationBucketsSeconds = []float64{0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1}
var cachePayloadBucketsBytes = []float64{512, 1024, 4096, 16384, 65536, 262144, 1048576, 4194304}
var errCacheDisabled = errors.New("workbench redis cache disabled")
type cacheConfig struct {
@@ -60,15 +63,38 @@ type derivedCache struct {
}
type cacheMetrics struct {
mu sync.Mutex
Hits uint64
Misses uint64
Sets uint64
Deletes uint64
Errors uint64
LastErrorKind string
LastErrorOp string
LastErrorAt time.Time
mu sync.Mutex
Hits uint64
Misses uint64
Sets uint64
Deletes uint64
Errors uint64
LastErrorKind string
LastErrorOp string
LastErrorAt time.Time
Requests map[string]cacheCounterSeries
Stale map[string]cacheCounterSeries
DBQueryAvoided map[string]cacheCounterSeries
OperationDurations map[string]*cacheHistogramSeries
PayloadBytes map[string]*cacheHistogramSeries
}
type cacheMetricLabels struct {
Class string
Operation string
Status string
}
type cacheCounterSeries struct {
Labels cacheMetricLabels
Value uint64
}
type cacheHistogramSeries struct {
Labels cacheMetricLabels
Buckets []uint64
Sum float64
Count uint64
}
type cacheError struct {
@@ -144,12 +170,15 @@ func (c *derivedCache) BuildKey(parts cacheKeyParts) (string, error) {
}
func (c *derivedCache) Get(ctx context.Context, key string, target any) (bool, error) {
startedAt := time.Now()
class := cacheKeyClass(key)
if !c.usable() {
return false, nil
}
if err := validateCacheKey(c.config.MaxKeyBytes, key); err != nil {
wrapped := cacheError{Op: "get", Kind: classifyCacheError(err), Err: err}
c.metrics.recordError(wrapped)
c.metrics.recordCacheObservation(class, "get", "error", time.Since(startedAt), 0)
return false, wrapped
}
var payload []byte
@@ -160,42 +189,51 @@ func (c *derivedCache) Get(ctx context.Context, key string, target any) (bool, e
})
if errors.Is(err, redis.Nil) {
c.metrics.recordMiss()
c.metrics.recordCacheObservation(class, "get", "miss", time.Since(startedAt), 0)
return false, nil
}
if err != nil {
wrapped := cacheError{Op: "get", Kind: classifyCacheError(err), Err: err}
c.metrics.recordError(wrapped)
c.metrics.recordCacheObservation(class, "get", "unavailable", time.Since(startedAt), 0)
return false, wrapped
}
if len(payload) > c.config.MaxPayloadBytes {
wrapped := cacheError{Op: "get", Kind: "cache_payload_too_large", Err: fmt.Errorf("payload bytes %d exceeds %d", len(payload), c.config.MaxPayloadBytes)}
c.metrics.recordError(wrapped)
c.metrics.recordCacheObservation(class, "get", "error", time.Since(startedAt), len(payload))
return false, wrapped
}
if target != nil {
if err := json.Unmarshal(payload, target); err != nil {
wrapped := cacheError{Op: "get", Kind: "cache_payload_decode_failed", Err: err}
c.metrics.recordError(wrapped)
c.metrics.recordCacheObservation(class, "get", "error", time.Since(startedAt), len(payload))
return false, wrapped
}
}
c.metrics.recordHit()
c.metrics.recordCacheObservation(class, "get", "hit", time.Since(startedAt), len(payload))
return true, nil
}
func (c *derivedCache) Set(ctx context.Context, key string, value any, ttl time.Duration) error {
startedAt := time.Now()
class := cacheKeyClass(key)
if !c.usable() {
return nil
}
if err := validateCacheKey(c.config.MaxKeyBytes, key); err != nil {
wrapped := cacheError{Op: "set", Kind: classifyCacheError(err), Err: err}
c.metrics.recordError(wrapped)
c.metrics.recordCacheObservation(class, "set", "error", time.Since(startedAt), 0)
return wrapped
}
payload, err := cachePayloadBytes(value, c.config.MaxPayloadBytes)
if err != nil {
wrapped := cacheError{Op: "set", Kind: classifyCacheError(err), Err: err}
c.metrics.recordError(wrapped)
c.metrics.recordCacheObservation(class, "set", "error", time.Since(startedAt), 0)
return wrapped
}
err = c.withTimeout(ctx, func(opCtx context.Context) error {
@@ -204,12 +242,36 @@ func (c *derivedCache) Set(ctx context.Context, key string, value any, ttl time.
if err != nil {
wrapped := cacheError{Op: "set", Kind: classifyCacheError(err), Err: err}
c.metrics.recordError(wrapped)
c.metrics.recordCacheObservation(class, "set", "unavailable", time.Since(startedAt), len(payload))
return wrapped
}
c.metrics.recordSet()
c.metrics.recordCacheObservation(class, "set", "stored", time.Since(startedAt), len(payload))
return nil
}
func (c *derivedCache) RecordStale(class string) {
if c == nil {
return
}
c.metrics.recordStale(class)
}
func (c *derivedCache) RecordDBQueryAvoided(class string) {
if c == nil {
return
}
c.metrics.recordDBQueryAvoided(class)
}
func (c *derivedCache) PrometheusText(service string) string {
if c == nil {
var metrics cacheMetrics
return metrics.prometheusText(service)
}
return c.metrics.prometheusText(service)
}
func (c *derivedCache) Delete(ctx context.Context, keys ...string) error {
if !c.usable() || len(keys) == 0 {
return nil
@@ -520,6 +582,15 @@ func cacheHash(value string) string {
return hex.EncodeToString(digest[:])[:24]
}
func cacheKeyClass(key string) string {
for _, part := range strings.Split(key, ":") {
if strings.HasPrefix(part, "class=") {
return safeCacheToken(strings.TrimPrefix(part, "class="))
}
}
return "unknown"
}
func (m *cacheMetrics) recordHit() {
m.mu.Lock()
defer m.mu.Unlock()
@@ -556,16 +627,79 @@ func (m *cacheMetrics) recordError(err error) {
m.LastErrorAt = time.Now().UTC()
}
func (m *cacheMetrics) recordCacheObservation(class string, operation string, status string, duration time.Duration, payloadBytes int) {
m.mu.Lock()
defer m.mu.Unlock()
labels := cacheLabels(class, operation, status)
m.incrementCounterLocked(&m.Requests, labels)
m.recordHistogramLocked(&m.OperationDurations, labels, duration.Seconds(), cacheOperationDurationBucketsSeconds)
if payloadBytes > 0 {
m.recordHistogramLocked(&m.PayloadBytes, labels, float64(payloadBytes), cachePayloadBucketsBytes)
}
}
func (m *cacheMetrics) recordStale(class string) {
m.mu.Lock()
defer m.mu.Unlock()
m.incrementCounterLocked(&m.Stale, cacheLabels(class, "read", "stale"))
}
func (m *cacheMetrics) recordDBQueryAvoided(class string) {
m.mu.Lock()
defer m.mu.Unlock()
m.incrementCounterLocked(&m.DBQueryAvoided, cacheLabels(class, "read", "hit"))
}
func (m *cacheMetrics) incrementCounterLocked(target *map[string]cacheCounterSeries, labels cacheMetricLabels) {
if *target == nil {
*target = map[string]cacheCounterSeries{}
}
key := cacheMetricKey(labels)
entry := (*target)[key]
if entry.Labels.Class == "" {
entry.Labels = labels
}
entry.Value++
(*target)[key] = entry
}
func (m *cacheMetrics) recordHistogramLocked(target *map[string]*cacheHistogramSeries, labels cacheMetricLabels, value float64, buckets []float64) {
if value < 0 {
value = 0
}
if *target == nil {
*target = map[string]*cacheHistogramSeries{}
}
key := cacheMetricKey(labels)
entry := (*target)[key]
if entry == nil {
entry = &cacheHistogramSeries{Labels: labels, Buckets: make([]uint64, len(buckets))}
(*target)[key] = entry
}
for index, bucket := range buckets {
if value <= bucket {
entry.Buckets[index]++
}
}
entry.Sum += value
entry.Count++
}
func (m *cacheMetrics) snapshot() map[string]any {
m.mu.Lock()
defer m.mu.Unlock()
result := map[string]any{
"hits": int64(m.Hits),
"misses": int64(m.Misses),
"sets": int64(m.Sets),
"deletes": int64(m.Deletes),
"errors": int64(m.Errors),
"valuesRedacted": true,
"hits": int64(m.Hits),
"misses": int64(m.Misses),
"sets": int64(m.Sets),
"deletes": int64(m.Deletes),
"errors": int64(m.Errors),
"requestSeries": len(m.Requests),
"operationDurationSeries": len(m.OperationDurations),
"payloadBytesSeries": len(m.PayloadBytes),
"staleSeries": len(m.Stale),
"dbQueryAvoidedSeries": len(m.DBQueryAvoided),
"valuesRedacted": true,
}
if m.LastErrorKind != "" {
result["lastErrorKind"] = m.LastErrorKind
@@ -575,6 +709,132 @@ func (m *cacheMetrics) snapshot() map[string]any {
return result
}
func (m *cacheMetrics) prometheusText(service string) string {
m.mu.Lock()
defer m.mu.Unlock()
if service == "" {
service = "hwlab-workbench-runtime"
}
lines := []string{
"# HELP workbench_cache_requests_total Workbench derived Redis cache requests by low-cardinality class/status.",
"# TYPE workbench_cache_requests_total counter",
}
for _, entry := range m.Requests {
lines = append(lines, prometheusCounterLine("workbench_cache_requests_total", service, entry.Labels, entry.Value))
}
lines = append(lines,
"# HELP workbench_cache_hit_ratio Workbench derived Redis cache hit ratio by class and operation.",
"# TYPE workbench_cache_hit_ratio gauge",
)
for _, line := range m.prometheusHitRatioLinesLocked(service) {
lines = append(lines, line)
}
lines = append(lines,
"# HELP workbench_cache_operation_duration_seconds Workbench derived Redis cache operation duration in seconds.",
"# TYPE workbench_cache_operation_duration_seconds histogram",
)
lines = append(lines, prometheusHistogramLines("workbench_cache_operation_duration_seconds", service, m.OperationDurations, cacheOperationDurationBucketsSeconds)...)
lines = append(lines,
"# HELP workbench_cache_payload_bytes Workbench derived Redis cache payload size in bytes.",
"# TYPE workbench_cache_payload_bytes histogram",
)
lines = append(lines, prometheusHistogramLines("workbench_cache_payload_bytes", service, m.PayloadBytes, cachePayloadBucketsBytes)...)
lines = append(lines,
"# HELP workbench_cache_stale_total Workbench derived Redis cache stale reads detected by payload freshness metadata.",
"# TYPE workbench_cache_stale_total counter",
)
for _, entry := range m.Stale {
lines = append(lines, prometheusCounterLine("workbench_cache_stale_total", service, entry.Labels, entry.Value))
}
lines = append(lines,
"# HELP workbench_cache_unavailable_total Workbench derived Redis cache unavailable/error requests.",
"# TYPE workbench_cache_unavailable_total counter",
)
for _, entry := range m.Requests {
if entry.Labels.Status == "unavailable" || entry.Labels.Status == "error" {
lines = append(lines, prometheusCounterLine("workbench_cache_unavailable_total", service, entry.Labels, entry.Value))
}
}
lines = append(lines,
"# HELP workbench_db_query_avoided_total Workbench database reads avoided by derived Redis cache hits.",
"# TYPE workbench_db_query_avoided_total counter",
)
for _, entry := range m.DBQueryAvoided {
lines = append(lines, prometheusCounterLine("workbench_db_query_avoided_total", service, entry.Labels, entry.Value))
}
lines = append(lines, "")
return strings.Join(lines, "\n")
}
func (m *cacheMetrics) prometheusHitRatioLinesLocked(service string) []string {
type totals struct{ hit, total uint64 }
groups := map[string]totals{}
labels := map[string]cacheMetricLabels{}
for _, entry := range m.Requests {
if entry.Labels.Operation != "get" {
continue
}
key := entry.Labels.Class + "\x00" + entry.Labels.Operation
group := groups[key]
if entry.Labels.Status == "hit" {
group.hit += entry.Value
}
group.total += entry.Value
groups[key] = group
labels[key] = cacheMetricLabels{Class: entry.Labels.Class, Operation: entry.Labels.Operation, Status: "all"}
}
lines := []string{}
for key, group := range groups {
ratio := 0.0
if group.total > 0 {
ratio = float64(group.hit) / float64(group.total)
}
lines = append(lines, prometheusGaugeLine("workbench_cache_hit_ratio", service, labels[key], ratio))
}
return lines
}
func prometheusHistogramLines(name string, service string, series map[string]*cacheHistogramSeries, buckets []float64) []string {
lines := []string{}
for _, entry := range series {
for index, bucket := range buckets {
lines = append(lines, fmt.Sprintf("%s_bucket{%s,le=\"%s\"} %d", name, prometheusLabels(service, entry.Labels), prometheusFloat(bucket), entry.Buckets[index]))
}
lines = append(lines, fmt.Sprintf("%s_bucket{%s,le=\"+Inf\"} %d", name, prometheusLabels(service, entry.Labels), entry.Count))
lines = append(lines, fmt.Sprintf("%s_sum{%s} %.6f", name, prometheusLabels(service, entry.Labels), entry.Sum))
lines = append(lines, fmt.Sprintf("%s_count{%s} %d", name, prometheusLabels(service, entry.Labels), entry.Count))
}
return lines
}
func prometheusCounterLine(name string, service string, labels cacheMetricLabels, value uint64) string {
return fmt.Sprintf("%s{%s} %d", name, prometheusLabels(service, labels), value)
}
func prometheusGaugeLine(name string, service string, labels cacheMetricLabels, value float64) string {
return fmt.Sprintf("%s{%s} %.6f", name, prometheusLabels(service, labels), value)
}
func prometheusLabels(service string, labels cacheMetricLabels) string {
return fmt.Sprintf("service=\"%s\",cache_key_class=\"%s\",operation=\"%s\",cache_status=\"%s\"", prometheusEscape(service), prometheusEscape(labels.Class), prometheusEscape(labels.Operation), prometheusEscape(labels.Status))
}
func prometheusEscape(value string) string {
return strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(value, "\\", "\\\\"), "\n", "\\n"), "\"", "\\\"")
}
func prometheusFloat(value float64) string {
return strconv.FormatFloat(value, 'f', -1, 64)
}
func cacheLabels(class string, operation string, status string) cacheMetricLabels {
return cacheMetricLabels{Class: safeCacheToken(first(class, "unknown")), Operation: safeCacheToken(first(operation, "unknown")), Status: safeCacheToken(first(status, "unknown"))}
}
func cacheMetricKey(labels cacheMetricLabels) string {
return labels.Class + "\x00" + labels.Operation + "\x00" + labels.Status
}
func envBool(name string, fallback bool) bool {
value := strings.ToLower(strings.TrimSpace(os.Getenv(name)))
if value == "" {