/ services / api / metricsOptOut.ts
metricsOptOut.ts
  1  import axios from 'axios'
  2  import { hasProfileScope, isClaudeAISubscriber } from '../../utils/auth.js'
  3  import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
  4  import { logForDebugging } from '../../utils/debug.js'
  5  import { errorMessage } from '../../utils/errors.js'
  6  import { getAuthHeaders, withOAuth401Retry } from '../../utils/http.js'
  7  import { logError } from '../../utils/log.js'
  8  import { memoizeWithTTLAsync } from '../../utils/memoize.js'
  9  import { isEssentialTrafficOnly } from '../../utils/privacyLevel.js'
 10  import { getClaudeCodeUserAgent } from '../../utils/userAgent.js'
 11  
 12  type MetricsEnabledResponse = {
 13    metrics_logging_enabled: boolean
 14  }
 15  
 16  type MetricsStatus = {
 17    enabled: boolean
 18    hasError: boolean
 19  }
 20  
 21  // In-memory TTL — dedupes calls within a single process
 22  const 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.
 27  const 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   */
 33  async 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  
 53  async 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
 84  const 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   */
 94  async 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   */
128  export 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
157  export const _clearMetricsEnabledCacheForTesting = (): void => {
158    memoizedCheckMetrics.cache.clear()
159  }