← back to the garden

evergreen

Building an Analytics Toolkit with LLMs, CLI, and Google APIs

planted · March 16, 2026
#google-apis#analytics#ai#cli#developer-experience

TL;DR: We combined Claude AI, Node.js CLI scripts, and six Google APIs into a single analytics toolkit. Here's what we learned about authentication, security, and the sheer pain of Google's API ecosystem.

The Problem

We needed to audit our GA4 implementation. Conversion rates looked suspicious (99.91% session key event rate is not real). Traffic attribution was broken. Bot activity was inflating engagement metrics. And we had resumes piling up in Gmail that needed ranking.

The obvious answer was to build tooling around Google's APIs. The less obvious part was how painful that would actually be.

Why Google APIs Are Brutally Hard

Google's API ecosystem has one of the steepest learning curves in the industry.

The Authentication Maze

Before you write a single line of business logic, you're navigating a labyrinth of authentication methods. We ended up implementing three different auth patterns in a single project:

  1. Application Default Credentials (ADC) for developers whose organizations block service account key creation
  2. Service Account Keys as the traditional approach, but increasingly restricted by org policies
  3. Workload Identity Federation for cross-cloud deployments (our AWS-to-GCP bridge)

Each method has different setup steps, different failure modes, and different security implications. The documentation exists, but it's scattered across dozens of pages, and the error messages when something goes wrong are often cryptic.

Here's the pattern we settled on, a single config switch that every class in the project respects:

// config.js
export const config = {
  authMethod: "adc",  // 'adc' or 'service-account'
  credentialsPath: "./credentials.json",
  propertyId: "<your-ga4-property-id>",
};

Every API client in the project reads this once:

const authConfig = authMethod === 'service-account'
  ? { keyFile: credentialsPath, scopes: ['https://www.googleapis.com/auth/analytics.readonly'] }
  : { scopes: ['https://www.googleapis.com/auth/analytics.readonly'] };

This seems trivial, but it took multiple iterations to get right. The GA4 Data API client (BetaAnalyticsDataClient) accepts keyFilename, while GoogleAuth accepts both keyFilename and keyFile as aliases. The inconsistency isn't across libraries so much as within their documentation. You'll find examples using one or the other with no indication that both work.

Six APIs, Six Sets of Scopes

Our toolkit talks to six Google APIs:

API Scope What It Does
GA4 Data API analytics.readonly Session metrics, engagement data, channel distribution
GA4 Admin API analytics.readonly, analytics.edit Conversion events, custom dimensions, property config
Tag Manager API tagmanager.readonly GTM container inspection, tag/trigger/variable validation
Gmail API gmail.readonly Email search, attachment downloads for resume parsing
Google Ads API adwords Campaign validation and ad conversion tracking
Cloud Platform cloud-platform Cross-service operations

Each API has its own:

  • Enable/disable toggle in the Google Cloud Console
  • Permission model (GA4 property-level access is separate from GCP IAM)
  • Rate limits and quota behavior
  • Client library with its own initialization pattern

The GA4 Admin API returns data differently from the GA4 Data API, even though they're both "GA4." The Data API uses indexed arrays where order corresponds to your query:

// Order matters: index 0 is sessions because we requested it first
const sessions = parseFloat(row.metricValues[0].value);
const channel = row.dimensionValues[0].value;

There's no named field access. If you reorder your metrics request, all your parsing code breaks silently.

The Property ID Trap

GA4 has two different identifiers that look similar:

  • Measurement ID: G-XXXXXXXXXX (used in tracking code)
  • Property ID: 123456789 (used in API calls)

Use the wrong one and you get a confusing "Property not found" error. We've watched experienced developers lose 30 minutes to this.

Where the LLM Changed Everything

Most of Google's API complexity is accidental complexity. The actual business logic, "give me session counts grouped by channel," is straightforward. The hard part is the 200 lines of authentication, error handling, and response parsing that wrap every 10-line query.

Claude as a Force Multiplier for API Integration

We used Claude (the API, via @anthropic-ai/sdk) directly in our resume ranking pipeline. But more importantly, we used Claude Code (the CLI tool) to build the toolkit itself.

