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
|
# 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
|
||||||
|
|||||||
@ -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キー
|
||||||
|
|||||||
@ -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,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
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: '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' },
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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) */
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user