diff --git a/api/mw/monitor/monitor.go b/api/mw/monitor/monitor.go new file mode 100644 index 000000000..79980e7d5 --- /dev/null +++ b/api/mw/monitor/monitor.go @@ -0,0 +1,147 @@ +/* +Copyright 2024 The west2-online Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package monitor + +import ( + "context" + "sync" + "time" + + "github.com/cloudwego/hertz/pkg/app" + oteltrace "go.opentelemetry.io/otel/trace" + + "github.com/west2-online/fzuhelper-server/pkg/errno" +) + +// MonitorConfig 是 API 监控的配置参数。 +type MonitorConfig struct { + Enabled bool + Window time.Duration + CheckInterval time.Duration + Threshold float64 + MinRequests int64 + Cooldown time.Duration + Blacklist map[string]struct{} +} + +var ( + apiMonitorInstance *apiMonitor + apiMonitorStartOnce sync.Once + apiMonitorStop = func(context.Context) {} +) + +// StartAPIMonitor 启动 API 监控后台检查,并返回用于优雅停止监控的函数。 +// 停止函数会等待后台检查 goroutine 退出,便于服务关闭时完成资源清理。 +func StartAPIMonitor(cfg MonitorConfig) func(context.Context) { + // 监控进程生命周期与服务进程一致,只允许初始化一次。 + apiMonitorStartOnce.Do(func() { + apiMonitorInstance = newAPIMonitor(cfg) + if !apiMonitorInstance.enabled() { + // 未启用监控时不启动后台任务,返回默认的空操作停止函数。 + return + } + + interval := apiMonitorInstance.checkInterval() + if interval <= 0 { + // 无效的检查间隔不能用于创建 ticker,避免启动时 panic。 + return + } + + // cancel 用于通知后台任务退出,done 用于确认检查 goroutine 已结束。 + ctx, cancel := context.WithCancel(context.Background()) + checkDone := make(chan struct{}) + go func() { + defer close(checkDone) + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + // 定期清理滑动窗口并检查各路由的错误率。 + apiMonitorInstance.check() + case <-ctx.Done(): + // 服务关闭时优先响应取消信号,不再执行新的检查。 + return + } + } + }() + + apiMonitorStop = func(ctx context.Context) { + cancel() + // 等待检查循环退出,避免服务关闭后仍残留后台任务。 + select { + case <-checkDone: + case <-ctx.Done(): + return + } + } + }) + + // API 服务会在 OnShutdown hook 中调用该函数。 + return apiMonitorStop +} + +func APIMonitorMiddleware() app.HandlerFunc { + return func(ctx context.Context, c *app.RequestContext) { + if apiMonitorInstance == nil || !apiMonitorInstance.enabled() { + c.Next(ctx) + return + } + + c.Next(ctx) + + now := time.Now() + event := buildRequestEvent( + routeName(c), + c.Response.Body(), + getTraceIDFromContext(ctx), + now, + ) + apiMonitorInstance.record(event) + } +} + +func getTraceIDFromContext(ctx context.Context) string { + spanCtx := oteltrace.SpanContextFromContext(ctx) + if !spanCtx.IsValid() { + return "" + } + return spanCtx.TraceID().String() +} + +func routeName(c *app.RequestContext) string { + if route := c.FullPath(); route != "" { + return route + } + return string(c.Path()) +} + +// MarkAPIMonitorPanic 在 panic 恢复时记录一条错误事件,供滑动窗口统计。 +func MarkAPIMonitorPanic(ctx context.Context, c *app.RequestContext) { + if apiMonitorInstance == nil || !apiMonitorInstance.enabled() { + return + } + event := requestEvent{ + route: routeName(c), + errorCode: errno.InternalServiceErrorCode, + traceID: getTraceIDFromContext(ctx), + timestamp: time.Now(), + } + apiMonitorInstance.record(event) +} diff --git a/api/mw/monitor/monitor_test.go b/api/mw/monitor/monitor_test.go new file mode 100644 index 000000000..119e878d4 --- /dev/null +++ b/api/mw/monitor/monitor_test.go @@ -0,0 +1,322 @@ +/* +Copyright 2024 The west2-online Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package monitor + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestBuildRequestEvent(t *testing.T) { + type testCase struct { + name string + body []byte + traceID string + errorCode int64 + } + + testCases := []testCase{ + { + name: "success string code", + body: []byte(`{"code":"10000","message":"ok"}`), + traceID: "trace-ok", + }, + { + name: "success response with 5xx trace", + body: []byte(`{"code":"10000","message":"ok"}`), + traceID: "trace-5xx", + }, + { + name: "panic recovered", + body: []byte(`{"code":50001,"message":"panic recovered"}`), + traceID: "trace-panic", + errorCode: 50001, + }, + { + name: "internal error string code", + body: []byte(`{"code":"50001","message":"internal error"}`), + traceID: "trace-biz", + errorCode: 50001, + }, + { + name: "auth error", + body: []byte(`{"code":"30002","message":"auth invalid"}`), + traceID: "trace-auth", + errorCode: 30002, + }, + { + name: "parameter error", + body: []byte(`{"code":"20001","message":"param error"}`), + traceID: "trace-param", + errorCode: 20001, + }, + { + name: "business error", + body: []byte(`{"code":"40001","message":"biz error"}`), + traceID: "trace-biz-4xx", + errorCode: 40001, + }, + { + name: "internal error numeric code", + body: []byte(`{"code":50001,"message":"internal error"}`), + traceID: "trace-biz-int", + errorCode: 50001, + }, + { + name: "paper response", + body: []byte(`{"code":2000,"msg":"Success"}`), + traceID: "trace-paper", + }, + } + + now := time.Now() + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + event := buildRequestEvent("/api/foo", tc.body, tc.traceID, now) + + assert.Equal(t, "/api/foo", event.route) + assert.Equal(t, tc.errorCode, event.errorCode) + assert.Equal(t, tc.traceID, event.traceID) + assert.Equal(t, now, event.timestamp) + }) + } +} + +func TestCompactWindow(t *testing.T) { + type testCase struct { + name string + events []requestEvent + cutoff time.Time + expectedRoute string + } + + now := time.Date(2026, time.July, 30, 12, 0, 0, 0, time.UTC) + testCases := []testCase{ + { + name: "remove events before cutoff", + events: []requestEvent{ + {route: "/expired", timestamp: now.Add(-2 * time.Minute)}, + {route: "/kept", timestamp: now.Add(-30 * time.Second)}, + }, + cutoff: now.Add(-time.Minute), + expectedRoute: "/kept", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + kept := compactWindow(tc.events, tc.cutoff) + + if assert.Len(t, kept, 1) { + assert.Equal(t, tc.expectedRoute, kept[0].route) + } + }) + } +} + +func TestAggregateRouteStats(t *testing.T) { + testCases := []struct { + name string + events []requestEvent + expected map[string]routeStat + }{ + { + name: "aggregate requests and reportable errors", + events: []requestEvent{ + {route: "/api/foo", traceID: "trace-1"}, + {route: "/api/foo", errorCode: 50001, traceID: "trace-2"}, + {route: "/api/foo", errorCode: 30002, traceID: "trace-ignored"}, + {route: "/api/bar", errorCode: 40001, traceID: "trace-3"}, + }, + expected: map[string]routeStat{ + "/api/foo": { + requests: 3, + errors: 2, + errorRate: 0.6667, + traceID: "trace-2", + errorCode: 50001, + }, + "/api/bar": { + requests: 1, + errors: 1, + errorRate: 1, + traceID: "trace-3", + errorCode: 40001, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + stats := aggregateRouteStats(tc.events) + + for route, expected := range tc.expected { + actual, ok := stats[route] + if assert.True(t, ok) { + assert.Equal(t, expected.requests, actual.requests) + assert.Equal(t, expected.errors, actual.errors) + assert.InDelta(t, expected.errorRate, actual.errorRate, 0.0001) + assert.Equal(t, expected.traceID, actual.traceID) + assert.Equal(t, expected.errorCode, actual.errorCode) + } + } + }) + } +} + +func TestMonitorRecordSkipsDisabledAndBlacklisted(t *testing.T) { + testCases := []struct { + name string + config MonitorConfig + events []requestEvent + expectedRoute string + expectedCount int + }{ + { + name: "disabled monitor skips events", + events: []requestEvent{ + {route: "/api/foo"}, + }, + expectedCount: 0, + }, + { + name: "blacklisted route is skipped", + config: MonitorConfig{ + Enabled: true, + Blacklist: map[string]struct{}{"/api/foo": {}}, + }, + events: []requestEvent{ + {route: "/api/foo"}, + {route: "/api/bar"}, + }, + expectedRoute: "/api/bar", + expectedCount: 1, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + monitor := newAPIMonitor(tc.config) + for _, event := range tc.events { + monitor.record(event) + } + + assert.Len(t, monitor.events, tc.expectedCount) + if tc.expectedCount > 0 { + assert.Equal(t, tc.expectedRoute, monitor.events[0].route) + } + }) + } +} + +func TestStartAPIMonitorStopReturnsBeforeNextCheck(t *testing.T) { + testCases := []struct { + name string + checkInterval time.Duration + }{ + { + name: "stop returns before next check", + checkInterval: time.Hour, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + apiMonitorInstance = nil + apiMonitorStartOnce = sync.Once{} + apiMonitorStop = func(context.Context) {} + defer func() { + apiMonitorInstance = nil + apiMonitorStartOnce = sync.Once{} + apiMonitorStop = func(context.Context) {} + }() + + stop := StartAPIMonitor(MonitorConfig{ + Enabled: true, + CheckInterval: tc.checkInterval, + }) + + done := make(chan struct{}) + go func() { + stop(context.Background()) + close(done) + }() + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("api monitor stop should not wait for the next check interval") + } + }) + } +} + +func TestMonitorAlertCooldownAndRecover(t *testing.T) { + testCases := []struct { + name string + config MonitorConfig + stat routeStat + firstCheck time.Time + }{ + { + name: "cooldown prevents duplicate alert and recovery clears state", + config: MonitorConfig{ + Enabled: true, + Window: time.Minute, + CheckInterval: time.Second, + Threshold: 0.5, + MinRequests: 2, + Cooldown: 10 * time.Minute, + }, + stat: routeStat{ + requests: 2, + errors: 1, + errorRate: 0.5, + traceID: "trace-alert", + errorCode: 50001, + }, + firstCheck: time.Date(2026, time.July, 30, 12, 0, 0, 0, time.UTC), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + monitor := newAPIMonitor(tc.config) + + monitor.checkRoute(tc.firstCheck, "/api/foo", tc.stat) + firstAlert := monitor.alerts["/api/foo"] + assert.True(t, firstAlert.firing) + assert.Equal(t, "trace-alert", firstAlert.lastTrace) + assert.Equal(t, int64(50001), firstAlert.lastCode) + + tc.stat.traceID = "trace-cooldown" + monitor.checkRoute(tc.firstCheck.Add(time.Minute), "/api/foo", tc.stat) + assert.Equal(t, firstAlert.lastAlert, monitor.alerts["/api/foo"].lastAlert) + assert.Equal(t, "trace-alert", monitor.alerts["/api/foo"].lastTrace) + + tc.stat.errorRate = 0.1 + monitor.checkRoute(tc.firstCheck.Add(2*time.Minute), "/api/foo", tc.stat) + _, ok := monitor.alerts["/api/foo"] + assert.False(t, ok) + }) + } +} diff --git a/api/mw/monitor/state.go b/api/mw/monitor/state.go new file mode 100644 index 000000000..01fb03f6f --- /dev/null +++ b/api/mw/monitor/state.go @@ -0,0 +1,263 @@ +/* +Copyright 2024 The west2-online Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package monitor + +import ( + "encoding/json" + "strconv" + "sync" + "time" + + "go.uber.org/zap" + + "github.com/west2-online/fzuhelper-server/pkg/errno" + "github.com/west2-online/fzuhelper-server/pkg/logger" +) + +// requestEvent 是滑动窗口计算保留的最小请求事件。 +type requestEvent struct { + route string + errorCode int64 + traceID string + timestamp time.Time +} + +// routeStat 是按路由聚合后的报警判断数据。 +type routeStat struct { + requests int64 + errors int64 + errorRate float64 + traceID string + errorCode int64 +} + +// alertState 记录单个路由当前的报警状态。 +type alertState struct { + firing bool + lastAlert time.Time + lastTrace string + lastCode int64 + lastErrors int64 +} + +// apiMonitor 维护滑动窗口事件和路由报警状态。 +type apiMonitor struct { + mu sync.Mutex + cfg MonitorConfig + events []requestEvent + alerts map[string]alertState +} + +func newAPIMonitor(cfg MonitorConfig) *apiMonitor { + if cfg.Blacklist == nil { + cfg.Blacklist = make(map[string]struct{}) + } + return &apiMonitor{ + cfg: cfg, + events: make([]requestEvent, 0), + alerts: make(map[string]alertState), + } +} + +func (m *apiMonitor) enabled() bool { + return m.cfg.Enabled +} + +func (m *apiMonitor) checkInterval() time.Duration { + return m.cfg.CheckInterval +} + +func (m *apiMonitor) shouldIgnore(route string) bool { + _, ok := m.cfg.Blacklist[route] + return ok +} + +// 追加到滑动窗口维护中枢 +func (m *apiMonitor) record(event requestEvent) { + if !m.cfg.Enabled || m.shouldIgnore(event.route) { + return + } + + m.mu.Lock() + defer m.mu.Unlock() + m.events = append(m.events, event) +} + +// 删除过期事件,并且判断是否报错 +func (m *apiMonitor) check() { + if !m.cfg.Enabled { + return + } + + now := time.Now() + m.mu.Lock() + defer m.mu.Unlock() + + cutoff := now.Add(-m.cfg.Window) + m.events = compactWindow(m.events, cutoff) + stats := aggregateRouteStats(m.events) + + for route, stat := range stats { + m.checkRoute(now, route, stat) + } + for route, alert := range m.alerts { + if _, ok := stats[route]; !ok && alert.firing { + m.logRecovered(route, routeStat{}) + delete(m.alerts, route) + } + } +} + +// 判断是否报错 +func (m *apiMonitor) checkRoute(now time.Time, route string, stat routeStat) { + if stat.requests < m.cfg.MinRequests { + return + } + + alert := m.alerts[route] + if stat.errorRate >= m.cfg.Threshold { + if !alert.firing || now.Sub(alert.lastAlert) >= m.cfg.Cooldown { + alert.firing = true + alert.lastAlert = now + alert.lastTrace = stat.traceID + alert.lastCode = stat.errorCode + alert.lastErrors = stat.errors + m.alerts[route] = alert + m.logAlert(route, stat) + } + return + } + + if alert.firing { + m.logRecovered(route, stat) + delete(m.alerts, route) + } +} + +// 生成事件 +func buildRequestEvent(route string, responseBody []byte, traceID string, now time.Time) requestEvent { + code, ok := responseCode(responseBody) + if !ok || isSuccessCode(code) { + code = 0 + } + + return requestEvent{ + route: route, + errorCode: code, + traceID: traceID, + timestamp: now, + } +} + +// 删除过期时间的事件 +func compactWindow(events []requestEvent, cutoff time.Time) []requestEvent { + kept := events[:0] + for _, event := range events { + if !event.timestamp.Before(cutoff) { + kept = append(kept, event) + } + } + return kept +} + +// 计算错误率并生成报错这个事件 +func aggregateRouteStats(events []requestEvent) map[string]routeStat { + stats := make(map[string]routeStat) + for _, event := range events { + stat := stats[event.route] + stat.requests++ + if isMonitorError(event.errorCode) { + stat.errors++ + if stat.traceID == "" { + stat.traceID = event.traceID + stat.errorCode = event.errorCode + } + } + stats[event.route] = stat + } + + for route, stat := range stats { + stat.errorRate = float64(stat.errors) / float64(stat.requests) + stats[route] = stat + } + return stats +} + +func isMonitorError(code int64) bool { + return code != 0 && !isSuccessCode(code) +} + +func isSuccessCode(code int64) bool { + switch code { + case errno.SuccessCode, errno.SuccessCodePaper: + return true + default: + return false + } +} + +func responseCode(responseBody []byte) (int64, bool) { + if len(responseBody) == 0 { + return 0, false + } + + var payload struct { + Code any `json:"code"` + } + if err := json.Unmarshal(responseBody, &payload); err != nil { + return 0, false + } + + switch code := payload.Code.(type) { + case string: + value, err := strconv.ParseInt(code, 10, 64) + if err != nil { + return 0, false + } + return value, true + case float64: + return int64(code), true + default: + return 0, false + } +} + +func (m *apiMonitor) logAlert(route string, stat routeStat) { + logger.Error("api service anomaly detected", + m.logFields("api_service_anomaly", route, stat)..., + ) +} + +func (m *apiMonitor) logRecovered(route string, stat routeStat) { + logger.Info("api service anomaly recovered", + m.logFields("api_service_anomaly_recovered", route, stat)..., + ) +} + +func (m *apiMonitor) logFields(event string, route string, stat routeStat) []zap.Field { + return []zap.Field{ + zap.String("event", event), + zap.String("route", route), + zap.String("traceid", stat.traceID), + zap.Int64("error_code", stat.errorCode), + zap.Int64("requests", stat.requests), + zap.Int64("errors", stat.errors), + zap.Float64("error_rate", stat.errorRate), + zap.Float64("threshold", m.cfg.Threshold), + zap.Int64("min_requests", m.cfg.MinRequests), + } +} diff --git a/cmd/api/main.go b/cmd/api/main.go index e6d2bdba5..1db8ef6f2 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -20,6 +20,7 @@ package main import ( "context" + "time" sentinel "github.com/alibaba/sentinel-golang/api" "github.com/alibaba/sentinel-golang/core/flow" @@ -31,6 +32,7 @@ import ( "github.com/hertz-contrib/opensergo/sentinel/adapter" "github.com/west2-online/fzuhelper-server/api/mcp" + "github.com/west2-online/fzuhelper-server/api/mw/monitor" hertztracing "github.com/hertz-contrib/obs-opentelemetry/tracing" @@ -83,6 +85,13 @@ func main() { // Tracing h.Use(hertztracing.ServerMiddleware(traceCfg)) + // API monitor + stopAPIMonitor := monitor.StartAPIMonitor(apiMonitorConfig()) + h.OnShutdown = append(h.OnShutdown, func(ctx context.Context) { + stopAPIMonitor(ctx) + }) + h.Use(monitor.APIMonitorMiddleware()) + // register http2 server factory h.AddProtocol("h2", factory.NewServerFactory()) @@ -112,6 +121,7 @@ func main() { } func recoveryHandler(ctx context.Context, c *app.RequestContext, err interface{}, stack []byte) { + monitor.MarkAPIMonitorPanic(ctx, c) logger.Errorf("[Recovery] InternalServiceError err=%v\n stack=%s\n", err, stack) c.JSON(consts.StatusInternalServerError, map[string]interface{}{ "code": errno.InternalServiceErrorCode, @@ -119,6 +129,29 @@ func recoveryHandler(ctx context.Context, c *app.RequestContext, err interface{} }) } +// 初始化对应对应的配置文件 +func apiMonitorConfig() monitor.MonitorConfig { + cfg := config.APIMonitor + if cfg == nil { + return monitor.MonitorConfig{} + } + + blacklist := make(map[string]struct{}, len(cfg.RouteBlacklist)) + for _, route := range cfg.RouteBlacklist { + blacklist[route] = struct{}{} + } + + return monitor.MonitorConfig{ + Enabled: cfg.Enabled, + Window: time.Duration(cfg.WindowSeconds) * time.Second, + CheckInterval: time.Duration(cfg.CheckIntervalSeconds) * time.Second, + Threshold: cfg.ErrorRateThreshold, + MinRequests: cfg.MinRequests, + Cooldown: time.Duration(cfg.AlertCooldownSeconds) * time.Second, + Blacklist: blacklist, + } +} + func initSentinel() { err := sentinel.InitDefault() if err != nil { diff --git a/config/config.example.yaml b/config/config.example.yaml index 0d5bf5cc2..a0d6b62b2 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -7,11 +7,6 @@ server: name: 'fzuhelper' log-level: 'INFO' # OPTIONS: TRACE, DEBUG, INFO(default), NOTICE, WARN, ERROR, FATAL -signed_location_api_url: - endpoint: "http://127.0.0.1:8888/v1/location/get_signed_location_api_url" #示例 - enabled: true - disable_msg: "Service is unavailable" - mcp: name: fzuhelper-mcp version: '1.0.0' @@ -92,6 +87,24 @@ umeng: friend: max-nums : 3 # 兜底默认值,优先读取数据库 friend_config 表中 config_key='max_num' 的记录 +api-monitor: + enabled: true + window-seconds: 300 + check-interval-seconds: 30 + error-rate-threshold: 0.05 + min-requests: 100 + alert-cooldown-seconds: 600 + route-blacklist: + - /ping + - /health + - /metrics + - /favicon.ico + +signed_location_api_url: + endpoint: "http://127.0.0.1:8888/v1/location/get_signed_location_api_url" #示例 + enabled: true + disable_msg: "Service is unavailable" + elasticsearch: addr: 127.0.0.1:9200 host: 127.0.0.1 diff --git a/config/config.go b/config/config.go index 9e1e46794..a4b4b4587 100644 --- a/config/config.go +++ b/config/config.go @@ -51,6 +51,7 @@ var ( VersionUploadService *url Vendors *vendors Friend *friend + APIMonitor *apiMonitorConfig runtimeViper = viper.New() ) @@ -148,6 +149,7 @@ func configMapping(srv string) { VersionUploadService = &c.Url Umeng = &c.Umeng Friend = &c.Friend + APIMonitor = &c.APIMonitor if upy, ok := c.UpYuns[srv]; ok { UpYun = &upy } diff --git a/config/types.go b/config/types.go index a69db060f..63bd07661 100644 --- a/config/types.go +++ b/config/types.go @@ -191,10 +191,19 @@ type signedLocationApiUrl struct { DisableMsg string `mapstructure:"disable_msg"` } +type apiMonitorConfig struct { + Enabled bool `mapstructure:"enabled"` + WindowSeconds int64 `mapstructure:"window-seconds"` + CheckIntervalSeconds int64 `mapstructure:"check-interval-seconds"` + ErrorRateThreshold float64 `mapstructure:"error-rate-threshold"` + MinRequests int64 `mapstructure:"min-requests"` + AlertCooldownSeconds int64 `mapstructure:"alert-cooldown-seconds"` + RouteBlacklist []string `mapstructure:"route-blacklist"` +} + type config struct { Server server - SignedLocationApiUrl signedLocationApiUrl `mapstructure:"signed_location_api_url"` - MCP mcp `mapstructure:"mcp"` + MCP mcp `mapstructure:"mcp"` Admin admin AI ai Snowflake snowflake @@ -213,4 +222,6 @@ type config struct { Url url Vendors vendors Friend friend + SignedLocationApiUrl signedLocationApiUrl `mapstructure:"signed_location_api_url"` + APIMonitor apiMonitorConfig `mapstructure:"api-monitor"` } diff --git a/k8s/config/configmap.yaml.example b/k8s/config/configmap.yaml.example index 58e6cf78c..e41a4ff7d 100644 --- a/k8s/config/configmap.yaml.example +++ b/k8s/config/configmap.yaml.example @@ -83,6 +83,19 @@ data: app_key: "" app_master_secret: "" + api-monitor: + enabled: true + window-seconds: 300 + check-interval-seconds: 30 + error-rate-threshold: 0.05 + min-requests: 100 + alert-cooldown-seconds: 600 + route-blacklist: + - /ping + - /health + - /metrics + - /favicon.ico + redis: addr: redis-master.fzuhelper.svc.cluster.local:6379 password: fzu-helper