When you're staring at Google's documentation trying to figure out why analyticsadmin.properties.conversionEvents.list returns an empty array despite your service account having Viewer access, an LLM that understands the API surface is invaluable. It can:

  • Spot the difference between keyFile and keyFilename across Google's client libraries
  • Generate the correct scope combinations for multi-API workflows
  • Translate between the GA4 Admin API's protobuf-style responses and something you can actually work with
  • Write the boilerplate auth switching code so you can focus on analytics logic

The Resume Ranking Pipeline

Our most interesting LLM integration pipes Gmail attachments through Claude for analysis:

flowchart LR
    gmail["Gmail API\n(fetch emails)"] --> parser["PDF/DOCX\nParser"] --> claude["Claude API\n(analyze & rank)"] --> report["Report"]

The Claude integration is refreshingly simple compared to Google's APIs:

import Anthropic from '@anthropic-ai/sdk';

const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });

const message = await client.messages.create({
  model: 'claude-sonnet-4-20250514',
  max_tokens: 2000,
  messages: [{ role: 'user', content: prompt }]
});

One API key. One client. One method. No scopes, no IAM roles, no property-level access grants.

CLI-First Architecture

We deliberately chose a CLI-first approach over building a web UI:

npm run audit                    # Full GA4 audit
npm run validate:e2e             # End-to-end pipeline validation
npm run rank:resumes             # AI-powered resume ranking
node audit.js --start 30daysAgo --end today  # Custom date range

Why CLI?

  1. Composability: pipe outputs, chain commands, integrate with cron
  2. No server to secure: no exposed endpoints, no session management, no CORS
  3. Git-friendly: JSON and Markdown reports commit cleanly
  4. LLM-friendly: Claude Code can read, modify, and run these scripts directly

Each script is self-contained with a #!/usr/bin/env node shebang. No build step. No transpilation. Just node script.js and you're running.

Security Guidelines

1. Default to ADC, Not Service Account Keys

Service account keys are static credentials sitting in a JSON file. They don't expire automatically. They're easy to accidentally commit. They're easy to copy to the wrong machine.

ADC (Application Default Credentials) uses your existing gcloud authentication:

gcloud auth application-default login

This creates a refresh token at ~/.config/gcloud/application_default_credentials.json that:

  • Is tied to your Google account's session
  • Can be revoked centrally
  • Doesn't need to be distributed to team members
  • Works with your org's existing access controls

Important caveat: ADC's default cloud-platform scope works for most GCP services but does NOT cover Google Analytics APIs. The Analytics Admin and Data APIs require explicit analytics.edit or analytics.readonly scopes, and Google's default gcloud OAuth client blocks these. You'll need to create a custom OAuth client in your GCP project (APIs & Services, Credentials, OAuth client ID, Desktop app) and use it to authenticate with analytics scopes. This is a one-time setup per project, but it's a gotcha that isn't documented well.

We wrote a dedicated validation script (check-adc.js) that verifies the entire ADC chain:

// 1. Is gcloud installed?
const { stdout } = await execAsync('gcloud --version');

// 2. Does the ADC file exist?
const adcPath = join(homedir(), '.config', 'gcloud', 'application_default_credentials.json');

// 3. Is there an active account?
const { stdout: account } = await execAsync(
  'gcloud auth list --filter=status:ACTIVE --format="value(account)"'
);

// 4. Is a default project configured?
const { stdout: project } = await execAsync('gcloud config get-value project');

If any step fails, it tells you exactly what to run to fix it. This saved us hours of debugging.

2. Principle of Least Scope

Every API client requests only the scopes it needs:

// Gmail client: only read access, never send
scopes: ['https://www.googleapis.com/auth/gmail.readonly']

// GTM validator: only inspect, never modify
scopes: ['https://www.googleapis.com/auth/tagmanager.readonly']

// GA4 auditor: read-only for auditing
scopes: ['https://www.googleapis.com/auth/analytics.readonly']

The analytics.edit scope is only used in the script that explicitly modifies conversion settings. The audit scripts never request write access.

3. Git-Ignore Everything Sensitive

Our .gitignore is aggressive:

