# Observed inventory: one normalized CSV per tool, read-only

Every route below ends in the same CSV, so one drift check covers GA4, Mixpanel, Amplitude, PostHog and Segment. Facts and tool names were checked on 2026-11-01 and 2026-11-02, and the MCP tool lists, BigQuery cost fields and Amplitude export limits again on 2026-11-06; MCP tool lists change, so inspect the connected catalog at run time and record the date.

Read-only comes from the connected identity's role, the server's read-only switch or the client's tool deny list. A prompt that says "read only" removes no permission, so set one of the three before the first call and record which one in the baseline.

## Contents

- The normalized CSV
- Value types per source
- Time zones per route
- GA4
- Mixpanel
- Amplitude
- PostHog
- Segment
- RudderStack and Avo
- Completeness of reads

## The normalized CSV

Columns: [../assets/observed-inventory-template.csv](../assets/observed-inventory-template.csv), a header-only file. Copy the header into the scratch folder and fill the copy.

- Save each query's result as its own CSV with its header row and pass each with its own `--observed`; the script matches columns by name. Only GA4 Parts A, B and F share one column order and can be stacked.
- Pass each row once. Drift stops with exit code 2 when it reads the same tool, source label, event, property and type twice, because summing them doubles every count: the same file twice, a stacked file beside its parts, or a daily report left unsummed. Rows whose raw labels differ but map to one source (analytics.js and analytics-next) are summed.

Row rules:

- Event-level rows (property empty): `rows` counts events in the audit window; `rows` 0 with a `last_seen` date means the event was seen only before the window, which is the "stopped" signal. `first_seen` and `last_seen` cover the 90-day lookback, per source, which the stop rule reads. `duplicate_keys` counts repeated occurrence keys beyond the first (empty means not checked); `transport_duplicates` counts repeated insert or message IDs; `max_params` is the widest row in custom parameters.
- Names-only routes (a Lexicon CSV, `get_amp_taxonomy`, Segment Source Schema without counts) leave `rows` empty on event rows. The script reads an empty cell as "listed without counts" and 0 as zero rows, so never write 0 for an unknown count.
- Property rows: one row per property and value type. `rows` counts occurrences of that type in the window; `event_rows` is the event's rows for that source (the script falls back to the event-level row). `values` holds `value=count` pairs joined by `|`, top 50 then `<other>=n`, only for enumerated properties. `pii_hits`, `sentinel_hits` (undefined, null, NaN, [object Object], None, nil) and `empty_hits` count matching occurrences.
- Column-level rows: `rows` left empty. A type with no counts, from a warehouse `information_schema`, a Lexicon export or PostHog's detected types. D10 compares the type; D07 needs counts and prints NOT CHECKED.
- Identifier rows: `value_type` identifier, one row per event plus a `*` total per source, and `values` holding classes only (uuid, numeric, numeric_over_2_53, email_like, short_under_5, placeholder, other, null). Never the IDs. When only per-event rows exist, the script sums them into the total.
- Redaction tokens: `<email>`, `<phone>`, `<url_query>`, `<long_text>`, `<other>` and `<rare>` (values seen fewer than 5 times, folded when a property has more than 50 distinct values). Values starting with `+`, `-` or `@` get a leading quote so a spreadsheet does not run them.
- Header synonyms the script accepts: row_count, occurrences or count for rows; event_name for event; key or param for property; type or data_type for value_type; platform or lib for source. Sources are mapped to web, ios, android or server: known library names first (analytics-node and posthog-node become server, iphone becomes ios), then name patterns (android or kotlin; ios or swift; js, javascript or browser; node, python, ruby, go, java, php and .NET). A cross-platform library such as react-native stays as written; profile again with an OS field as `--source-key` to split it. A source that looks like an app version stops the run, because a version split is read by hand.
- An inventory with event rows only (names and counts) runs the event rules; property rules print NOT CHECKED for that tool. An inventory without a source column runs every rule except D06, which prints NOT CHECKED.

