feat: analyticsをproject設定とenv overrideに対応
This commit is contained in:
parent
f479869d72
commit
22901cd8cb
@ -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
|
||||
|
||||
@ -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キー
|
||||
|
||||
@ -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<string, unknown> = {};
|
||||
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<string, unknown> = {};
|
||||
applyGlobalConfigEnvOverrides(raw);
|
||||
|
||||
expect(raw.analytics).toEqual({
|
||||
enabled: true,
|
||||
events_path: '/tmp/global-analytics',
|
||||
retention_days: 14,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
8
src/infra/config/env/config-env-overrides.ts
vendored
8
src/infra/config/env/config-env-overrides.ts
vendored
@ -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' },
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<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
|
||||
*/
|
||||
@ -80,6 +106,7 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
|
||||
return {
|
||||
...DEFAULT_PROJECT_CONFIG,
|
||||
...(parsedConfig as ProjectLocalConfig),
|
||||
analytics: normalizeAnalytics(parsedConfig.analytics as Record<string, unknown> | 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<string, unknown> = { ...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;
|
||||
|
||||
@ -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) */
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user