*-credentials.json     # Service account keys
oauth-token.json       # OAuth tokens
.env                   # Environment variables
reports/*.json         # Reports may contain property IDs and metric data
reports/*.md           # Same for markdown reports
*.log                  # Debug logs might contain tokens

Reports are excluded because they contain property IDs, event names, and traffic patterns that are business-sensitive even if they're not credentials.

4. Environment Variables for API Keys

The Claude API key is never in config files:

constructor(apiKey, jobDescription = null) {
  if (!apiKey) {
    throw new Error(
      'Claude API key is required. Set ANTHROPIC_API_KEY environment variable.'
    );
  }
  this.client = new Anthropic({ apiKey });
}

Called from the entry point:

const ranker = new ResumeRanker(process.env.ANTHROPIC_API_KEY, jobDescription);

Hard fail if it's missing. No fallbacks. No "maybe check this file" behavior.

5. Graceful Degradation, Not Silent Failures

Auth failures should be loud and helpful:

if (error.message.includes('PERMISSION_DENIED')) {
  console.error('Possible fixes:');
  if (config.authMethod === 'adc') {
    console.error('  1. Verify your Google account has GA4 property access');
    console.error('  2. Check for "Viewer" role in GA4 Admin');
    console.error('  3. Re-authenticate: gcloud auth application-default login');
  }
} else if (error.message.includes('Could not load the default credentials')) {
  console.error('ADC not configured. Run:');
  console.error('  gcloud auth application-default login');
}

When a non-critical API call fails (like fetching conversion events during an audit), return empty data and continue. Don't crash the entire audit:

async getConversionEvents() {
  try {
    const response = await this.analyticsAdmin.properties.conversionEvents.list({
      parent: `properties/${this.propertyId}`,
    });
    return response.data.conversionEvents || [];
  } catch (error) {
    console.error('Error fetching conversion events:', error.message);
    return [];
  }
}

6. Workload Identity for Production

For production deployments, we use Workload Identity Federation instead of service account keys:

{
  "type": "external_account",
  "subject_token_type": "urn:ietf:params:aws:token-type:aws4_request",
  "service_account_impersonation_url": "https://iamcredentials.googleapis.com/..."
}

This lets AWS workloads authenticate to GCP without any static credentials. The config file itself contains no secrets, it's safe to commit. The actual authentication happens through AWS STS token exchange at runtime.

The End-to-End Validation Story

Our most complex script orchestrates three APIs in sequence to validate the entire analytics pipeline:

flowchart LR
    code["TypeScript\nCode Parser"] --> gtm["GTM API\nValidation"] --> ga4["GA4 API\nValidation"]

It parses the frontend repo's TypeScript to extract tracked events, then verifies each event has a corresponding GTM tag, then confirms GA4 is receiving the data. Three separate auth contexts, three APIs, one coherent validation report.

// Step 1: Parse TypeScript (no API needed)
const codeEvents = codeParser.parseAnalyticsFile();

// Step 2: Validate GTM (tagmanager.readonly scope)
const gtmResults = await gtmValidator.validate(envConfig.gtmId);

// Step 3: Validate GA4 (analytics.readonly scope)
const ga4Results = await ga4Validator.validate(envConfig.ga4PropertyId);

Per-environment configs (dev/uat/prod) mean we can validate any environment with a flag:

npm run validate:e2e:dev   # Dev GTM + GA4
npm run validate:e2e:prod  # Production GTM + GA4

What We'd Do Differently

  1. Set up a custom OAuth client from day one. ADC with cloud-platform scope doesn't cover Analytics APIs. Create an OAuth Desktop client in your GCP project before writing any analytics code. This one step saves hours of debugging scope errors.

  2. Write the auth validation script early. check-adc.js saved more debugging time than any other script in the project. Build your "is everything connected?" check before your business logic.

  3. Don't underestimate scope creep in Google APIs. We started with "just read GA4 data" and ended up needing six APIs with eight scopes. Each new API is another round of enable-in-console, grant-access, test-connection.

  4. Use the LLM for the boilerplate, keep the logic yours. Claude Code is exceptional at generating the auth switching code, the error handling wrappers, and the response parsing. That freed us to focus on what actually matters: the analytics logic and the ranking algorithms.