## Value types per source

Vocabulary: string, numeric_string, timestamp, integer, number, boolean, array, object, null.

- Raw JSON exports through `scripts/profile_export.py`: text becomes string, numeric_string (a plain decimal number) or timestamp (a leading ISO date); true and false become boolean (checked before integers); whole numbers become integer, decimals number; lists array; objects object.
- GA4 BigQuery export: `string_value` becomes string, numeric_string or timestamp by pattern; `int_value` integer; `double_value` number (`float_value` is not in use). Whole numbers in `int_value` and decimals in `double_value` are both numbers, so a planned number with both passes.
- PostHog `JSONType`: String (then the same patterns), Int64 and UInt64 integer, Double number, Bool boolean, Array array, Object object, Null null.
- Mixpanel Lexicon types (column level, if the export carries them): String, Numeric as number, Boolean, Date as timestamp, List as array, Object.
- Segment warehouse columns: column level from Query 2 (varchar or STRING, integer or INT64, float or FLOAT64 as number, boolean, timestamp), and row counts per type from Query 2b.

## Time zones per route

Record the time zone of every inventory and every reference count; D12 compares them before judging a ratio near 1.0.

- GA4: `event_date` and the table suffixes follow the property time zone; `event_timestamp` is UTC in microseconds. The GA4 SQL shows how to cut days in UTC by widening the suffix range one day each side and filtering on `event_timestamp`.
- Amplitude Export API: start and end hours are UTC; the profiler cuts days from `event_time`, which the export gives in UTC.
- Mixpanel raw export: older projects export project-time timestamps, newer ones UTC. Mixpanel's page gives 1 January 2023 for UTC and 11 January 2023 for project time, so check a project created between them.
- Segment warehouse: the SQL here filters `received_at` with UTC literals.
- PostHog: check in the PostHog SQL docs which time zone date literals use for the project.

## GA4

