feat: analyticsをproject設定とenv overrideに対応

This commit is contained in:
nrslib 2026-02-20 08:37:40 +09:00
parent f479869d72
commit 22901cd8cb
8 changed files with 204 additions and 1 deletions

View File

@ -29,6 +29,10 @@ concurrency: 2 # Concurrent task execution for takt run (1-10)
# run_abort: true # run_abort: true
# observability: # observability:
# provider_events: false # Persist provider stream events # 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) # Credentials (environment variables take priority)
# anthropic_api_key: "sk-ant-..." # Claude API key # anthropic_api_key: "sk-ant-..." # Claude API key

View File

@ -29,6 +29,10 @@ concurrency: 2 # takt run の同時実行数1-10
# run_abort: true # run_abort: true
# observability: # observability:
# provider_events: false # providerイベントログを記録 # provider_events: false # providerイベントログを記録
# analytics:
# enabled: true # 分析メトリクスの収集を有効化
# events_path: ~/.takt/analytics/events # 分析イベント保存先
# retention_days: 30 # 分析イベント保持日数
# 認証情報(環境変数優先) # 認証情報(環境変数優先)
# anthropic_api_key: "sk-ant-..." # Claude APIキー # anthropic_api_key: "sk-ant-..." # Claude APIキー

View File

@ -53,10 +53,29 @@ describe('config env overrides', () => {
it('should apply project env overrides from generated env names', () => { it('should apply project env overrides from generated env names', () => {
process.env.TAKT_VERBOSE = 'true'; process.env.TAKT_VERBOSE = 'true';
process.env.TAKT_ANALYTICS_EVENTS_PATH = '/tmp/project-analytics';
const raw: Record<string, unknown> = {}; const raw: Record<string, unknown> = {};
applyProjectConfigEnvOverrides(raw); applyProjectConfigEnvOverrides(raw);
expect(raw.verbose).toBe(true); 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<string, unknown> = {};
applyGlobalConfigEnvOverrides(raw);
expect(raw.analytics).toEqual({
enabled: true,
events_path: '/tmp/global-analytics',
retention_days: 14,
});
}); });
}); });

View File

