mirror of
https://github.com/lukaszraczylo/filepuff-mcp.git
synced 2026-06-10 22:59:01 +00:00
9205b2bc26
- [x] Add API reference documentation with tool descriptions and examples - [x] Add ERROR_CODES reference with error descriptions and remediation steps - [x] Add PERFORMANCE tuning guide with caching and optimization details - [x] Add GitHub Actions workflows for linting and security scanning - [x] Add golangci-lint configuration with comprehensive linter settings - [x] Add pre-commit hooks configuration for local development - [x] Add API documentation generator tool (cmd/docgen) - [x] Update Go version from 1.24 to 1.25 across workflows - [x] Add static build configuration to goreleaser - [x] Add metrics package with Prometheus-style metric types - [x] Add parser benchmarks for performance testing - [x] Add LSP manager integration tests - [x] Add server integration tests with MCP protocol flow testing - [x] Extract regex cache to shared utility package - [x] Add context cancellation handling in AST queries - [x] Add graceful shutdown with timeout to server - [x] Add configurable max parse size (MaxParseSize) - [x] Add Config.Validate() method with comprehensive checks - [x] Add parser cache statistics tracking - [x] Add file permission preservation in edit operations - [x] Improve line splitting for large files with bufio.Scanner - [x] Add comprehensive config tests for edge cases - [x] Update Makefile with new targets and documentation
465 lines
11 KiB
Go
465 lines
11 KiB
Go
// Package metrics provides Prometheus-style metrics collection for the MCP server.
|
|
// It offers low-overhead, thread-safe metric types suitable for observability.
|
|
package metrics
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
// Counter is a monotonically increasing metric.
|
|
type Counter struct {
|
|
name string
|
|
help string
|
|
labels map[string]string
|
|
value atomic.Int64
|
|
}
|
|
|
|
// NewCounter creates a new counter metric.
|
|
func NewCounter(name, help string, labels map[string]string) *Counter {
|
|
return &Counter{
|
|
name: name,
|
|
help: help,
|
|
labels: labels,
|
|
}
|
|
}
|
|
|
|
// Inc increments the counter by 1.
|
|
func (c *Counter) Inc() {
|
|
c.value.Add(1)
|
|
}
|
|
|
|
// Add adds the given value to the counter.
|
|
func (c *Counter) Add(delta int64) {
|
|
c.value.Add(delta)
|
|
}
|
|
|
|
// Value returns the current counter value.
|
|
func (c *Counter) Value() int64 {
|
|
return c.value.Load()
|
|
}
|
|
|
|
// Reset resets the counter to 0.
|
|
func (c *Counter) Reset() {
|
|
c.value.Store(0)
|
|
}
|
|
|
|
// Gauge is a metric that can go up or down.
|
|
type Gauge struct {
|
|
name string
|
|
help string
|
|
labels map[string]string
|
|
value atomic.Int64
|
|
}
|
|
|
|
// NewGauge creates a new gauge metric.
|
|
func NewGauge(name, help string, labels map[string]string) *Gauge {
|
|
return &Gauge{
|
|
name: name,
|
|
help: help,
|
|
labels: labels,
|
|
}
|
|
}
|
|
|
|
// Set sets the gauge to the given value.
|
|
func (g *Gauge) Set(val int64) {
|
|
g.value.Store(val)
|
|
}
|
|
|
|
// Inc increments the gauge by 1.
|
|
func (g *Gauge) Inc() {
|
|
g.value.Add(1)
|
|
}
|
|
|
|
// Dec decrements the gauge by 1.
|
|
func (g *Gauge) Dec() {
|
|
g.value.Add(-1)
|
|
}
|
|
|
|
// Add adds the given value to the gauge.
|
|
func (g *Gauge) Add(delta int64) {
|
|
g.value.Add(delta)
|
|
}
|
|
|
|
// Value returns the current gauge value.
|
|
func (g *Gauge) Value() int64 {
|
|
return g.value.Load()
|
|
}
|
|
|
|
// Histogram tracks the distribution of values in predefined buckets.
|
|
type Histogram struct {
|
|
name string
|
|
help string
|
|
labels map[string]string
|
|
buckets []float64
|
|
counts []atomic.Int64
|
|
sum atomic.Int64 // sum of all observed values (in nanoseconds for durations)
|
|
count atomic.Int64 // total count of observations
|
|
}
|
|
|
|
// DefaultDurationBuckets are default buckets for request durations (in seconds).
|
|
var DefaultDurationBuckets = []float64{
|
|
0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0,
|
|
}
|
|
|
|
// NewHistogram creates a new histogram with the given buckets.
|
|
func NewHistogram(name, help string, labels map[string]string, buckets []float64) *Histogram {
|
|
if buckets == nil {
|
|
buckets = DefaultDurationBuckets
|
|
}
|
|
// Ensure buckets are sorted
|
|
sorted := make([]float64, len(buckets))
|
|
copy(sorted, buckets)
|
|
sort.Float64s(sorted)
|
|
|
|
h := &Histogram{
|
|
name: name,
|
|
help: help,
|
|
labels: labels,
|
|
buckets: sorted,
|
|
counts: make([]atomic.Int64, len(sorted)+1), // +1 for +Inf bucket
|
|
}
|
|
return h
|
|
}
|
|
|
|
// Observe records a value in the histogram.
|
|
func (h *Histogram) Observe(val float64) {
|
|
h.count.Add(1)
|
|
// Store sum in nanoseconds for precision with durations
|
|
h.sum.Add(int64(val * 1e9))
|
|
|
|
// Find bucket and increment
|
|
for i, bound := range h.buckets {
|
|
if val <= bound {
|
|
h.counts[i].Add(1)
|
|
return
|
|
}
|
|
}
|
|
// Value exceeds all buckets, add to +Inf
|
|
h.counts[len(h.buckets)].Add(1)
|
|
}
|
|
|
|
// ObserveDuration records a duration in seconds.
|
|
func (h *Histogram) ObserveDuration(d time.Duration) {
|
|
h.Observe(d.Seconds())
|
|
}
|
|
|
|
// Count returns the total number of observations.
|
|
func (h *Histogram) Count() int64 {
|
|
return h.count.Load()
|
|
}
|
|
|
|
// Sum returns the sum of all observations.
|
|
func (h *Histogram) Sum() float64 {
|
|
return float64(h.sum.Load()) / 1e9
|
|
}
|
|
|
|
// Registry holds all registered metrics.
|
|
type Registry struct {
|
|
mu sync.RWMutex
|
|
counters map[string]*Counter
|
|
gauges map[string]*Gauge
|
|
histograms map[string]*Histogram
|
|
}
|
|
|
|
// NewRegistry creates a new metrics registry.
|
|
func NewRegistry() *Registry {
|
|
return &Registry{
|
|
counters: make(map[string]*Counter),
|
|
gauges: make(map[string]*Gauge),
|
|
histograms: make(map[string]*Histogram),
|
|
}
|
|
}
|
|
|
|
// Counter returns or creates a counter with the given name.
|
|
func (r *Registry) Counter(name, help string, labels map[string]string) *Counter {
|
|
key := metricKey(name, labels)
|
|
r.mu.RLock()
|
|
if c, ok := r.counters[key]; ok {
|
|
r.mu.RUnlock()
|
|
return c
|
|
}
|
|
r.mu.RUnlock()
|
|
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
// Double-check after acquiring write lock
|
|
if c, ok := r.counters[key]; ok {
|
|
return c
|
|
}
|
|
|
|
c := NewCounter(name, help, labels)
|
|
r.counters[key] = c
|
|
return c
|
|
}
|
|
|
|
// Gauge returns or creates a gauge with the given name.
|
|
func (r *Registry) Gauge(name, help string, labels map[string]string) *Gauge {
|
|
key := metricKey(name, labels)
|
|
r.mu.RLock()
|
|
if g, ok := r.gauges[key]; ok {
|
|
r.mu.RUnlock()
|
|
return g
|
|
}
|
|
r.mu.RUnlock()
|
|
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
if g, ok := r.gauges[key]; ok {
|
|
return g
|
|
}
|
|
|
|
g := NewGauge(name, help, labels)
|
|
r.gauges[key] = g
|
|
return g
|
|
}
|
|
|
|
// Histogram returns or creates a histogram with the given name.
|
|
func (r *Registry) Histogram(name, help string, labels map[string]string, buckets []float64) *Histogram {
|
|
key := metricKey(name, labels)
|
|
r.mu.RLock()
|
|
if h, ok := r.histograms[key]; ok {
|
|
r.mu.RUnlock()
|
|
return h
|
|
}
|
|
r.mu.RUnlock()
|
|
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
if h, ok := r.histograms[key]; ok {
|
|
return h
|
|
}
|
|
|
|
h := NewHistogram(name, help, labels, buckets)
|
|
r.histograms[key] = h
|
|
return h
|
|
}
|
|
|
|
// metricKey creates a unique key for a metric based on name and labels.
|
|
func metricKey(name string, labels map[string]string) string {
|
|
if len(labels) == 0 {
|
|
return name
|
|
}
|
|
var parts []string
|
|
for k, v := range labels {
|
|
parts = append(parts, fmt.Sprintf("%s=%q", k, v))
|
|
}
|
|
sort.Strings(parts)
|
|
return name + "{" + strings.Join(parts, ",") + "}"
|
|
}
|
|
|
|
// formatLabels formats labels for Prometheus output.
|
|
func formatLabels(labels map[string]string) string {
|
|
if len(labels) == 0 {
|
|
return ""
|
|
}
|
|
var parts []string
|
|
for k, v := range labels {
|
|
parts = append(parts, fmt.Sprintf("%s=%q", k, v))
|
|
}
|
|
sort.Strings(parts)
|
|
return "{" + strings.Join(parts, ",") + "}"
|
|
}
|
|
|
|
// Expose returns all metrics in Prometheus text format.
|
|
func (r *Registry) Expose() string {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
|
|
var sb strings.Builder
|
|
|
|
// Export counters
|
|
for _, c := range r.counters {
|
|
if c.help != "" {
|
|
sb.WriteString(fmt.Sprintf("# HELP %s %s\n", c.name, c.help))
|
|
}
|
|
sb.WriteString(fmt.Sprintf("# TYPE %s counter\n", c.name))
|
|
sb.WriteString(fmt.Sprintf("%s%s %d\n", c.name, formatLabels(c.labels), c.value.Load()))
|
|
}
|
|
|
|
// Export gauges
|
|
for _, g := range r.gauges {
|
|
if g.help != "" {
|
|
sb.WriteString(fmt.Sprintf("# HELP %s %s\n", g.name, g.help))
|
|
}
|
|
sb.WriteString(fmt.Sprintf("# TYPE %s gauge\n", g.name))
|
|
sb.WriteString(fmt.Sprintf("%s%s %d\n", g.name, formatLabels(g.labels), g.value.Load()))
|
|
}
|
|
|
|
// Export histograms
|
|
for _, h := range r.histograms {
|
|
if h.help != "" {
|
|
sb.WriteString(fmt.Sprintf("# HELP %s %s\n", h.name, h.help))
|
|
}
|
|
sb.WriteString(fmt.Sprintf("# TYPE %s histogram\n", h.name))
|
|
|
|
// Cumulative bucket counts
|
|
var cumulative int64
|
|
for i, bound := range h.buckets {
|
|
cumulative += h.counts[i].Load()
|
|
labelStr := formatLabels(h.labels)
|
|
if labelStr == "" {
|
|
sb.WriteString(fmt.Sprintf("%s_bucket{le=\"%g\"} %d\n", h.name, bound, cumulative))
|
|
} else {
|
|
// Insert le label into existing labels
|
|
sb.WriteString(fmt.Sprintf("%s_bucket%s %d\n", h.name,
|
|
strings.Replace(labelStr, "}", fmt.Sprintf(",le=\"%g\"}", bound), 1), cumulative))
|
|
}
|
|
}
|
|
// +Inf bucket
|
|
cumulative += h.counts[len(h.buckets)].Load()
|
|
labelStr := formatLabels(h.labels)
|
|
if labelStr == "" {
|
|
sb.WriteString(fmt.Sprintf("%s_bucket{le=\"+Inf\"} %d\n", h.name, cumulative))
|
|
} else {
|
|
sb.WriteString(fmt.Sprintf("%s_bucket%s %d\n", h.name,
|
|
strings.Replace(labelStr, "}", ",le=\"+Inf\"}", 1), cumulative))
|
|
}
|
|
|
|
// Sum and count
|
|
sb.WriteString(fmt.Sprintf("%s_sum%s %g\n", h.name, formatLabels(h.labels), h.Sum()))
|
|
sb.WriteString(fmt.Sprintf("%s_count%s %d\n", h.name, formatLabels(h.labels), h.count.Load()))
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
// Reset resets all metrics to zero.
|
|
func (r *Registry) Reset() {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
for _, c := range r.counters {
|
|
c.Reset()
|
|
}
|
|
for _, g := range r.gauges {
|
|
g.Set(0)
|
|
}
|
|
// Note: Histograms don't have a simple reset due to atomic bucket counts
|
|
}
|
|
|
|
// ServerMetrics provides pre-defined metrics for the MCP server.
|
|
type ServerMetrics struct {
|
|
registry *Registry
|
|
|
|
// Request metrics
|
|
RequestsTotal *Counter
|
|
RequestErrors *Counter
|
|
RequestDuration *Histogram
|
|
|
|
// Cache metrics
|
|
CacheHits *Counter
|
|
CacheMisses *Counter
|
|
|
|
// LSP metrics
|
|
ActiveLSPServers *Gauge
|
|
|
|
// Parse metrics
|
|
ParseDuration *Histogram
|
|
ParseErrors *Counter
|
|
}
|
|
|
|
// NewServerMetrics creates a new set of server metrics.
|
|
func NewServerMetrics() *ServerMetrics {
|
|
r := NewRegistry()
|
|
|
|
return &ServerMetrics{
|
|
registry: r,
|
|
|
|
RequestsTotal: r.Counter(
|
|
"mcp_requests_total",
|
|
"Total number of MCP requests processed",
|
|
nil,
|
|
),
|
|
RequestErrors: r.Counter(
|
|
"mcp_request_errors_total",
|
|
"Total number of MCP request errors",
|
|
nil,
|
|
),
|
|
RequestDuration: r.Histogram(
|
|
"mcp_request_duration_seconds",
|
|
"Request duration in seconds",
|
|
nil,
|
|
DefaultDurationBuckets,
|
|
),
|
|
|
|
CacheHits: r.Counter(
|
|
"mcp_cache_hits_total",
|
|
"Total number of cache hits",
|
|
nil,
|
|
),
|
|
CacheMisses: r.Counter(
|
|
"mcp_cache_misses_total",
|
|
"Total number of cache misses",
|
|
nil,
|
|
),
|
|
|
|
ActiveLSPServers: r.Gauge(
|
|
"mcp_lsp_servers_active",
|
|
"Number of active LSP server connections",
|
|
nil,
|
|
),
|
|
|
|
ParseDuration: r.Histogram(
|
|
"mcp_parse_duration_seconds",
|
|
"Parse duration in seconds",
|
|
nil,
|
|
[]float64{0.0001, 0.0005, 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0},
|
|
),
|
|
ParseErrors: r.Counter(
|
|
"mcp_parse_errors_total",
|
|
"Total number of parse errors",
|
|
nil,
|
|
),
|
|
}
|
|
}
|
|
|
|
// Expose returns all metrics in Prometheus text format.
|
|
func (m *ServerMetrics) Expose() string {
|
|
return m.registry.Expose()
|
|
}
|
|
|
|
// Registry returns the underlying metrics registry.
|
|
func (m *ServerMetrics) Registry() *Registry {
|
|
return m.registry
|
|
}
|
|
|
|
// RecordRequest records a request with its duration and error status.
|
|
func (m *ServerMetrics) RecordRequest(duration time.Duration, err error) {
|
|
m.RequestsTotal.Inc()
|
|
m.RequestDuration.ObserveDuration(duration)
|
|
if err != nil {
|
|
m.RequestErrors.Inc()
|
|
}
|
|
}
|
|
|
|
// RecordParse records a parse operation with its duration and error status.
|
|
func (m *ServerMetrics) RecordParse(duration time.Duration, err error) {
|
|
m.ParseDuration.ObserveDuration(duration)
|
|
if err != nil {
|
|
m.ParseErrors.Inc()
|
|
}
|
|
}
|
|
|
|
// RecordCacheHit records a cache hit.
|
|
func (m *ServerMetrics) RecordCacheHit() {
|
|
m.CacheHits.Inc()
|
|
}
|
|
|
|
// RecordCacheMiss records a cache miss.
|
|
func (m *ServerMetrics) RecordCacheMiss() {
|
|
m.CacheMisses.Inc()
|
|
}
|
|
|
|
// SetActiveLSPServers sets the number of active LSP servers.
|
|
func (m *ServerMetrics) SetActiveLSPServers(count int64) {
|
|
m.ActiveLSPServers.Set(count)
|
|
}
|