-
-
Notifications
You must be signed in to change notification settings - Fork 2.7k
feat(usage statistics): add configurable usage stats persistence and management APIs #2125
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -42,6 +42,7 @@ type Handler struct { | |
| failedAttempts map[string]*attemptInfo // keyed by client IP | ||
| authManager *coreauth.Manager | ||
| usageStats *usage.RequestStatistics | ||
| usagePersistence *usage.PersistenceManager | ||
| tokenStore coreauth.Store | ||
| localPassword string | ||
| allowRemoteOverride bool | ||
|
|
@@ -61,10 +62,14 @@ func NewHandler(cfg *config.Config, configFilePath string, manager *coreauth.Man | |
| failedAttempts: make(map[string]*attemptInfo), | ||
| authManager: manager, | ||
| usageStats: usage.GetRequestStatistics(), | ||
| usagePersistence: usage.NewPersistenceManager(usage.GetRequestStatistics(), filepath.Dir(configFilePath)), | ||
| tokenStore: sdkAuth.GetTokenStore(), | ||
| allowRemoteOverride: envSecret != "", | ||
| envSecret: envSecret, | ||
| } | ||
| if cfg != nil { | ||
| h.usagePersistence.ApplyConfig(cfg.UsagePersistence) | ||
| } | ||
| h.startAttemptCleanup() | ||
| return h | ||
| } | ||
|
|
@@ -105,14 +110,22 @@ func NewHandlerWithoutConfigFilePath(cfg *config.Config, manager *coreauth.Manag | |
| } | ||
|
|
||
| // SetConfig updates the in-memory config reference when the server hot-reloads. | ||
| func (h *Handler) SetConfig(cfg *config.Config) { h.cfg = cfg } | ||
| func (h *Handler) SetConfig(cfg *config.Config) { | ||
| h.cfg = cfg | ||
| if h != nil && h.usagePersistence != nil && cfg != nil { | ||
| h.usagePersistence.ApplyConfig(cfg.UsagePersistence) | ||
| } | ||
| } | ||
|
Comment on lines
+113
to
+118
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The func (h *Handler) SetConfig(cfg *config.Config) {
h.cfg = cfg
} |
||
|
|
||
| // SetAuthManager updates the auth manager reference used by management endpoints. | ||
| func (h *Handler) SetAuthManager(manager *coreauth.Manager) { h.authManager = manager } | ||
|
|
||
| // SetUsageStatistics allows replacing the usage statistics reference. | ||
| func (h *Handler) SetUsageStatistics(stats *usage.RequestStatistics) { h.usageStats = stats } | ||
|
|
||
| // SetUsagePersistenceManager replaces the usage persistence manager. | ||
| func (h *Handler) SetUsagePersistenceManager(manager *usage.PersistenceManager) { h.usagePersistence = manager } | ||
|
|
||
| // SetLocalPassword configures the runtime-local password accepted for localhost requests. | ||
| func (h *Handler) SetLocalPassword(password string) { h.localPassword = password } | ||
|
|
||
|
|
@@ -134,6 +147,16 @@ func (h *Handler) SetPostAuthHook(hook coreauth.PostAuthHook) { | |
| h.postAuthHook = hook | ||
| } | ||
|
|
||
| // Stop releases background resources owned by management handler. | ||
| func (h *Handler) Stop() { | ||
| if h == nil { | ||
| return | ||
| } | ||
| if h.usagePersistence != nil { | ||
| h.usagePersistence.Stop(true) | ||
| } | ||
| } | ||
|
|
||
| // Middleware enforces access control for management endpoints. | ||
| // All requests (local and remote) require a valid management key. | ||
| // Additionally, remote access requires allow-remote-management=true. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -176,6 +176,8 @@ type Server struct { | |
| keepAliveOnTimeout func() | ||
| keepAliveHeartbeat chan struct{} | ||
| keepAliveStop chan struct{} | ||
|
|
||
| usagePersistence *usage.PersistenceManager | ||
| } | ||
|
|
||
| // NewServer creates and initializes a new API server instance. | ||
|
|
@@ -251,6 +253,7 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk | |
| currentPath: wd, | ||
| envManagementSecret: envManagementSecret, | ||
| wsRoutes: make(map[string]struct{}), | ||
| usagePersistence: usage.NewPersistenceManager(usage.GetRequestStatistics(), filepath.Dir(configFilePath)), | ||
| } | ||
| s.wsAuthEnabled.Store(cfg.WebsocketAuth) | ||
| // Save initial YAML snapshot | ||
|
|
@@ -263,6 +266,10 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk | |
| auth.SetQuotaCooldownDisabled(cfg.DisableCooling) | ||
| // Initialize management handler | ||
| s.mgmt = managementHandlers.NewHandler(cfg, configFilePath, authManager) | ||
| if s.usagePersistence != nil { | ||
| s.usagePersistence.ApplyConfig(cfg.UsagePersistence) | ||
| s.mgmt.SetUsagePersistenceManager(s.usagePersistence) | ||
|
Comment on lines
+269
to
+271
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| } | ||
| if optionState.localPassword != "" { | ||
| s.mgmt.SetLocalPassword(optionState.localPassword) | ||
| } | ||
|
|
@@ -489,6 +496,9 @@ func (s *Server) registerManagementRoutes() { | |
| mgmt.GET("/usage", s.mgmt.GetUsageStatistics) | ||
| mgmt.GET("/usage/export", s.mgmt.ExportUsageStatistics) | ||
| mgmt.POST("/usage/import", s.mgmt.ImportUsageStatistics) | ||
| mgmt.GET("/usage/persistence-status", s.mgmt.GetUsagePersistenceStatus) | ||
| mgmt.POST("/usage/save", s.mgmt.SaveUsageStatistics) | ||
| mgmt.POST("/usage/load", s.mgmt.LoadUsageStatistics) | ||
| mgmt.GET("/config", s.mgmt.GetConfig) | ||
| mgmt.GET("/config.yaml", s.mgmt.GetConfigYAML) | ||
| mgmt.PUT("/config.yaml", s.mgmt.PutConfigYAML) | ||
|
|
@@ -514,6 +524,10 @@ func (s *Server) registerManagementRoutes() { | |
| mgmt.PUT("/usage-statistics-enabled", s.mgmt.PutUsageStatisticsEnabled) | ||
| mgmt.PATCH("/usage-statistics-enabled", s.mgmt.PutUsageStatisticsEnabled) | ||
|
|
||
| mgmt.GET("/usage-persistence", s.mgmt.GetUsagePersistence) | ||
| mgmt.PUT("/usage-persistence", s.mgmt.PutUsagePersistence) | ||
| mgmt.PATCH("/usage-persistence", s.mgmt.PutUsagePersistence) | ||
|
|
||
| mgmt.GET("/proxy-url", s.mgmt.GetProxyURL) | ||
| mgmt.PUT("/proxy-url", s.mgmt.PutProxyURL) | ||
| mgmt.PATCH("/proxy-url", s.mgmt.PutProxyURL) | ||
|
|
@@ -830,6 +844,10 @@ func (s *Server) Stop(ctx context.Context) error { | |
| } | ||
| } | ||
|
|
||
| if s.mgmt != nil { | ||
| s.mgmt.Stop() | ||
| } | ||
|
|
||
| // Shutdown the HTTP server. | ||
| if err := s.server.Shutdown(ctx); err != nil { | ||
|
Comment on lines
+848
to
852
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The stop sequence flushes usage persistence before Useful? React with 👍 / 👎. |
||
| return fmt.Errorf("failed to shutdown HTTP server: %v", err) | ||
|
|
@@ -904,6 +922,10 @@ func (s *Server) UpdateClients(cfg *config.Config) { | |
| usage.SetStatisticsEnabled(cfg.UsageStatisticsEnabled) | ||
| } | ||
|
|
||
| if s.usagePersistence != nil && (oldCfg == nil || !reflect.DeepEqual(oldCfg.UsagePersistence, cfg.UsagePersistence)) { | ||
| s.usagePersistence.ApplyConfig(cfg.UsagePersistence) | ||
| } | ||
|
|
||
| if s.requestLogger != nil && (oldCfg == nil || oldCfg.ErrorLogsMaxFiles != cfg.ErrorLogsMaxFiles) { | ||
| if setter, ok := s.requestLogger.(interface{ SetErrorLogsMaxFiles(int) }); ok { | ||
| setter.SetErrorLogsMaxFiles(cfg.ErrorLogsMaxFiles) | ||
|
|
@@ -970,6 +992,7 @@ func (s *Server) UpdateClients(cfg *config.Config) { | |
| if s.mgmt != nil { | ||
| s.mgmt.SetConfig(cfg) | ||
| s.mgmt.SetAuthManager(s.handlers.AuthManager) | ||
| s.mgmt.SetUsagePersistenceManager(s.usagePersistence) | ||
| } | ||
|
|
||
| // Notify Amp module only when Amp config has changed. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -64,6 +64,9 @@ type Config struct { | |
| // UsageStatisticsEnabled toggles in-memory usage aggregation; when false, usage data is discarded. | ||
| UsageStatisticsEnabled bool `yaml:"usage-statistics-enabled" json:"usage-statistics-enabled"` | ||
|
|
||
| // UsagePersistence controls optional periodic persistence of usage statistics. | ||
| UsagePersistence UsagePersistenceConfig `yaml:"usage-persistence" json:"usage-persistence"` | ||
|
|
||
| // DisableCooling disables quota cooldown scheduling when true. | ||
| DisableCooling bool `yaml:"disable-cooling" json:"disable-cooling"` | ||
|
|
||
|
|
@@ -128,6 +131,16 @@ type Config struct { | |
| legacyMigrationPending bool `yaml:"-" json:"-"` | ||
| } | ||
|
|
||
| // UsagePersistenceConfig defines usage statistics persistence behavior. | ||
| type UsagePersistenceConfig struct { | ||
| // Enabled toggles automatic usage persistence. | ||
| Enabled bool `yaml:"enabled" json:"enabled"` | ||
| // FilePath is the output file path for usage snapshots. Relative paths are resolved from config directory. | ||
| FilePath string `yaml:"file-path" json:"file-path"` | ||
| // IntervalSeconds controls periodic flush interval in seconds. | ||
| IntervalSeconds int `yaml:"interval-seconds" json:"interval-seconds"` | ||
| } | ||
|
|
||
| // ClaudeHeaderDefaults configures default header values injected into Claude API requests | ||
| // when the client does not send them. Update these when Claude Code releases a new version. | ||
| type ClaudeHeaderDefaults struct { | ||
|
|
@@ -553,6 +566,9 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) { | |
| cfg.LogsMaxTotalSizeMB = 0 | ||
| cfg.ErrorLogsMaxFiles = 10 | ||
| cfg.UsageStatisticsEnabled = false | ||
| cfg.UsagePersistence.Enabled = false | ||
| cfg.UsagePersistence.FilePath = "usage-statistics.json" | ||
| cfg.UsagePersistence.IntervalSeconds = 30 | ||
|
Comment on lines
+570
to
+571
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The default file path |
||
| cfg.DisableCooling = false | ||
| cfg.Pprof.Enable = false | ||
| cfg.Pprof.Addr = DefaultPprofAddr | ||
|
|
@@ -618,6 +634,14 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) { | |
| cfg.MaxRetryCredentials = 0 | ||
| } | ||
|
|
||
| cfg.UsagePersistence.FilePath = strings.TrimSpace(cfg.UsagePersistence.FilePath) | ||
| if cfg.UsagePersistence.FilePath == "" { | ||
| cfg.UsagePersistence.FilePath = "usage-statistics.json" | ||
| } | ||
| if cfg.UsagePersistence.IntervalSeconds <= 0 { | ||
| cfg.UsagePersistence.IntervalSeconds = 30 | ||
| } | ||
|
|
||
| // Sanitize Gemini API key configuration and migrate legacy entries. | ||
| cfg.SanitizeGeminiKeys() | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
PersistenceManageris instantiated here withinNewHandler, but it is immediately replaced by theServerviaSetUsagePersistenceManager. This creates a temporary, unusedPersistenceManagerinstance on everyNewHandlercall, which is inefficient and confusing. Themanagement.Handlershould receive its dependencies rather than creating them.