@ -37,6 +37,7 @@ import {
isVerboseMode, isVerboseMode,
invalidateGlobalConfigCache, invalidateGlobalConfigCache,
} from '../infra/config/index.js'; } from '../infra/config/index.js';
import { loadConfig } from '../infra/config/loadConfig.js';
describe('getBuiltinPiece', () => { describe('getBuiltinPiece', () => {
it('should return builtin piece when it exists in resources', () => { 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', () => { describe('isVerboseMode', () => {
let testDir: string; let testDir: string;
let originalTaktConfigDir: string | undefined; let originalTaktConfigDir: string | undefined;

View File

@ -82,6 +82,10 @@ const GLOBAL_ENV_SPECS: readonly EnvSpec[] = [
{ path: 'model', type: 'string' }, { path: 'model', type: 'string' },
{ path: 'observability', type: 'json' }, { path: 'observability', type: 'json' },
{ path: 'observability.provider_events', type: 'boolean' }, { 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: 'worktree_dir', type: 'string' },
{ path: 'auto_pr', type: 'boolean' }, { path: 'auto_pr', type: 'boolean' },
{ path: 'draft_pr', type: 'boolean' }, { path: 'draft_pr', type: 'boolean' },
@ -126,6 +130,10 @@ const PROJECT_ENV_SPECS: readonly EnvSpec[] = [
{ path: 'piece', type: 'string' }, { path: 'piece', type: 'string' },
{ path: 'provider', type: 'string' }, { path: 'provider', type: 'string' },
{ path: 'verbose', type: 'boolean' }, { 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', type: 'json' },
{ path: 'provider_options.codex.network_access', type: 'boolean' }, { path: 'provider_options.codex.network_access', type: 'boolean' },
{ path: 'provider_options.opencode.network_access', type: 'boolean' }, { path: 'provider_options.opencode.network_access', type: 'boolean' },

View File

@ -1,6 +1,7 @@
import type { GlobalConfig } from '../../core/models/index.js'; import type { GlobalConfig } from '../../core/models/index.js';
import type { MovementProviderOptions } from '../../core/models/piece-types.js'; import type { MovementProviderOptions } from '../../core/models/piece-types.js';
import type { ProviderPermissionProfiles } from '../../core/models/provider-profiles.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 { loadGlobalConfig } from './global/globalConfig.js';
import { loadProjectConfig } from './project/projectConfig.js'; import { loadProjectConfig } from './project/projectConfig.js';
import { envVarNameFromPath } from './env/config-env-overrides.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, draftPr: project.draft_pr ?? global.draftPr,
model: resolveModel(global, provider), model: resolveModel(global, provider),
verbose: resolveVerbose(project.verbose, global.verbose), verbose: resolveVerbose(project.verbose, global.verbose),
analytics: mergeAnalytics(global.analytics, project.analytics),
providerOptions: mergeProviderOptions(global.providerOptions, project.providerOptions), providerOptions: mergeProviderOptions(global.providerOptions, project.providerOptions),
providerProfiles: mergeProviderProfiles(global.providerProfiles, project.providerProfiles), providerProfiles: mergeProviderProfiles(global.providerProfiles, project.providerProfiles),
}; };
@ -84,6 +86,24 @@ function mergeProviderOptions(
return Object.keys(result).length > 0 ? result : undefined; 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( function mergeProviderProfiles(
globalProfiles: ProviderPermissionProfiles | undefined, globalProfiles: ProviderPermissionProfiles | undefined,
projectProfiles: ProviderPermissionProfiles | undefined, projectProfiles: ProviderPermissionProfiles | undefined,

View File

@ -10,6 +10,7 @@ import { parse, stringify } from 'yaml';
import { copyProjectResourcesToDir } from '../../resources/index.js'; import { copyProjectResourcesToDir } from '../../resources/index.js';
import type { ProjectLocalConfig } from '../types.js'; import type { ProjectLocalConfig } from '../types.js';
import type { ProviderPermissionProfiles } from '../../../core/models/provider-profiles.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 { applyProjectConfigEnvOverrides } from '../env/config-env-overrides.js';
import { normalizeProviderOptions } from '../loaders/pieceParser.js'; import { normalizeProviderOptions } from '../loaders/pieceParser.js';
@ -58,6 +59,31 @@ function denormalizeProviderProfiles(profiles: ProviderPermissionProfiles | unde
}])) as Record<string, { default_permission_mode: string; movement_permission_overrides?: Record<string, string> }>; }])) as Record<string, { default_permission_mode: string; movement_permission_overrides?: Record<string, string> }>;
} }
function normalizeAnalytics(raw: Record<string, unknown> | 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<string, unknown> | undefined {
if (!config) return undefined;
const raw: Record<string, unknown> = {};
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 * Load project configuration from .takt/config.yaml
*/ */
@ -80,6 +106,7 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
return { return {
...DEFAULT_PROJECT_CONFIG, ...DEFAULT_PROJECT_CONFIG,
...(parsedConfig as ProjectLocalConfig), ...(parsedConfig as ProjectLocalConfig),
analytics: normalizeAnalytics(parsedConfig.analytics as Record<string, unknown> | undefined),
providerOptions: normalizeProviderOptions(parsedConfig.provider_options as { providerOptions: normalizeProviderOptions(parsedConfig.provider_options as {
codex?: { network_access?: boolean }; codex?: { network_access?: boolean };
opencode?: { 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) // Copy project resources (only copies files that don't exist)
copyProjectResourcesToDir(configDir); copyProjectResourcesToDir(configDir);
const savePayload: ProjectLocalConfig = { ...config }; const savePayload: Record<string, unknown> = { ...config };
const rawAnalytics = denormalizeAnalytics(config.analytics);
if (rawAnalytics) {
savePayload.analytics = rawAnalytics;
} else {
delete savePayload.analytics;
}
const rawProfiles = denormalizeProviderProfiles(config.providerProfiles); const rawProfiles = denormalizeProviderProfiles(config.providerProfiles);
if (rawProfiles && Object.keys(rawProfiles).length > 0) { if (rawProfiles && Object.keys(rawProfiles).length > 0) {
savePayload.provider_profiles = rawProfiles; savePayload.provider_profiles = rawProfiles;

View File

@ -4,6 +4,7 @@
import type { MovementProviderOptions } from '../../core/models/piece-types.js'; import type { MovementProviderOptions } from '../../core/models/piece-types.js';
import type { ProviderPermissionProfiles } from '../../core/models/provider-profiles.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 */ /** Project configuration stored in .takt/config.yaml */
export interface ProjectLocalConfig { export interface ProjectLocalConfig {
@ -17,6 +18,8 @@ export interface ProjectLocalConfig {
draft_pr?: boolean; draft_pr?: boolean;
/** Verbose output mode */ /** Verbose output mode */
verbose?: boolean; verbose?: boolean;
/** Project-level analytics overrides */
analytics?: AnalyticsConfig;
/** Provider-specific options (overrides global, overridden by piece/movement) */ /** Provider-specific options (overrides global, overridden by piece/movement) */
provider_options?: MovementProviderOptions; provider_options?: MovementProviderOptions;
/** Provider-specific options (camelCase alias) */ /** Provider-specific options (camelCase alias) */