Skip to main content

Setup runbook — Google Search Console + GA4 Singer taps

End-to-end steps to start populating external_metrics from real Google data sources. One-time OAuth setup; both taps share the same OAuth client + refresh token. Time required: ~20 minutes the first time. Subsequent runs are zero-touch.

Prerequisites

  • A Google account that owns (or has admin access to) the GSC properties and GA4 properties you want to ingest
  • Python on your local machine (any 3.10+) — only needed for the one-time OAuth helper
  • Poindexter operator access (DB + CLI)
The seed rows are already in external_taps (commit 5f6a4075’s follow-up — gsc_main and ga4_main, both enabled=false). The operator runbook is the path from “rows exist” to “data flowing.”

Step 1 — Google Cloud Console: enable APIs + create OAuth client

  1. Go to https://console.cloud.google.com/. Pick or create a project (any name; “poindexter-analytics” works).
  2. Enable the two APIs the taps use:
  3. Create OAuth credentials:
    • Go to https://console.cloud.google.com/apis/credentials
    • Configure consent screen (if you haven’t already):
      • User type: External
      • App name: Poindexter Analytics (anything works — only you will see this)
      • User support email + developer email: your email
      • Scopes: leave default; the taps request the scopes themselves
      • Test users: add your own Google email
      • Save and continue all the way through
    • Then + CREATE CREDENTIALS → OAuth client ID:
      • Application type: Desktop app
      • Name: poindexter-singer-tap
      • Click Create
    • Copy the Client ID and Client secret (the dialog shows them once; you can also re-open later).
You now have:
  • client_id (looks like 123456789-abc...apps.googleusercontent.com)
  • client_secret (looks like GOCSPX-...)

Step 2 — Get a refresh_token

From the repo root, in any Python environment:
What happens:
  1. Browser opens to Google’s OAuth consent screen.
  2. Sign in with the Google account that owns your GSC + GA4 properties.
  3. Click Allow for both scopes (webmasters.readonly + analytics.readonly).
  4. Browser shows “The authentication flow has completed.”
  5. Terminal prints a refresh_token plus copy-paste-ready poindexter commands.
The refresh_token is long-lived — Google does not expire these unless the user revokes the grant or the app goes 6+ months without using it. Store it carefully.
If you don’t see a refresh_token in the output, Google didn’t issue one (this happens when you’ve already consented). Revoke the app at https://myaccount.google.com/permissions and run the helper again.

Step 3 — Store credentials in Poindexter

Three settings. The first one (client_id) is not encrypted; the other two are.
Verify with poindexter settings list --search google_oauth. The two --secret rows will show enc:v1:... ciphertext — that’s correct.

Step 4 — Update the tap rows with your specifics

The two rows shipped with empty placeholders. Update each with your real values. The two OAuth secrets you just stored in Step 3 are referenced by key, never pasted in. external_taps.config is a plain (unencrypted) jsonb column — readable by any SQL session, pgAdmin browse, or DB dump — so client_secret/refresh_token go through config.secret_fields instead, which tap.singer_subprocess resolves via site_config.get_secret() immediately before spawning the tap (see docs/integrations/tap_singer_subprocess.md). Only the non-secret client_id goes in tap_config directly.

GSC

site_urls must match an EXACT property URL registered in your GSC account (typically https://example.com for domain properties or https://www.example.com/ with the trailing slash for URL-prefix properties — check at https://search.google.com/search-console).

GA4

You’ll need your GA4 property ID — find it at https://analytics.google.com → Admin → Property → Property details → “Property ID” (a number like 123456789). Note GA4’s tap expects the client id/secret fields named oauth_client_id/oauth_client_secret rather than GSC’s client_id/client_secret.

Step 5 — Install the tap binaries

The taps run as subprocesses; the worker needs them on PATH (or config.command needs to name an absolute path where they live). Two options: Isolates the taps’ dependencies from the worker’s shared Python env. ~/.poindexter is usually root-owned in the container, so create the venv directory as root first if appuser can’t write to it:
Then point the row’s config.command at the absolute venv binary path:

Option B — install in the worker container’s shared env

Known risk, hit in production (2026-07-14): tap-google-search-console’s dependency singer-python==6.0.1 pins jsonschema==2.*. The container installs --user by default (its base image’s site-packages is read-only), and pip’s resolver only checks the package it’s installing against its own dependency tree — it doesn’t check the shared env’s other already-installed packages, so it happily downgrades jsonschema, silently breaking prefect (needs >=4.18), litellm (needs >=4.0), and mcp (needs >=4.20). The running process is unaffected immediately (already-imported modules stay in memory), but the next container restart picks up the broken version. Always run docker exec poindexter-worker pip check right after installing — if it reports a conflict, pip uninstall the packages you just added (this restores the base image’s own compatible versions from /usr/local/lib/..., which --user installs only ever shadow, never overwrite) and use Option A instead. Option A avoids this class of conflict entirely — its venv’s site-packages never touches the worker’s shared environment.

Step 6 — Test on demand

Run this inside the worker container, not on a Windows host shell. Whichever option you picked in Step 5, config.command names a path that only exists inside poindexter-worker (a venv binary, or a bare command resolved via the container’s PATH) — invoked from a Windows host, asyncio.create_subprocess_exec fails with FileNotFoundError: [WinError 2] The system cannot find the file specified, because Windows has no /root/ or /home/appuser/ filesystem at all. This isn’t a bug to fix; it’s the same class of gotcha as running the Ollama-backed CLI commands on host instead of in-container. Also note poindexter itself isn’t on the container’s PATH — invoke it as a module:
Note: the taps run CLI command only invokes enabled rows. For the on-demand test BEFORE enabling, either temporarily flip enabled and back, or call the runner inline (still inside the container, for the same reason as above):
A successful run reports records: <N> and you’ll see fresh rows in external_metrics:

Step 7 — Flip enabled and let the scheduler take over

The schedule is every 6 hours for both. Watch the Integrations & Admin Grafana dashboard — the total_records column on the External taps table will tick up after each run.

Troubleshooting

Where data lands

external_metrics table. The mapping config on each row determines which fields become metric rows vs which become dimensions in the jsonb. For GSC each performance row produces 4 external_metrics rows (impressions / clicks / ctr / position); for GA4 each page_metrics row produces 4 (sessions / screenPageViews / engagementRate / userEngagementDuration). Writes are idempotent. Singer taps re-emit their whole backfill window on every run (every 6h), so the writer upserts on the natural key (source, metric_name, date, slug, dimensions) — a re-emitted row updates in place (last-write-wins) rather than appending a copy. This lets a metric that finalises over several days (GSC clicks mature over ~3 days) converge on the latest value, and keeps SUM(metric_value) aggregates (e.g. rollup_post_performance) accurate. The ux_external_metrics_natural_key unique index (NULLS NOT DISTINCT, so the site-wide performance_report_date rows with NULL slug still dedup) is the arbiter — migration 20260704_033500, which also one-time-deduped the ~382k rows the pre-upsert writer had accumulated. Query examples:
Wire these into Grafana panels once the data starts flowing.
  • docs/integrations/tap_singer_subprocess.md — the dispatcher these rows use
  • docs/integrations/tap_external_metrics_writer.md — the record_handler that writes to external_metrics
  • GH-103 / GH-27 — feedback-loop tables; external_metrics is one of the eight