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