source dump of claude code
at main 159 lines 5.4 kB view raw
1import axios from 'axios' 2import { hasProfileScope, isClaudeAISubscriber } from '../../utils/auth.js' 3import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' 4import { logForDebugging } from '../../utils/debug.js' 5import { errorMessage } from '../../utils/errors.js' 6import { getAuthHeaders, withOAuth401Retry } from '../../utils/http.js' 7import { logError } from '../../utils/log.js' 8import { memoizeWithTTLAsync } from '../../utils/memoize.js' 9import { isEssentialTrafficOnly } from '../../utils/privacyLevel.js' 10import { getClaudeCodeUserAgent } from '../../utils/userAgent.js' 11 12type MetricsEnabledResponse = { 13 metrics_logging_enabled: boolean 14} 15 16type MetricsStatus = { 17 enabled: boolean 18 hasError: boolean 19} 20 21// In-memory TTL — dedupes calls within a single process 22const CACHE_TTL_MS = 60 * 60 * 1000 23 24// Disk TTL — org settings rarely change. When disk cache is fresher than this, 25// we skip the network entirely (no background refresh). This is what collapses 26// N `claude -p` invocations into ~1 API call/day. 27const DISK_CACHE_TTL_MS = 24 * 60 * 60 * 1000 28 29/** 30 * Internal function to call the API and check if metrics are enabled 31 * This is wrapped by memoizeWithTTLAsync to add caching behavior 32 */ 33async function _fetchMetricsEnabled(): Promise<MetricsEnabledResponse> { 34 const authResult = getAuthHeaders() 35 if (authResult.error) { 36 throw new Error(`Auth error: ${authResult.error}`) 37 } 38 39 const headers = { 40 'Content-Type': 'application/json', 41 'User-Agent': getClaudeCodeUserAgent(), 42 ...authResult.headers, 43 } 44 45 const endpoint = `https://api.anthropic.com/api/claude_code/organizations/metrics_enabled` 46 const response = await axios.get<MetricsEnabledResponse>(endpoint, { 47 headers, 48 timeout: 5000, 49 }) 50 return response.data 51} 52 53async function _checkMetricsEnabledAPI(): Promise<MetricsStatus> { 54 // Incident kill switch: skip the network call when nonessential traffic is disabled. 55 // Returning enabled:false sheds load at the consumer (bigqueryExporter skips 56 // export). Matches the non-subscriber early-return shape below. 57 if (isEssentialTrafficOnly()) { 58 return { enabled: false, hasError: false } 59 } 60 61 try { 62 const data = await withOAuth401Retry(_fetchMetricsEnabled, { 63 also403Revoked: true, 64 }) 65 66 logForDebugging( 67 `Metrics opt-out API response: enabled=${data.metrics_logging_enabled}`, 68 ) 69 70 return { 71 enabled: data.metrics_logging_enabled, 72 hasError: false, 73 } 74 } catch (error) { 75 logForDebugging( 76 `Failed to check metrics opt-out status: ${errorMessage(error)}`, 77 ) 78 logError(error) 79 return { enabled: false, hasError: true } 80 } 81} 82 83// Create memoized version with custom error handling 84const memoizedCheckMetrics = memoizeWithTTLAsync( 85 _checkMetricsEnabledAPI, 86 CACHE_TTL_MS, 87) 88 89/** 90 * Fetch (in-memory memoized) and persist to disk on change. 91 * Errors are not persisted — a transient failure should not overwrite a 92 * known-good disk value. 93 */ 94async function refreshMetricsStatus(): Promise<MetricsStatus> { 95 const result = await memoizedCheckMetrics() 96 if (result.hasError) { 97 return result 98 } 99 100 const cached = getGlobalConfig().metricsStatusCache 101 const unchanged = cached !== undefined && cached.enabled === result.enabled 102 // Skip write when unchanged AND timestamp still fresh — avoids config churn 103 // when concurrent callers race past a stale disk entry and all try to write. 104 if (unchanged && Date.now() - cached.timestamp < DISK_CACHE_TTL_MS) { 105 return result 106 } 107 108 saveGlobalConfig(current => ({ 109 ...current, 110 metricsStatusCache: { 111 enabled: result.enabled, 112 timestamp: Date.now(), 113 }, 114 })) 115 return result 116} 117 118/** 119 * Check if metrics are enabled for the current organization. 120 * 121 * Two-tier cache: 122 * - Disk (24h TTL): survives process restarts. Fresh disk cache → zero network. 123 * - In-memory (1h TTL): dedupes the background refresh within a process. 124 * 125 * The caller (bigqueryExporter) tolerates stale reads — a missed export or 126 * an extra one during the 24h window is acceptable. 127 */ 128export async function checkMetricsEnabled(): Promise<MetricsStatus> { 129 // Service key OAuth sessions lack user:profile scope → would 403. 130 // API key users (non-subscribers) fall through and use x-api-key auth. 131 // This check runs before the disk read so we never persist auth-state-derived 132 // answers — only real API responses go to disk. Otherwise a service-key 133 // session would poison the cache for a later full-OAuth session. 134 if (isClaudeAISubscriber() && !hasProfileScope()) { 135 return { enabled: false, hasError: false } 136 } 137 138 const cached = getGlobalConfig().metricsStatusCache 139 if (cached) { 140 if (Date.now() - cached.timestamp > DISK_CACHE_TTL_MS) { 141 // saveGlobalConfig's fallback path (config.ts:731) can throw if both 142 // locked and fallback writes fail — catch here so fire-and-forget 143 // doesn't become an unhandled rejection. 144 void refreshMetricsStatus().catch(logError) 145 } 146 return { 147 enabled: cached.enabled, 148 hasError: false, 149 } 150 } 151 152 // First-ever run on this machine: block on the network to populate disk. 153 return refreshMetricsStatus() 154} 155 156// Export for testing purposes only 157export const _clearMetricsEnabledCacheForTesting = (): void => { 158 memoizedCheckMetrics.cache.clear() 159}