diff --git a/builtins/en/config.yaml b/builtins/en/config.yaml index d4bee1d..729e4bf 100644 --- a/builtins/en/config.yaml +++ b/builtins/en/config.yaml @@ -29,6 +29,10 @@ concurrency: 2 # Concurrent task execution for takt run (1-10) # run_abort: true # observability: # provider_events: false # Persist provider stream events +# analytics: +# enabled: true # Enable analytics metrics collection +# events_path: ~/.takt/analytics/events # Analytics event directory +# retention_days: 30 # Analytics event retention (days) # Credentials (environment variables take priority) # anthropic_api_key: "sk-ant-..." # Claude API key diff --git a/builtins/ja/config.yaml b/builtins/ja/config.yaml index 323f511..57b596e 100644 --- a/builtins/ja/config.yaml +++ b/builtins/ja/config.yaml @@ -29,6 +29,10 @@ concurrency: 2 # takt run の同時実行数(1-10) # run_abort: true # observability: # provider_events: false # providerイベントログを記録 +# analytics: +# enabled: true # 分析メトリクスの収集を有効化 +# events_path: ~/.takt/analytics/events # 分析イベント保存先 +# retention_days: 30 # 分析イベント保持日数 # 認証情報(環境変数優先) # anthropic_api_key: "sk-ant-..." # Claude APIキー diff --git a/src/__tests__/config-env-overrides.test.ts b/src/__tests__/config-env-overrides.test.ts index 144031f..f92883a 100644 --- a/src/__tests__/config-env-overrides.test.ts +++ b/src/__tests__/config-env-overrides.test.ts @@ -53,10 +53,29 @@ describe('config env overrides', () => { it('should apply project env overrides from generated env names', () => { process.env.TAKT_VERBOSE = 'true'; + process.env.TAKT_ANALYTICS_EVENTS_PATH = '/tmp/project-analytics'; const raw: Record = {}; applyProjectConfigEnvOverrides(raw); expect(raw.verbose).toBe(true); + expect(raw.analytics).toEqual({ + events_path: '/tmp/project-analytics', + }); + }); + + it('should apply analytics env overrides for global config', () => { + process.env.TAKT_ANALYTICS_ENABLED = 'true'; + process.env.TAKT_ANALYTICS_EVENTS_PATH = '/tmp/global-analytics'; + process.env.TAKT_ANALYTICS_RETENTION_DAYS = '14'; + + const raw: Record = {}; + applyGlobalConfigEnvOverrides(raw); + + expect(raw.analytics).toEqual({ + enabled: true, + events_path: '/tmp/global-analytics', + retention_days: 14, + }); }); }); diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 7e5284b..525ae7f 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -37,6 +37,7 @@ import { isVerboseMode, invalidateGlobalConfigCache, } from '../infra/config/index.js'; +import { loadConfig } from '../infra/config/loadConfig.js'; describe('getBuiltinPiece', () => { it('should return builtin piece when it exists in resources', () => { @@ -389,6 +390,117 @@ describe('loadProjectConfig provider_options', () => { }); }); +describe('analytics config resolution', () => { + let testDir: string; + let originalTaktConfigDir: string | undefined; + let originalAnalyticsEnabled: string | undefined; + let originalAnalyticsEventsPath: string | undefined; + let originalAnalyticsRetentionDays: string | undefined; + + beforeEach(() => { + testDir = join(tmpdir(), `takt-test-${randomUUID()}`); + mkdirSync(testDir, { recursive: true }); + originalTaktConfigDir = process.env.TAKT_CONFIG_DIR; + originalAnalyticsEnabled = process.env.TAKT_ANALYTICS_ENABLED; + originalAnalyticsEventsPath = process.env.TAKT_ANALYTICS_EVENTS_PATH; + originalAnalyticsRetentionDays = process.env.TAKT_ANALYTICS_RETENTION_DAYS; + process.env.TAKT_CONFIG_DIR = join(testDir, 'global-takt'); + delete process.env.TAKT_ANALYTICS_ENABLED; + delete process.env.TAKT_ANALYTICS_EVENTS_PATH; + delete process.env.TAKT_ANALYTICS_RETENTION_DAYS; + invalidateGlobalConfigCache(); + }); + + afterEach(() => { + if (originalTaktConfigDir === undefined) { + delete process.env.TAKT_CONFIG_DIR; + } else { + process.env.TAKT_CONFIG_DIR = originalTaktConfigDir; + } + if (originalAnalyticsEnabled === undefined) { + delete process.env.TAKT_ANALYTICS_ENABLED; + } else { + process.env.TAKT_ANALYTICS_ENABLED = originalAnalyticsEnabled; + } + if (originalAnalyticsEventsPath === undefined) { + delete process.env.TAKT_ANALYTICS_EVENTS_PATH; + } else { + process.env.TAKT_ANALYTICS_EVENTS_PATH = originalAnalyticsEventsPath; + } + if (originalAnalyticsRetentionDays === undefined) { + delete process.env.TAKT_ANALYTICS_RETENTION_DAYS; + } else { + process.env.TAKT_ANALYTICS_RETENTION_DAYS = originalAnalyticsRetentionDays; + } + invalidateGlobalConfigCache(); + + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('should normalize project analytics config from snake_case', () => { + const projectConfigDir = getProjectConfigDir(testDir); + mkdirSync(projectConfigDir, { recursive: true }); + writeFileSync(join(projectConfigDir, 'config.yaml'), [ + 'piece: default', + 'analytics:', + ' enabled: false', + ' events_path: .takt/project-analytics/events', + ' retention_days: 7', + ].join('\n')); + + const config = loadProjectConfig(testDir); + + expect(config.analytics).toEqual({ + enabled: false, + eventsPath: '.takt/project-analytics/events', + retentionDays: 7, + }); + }); + + it('should apply TAKT_ANALYTICS_* env overrides for project config', () => { + process.env.TAKT_ANALYTICS_ENABLED = 'true'; + process.env.TAKT_ANALYTICS_EVENTS_PATH = '/tmp/project-analytics'; + process.env.TAKT_ANALYTICS_RETENTION_DAYS = '5'; + + const config = loadProjectConfig(testDir); + expect(config.analytics).toEqual({ + enabled: true, + eventsPath: '/tmp/project-analytics', + retentionDays: 5, + }); + }); + + it('should merge analytics as project > global in loadConfig', () => { + const globalConfigDir = process.env.TAKT_CONFIG_DIR!; + mkdirSync(globalConfigDir, { recursive: true }); + writeFileSync(join(globalConfigDir, 'config.yaml'), [ + 'language: ja', + 'analytics:', + ' enabled: true', + ' events_path: /tmp/global-analytics', + ' retention_days: 30', + ].join('\n')); + + const projectConfigDir = getProjectConfigDir(testDir); + mkdirSync(projectConfigDir, { recursive: true }); + writeFileSync(join(projectConfigDir, 'config.yaml'), [ + 'piece: default', + 'analytics:', + ' events_path: /tmp/project-analytics', + ' retention_days: 14', + ].join('\n')); + + const config = loadConfig(testDir); + expect(config.analytics).toEqual({ + enabled: true, + eventsPath: '/tmp/project-analytics', + retentionDays: 14, + }); + }); +}); + describe('isVerboseMode', () => { let testDir: string; let originalTaktConfigDir: string | undefined; diff --git a/src/infra/config/env/config-env-overrides.ts b/src/infra/config/env/config-env-overrides.ts index 528e94f..166b919 100644 --- a/src/infra/config/env/config-env-overrides.ts +++ b/src/infra/config/env/config-env-overrides.ts @@ -82,6 +82,10 @@ const GLOBAL_ENV_SPECS: readonly EnvSpec[] = [ { path: 'model', type: 'string' }, { path: 'observability', type: 'json' }, { path: 'observability.provider_events', type: 'boolean' }, + { path: 'analytics', type: 'json' }, + { path: 'analytics.enabled', type: 'boolean' }, + { path: 'analytics.events_path', type: 'string' }, + { path: 'analytics.retention_days', type: 'number' }, { path: 'worktree_dir', type: 'string' }, { path: 'auto_pr', type: 'boolean' }, { path: 'draft_pr', type: 'boolean' }, @@ -126,6 +130,10 @@ const PROJECT_ENV_SPECS: readonly EnvSpec[] = [ { path: 'piece', type: 'string' }, { path: 'provider', type: 'string' }, { path: 'verbose', type: 'boolean' }, + { path: 'analytics', type: 'json' }, + { path: 'analytics.enabled', type: 'boolean' }, + { path: 'analytics.events_path', type: 'string' }, + { path: 'analytics.retention_days', type: 'number' }, { path: 'provider_options', type: 'json' }, { path: 'provider_options.codex.network_access', type: 'boolean' }, { path: 'provider_options.opencode.network_access', type: 'boolean' }, diff --git a/src/infra/config/loadConfig.ts b/src/infra/config/loadConfig.ts index b46e4a3..01f2afb 100644 --- a/src/infra/config/loadConfig.ts +++ b/src/infra/config/loadConfig.ts @@ -1,6 +1,7 @@ import type { GlobalConfig } from '../../core/models/index.js'; import type { MovementProviderOptions } from '../../core/models/piece-types.js'; import type { ProviderPermissionProfiles } from '../../core/models/provider-profiles.js'; +import type { AnalyticsConfig } from '../../core/models/global-config.js'; import { loadGlobalConfig } from './global/globalConfig.js'; import { loadProjectConfig } from './project/projectConfig.js'; import { envVarNameFromPath } from './env/config-env-overrides.js'; @@ -26,6 +27,7 @@ export function loadConfig(projectDir: string): LoadedConfig { draftPr: project.draft_pr ?? global.draftPr, model: resolveModel(global, provider), verbose: resolveVerbose(project.verbose, global.verbose), + analytics: mergeAnalytics(global.analytics, project.analytics), providerOptions: mergeProviderOptions(global.providerOptions, project.providerOptions), providerProfiles: mergeProviderProfiles(global.providerProfiles, project.providerProfiles), }; @@ -84,6 +86,24 @@ function mergeProviderOptions( return Object.keys(result).length > 0 ? result : undefined; } +function mergeAnalytics( + globalAnalytics: AnalyticsConfig | undefined, + projectAnalytics: AnalyticsConfig | undefined, +): AnalyticsConfig | undefined { + if (!globalAnalytics && !projectAnalytics) return undefined; + + const merged: AnalyticsConfig = { + enabled: projectAnalytics?.enabled ?? globalAnalytics?.enabled, + eventsPath: projectAnalytics?.eventsPath ?? globalAnalytics?.eventsPath, + retentionDays: projectAnalytics?.retentionDays ?? globalAnalytics?.retentionDays, + }; + + if (merged.enabled === undefined && merged.eventsPath === undefined && merged.retentionDays === undefined) { + return undefined; + } + return merged; +} + function mergeProviderProfiles( globalProfiles: ProviderPermissionProfiles | undefined, projectProfiles: ProviderPermissionProfiles | undefined, diff --git a/src/infra/config/project/projectConfig.ts b/src/infra/config/project/projectConfig.ts index 2671ba9..34c5bef 100644 --- a/src/infra/config/project/projectConfig.ts +++ b/src/infra/config/project/projectConfig.ts @@ -10,6 +10,7 @@ import { parse, stringify } from 'yaml'; import { copyProjectResourcesToDir } from '../../resources/index.js'; import type { ProjectLocalConfig } from '../types.js'; import type { ProviderPermissionProfiles } from '../../../core/models/provider-profiles.js'; +import type { AnalyticsConfig } from '../../../core/models/global-config.js'; import { applyProjectConfigEnvOverrides } from '../env/config-env-overrides.js'; import { normalizeProviderOptions } from '../loaders/pieceParser.js'; @@ -58,6 +59,31 @@ function denormalizeProviderProfiles(profiles: ProviderPermissionProfiles | unde }])) as Record }>; } +function normalizeAnalytics(raw: Record | undefined): AnalyticsConfig | undefined { + if (!raw) return undefined; + const enabled = typeof raw.enabled === 'boolean' ? raw.enabled : undefined; + const eventsPath = typeof raw.events_path === 'string' + ? raw.events_path + : (typeof raw.eventsPath === 'string' ? raw.eventsPath : undefined); + const retentionDays = typeof raw.retention_days === 'number' + ? raw.retention_days + : (typeof raw.retentionDays === 'number' ? raw.retentionDays : undefined); + + if (enabled === undefined && eventsPath === undefined && retentionDays === undefined) { + return undefined; + } + return { enabled, eventsPath, retentionDays }; +} + +function denormalizeAnalytics(config: AnalyticsConfig | undefined): Record | undefined { + if (!config) return undefined; + const raw: Record = {}; + if (config.enabled !== undefined) raw.enabled = config.enabled; + if (config.eventsPath) raw.events_path = config.eventsPath; + if (config.retentionDays !== undefined) raw.retention_days = config.retentionDays; + return Object.keys(raw).length > 0 ? raw : undefined; +} + /** * Load project configuration from .takt/config.yaml */ @@ -80,6 +106,7 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig { return { ...DEFAULT_PROJECT_CONFIG, ...(parsedConfig as ProjectLocalConfig), + analytics: normalizeAnalytics(parsedConfig.analytics as Record | undefined), providerOptions: normalizeProviderOptions(parsedConfig.provider_options as { codex?: { network_access?: boolean }; opencode?: { network_access?: boolean }; @@ -109,7 +136,13 @@ export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig // Copy project resources (only copies files that don't exist) copyProjectResourcesToDir(configDir); - const savePayload: ProjectLocalConfig = { ...config }; + const savePayload: Record = { ...config }; + const rawAnalytics = denormalizeAnalytics(config.analytics); + if (rawAnalytics) { + savePayload.analytics = rawAnalytics; + } else { + delete savePayload.analytics; + } const rawProfiles = denormalizeProviderProfiles(config.providerProfiles); if (rawProfiles && Object.keys(rawProfiles).length > 0) { savePayload.provider_profiles = rawProfiles; diff --git a/src/infra/config/types.ts b/src/infra/config/types.ts index d45d7ad..159f52c 100644 --- a/src/infra/config/types.ts +++ b/src/infra/config/types.ts @@ -4,6 +4,7 @@ import type { MovementProviderOptions } from '../../core/models/piece-types.js'; import type { ProviderPermissionProfiles } from '../../core/models/provider-profiles.js'; +import type { AnalyticsConfig } from '../../core/models/global-config.js'; /** Project configuration stored in .takt/config.yaml */ export interface ProjectLocalConfig { @@ -17,6 +18,8 @@ export interface ProjectLocalConfig { draft_pr?: boolean; /** Verbose output mode */ verbose?: boolean; + /** Project-level analytics overrides */ + analytics?: AnalyticsConfig; /** Provider-specific options (overrides global, overridden by piece/movement) */ provider_options?: MovementProviderOptions; /** Provider-specific options (camelCase alias) */