- Preferred route: the BigQuery export with [../assets/ga4-bigquery-inventory.sql](../assets/ga4-bigquery-inventory.sql), in the console or through Google's BigQuery remote MCP server with `execute_sql_readonly` only. Part A event level (90-day lookback on cheap columns, custom parameter count), Part B parameters of planned events (window), Part C top values of enumerated parameters with the `<other>` tail, Part D repeated occurrence keys, Part E user properties on planned events (scope confusion), Part F user_id classes per planned event.
- BigQuery MCP: `execute_sql_readonly` takes one SELECT per call and caps rows and run time (check the current limits on Google's BigQuery MCP page). Replace the DECLARE lines with literals, send each Part as its own call, and label the inventory partial when a result reaches the cap. Set `dryRun` true first for the estimate, then `jobTimeoutMs` and a label on the run, spelled as the client's tool schema spells them, because Google's prose uses snake_case and its schema camelCase. The tool has no maximum bytes billed field (checked 2026-11-06). A custom query quota set by an admin is a backstop, not a per-query cap: it covers on-demand pricing, is approximate, and Google says it can't be assigned to one user or service account. `execute_sql` and `cancel_job` are not read-only: deny both in the client, and an admin blocks them with an IAM deny policy.
- Cost and coverage: dry-run (the console's estimate, or `dryRun` true on `execute_sql_readonly`), set Maximum bytes billed in the console (`bq` and the API also take it), and remember LIMIT does not limit bytes scanned. Run a query only when its estimate is under the cap the user set, or after the user approves it; otherwise hand the query over. Daily tables only; intraday tables are deleted when the daily table completes and miss fields. Standard properties have a daily batch export limit of one million events; check every day in the window has a table.
- Data API through Google's GA4 MCP server (needs the `analytics.readonly` scope): `run_report` with the eventName, platform and date dimensions and the eventCount metric gives names, sources, counts and dates, so event rules only. Sum the counts into one row per event and platform, and keep the earliest and latest dates as `first_seen` and `last_seen`. Without platform, D06 cannot run; without date, D01 cannot see stops inside the window. Registered parameters can be read as custom dimensions, but values arrive as strings, so types cannot be checked. Start the server without `--allow-write`; its write tools (`create_property_annotation`, `create_custom_dimension` and `create_key_event`, checked 2026-11-06) need that flag and the `analytics.edit` scope. Reports can withhold or group low-volume rows, so a name missing from a report is "not observed in the report" until BigQuery or DebugView says otherwise.
- Custom definitions for D15: `get_custom_dimensions_and_metrics` lists the registered dimensions and metrics; save parameter_name, scope and kind as CSV ([../assets/ga4-custom-definitions-template.csv](../assets/ga4-custom-definitions-template.csv)) and pass it with `--ga4-definitions`. Check the quota on the live limits page before proposing new registrations.
- Property tier: Analytics 360 allows 100 parameters per event against 25 on standard properties; pass `--ga4-param-limit 100` for 360 and record the tier in the baseline.
- Event rules in Admin: Admin > Data display > Events, then Modify event or Create event (Editor role or above). Modifications run before created events, in list order; they do not apply to historical data or to Measurement Protocol server-to-server events; up to 50 of each. An observed event with no emitter in code may be a created event; a planned name missing from GA4 may have been renamed by a modification. List the rules in the report.
- Server routes: the `platform` field is the data stream platform, so Measurement Protocol and server-side rows arrive as web, ios or android. D06 accepts stream platforms for a GA4 event planned from a server.
- One test payload: DebugView with debug mode on a test device or test account.
- Vendor events and parameters: [../assets/vendor-events.txt](../assets/vendor-events.txt) and [../assets/vendor-params.txt](../assets/vendor-params.txt). Confirm parameter names from keys only, never values: use one DebugView capture from a test device, or run `SELECT DISTINCT ep.key FROM your-project.analytics_XXXXXXXXX.events_YYYYMMDD, UNNEST(event_params) AS ep WHERE event_name = 'page_view'`.

## Mixpanel

- Lexicon CSV: owners, admins and analysts can export; the link arrives by email and works for 14 days; one file holds up to 5,000 events, 2,000 properties per event and 2,000 user profile properties. Query counts include API queries. Mixpanel's docs do not list the columns, so map by header and leave `rows` empty, since the file holds no window counts.
- Use an Analyst-role account for the Lexicon CSV. Analysts can download it but cannot hide, describe, tag, merge or drop data in Lexicon; Consumers cannot download it. The role blocks Lexicon edits only: an Analyst can still create boards, cohorts, custom events and custom properties and upload lookup tables, so it is no boundary for MCP.
- MCP read tools: Get-Events, List-Properties, Get-Property-Values, Get-Issues and Find-Duplicate-Groups (both are leads, not findings), Run-Query, Get-Lexicon-URL. Mixpanel documents no read-only mode and no switch for single tools: users act with their own role, writes are logged in the audit log with Origin MCP, reads are not logged. Before the first call, deny in the client every tool whose name starts with Create, Update, Delete, Duplicate, Edit, Bulk-Edit, Rename, Dismiss or Merge, and record that deny list as the boundary. If the client cannot deny tools, use the Lexicon CSV and the raw export.
- Raw Event Export: JSON Lines through `scripts/profile_export.py --format mixpanel`. Limits: 60 queries per hour, 3 per second, 100 concurrent. Raw exports are not deduplicated; the duplicate tuple is event name, distinct_id, time and `$insert_id`, which the profiler reports as `transport_duplicates`. Leave the optional `limit` parameter unset: a capped response is incomplete by design and cannot support "never seen". The user runs the export with their own service account; credentials never enter the chat.
- Audit raw names, never display names: Lexicon display names and merges do not change raw data. Report dropdowns hide events not fired in 30 days and properties or values not sent in 28 days, so a dropdown is not an inventory. Hidden events are still ingested and billed.
- Types: numbers are 64-bit floats, so integers are exact only up to 2^53 (send long IDs as strings). Reports cast non-boolean values: "false", 0, null, undefined, empty and not set become false, any other set value true, so "no" counts as true. Strings are limited to 255 bytes; list and object properties to 8 KB on events.
- The JavaScript SDK adds UTM parameters and advertising click IDs (gclid, fbclid, msclkid and others) to events, so they are vendor parameters, not D09 findings. Segment's Mixpanel destination sends "Loaded a Page" and "Loaded a Screen" by default, or "Viewed <name> Page" with the named and categorized page settings (D04).

## Amplitude

- `get_amp_taxonomy` through the Amplitude MCP server, connected with a role that grants only Use MCP (read); `includeDeleted` shows deleted items. Leave `rows` empty on its event rows. Never call a `manage_amp_*` tool: `manage_amp_events` and `manage_amp_properties` need Use MCP (write) plus Update Tracking Plan, and deletes on main take effect immediately.
- Protected Schema (Growth and Enterprise) marks or rejects unplanned events (rejected events are not stored) and unplanned properties and values (the event is stored without the property). Validation errors show 24 hours; Amplitude Observe shows arrival up to 14 days. Amplitude's planned-unseen guide uses a review period such as 90 days and says to record "not observed in Amplitude Data", not "never arrived", unless an authorized raw source confirms it.
- Export API through the profiler: a ZIP of JSON files with one event per line (the profiler reads plain and gzip members). Start and end are YYYYMMDDTHH in UTC, inclusive, selected by `server_upload_time`; 6 GB per request; 365 days per query; data is available up to 2 hours after upload, so a 404 for recent hours proves nothing. Because ranges select by upload time, events queued offline on mobile arrive in later hours: export through a few days past the window end and let the profiler filter on `event_time` with `--since` and `--until`. It needs the project API key and secret key, so the user runs it and the agent never asks for them. Split rows by app version in a separate run (`--source-key version_name`) and read that file by hand.
- Ingestion (HTTP V2 API docs): a later event with the same device_id and insert_id within 7 days is ignored, so an export rarely shows transport duplicates; a retry that sends a new insert ID is kept and shows as a repeated occurrence key. User and device IDs shorter than 5 characters are removed from the event unless `min_id_length` is lowered, and the documented invalid IDs (anonymous, nil, none, null, n/a, na, undefined, unknown, the empty string, the all-zero UUID, {}, lmy47d, 0 and -1) return a 400 error. Check short and invalid IDs upstream (D14).
- Limits: 2,000 event types, 2,000 event properties and 1,000 user properties per project; beyond them new types are not indexed. Most strings are truncated at 1,024 characters. Hidden, inactive and backfilled events still count toward volume. `[Amplitude]` events are SDK events (vendor). Segment's Amplitude destination sends "Loaded a Page" with Track All Pages and "Loaded <name> Page" with the named page settings (D04).

## PostHog

- MCP server in read-only mode: `https://mcp.posthog.com/mcp?readonly=true`, or the header `x-posthog-read-only: true`; read-only mode removes every tool that creates, updates or deletes. A personal API key with the MCP Server preset scopes one project; the user sets it up outside the chat. Read tools used here: read-data-schema, event-definition-get, execute-sql.
- `execute-sql` with [../assets/posthog-hogql-inventory.sql](../assets/posthog-hogql-inventory.sql): Query 3 for event level, Query 1 for planned properties with `JSONType`, Query 2 to discover keys by hand, Query 4 for redacted top values with the `<other>` tail. Test on one day first.
- Detected property types in Data management are column level, and a user can change a detected type. Event definitions created ahead of capture show "-" for first and last seen. Schema management (experimental) defines typed property groups with a Required flag; compare it as an enforcement copy.
- `$` defaults, `$feature/<flag_key>` properties and the UTM parameters posthog-js records as plain event properties are vendor properties. The mobile SDKs send Application Opened and the other lifecycle events when that option is on.

## Segment

- The plan copy: Download Tracking Plan exports a CSV through the Download History page.
- Source Schema per source: events, properties and traits with last seen and allowed versus blocked counts, plus a Planned column when a plan is connected. Array properties show a child named `<property>.$`, which is not drift. Record the timeframe shown, and leave `rows` empty unless the counts cover the audit window.
- Violations view: filters to the past hour, 24 hours or 7 days and shows sample payloads for missing required properties, invalid data types and failed conditional rules. Track violations aggregate on `event`, Page and Screen on `name`. For 28 days, forward violations as "Violation Generated" track calls (eventName, violationType, violationField, violationDescription, sourceName, trackingPlanName) to a source with a warehouse; forwarding adds 1 MTU and API volume on API-billed plans, and the forwarded events do not appear in the Schema tab.
- Warehouse: [../assets/segment-warehouse-inventory.sql](../assets/segment-warehouse-inventory.sql). Query 1 event level from `tracks` (`event` slug versus `event_text` original name), Query 2 column types from `information_schema`, Query 2b share present and types per planned property, Query 2c top values with the `<other>` tail, Query 3 forwarded violations, Query 4 user_id classes per event. The warehouse is the upstream copy: compare a property's share present there with each tool to find enforcement that drops it silently.
- Mobile libraries send Application Installed, Opened and Updated, plus Backgrounded or Foregrounded on some libraries, when lifecycle tracking is on; they are vendor events.
- Delivery modes: Omit Properties, violation blocking and trait omission apply only to cloud-mode and storage destinations; mobile device-mode events bypass blocking. Blocked events are discarded unless forwarded.
- Twilio's MCP server searches docs only; it reads no workspace data.

## RudderStack and Avo

- RudderStack violation types map to rules: Unplanned-Event to D02, Required-Missing to D07, Datatype-Mismatch to D10, Additional-Properties to D09, Unknown-Violation reviewed by hand. Its MCP server has read tools for tracking plans, event schemas, validation metrics and violations (`list_tracking_plans`, `list_tracking_plan_events`, `get_source_event_schemas`, `get_source_tracking_plan_event_metrics`, `get_source_tracking_plan_event_violations`). It documents no read-only mode and no per-tool scopes. Its write tools switch the active workspace, create or update transformations and audiences, connect them to destinations and, with audience activation, create Reverse ETL syncs and trigger sync runs. Its sensitive execution tools run transformation code or temporary warehouse work. Allow only read tools in the client, checking each one in the RudderStack MCP Tool Reference (checked 2026-11-06), and leave out `get_live_events`: it is listed as read, but it turns on live-event capture and its content can be sensitive.
- Avo Inspector issue types map to: event unexpected by tracking plan D02; event unexpected on source D06; property type does not match tracking plan, and property type inconsistent, D10; property unexpected on event D09; required property sometimes missing D07; property value does not match constraints D11. The Issues view covers 24 hours. Avo MCP read tools: `list_workspaces`, `search`, `get` (type `workspace_config` returns the audit rules, type `branch` returns a branch) and `list_branches`; `describe_tool` needs no scope. `save_items`, `workflow` and `give_feedback` need the write scope, a separate consent step on first use, so decline it.

## Completeness of reads

- Paginate to the end and compare accumulated rows with any total the tool reports.
- If a deploy, publish or plan merge happened during the read, start again.
- Label partial inventories (a cap reached, `--max-records` used, a page missing) in the baseline. A partial inventory cannot support "never seen".
- A window shorter than the audit window never proves absence: Avo issues 24 hours, Amplitude validation errors 24 hours, Amplitude Observe 14 days, Segment violations 7 days, Mixpanel dropdowns 30 days for events and 28 for properties.
