diff --git a/cmd/thv/app/config.go b/cmd/thv/app/config.go index dd09d3888..8b51df825 100644 --- a/cmd/thv/app/config.go +++ b/cmd/thv/app/config.go @@ -70,6 +70,13 @@ var unsetRegistryCmd = &cobra.Command{ RunE: unsetRegistryCmdFunc, } +var usageMetricsCmd = &cobra.Command{ + Use: "usage-metrics ", + Short: "Enable or disable anonymous usage metrics", + Args: cobra.ExactArgs(1), + RunE: usageMetricsCmdFunc, +} + var ( allowPrivateRegistryIp bool ) @@ -92,6 +99,7 @@ func init() { ) configCmd.AddCommand(getRegistryCmd) configCmd.AddCommand(unsetRegistryCmd) + configCmd.AddCommand(usageMetricsCmd) // Add OTEL parent command to config configCmd.AddCommand(OtelCmd) @@ -216,3 +224,31 @@ func unsetRegistryCmdFunc(_ *cobra.Command, _ []string) error { fmt.Println("Will use built-in registry.") return nil } + +func usageMetricsCmdFunc(_ *cobra.Command, args []string) error { + action := args[0] + + var disable bool + switch action { + case "enable": + disable = false + case "disable": + disable = true + default: + return fmt.Errorf("invalid argument: %s (expected 'enable' or 'disable')", action) + } + + err := config.UpdateConfig(func(c *config.Config) { + c.DisableUsageMetrics = disable + }) + if err != nil { + return fmt.Errorf("failed to update configuration: %w", err) + } + + if disable { + fmt.Println("Usage metrics disabled.") + } else { + fmt.Println("Usage metrics enabled.") + } + return nil +} diff --git a/cmd/thv/app/run_flags.go b/cmd/thv/app/run_flags.go index ef61cb91c..9d836cf5a 100644 --- a/cmd/thv/app/run_flags.go +++ b/cmd/thv/app/run_flags.go @@ -493,6 +493,10 @@ func configureMiddlewareAndOptions( ) ([]runner.RunConfigBuilderOption, error) { var opts []runner.RunConfigBuilderOption + // Load application config for global settings + configProvider := cfg.NewDefaultProvider() + appConfig := configProvider.GetConfig() + // Configure middleware from flags tokenExchangeConfig, err := runFlags.RemoteAuthFlags.BuildTokenExchangeConfig() if err != nil { @@ -514,6 +518,7 @@ func configureMiddlewareAndOptions( runFlags.AuditConfig, serverName, transportType, + appConfig.DisableUsageMetrics, ), ) diff --git a/docs/arch/02-core-concepts.md b/docs/arch/02-core-concepts.md index e4f28df95..c19d571f4 100644 --- a/docs/arch/02-core-concepts.md +++ b/docs/arch/02-core-concepts.md @@ -108,19 +108,20 @@ A **proxy** is the component that sits between MCP clients and MCP servers, forw **Middleware** is a composable layer in the request processing chain. Each middleware can inspect, modify, or reject requests. -**Eight middleware types:** - -1. **Authentication** (`auth`) - JWT token validation -2. **Token Exchange** (`tokenexchange`) - OAuth token exchange -3. **MCP Parser** (`mcp-parser`) - JSON-RPC parsing -4. **Tool Filter** (`tool-filter`) - Filter and override tools in `tools/list` responses -5. **Tool Call Filter** (`tool-call-filter`) - Validate and map `tools/call` requests -6. **Telemetry** (`telemetry`) - OpenTelemetry instrumentation -7. **Authorization** (`authorization`) - Cedar policy evaluation -8. **Audit** (`audit`) - Request logging +**Middleware types:** + +- **Authentication** (`auth`) - JWT token validation +- **Token Exchange** (`tokenexchange`) - OAuth token exchange +- **MCP Parser** (`mcp-parser`) - JSON-RPC parsing +- **Tool Filter** (`tool-filter`) - Filter and override tools in `tools/list` responses +- **Tool Call Filter** (`tool-call-filter`) - Validate and map `tools/call` requests +- **Usage Metrics** (`usagemetrics`) - Anonymous usage metrics for ToolHive development (opt-out: `thv config usage-metrics disable`) +- **Telemetry** (`telemetry`) - OpenTelemetry instrumentation +- **Authorization** (`authorization`) - Cedar policy evaluation +- **Audit** (`audit`) - Request logging **Execution order (request flow):** -Middleware applied in reverse configuration order. Requests flow through: Audit* → Authorization* → Telemetry* → Parser → Token Exchange* → Auth → Tool Call Filter* → Tool Filter* → MCP Server +Middleware applied in reverse configuration order. Requests flow through: Audit* → Authorization* → Telemetry* → Usage Metrics* → Parser → Token Exchange* → Auth → Tool Call Filter* → Tool Filter* → MCP Server (*optional middleware, only present if configured) @@ -719,7 +720,7 @@ graph LR style Chain fill:#fff9c4 ``` -Requests pass through up to 8 middleware components (Auth, Token Exchange, Tool Filter, Tool Call Filter, Parser, Telemetry, Authorization, Audit). See `docs/middleware.md` for complete middleware architecture and execution order. +Requests pass through up to 9 middleware components (Auth, Token Exchange, Tool Filter, Tool Call Filter, Parser, Usage Metrics, Telemetry, Authorization, Audit). See `docs/middleware.md` for complete middleware architecture and execution order. ### Data Hierarchy diff --git a/docs/cli/thv_config.md b/docs/cli/thv_config.md index c19d24be4..8a503af4a 100644 --- a/docs/cli/thv_config.md +++ b/docs/cli/thv_config.md @@ -39,4 +39,5 @@ The config command provides subcommands to manage application configuration sett * [thv config set-registry](thv_config_set-registry.md) - Set the MCP server registry * [thv config unset-ca-cert](thv_config_unset-ca-cert.md) - Remove the configured CA certificate * [thv config unset-registry](thv_config_unset-registry.md) - Remove the configured registry +* [thv config usage-metrics](thv_config_usage-metrics.md) - Enable or disable anonymous usage metrics diff --git a/docs/cli/thv_config_usage-metrics.md b/docs/cli/thv_config_usage-metrics.md new file mode 100644 index 000000000..9fa161e58 --- /dev/null +++ b/docs/cli/thv_config_usage-metrics.md @@ -0,0 +1,35 @@ +--- +title: thv config usage-metrics +hide_title: true +description: Reference for ToolHive CLI command `thv config usage-metrics` +last_update: + author: autogenerated +slug: thv_config_usage-metrics +mdx: + format: md +--- + +## thv config usage-metrics + +Enable or disable anonymous usage metrics + +``` +thv config usage-metrics [flags] +``` + +### Options + +``` + -h, --help help for usage-metrics +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv config](thv_config.md) - Manage application configuration + diff --git a/docs/middleware.md b/docs/middleware.md index 5af81fc97..9e19c5348 100644 --- a/docs/middleware.md +++ b/docs/middleware.md @@ -12,9 +12,10 @@ The middleware chain consists of the following components: 2. **Token Exchange Middleware**: Exchanges JWT tokens for external service tokens (optional) 3. **MCP Parsing Middleware**: Parses JSON-RPC MCP requests and extracts structured data 4. **Tool Mapping Middleware**: Enables tool filtering and override capabilities through two complementary middleware components that process outgoing `tools/list` responses and incoming `tools/call` requests (optional) -5. **Telemetry Middleware**: Instruments requests with OpenTelemetry (optional) -6. **Authorization Middleware**: Evaluates Cedar policies to authorize requests (optional) -7. **Audit Middleware**: Logs request events for compliance and monitoring (optional) +5. **Usage Metrics Middleware**: Collects anonymous usage metrics for ToolHive development (optional) +6. **Telemetry Middleware**: Instruments requests with OpenTelemetry (optional) +7. **Authorization Middleware**: Evaluates Cedar policies to authorize requests (optional) +8. **Audit Middleware**: Logs request events for compliance and monitoring (optional) ## Architecture Diagram @@ -177,7 +178,48 @@ Both components must be in place for the features to work correctly, as they ens **Note**: When either filtering or override is configured, both middleware components are automatically enabled and configured with the same parameters to ensure consistent behavior, however it is an explicit design choice to avoid sharing any state between the two middleware components. -### 5. Telemetry Middleware +### 5. Usage Metrics Middleware + +**Purpose**: Tracks tool call counts for usage analytics and usage metrics. + +**Location**: `pkg/usagemetrics/middleware.go` + +**Responsibilities**: +- Count `tools/call` requests by examining parsed MCP data +- Aggregate counts in-memory with atomic operations +- Flush metrics to API endpoint periodically (every 15 minutes) +- Reset counts daily at midnight UTC +- Manage background flush goroutine lifecycle + +**Configuration**: +- Enabled by default +- Can be disabled via config: `thv config usage-metrics disable` +- Can be disabled via environment variable: `TOOLHIVE_USAGE_METRICS_ENABLED=false` +- Automatically disabled in CI environments + +**Dependencies**: +- Requires parsed MCP data from MCP Parsing middleware + +**Opting Out**: + +Users can opt out of anonymous usage metrics in two ways: + +```bash +# Via config (persistent) +thv config usage-metrics disable + +# Via environment variable (session-only) +export TOOLHIVE_USAGE_METRICS_ENABLED=false +``` + +To re-enable: +```bash +thv config usage-metrics enable +``` + +**Note**: This middleware collects anonymous usage metrics for ToolHive development. Failures do not break request processing. + +### 6. Telemetry Middleware **Purpose**: Instruments HTTP requests with OpenTelemetry tracing and metrics. @@ -197,7 +239,7 @@ Both components must be in place for the features to work correctly, as they ens - Sampling rate - Custom headers -### 6. Token Exchange Middleware +### 7. Token Exchange Middleware **Purpose**: Exchanges incoming JWT tokens for external service tokens using OAuth 2.0 Token Exchange. @@ -221,23 +263,6 @@ Both components must be in place for the features to work correctly, as they ens **Note**: This middleware is currently implemented but not registered in the supported middleware factories (`pkg/runner/middleware.go:15`). It can be used directly via the proxy command but is not available through the standard middleware configuration system. -### 7. Authorization Middleware - -**Purpose**: Evaluates Cedar policies to determine if requests are authorized. - -**Location**: `pkg/authz/middleware.go` - -**Responsibilities**: -- Retrieve parsed MCP data from context -- Create Cedar entities (Principal, Action, Resource) -- Evaluate Cedar policies against the request -- Allow or deny the request based on policy evaluation -- Filter list responses based on user permissions - -**Dependencies**: -- Requires JWT claims from Authentication middleware -- Requires parsed MCP data from MCP Parsing middleware - ### 8. Audit Middleware **Purpose**: Logs request events for compliance, monitoring, and debugging. diff --git a/pkg/api/v1/workload_service.go b/pkg/api/v1/workload_service.go index e310c4ccc..d03845ae5 100644 --- a/pkg/api/v1/workload_service.go +++ b/pkg/api/v1/workload_service.go @@ -6,6 +6,7 @@ import ( "time" "github.com/stacklok/toolhive/pkg/auth/remote" + "github.com/stacklok/toolhive/pkg/config" "github.com/stacklok/toolhive/pkg/container/runtime" "github.com/stacklok/toolhive/pkg/groups" "github.com/stacklok/toolhive/pkg/logger" @@ -32,6 +33,7 @@ type WorkloadService struct { containerRuntime runtime.Runtime debugMode bool imageRetriever retriever.Retriever + appConfig *config.Config } // NewWorkloadService creates a new WorkloadService instance @@ -41,12 +43,17 @@ func NewWorkloadService( containerRuntime runtime.Runtime, debugMode bool, ) *WorkloadService { + // Load application config for global settings + configProvider := config.NewDefaultProvider() + appConfig := configProvider.GetConfig() + return &WorkloadService{ workloadManager: workloadManager, groupManager: groupManager, containerRuntime: containerRuntime, debugMode: debugMode, imageRetriever: retriever.GetMCPServer, + appConfig: appConfig, } } @@ -253,6 +260,7 @@ func (s *WorkloadService) BuildFullRunConfig(ctx context.Context, req *createReq "", req.Name, transportType, + s.appConfig.DisableUsageMetrics, ), ) diff --git a/pkg/api/v1/workload_service_test.go b/pkg/api/v1/workload_service_test.go index 0a2136cba..772853c9d 100644 --- a/pkg/api/v1/workload_service_test.go +++ b/pkg/api/v1/workload_service_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" + "github.com/stacklok/toolhive/pkg/config" groupsmocks "github.com/stacklok/toolhive/pkg/groups/mocks" workloadsmocks "github.com/stacklok/toolhive/pkg/workloads/mocks" ) @@ -19,7 +20,7 @@ func TestWorkloadService_GetWorkloadNamesFromRequest(t *testing.T) { t.Run("with names", func(t *testing.T) { t.Parallel() - service := &WorkloadService{} + service := &WorkloadService{appConfig: &config.Config{}} req := bulkOperationRequest{ Names: []string{"workload1", "workload2"}, @@ -50,6 +51,7 @@ func TestWorkloadService_GetWorkloadNamesFromRequest(t *testing.T) { service := &WorkloadService{ groupManager: mockGroupManager, workloadManager: mockWorkloadManager, + appConfig: &config.Config{}, } req := bulkOperationRequest{ @@ -65,7 +67,7 @@ func TestWorkloadService_GetWorkloadNamesFromRequest(t *testing.T) { t.Run("invalid group name", func(t *testing.T) { t.Parallel() - service := &WorkloadService{} + service := &WorkloadService{appConfig: &config.Config{}} req := bulkOperationRequest{ Group: "invalid-group-name-with-special-chars!@#", @@ -91,6 +93,7 @@ func TestWorkloadService_GetWorkloadNamesFromRequest(t *testing.T) { service := &WorkloadService{ groupManager: mockGroupManager, + appConfig: &config.Config{}, } req := bulkOperationRequest{ @@ -123,6 +126,7 @@ func TestWorkloadService_GetWorkloadNamesFromRequest(t *testing.T) { service := &WorkloadService{ groupManager: mockGroupManager, workloadManager: mockWorkloadManager, + appConfig: &config.Config{}, } req := bulkOperationRequest{ diff --git a/pkg/api/v1/workloads_test.go b/pkg/api/v1/workloads_test.go index 9b7187bae..ffd23d3b3 100644 --- a/pkg/api/v1/workloads_test.go +++ b/pkg/api/v1/workloads_test.go @@ -13,6 +13,7 @@ import ( "go.uber.org/mock/gomock" "golang.org/x/sync/errgroup" + "github.com/stacklok/toolhive/pkg/config" "github.com/stacklok/toolhive/pkg/container/runtime" runtimemocks "github.com/stacklok/toolhive/pkg/container/runtime/mocks" "github.com/stacklok/toolhive/pkg/core" @@ -222,6 +223,7 @@ func TestCreateWorkload(t *testing.T) { groupManager: mockGroupManager, workloadManager: mockWorkloadManager, imageRetriever: mockRetriever, + appConfig: &config.Config{}, }, } @@ -414,6 +416,7 @@ func TestUpdateWorkload(t *testing.T) { groupManager: mockGroupManager, workloadManager: mockWorkloadManager, imageRetriever: mockRetriever, + appConfig: &config.Config{}, }, } diff --git a/pkg/config/config.go b/pkg/config/config.go index 5d1e8b6c1..bbb80b635 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -32,6 +32,7 @@ type Config struct { CACertificatePath string `yaml:"ca_certificate_path,omitempty"` OTEL OpenTelemetryConfig `yaml:"otel,omitempty"` DefaultGroupMigration bool `yaml:"default_group_migration,omitempty"` + DisableUsageMetrics bool `yaml:"disable_usage_metrics,omitempty"` } // Secrets contains the settings for secrets management. diff --git a/pkg/runner/config_builder.go b/pkg/runner/config_builder.go index ec25e6904..2e0807084 100644 --- a/pkg/runner/config_builder.go +++ b/pkg/runner/config_builder.go @@ -22,6 +22,7 @@ import ( "github.com/stacklok/toolhive/pkg/telemetry" "github.com/stacklok/toolhive/pkg/transport" "github.com/stacklok/toolhive/pkg/transport/types" + "github.com/stacklok/toolhive/pkg/usagemetrics" ) // BuildContext defines the context in which the RunConfigBuilder is being used @@ -454,6 +455,7 @@ func WithMiddlewareFromFlags( auditConfigPath string, serverName string, transportType string, + disableUsageMetrics bool, ) RunConfigBuilderOption { return func(b *runConfigBuilder) error { var middlewareConfigs []types.MiddlewareConfig @@ -472,7 +474,7 @@ func WithMiddlewareFromFlags( middlewareConfigs = addToolFilterMiddlewares(middlewareConfigs, toolsFilter, toolsOverride) // Add core middlewares (always present) - middlewareConfigs = addCoreMiddlewares(middlewareConfigs, oidcConfig, tokenExchangeConfig) + middlewareConfigs = addCoreMiddlewares(middlewareConfigs, oidcConfig, tokenExchangeConfig, disableUsageMetrics) // Add optional middlewares middlewareConfigs = addTelemetryMiddleware(middlewareConfigs, telemetryConfig, serverName, transportType) @@ -539,6 +541,7 @@ func addCoreMiddlewares( middlewareConfigs []types.MiddlewareConfig, oidcConfig *auth.TokenValidatorConfig, tokenExchangeConfig *tokenexchange.Config, + disableUsageMetrics bool, ) []types.MiddlewareConfig { // Authentication middleware (always present) authParams := auth.MiddlewareParams{ @@ -566,6 +569,14 @@ func addCoreMiddlewares( middlewareConfigs = append(middlewareConfigs, *mcpParserConfig) } + // Usage metrics middleware (if enabled) + if usagemetrics.ShouldEnableMetrics(disableUsageMetrics) { + usageMetricsParams := usagemetrics.MiddlewareParams{} + if usageMetricsConfig, err := types.NewMiddlewareConfig(usagemetrics.MiddlewareType, usageMetricsParams); err == nil { + middlewareConfigs = append(middlewareConfigs, *usageMetricsConfig) + } + } + return middlewareConfigs } diff --git a/pkg/runner/config_builder_test.go b/pkg/runner/config_builder_test.go index edddd466e..6acd10f63 100644 --- a/pkg/runner/config_builder_test.go +++ b/pkg/runner/config_builder_test.go @@ -381,10 +381,9 @@ func TestAddCoreMiddlewares_TokenExchangeIntegration(t *testing.T) { var mws []types.MiddlewareConfig // OIDC config can be empty for this unit test since we're only testing token-exchange behavior. - mws = addCoreMiddlewares(mws, &auth.TokenValidatorConfig{}, nil) + mws = addCoreMiddlewares(mws, &auth.TokenValidatorConfig{}, nil, false) // Expect only auth + mcp parser when token-exchange config == nil - require.Len(t, mws, 2, "expected only auth and mcp parser middlewares when token-exchange config is nil") assert.Equal(t, auth.MiddlewareType, mws[0].Type, "first middleware should be auth") assert.Equal(t, mcp.ParserMiddlewareType, mws[1].Type, "second middleware should be MCP parser") @@ -410,10 +409,9 @@ func TestAddCoreMiddlewares_TokenExchangeIntegration(t *testing.T) { // ExternalTokenHeaderName not required for replace strategy } - mws = addCoreMiddlewares(mws, &auth.TokenValidatorConfig{}, teCfg) + mws = addCoreMiddlewares(mws, &auth.TokenValidatorConfig{}, teCfg, false) // Expect auth, token-exchange, then mcp parser — verify correct order and count. - require.Len(t, mws, 3, "expected auth, token-exchange and mcp parser middlewares when token-exchange config is provided") assert.Equal(t, auth.MiddlewareType, mws[0].Type, "first middleware should be auth") assert.Equal(t, tokenexchange.MiddlewareType, mws[1].Type, "second middleware should be token-exchange") assert.Equal(t, mcp.ParserMiddlewareType, mws[2].Type, "third middleware should be MCP parser") diff --git a/pkg/runner/middleware.go b/pkg/runner/middleware.go index d323649ac..097b3afbf 100644 --- a/pkg/runner/middleware.go +++ b/pkg/runner/middleware.go @@ -7,9 +7,11 @@ import ( "github.com/stacklok/toolhive/pkg/auth" "github.com/stacklok/toolhive/pkg/auth/tokenexchange" "github.com/stacklok/toolhive/pkg/authz" + cfg "github.com/stacklok/toolhive/pkg/config" "github.com/stacklok/toolhive/pkg/mcp" "github.com/stacklok/toolhive/pkg/telemetry" "github.com/stacklok/toolhive/pkg/transport/types" + "github.com/stacklok/toolhive/pkg/usagemetrics" ) // GetSupportedMiddlewareFactories returns a map of supported middleware types to their factory functions @@ -20,6 +22,7 @@ func GetSupportedMiddlewareFactories() map[string]types.MiddlewareFactory { mcp.ParserMiddlewareType: mcp.CreateParserMiddleware, mcp.ToolFilterMiddlewareType: mcp.CreateToolFilterMiddleware, mcp.ToolCallFilterMiddlewareType: mcp.CreateToolCallFilterMiddleware, + usagemetrics.MiddlewareType: usagemetrics.CreateMiddleware, telemetry.MiddlewareType: telemetry.CreateMiddleware, authz.MiddlewareType: authz.CreateMiddleware, audit.MiddlewareType: audit.CreateMiddleware, @@ -30,6 +33,7 @@ func GetSupportedMiddlewareFactories() map[string]types.MiddlewareFactory { // This function serves as a bridge between the old configuration style and the new generic middleware system func PopulateMiddlewareConfigs(config *RunConfig) error { var middlewareConfigs []types.MiddlewareConfig + // TODO: Consider extracting other middleware setup into helper functions like addUsageMetricsMiddleware // Authentication middleware (always present) authParams := auth.MiddlewareParams{ @@ -79,6 +83,16 @@ func PopulateMiddlewareConfigs(config *RunConfig) error { } middlewareConfigs = append(middlewareConfigs, *mcpParserConfig) + // Load application config for global settings + configProvider := cfg.NewDefaultProvider() + appConfig := configProvider.GetConfig() + + // Usage metrics middleware (if enabled) + middlewareConfigs, err = addUsageMetricsMiddleware(middlewareConfigs, appConfig.DisableUsageMetrics) + if err != nil { + return err + } + // Telemetry middleware (if enabled) if config.TelemetryConfig != nil { telemetryParams := telemetry.FactoryMiddlewareParams{ @@ -125,3 +139,17 @@ func PopulateMiddlewareConfigs(config *RunConfig) error { config.MiddlewareConfigs = middlewareConfigs return nil } + +// addUsageMetricsMiddleware adds usage metrics middleware if enabled +func addUsageMetricsMiddleware(middlewares []types.MiddlewareConfig, configDisabled bool) ([]types.MiddlewareConfig, error) { + if !usagemetrics.ShouldEnableMetrics(configDisabled) { + return middlewares, nil + } + + usageMetricsParams := usagemetrics.MiddlewareParams{} + usageMetricsConfig, err := types.NewMiddlewareConfig(usagemetrics.MiddlewareType, usageMetricsParams) + if err != nil { + return nil, fmt.Errorf("failed to create usage metrics middleware config: %w", err) + } + return append(middlewares, *usageMetricsConfig), nil +} diff --git a/pkg/updates/checker.go b/pkg/updates/checker.go index 1f2331e71..7946f6eee 100644 --- a/pkg/updates/checker.go +++ b/pkg/updates/checker.go @@ -76,6 +76,40 @@ const ( updateInterval = 30 * time.Minute ) +// TryGetAnonymousID returns the instance ID from the updates file if it exists. +// This is a read-only operation - it never generates a new ID. +// Returns empty string if the file doesn't exist or doesn't contain an instance ID. +// Use this for optional features like metrics that shouldn't trigger ID generation. +// TODO this should probably be extracted into its own package to handle instance ID generation. +func TryGetAnonymousID() (string, error) { + path, err := xdg.DataFile(updateFilePathSuffix) + if err != nil { + return "", fmt.Errorf("unable to access update file path: %w", err) + } + + // #nosec G304: File path is not configurable at this time. + rawContents, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + // File doesn't exist yet - return empty (don't generate) + return "", nil + } + return "", fmt.Errorf("failed to read update file: %w", err) + } + + var contents updateFile + if err := json.Unmarshal(rawContents, &contents); err != nil { + // If corrupted, try to recover the instance ID + if recoveredFile, recoverErr := recoverCorruptedJSON(rawContents); recoverErr == nil { + return recoveredFile.InstanceID, nil + } + return "", fmt.Errorf("failed to deserialize update file: %w", err) + } + + // Return whatever is in the file, even if empty + return contents.InstanceID, nil +} + // componentInfo represents component-specific update timing information. type componentInfo struct { LastCheck time.Time `json:"last_check"` diff --git a/pkg/usagemetrics/client.go b/pkg/usagemetrics/client.go new file mode 100644 index 000000000..006420ed3 --- /dev/null +++ b/pkg/usagemetrics/client.go @@ -0,0 +1,113 @@ +package usagemetrics + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "runtime" + "time" + + rt "github.com/stacklok/toolhive/pkg/container/runtime" + "github.com/stacklok/toolhive/pkg/logger" + "github.com/stacklok/toolhive/pkg/updates" + "github.com/stacklok/toolhive/pkg/versions" +) + +const ( + defaultEndpoint = "https://updates.stacklok.com/api/v1/toolcount" + defaultTimeout = 5 * time.Second + instanceIDHeader = "X-Instance-ID" + anonymousIDHeader = "X-Anonymous-Id" + userAgentHeader = "User-Agent" +) + +// Client sends usage metrics to the API +type Client struct { + endpoint string + client *http.Client + anonymousID string +} + +// NewClient creates a new metrics client +func NewClient(endpoint string) *Client { + if endpoint == "" { + endpoint = defaultEndpoint + } + + // Get anonymous ID once at client creation and cache for process duration + anonymousID, err := updates.TryGetAnonymousID() + if err != nil { + logger.Debugf("Failed to get anonymous ID during client creation: %v", err) + } + + return &Client{ + endpoint: endpoint, + anonymousID: anonymousID, + client: &http.Client{ + Timeout: defaultTimeout, + }, + } +} + +// SendMetrics sends the metrics record to the API +func (c *Client) SendMetrics(instanceID string, record MetricRecord) error { + // Use cached anonymous ID (set at client creation) + // Skip sending if anonymous ID is not initialized + if c.anonymousID == "" { + logger.Debugf("Skipping metrics send - anonymous ID not yet initialized") + return nil + } + + data, err := json.Marshal(record) + if err != nil { + return fmt.Errorf("failed to marshal metrics record: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, c.endpoint, bytes.NewBuffer(data)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set(instanceIDHeader, instanceID) // Proxy instance ID + req.Header.Set(anonymousIDHeader, c.anonymousID) // User anonymous ID + req.Header.Set(userAgentHeader, generateUserAgent()) + + resp, err := c.client.Do(req) + if err != nil { + return fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("API returned non-2xx status code: %d", resp.StatusCode) + } + + return nil +} + +// generateUserAgent creates the user agent string +// Format: toolhive/[local|operator] [version] [build_type] (os/arch) +func generateUserAgent() string { + // Determine component type + envType := "local" + if rt.IsKubernetesRuntime() { + envType = "operator" + } + + version := versions.GetVersionInfo().Version + if version != "" && version[0] != 'v' { + version = "v" + version + } + + // Get build type, buildType is set at building time + buildType := versions.BuildType + if buildType == "" { + buildType = "local_build" + } + + platform := fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH) + + return fmt.Sprintf("toolhive/%s %s %s (%s)", envType, version, buildType, platform) +} diff --git a/pkg/usagemetrics/client_test.go b/pkg/usagemetrics/client_test.go new file mode 100644 index 000000000..53bf251bd --- /dev/null +++ b/pkg/usagemetrics/client_test.go @@ -0,0 +1,120 @@ +package usagemetrics + +import ( + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newTestClient creates a client for testing with a pre-set anonymous ID +func newTestClient(endpoint, anonymousID string) *Client { + if endpoint == "" { + endpoint = defaultEndpoint + } + return &Client{ + endpoint: endpoint, + anonymousID: anonymousID, + client: &http.Client{ + Timeout: defaultTimeout, + }, + } +} + +func TestGenerateUserAgent(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + k8sEnvValue string + expectedPrefix string + }{ + { + name: "local environment", + k8sEnvValue: "", + expectedPrefix: "toolhive/local", + }, + { + name: "operator environment", + k8sEnvValue: "https://10.0.0.1:443", + expectedPrefix: "toolhive/operator", + }, + } + + for _, tt := range tests { + tt := tt // capture range variable + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Set environment variable + if tt.k8sEnvValue != "" { + os.Setenv("KUBERNETES_SERVICE_HOST", tt.k8sEnvValue) + defer os.Unsetenv("KUBERNETES_SERVICE_HOST") + } else { + os.Unsetenv("KUBERNETES_SERVICE_HOST") + } + + userAgent := generateUserAgent() + + // Verify it starts with expected prefix + assert.True(t, strings.HasPrefix(userAgent, tt.expectedPrefix), + "User-Agent should start with %s, got: %s", tt.expectedPrefix, userAgent) + + // Verify it contains version and platform info + assert.Contains(t, userAgent, "(") + assert.Contains(t, userAgent, ")") + }) + } +} + +func TestNewClient(t *testing.T) { + t.Parallel() + + // Test with default endpoint + client := NewClient("") + assert.NotNil(t, client) + assert.Equal(t, defaultEndpoint, client.endpoint) + + // Test with custom endpoint + customEndpoint := "https://custom.example.com/metrics" + client = NewClient(customEndpoint) + assert.NotNil(t, client) + assert.Equal(t, customEndpoint, client.endpoint) +} + +func TestSendMetrics_Non2xxStatusCode(t *testing.T) { + t.Parallel() + + // Create test server that returns 500 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + // Create client with test anonymous ID + client := newTestClient(server.URL, "test-anon-id") + record := MetricRecord{ + Count: 5, + Timestamp: "2025-01-01T00:00:00Z", + } + + err := client.SendMetrics("test-instance-id", record) + require.Error(t, err) + assert.Contains(t, err.Error(), "API returned non-2xx status code: 500") +} + +func TestGenerateUserAgent_BuildType(t *testing.T) { + t.Parallel() + + userAgent := generateUserAgent() + + // Verify user agent has expected format: toolhive/[type] [version] [build] (platform) + assert.NotEmpty(t, userAgent) + assert.True(t, strings.HasPrefix(userAgent, "toolhive/"), "User agent should start with 'toolhive/'") + assert.Contains(t, userAgent, "(", "User agent should contain platform info in parentheses") + assert.Contains(t, userAgent, ")", "User agent should contain platform info in parentheses") +} diff --git a/pkg/usagemetrics/collector.go b/pkg/usagemetrics/collector.go new file mode 100644 index 000000000..eab398d46 --- /dev/null +++ b/pkg/usagemetrics/collector.go @@ -0,0 +1,203 @@ +package usagemetrics + +import ( + "context" + "os" + "time" + + "github.com/google/uuid" + + "github.com/stacklok/toolhive/pkg/logger" + "github.com/stacklok/toolhive/pkg/updates" +) + +const ( + flushInterval = 15 * time.Minute + + // EnvVarUsageMetricsEnabled is the environment variable to disable usage metrics + EnvVarUsageMetricsEnabled = "TOOLHIVE_USAGE_METRICS_ENABLED" +) + +// ShouldEnableMetrics returns true if metrics collection should be enabled +// Checks: CI detection > Config disable > Environment variable > Default (true) +// If either config or env var explicitly disables metrics, they stay disabled +func ShouldEnableMetrics(configDisabled bool) bool { + return shouldEnableMetrics(configDisabled, updates.ShouldSkipUpdateChecks, os.Getenv) +} + +// shouldEnableMetrics is an internal testable version that accepts dependencies +func shouldEnableMetrics(configDisabled bool, isCI func() bool, getEnv func(string) string) bool { + // 1. Skip metrics in CI environments (automatic) + if isCI() { + return false + } + + // 2. Check config for explicit disable + if configDisabled { + return false + } + + // 3. Check environment variable for explicit opt-out + if envValue := getEnv(EnvVarUsageMetricsEnabled); envValue == "false" { + return false + } + + // 4. Default: enabled + return true +} + +// NewCollector creates a new metrics collector +func NewCollector() (*Collector, error) { + client := NewClient("") + collector := &Collector{ + instanceID: uuid.NewString(), // Generate new instance ID for this process + currentDate: getCurrentDateUTC(), + client: client, + stopCh: make(chan struct{}), + doneCh: make(chan struct{}), + flushCh: make(chan struct{}, 1), + } + + // Counter starts at 0 + collector.counter.Store(0) + + return collector, nil +} + +// Start begins the background goroutine for periodic flush +// If already started, this is a no-op to prevent goroutine leaks +func (c *Collector) Start() { + if c.started.Swap(true) { + return // Already started + } + go c.flushLoop() +} + +// IncrementToolCall increments the tool call counter atomically +func (c *Collector) IncrementToolCall() { + c.counter.Add(1) +} + +// Flush sends the current metrics to the API +// Checks for midnight boundary crossing and handles daily reset +func (c *Collector) Flush() error { + currentDate := getCurrentDateUTC() + count := c.counter.Load() + + logger.Debugf("Usage metrics flush triggered: count=%d, date=%s, instance_id=%s", + count, currentDate, c.instanceID) + + // Check if we crossed midnight UTC + if c.currentDate != currentDate { + // Midnight crossed! We need to flush the previous day's count + // IMPORTANT: We send a fake timestamp (previous day at 23:59:00Z) to ensure + // the backend attributes these calls to the correct day. The count includes + // calls from the entire previous day plus ~0-15 minutes from the current day + // (depending on when the flush runs), but we accept this small misattribution + // to avoid backend complexity. + + logger.Debugf("Date changed from %s to %s, flushing previous day's count", c.currentDate, currentDate) + + if count > 0 { + // Create fake timestamp for end of previous day + previousDayTimestamp := c.currentDate + "T23:59:00Z" + + record := MetricRecord{ + Count: count, + Timestamp: previousDayTimestamp, + } + + logger.Debugf("Flushing %d tool calls for previous day %s (midnight boundary)", count, c.currentDate) + + if err := c.client.SendMetrics(c.instanceID, record); err != nil { + logger.Debugf("Failed to send metrics for previous day: %v", err) + // Continue anyway - we'll reset for the new day + } + } + + // Reset for the new day + c.currentDate = currentDate + c.counter.Store(0) + + return nil + } + + // Normal flush - not a midnight boundary + // Send even if count is 0 to provide presence signal + + // Send with real current timestamp + now := time.Now().UTC() + timestamp := now.Format(time.RFC3339) // ISO 8601 format + + record := MetricRecord{ + Count: count, + Timestamp: timestamp, + } + + logger.Debugf("Flushing %d tool calls at %s", count, timestamp) + + if err := c.client.SendMetrics(c.instanceID, record); err != nil { + logger.Debugf("Failed to send metrics: %v", err) + // Don't return error - we'll retry on next flush + return nil + } + + return nil +} + +// Shutdown performs final flush and stops background goroutines +func (c *Collector) Shutdown(ctx context.Context) { + if c.shutdown.Load() { + return + } + + logger.Debugf("Shutting down usage metrics collector") + c.shutdown.Store(true) + + // Signal stop to background goroutines (if started) + if c.started.Load() { + close(c.stopCh) + } + + // Perform final flush + if err := c.Flush(); err != nil { + logger.Warnf("Failed to flush metrics on shutdown: %v", err) + } + + // Wait for goroutines to finish with timeout (only if started) + if c.started.Load() { + select { + case <-c.doneCh: + logger.Debugf("Collector stopped cleanly") + case <-ctx.Done(): + logger.Warnf("Collector shutdown timed out: %v", ctx.Err()) + } + } +} + +// GetCurrentCount returns the current count (for testing/debugging) +func (c *Collector) GetCurrentCount() int64 { + return c.counter.Load() +} + +// flushLoop periodically flushes metrics +func (c *Collector) flushLoop() { + ticker := time.NewTicker(flushInterval) + defer ticker.Stop() + defer close(c.doneCh) + + for { + select { + case <-ticker.C: + if err := c.Flush(); err != nil { + logger.Warnf("Periodic flush failed: %v", err) + } + case <-c.flushCh: + if err := c.Flush(); err != nil { + logger.Warnf("Manual flush failed: %v", err) + } + case <-c.stopCh: + return + } + } +} diff --git a/pkg/usagemetrics/collector_test.go b/pkg/usagemetrics/collector_test.go new file mode 100644 index 000000000..0fb1dd7c1 --- /dev/null +++ b/pkg/usagemetrics/collector_test.go @@ -0,0 +1,170 @@ +package usagemetrics + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewCollector(t *testing.T) { + t.Parallel() + + collector, err := NewCollector() + require.NoError(t, err) + require.NotNil(t, collector) + + // Verify initial state + assert.NotEmpty(t, collector.instanceID, "Instance ID should be generated") + assert.Equal(t, int64(0), collector.GetCurrentCount(), "Initial count should be 0") + assert.NotEmpty(t, collector.currentDate, "Current date should be set") + + // Cleanup + ctx := context.Background() + collector.Shutdown(ctx) +} + +func TestCollector_IncrementToolCall(t *testing.T) { + t.Parallel() + + collector, err := NewCollector() + require.NoError(t, err) + defer func() { + ctx := context.Background() + collector.Shutdown(ctx) + }() + + // Verify initial count + assert.Equal(t, int64(0), collector.GetCurrentCount()) + + // Increment once + collector.IncrementToolCall() + assert.Equal(t, int64(1), collector.GetCurrentCount()) + + // Increment multiple times + for i := 0; i < 10; i++ { + collector.IncrementToolCall() + } + assert.Equal(t, int64(11), collector.GetCurrentCount()) +} + +func TestCollector_Shutdown(t *testing.T) { + t.Parallel() + + collector, err := NewCollector() + require.NoError(t, err) + + // Increment some calls + collector.IncrementToolCall() + collector.IncrementToolCall() + + ctx := context.Background() + + // Shutdown should not error + collector.Shutdown(ctx) + + // Second shutdown should be idempotent + collector.Shutdown(ctx) +} + +func TestCollector_Start_PreventsDuplicateGoroutines(t *testing.T) { + t.Parallel() + + collector, err := NewCollector() + require.NoError(t, err) + defer func() { + ctx := context.Background() + collector.Shutdown(ctx) + }() + + // Call Start multiple times + collector.Start() + collector.Start() + collector.Start() + + // Verify started flag is set + assert.True(t, collector.started.Load(), "Collector should be marked as started") + + // If multiple goroutines were created, we'd see issues with concurrent + // access to the channels. The test passes if no race conditions occur. + // The -race flag in our test suite will catch this. +} + +func TestShouldEnableMetrics(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + configDisabled bool + envVarValue string + isCI bool + expected bool + }{ + { + name: "default enabled", + configDisabled: false, + envVarValue: "", + isCI: false, + expected: true, + }, + { + name: "config disabled", + configDisabled: true, + envVarValue: "", + isCI: false, + expected: false, + }, + { + name: "env var opt-out", + configDisabled: false, + envVarValue: "false", + isCI: false, + expected: false, + }, + { + name: "config disabled overrides env enabled", + configDisabled: true, + envVarValue: "true", + isCI: false, + expected: false, + }, + { + name: "CI environment disables metrics", + configDisabled: false, + envVarValue: "", + isCI: true, + expected: false, + }, + { + name: "CI environment overrides config and env", + configDisabled: false, + envVarValue: "true", + isCI: true, + expected: false, + }, + } + + for _, tt := range tests { + tt := tt // capture range variable + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Mock CI detection + mockIsCI := func() bool { + return tt.isCI + } + + // Mock environment variable getter + mockGetEnv := func(key string) string { + if key == EnvVarUsageMetricsEnabled { + return tt.envVarValue + } + return "" + } + + result := shouldEnableMetrics(tt.configDisabled, mockIsCI, mockGetEnv) + assert.Equal(t, tt.expected, result, "shouldEnableMetrics(%v) = %v, want %v", tt.configDisabled, result, tt.expected) + }) + } +} diff --git a/pkg/usagemetrics/middleware.go b/pkg/usagemetrics/middleware.go new file mode 100644 index 000000000..4440f3428 --- /dev/null +++ b/pkg/usagemetrics/middleware.go @@ -0,0 +1,81 @@ +package usagemetrics + +import ( + "context" + "net/http" + "time" + + "github.com/stacklok/toolhive/pkg/logger" + "github.com/stacklok/toolhive/pkg/mcp" + "github.com/stacklok/toolhive/pkg/transport/types" +) + +const ( + // MiddlewareType is the type identifier for usage metrics middleware + MiddlewareType = "usagemetrics" + + // shutdownTimeout is the maximum time to wait for collector shutdown + shutdownTimeout = 10 * time.Second +) + +// MiddlewareParams represents the parameters for usage metrics middleware +type MiddlewareParams struct { + // No parameters needed +} + +// Middleware implements the types.Middleware interface +type Middleware struct { + collector *Collector +} + +// Handler returns the middleware function +func (m *Middleware) Handler() types.MiddlewareFunction { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check if this is a tool call by examining the parsed MCP request + if parsed := mcp.GetParsedMCPRequest(r.Context()); parsed != nil { + if parsed.Method == "tools/call" { + // Increment the tool call counter + if m.collector != nil { + m.collector.IncrementToolCall() + } + } + } + + // Pass to next handler + next.ServeHTTP(w, r) + }) + } +} + +// Close cleans up any resources +func (m *Middleware) Close() error { + if m.collector != nil { + ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + m.collector.Shutdown(ctx) + } + return nil +} + +// CreateMiddleware is the factory function for creating usage metrics middleware +func CreateMiddleware(config *types.MiddlewareConfig, runner types.MiddlewareRunner) error { + // Create a new collector instance for this middleware + collector, err := NewCollector() + if err != nil { + logger.Warnf("Failed to initialize usage metrics: %v", err) + // Continue - metrics are non-critical, register no-op middleware + mw := &Middleware{} + runner.AddMiddleware(config.Type, mw) + return nil + } + + // Start the collector's background flush loop + collector.Start() + + mw := &Middleware{ + collector: collector, + } + runner.AddMiddleware(config.Type, mw) + return nil +} diff --git a/pkg/usagemetrics/middleware_test.go b/pkg/usagemetrics/middleware_test.go new file mode 100644 index 000000000..1e58be44a --- /dev/null +++ b/pkg/usagemetrics/middleware_test.go @@ -0,0 +1,165 @@ +package usagemetrics + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + "github.com/stacklok/toolhive/pkg/mcp" + "github.com/stacklok/toolhive/pkg/transport/types" + "github.com/stacklok/toolhive/pkg/transport/types/mocks" +) + +func TestMiddleware_Handler(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mcpMethod string + expectIncrement bool + }{ + { + name: "tool call increments counter", + mcpMethod: "tools/call", + expectIncrement: true, + }, + { + name: "non-tool call does not increment", + mcpMethod: "tools/list", + expectIncrement: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Initialize a collector for this test + collector, err := NewCollector() + assert.NoError(t, err) + defer func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + collector.Shutdown(ctx) + }() + + // Create middleware with collector instance + mw := &Middleware{ + collector: collector, + } + handler := mw.Handler() + + // Create a test request with MCP context + req := httptest.NewRequest(http.MethodPost, "/messages", nil) + + // Add parsed MCP request to context + parsedReq := &mcp.ParsedMCPRequest{ + Method: tt.mcpMethod, + IsRequest: true, + } + ctx := context.WithValue(req.Context(), mcp.MCPRequestContextKey, parsedReq) + req = req.WithContext(ctx) + + // Record initial count + initialCount := collector.GetCurrentCount() + + // Create response recorder + rr := httptest.NewRecorder() + + // Create a test handler that just returns 200 + testHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + // Wrap with middleware + wrappedHandler := handler(testHandler) + + // Execute request + wrappedHandler.ServeHTTP(rr, req) + + // Verify count + expectedCount := initialCount + if tt.expectIncrement { + expectedCount++ + } + assert.Equal(t, expectedCount, collector.GetCurrentCount()) + }) + } +} + +func TestMiddleware_Close(t *testing.T) { + t.Parallel() + + // Initialize collector + collector, err := NewCollector() + assert.NoError(t, err) + + middleware := &Middleware{ + collector: collector, + } + + // Test that Close returns nil and shuts down collector + err = middleware.Close() + assert.NoError(t, err) +} + +func TestCreateMiddleware(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config *types.MiddlewareConfig + setupMock func(*mocks.MockMiddlewareRunner) *Middleware + }{ + { + name: "success", + config: func() *types.MiddlewareConfig { + params := MiddlewareParams{} + paramsJSON, _ := json.Marshal(params) + return &types.MiddlewareConfig{ + Type: MiddlewareType, + Parameters: paramsJSON, + } + }(), + setupMock: func(mockRunner *mocks.MockMiddlewareRunner) *Middleware { + var capturedMw *Middleware + mockRunner.EXPECT().AddMiddleware(gomock.Any(), gomock.Any()).Do(func(_ string, mw types.Middleware) { + typedMw, ok := mw.(*Middleware) + assert.True(t, ok, "Expected middleware to be of type *Middleware") + capturedMw = typedMw + }) + return capturedMw + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Create mock controller and runner + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockRunner := mocks.NewMockMiddlewareRunner(ctrl) + capturedMw := tt.setupMock(mockRunner) + + // Execute + err := CreateMiddleware(tt.config, mockRunner) + assert.NoError(t, err) + + // Cleanup the middleware if it was created + if capturedMw != nil && capturedMw.collector != nil { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + capturedMw.collector.Shutdown(ctx) + } + }) + } +} diff --git a/pkg/usagemetrics/types.go b/pkg/usagemetrics/types.go new file mode 100644 index 000000000..d012f62c8 --- /dev/null +++ b/pkg/usagemetrics/types.go @@ -0,0 +1,36 @@ +// Package usagemetrics provides internal usage metrics for tracking usage and adoption. +package usagemetrics + +import ( + "sync/atomic" + "time" +) + +// MetricRecord is the payload sent to the metrics API +type MetricRecord struct { + Count int64 `json:"count"` + Timestamp string `json:"timestamp"` // ISO 8601 format in UTC (e.g., "2025-01-01T23:50:00Z") +} + +// Collector manages tool call counting and reporting +type Collector struct { + // Unique identifier for this proxy instance (UUID) + instanceID string + // Atomic counter for thread-safe increments + counter atomic.Int64 + // Current date in YYYY-MM-DD format (UTC) + currentDate string + // HTTP client + client *Client + // Lifecycle management + stopCh chan struct{} + doneCh chan struct{} + flushCh chan struct{} + started atomic.Bool + shutdown atomic.Bool +} + +// getCurrentDateUTC returns the current date in YYYY-MM-DD format (UTC) +func getCurrentDateUTC() string { + return time.Now().UTC().Format("2006-01-02") +}