Handler: tap.singer_subprocess
Run a Singer-protocol tap binary as a subprocess, parse its stdout (SCHEMA / RECORD / STATE messages), and route each record through a registered record-handler that writes to a target table. State persists back to the row’s state column on a clean exit so the next run resumes from the last bookmark.
The Singer protocol (https://github.com/singer-io/getting-started) is the de-facto standard for stdout-based ETL — 600+ taps already exist for Stripe, Google Search Console, GA4, HubSpot, MySQL, Postgres, Salesforce, etc. With this handler, Poindexter consumes any of them without per-source code on our side.
Row configuration
secret_fields (optional, {tap_config_field: app_settings_key}) is how a
tap gets credentials without them ever sitting in external_taps.config in
plaintext. Each entry names a field to merge into tap_config and the
app_settings key (set with poindexter settings set <key> <value> --secret) to resolve it from — fetched via site_config.get_secret()
immediately before config.json is written, and never persisted back to the
row. A key that resolves empty, or secret_fields set with no site_config
in scope, fails loud with a ValueError instead of handing the tap a blank
credential. Fields NOT listed in secret_fields are taken verbatim from
tap_config (for non-secret values like client_id or site_urls).
What happens at run time
- Read
config.command(shlex-split — no shell expansion, no injection vectors). - Read
config.tap_config, resolve anyconfig.secret_fieldsinto it, and write the merged result to a tempconfig.json(see above — the resolved secrets never touchrow.config). - Read
row.state— written to a tempstate.json(incremental bookmark resumption). - Spawn
<command> --config config.json --state state.json. - Parse stdout line-by-line:
- SCHEMA — register the schema for the named stream (RECORDs without a preceding SCHEMA fail).
- RECORD — dispatch to the registered
record_handlerwith{stream, record, schema, time_extracted}. - STATE — buffer the latest STATE.value; persist to the row only on a clean run.
- Wait for the subprocess. On exit 0 → commit state. On non-zero → record
last_errorwith stderr tail (last 200 lines, capped 2 KB), do not advance state.
Safety + limits
- No shell expansion.
shlex.splitcannot interpret;,&&, backticks, or$(...). Thecommandfield is operator-supplied but cannot escape into shell execution. - Timeout.
config.timeout_seconds(default 600s). On expiry, SIGTERM, then SIGKILL after 5s. The next run starts fresh from the unchanged state. - Max records cap.
config.max_records(default 50k) — prevents a runaway tap from filling the target table. - Stderr cap. Last 200 lines kept, drained concurrently so a chatty tap can’t fill the OS pipe and block.
- State atomicity. STATE only commits on exit 0. A failed mid-stream run leaves the bookmark unchanged, so the next run re-fetches the same window.
Operator runbook
First-time setup for a Singer tap (e.g. GSC)
-
Install the tap into Poindexter’s Python environment:
Or use any Singer-spec tap regardless of language — only
commandmatters. -
Build a
tap_config.jsonper the tap’s documentation. For GSC that’s an OAuth token + a list of properties to query. -
Store the OAuth credentials as secrets — never paste them into
external_taps.config(that column is plain jsonb, readable by any SQL session or DB dump): -
Insert a row — non-secret fields go straight in
tap_config; the two secrets above are referenced by key throughsecret_fieldsinstead: -
Test on demand:
-
Flip on:
Common failure modes
| Symptom | Likely cause | Fix |
|---|---|---|
tap exited 1 with auth-related stderr | OAuth token expired | refresh tap_config.refresh_token, re-run |
RECORD for stream X arrived before its SCHEMA | tap is non-compliant; emits records before schemas | report upstream; use a different tap |
tap timed out after 600s | tap is slow or stuck | bump timeout_seconds; consider narrower date range in tap_config |
| Records inserted to wrong table | record_handler mismatch | verify the row’s record_handler matches what your taps’ streams expect |
secret_fields.<field> references app_settings key ... but no value is set | the referenced key was never set, or was set without --secret and the plaintext app_settings seed path filtered it | poindexter settings set <key> <value> --secret, then re-run |
Multi-stream taps
Some taps emit many streams (page_metrics, query_metrics, device_metrics). Use:config.streams— whitelist; records on streams not listed are dropped.config.metrics_mapping— one mapping per stream the operator wants persisted; unmapped streams are silently skipped byexternal_metrics_writer.
Adding a brand-new record_handler
Register a handler under thetap surface:
external_taps.record_handler row at stripe_charge_writer. The tap.singer_subprocess dispatcher resolves the handler by name and routes RECORD messages to it.
Companion handler
tap.external_metrics_writer is the default record consumer for analytics-shaped taps. See docs/integrations/tap_external_metrics_writer.md for its mapping config.
Related
- Framework overview: Integrations
- GH-103 (Singer tap support — closes when this handler is in production use)
- GH-27 (feedback-loop tables —
external_metricsis the target for analytics taps) - Singer spec: https://github.com/singer-io/getting-started/blob/master/docs/SPEC.md