Answers a marketer's natural-language analysis request with a GA4 Data API report.
Deterministic work is done by the Python in scripts/; you handle the request-to-query
mapping and the interpretation of results.
Language
Respond in the user's language. If the user writes in Korean, answer in Korean; if in English, answer in English. This applies to every step — setup guidance, progress updates, results, and error explanations.
Never translate: GA4 API identifiers (eventCount, sessionSource, runReport),
file and directory names (.ga4/token.json), shell commands, and JSON field names.
Script output is never shown to the user as-is. Scripts always emit English
error/hint strings. When a script returns "ok": false, you MUST rewrite the
hint into the user's language before showing it. Never paste the English
through, and never show the raw JSON. Keep identifiers, paths, and commands
verbatim inside your rewritten text — translate only the prose around them.
Example — script returns: {"ok": false, "hint": "token.json is missing. Log in first with the ga-auth skill."} To a Korean-speaking user, say: 로그인 정보(.ga4/token.json)가 없어요. ga-auth 스킬로 먼저 로그인할게요. Not: "token.json is missing. Log in first with the ga-auth skill."
Interpreter Rules
ga4_query.py,ga4_realtime.py,ga4_pivot.py,ga4_cohort.py,ga4_funnel.py,ga4_metadata.py: must run with.ga4/venv/bin/python(requires google-analytics-data){SKILLS_DIR}= the directory these ga skills are installed in — the parent of this skill's directory (.claude/skillsin a Claude Code project; other agents use their own location). Substitute the actual path. Run commands from the project root.
Step 0: Status Check (always first)
Check .ga4/ to decide where to enter:
- Does
config.json(property_id) +token.jsonexist? If not, guide the user to connect with the ga-auth skill first. - Are the libraries ready? If
deps_readyfrompython3 {SKILLS_DIR}/ga-auth/scripts/ga4_status.pyisfalse, this skill runs the accompanyingdeps_fixcommand itself ("I'll install the libraries this analysis needs") and re-checks before proceeding — don't ask the marketer to run pip. Ifnull, the check itself failed; don't assume the deps are missing, just proceed. - Does
taxonomy.mdexist?- If it does, read it and map the request to events/parameters.
Use the Query Name column in the parameter table as-is —
method (standard)means querymethodwith no prefix,(metric)means it belongs inmetrics, andNot registered — cannot querymeans don't query it and tell the user it needs to be registered first. GuessingcustomEvent:from the name alone raises a 400.Reserved name — cannot be registered or queriedis different: do not ask for it to be registered. GA4 refuses reserved names and prefixes (user_id,session_id, anything starting withgoogle_/firebase_/ga_/_), so the request would be rejected and the value is never queryable through the Data API. Say that plainly instead of routing the marketer to their administrator.customEvent:x — prefer standard ymeans usey. GA4 has a standard field for that parameter, and a custom definition holds no data from before it was registered — so the custom one silently drops earlier periods that the standard field covers in full. Only fall back to thecustomEvent:name if the standard one returns nothing. Oldertaxonomy.mdfiles may still carry the Korean labels with the same meanings (조회명= Query Name,미등록 — 조회 불가= Not registered) — re-parsing the sheet with ga-taxonomy refreshes them to the English labels. - The taxonomy sheet describes what is supposed to happen, not what actually happened.
Before building on a mapped event, check how much it actually fired in the requested
period (one
eventName×eventCountquery is enough). In practice, 14 of 31 taxonomy entries had zero hits in the last 30 days, and some stayed single digits even over 90 days — building a funnel without knowing that leads to confidently reporting a meaningless number like "1 of 3 completed → 33% completion rate." When the sample is small, report the raw count instead of a percentage and say so. - If it doesn't exist, first survey the event landscape before analyzing: query
eventName×eventCount/activeUserswithlimit50 or higher to see what events this property has, then map the request. If the response hastruncated: true, raise the limit and query again (missing events can mean missing a core behavior entirely).
- If it does, read it and map the request to events/parameters.
Use the Query Name column in the parameter table as-is —
- Does
custom_definitions.jsonexist? (created by ga-taxonomy)- If it does, read it. Use it to translate the taxonomy's parameters into their
actual query names (
customEvent:...) and units — see the "Custom Definitions" section ofreferences/analysis-guide.mdfor details. - If it doesn't, you can find entries starting with
customEvent:,customUser:, orcustomItem:in thega4_metadata.py get-metadataresult and use them the same way.
- If it does, read it. Use it to translate the taxonomy's parameters into their
actual query names (
Platform Context
The status check reports platforms — which platforms this property collects
from, in GA4's own spelling (web, iOS, Android, or unknown). It changes
which events and dimensions can exist at all, so read it before concluding
anything is missing.
nullmeans it was never looked up. Do not assume a platform. Guessing one is the exact failure this field exists to prevent. Re-run.ga4/venv/bin/python {SKILLS_DIR}/ga-auth/scripts/ga4_admin.py get-property <PROPERTY_ID>and save it through the ga-auth skill's Step 4's items 4 and 5: the save rebuildsconfig.jsonfrom scratch, so currency, timezone and retention must be passed again in that same command or they drop tonull— which costs ga-use the currency it reports revenue in (realtime responses carry no metadata at all) and the retention value behind its empty-funnel check.[]means the property has no data streams — it is collecting nothing.- An
unknownentry means a stream could not be classified, so the list is not a complete answer. Do not read a missingwebas "app-only" whileunknownis present: say the property could not be classified rather than applying a web-only or app-only judgement to it. - If
platforms_age_daysis over 180, the list may predate a platform the team has since added. Re-check before relying on it. platformsis which streams are configured; theplatformdimension is which traffic actually arrived. A configured stream can still have zero data, so do not state one as if it were the other.
Analysis Flow
- Confirm what analysis is needed (metrics, period, breakdown dimensions, filters).
- If it's a compound question (matching one of the question types in the recipes —
conversion analysis, path/journey, performance summary (overview), diagnosing a change,
content performance, or comparing behavioral importance), read
references/analysis-recipes.mdfirst and plan the query (what/why/how to combine). For analyses where the target count can be small (conversions, segments), check the scale first, and set the analysis depth per the sample-size gate inreferences/analysis-recipes.md. - Map the request to actual event/parameter names — using what Step 0 gathered
(
taxonomy.md, or the queried event list if there was none). - If dimensions/metrics are uncertain, query the metadata — narrow it with
--search:
A property exposes ~400 dimensions and ~120 metrics, so the unfiltered dump is roughly 12k tokens; a single-field lookup with.ga4/venv/bin/python {SKILLS_DIR}/ga-use/scripts/ga4_metadata.py get-metadata --search item--searchis about 150.--searchis repeatable (entries matching any term are kept) and matches the API name or the UI name.--kind dimension|metricdrops the other list. A filtered response carries afilteredkey with the unfiltered totals — if that key is present, you are not looking at the whole catalogue. - Build the extended JSON spec and query (format in
references/analysis-guide.md):echo '<request JSON>' | .ga4/venv/bin/python {SKILLS_DIR}/ga-use/scripts/ga4_query.py - Refine the result into a table or summary and answer. On an empty result or error,
work around it per the retry loop in
references/analysis-recipes.md. Iftruncatedis true, you only saw the top slice — don't describe it as "the total"; either raiselimitand query again, or state that it's "based on the top N." If a funnel result hassteps_without_data, that step had no row in the response (GA4 omits steps with zero reach, so it usually means zero — but don't assume, pass it along as-is).
Working Principles (critical)
The audience is non-technical marketers:
- Break work into small steps, and explain "what/why" in one line at every step.
- Handle file placement and command execution yourself whenever possible.
- Translating natural language into a JSON spec is Claude's job — never ask the marketer for JSON.
Common Request Patterns
-
Top-N:
order_bys(desc) +limit. Example: top 10 events by count. (Pivots are per-axis, so this goes inpivots[].order_bys/pivots[].limit.) -
Sorting by
dimensionuses GA4's lexical order, not numeric or calendar order.hour/minuteare unpadded, so this script requests NUMERIC order type for those two automatically — no action needed.dayOfWeekNameandappVersionhave the same problem with no server-side fix (text and multi-part strings respectively; NUMERIC order type does not help either — measured live for both), so anotewarns instead: fordayOfWeekName, sort bydayOfWeek(0=Sunday..6=Saturday) and keep the name for display; forappVersion, there is no substitute dimension — sort the returned rows yourself by parsing each version's dot-separated parts as integers. Any other numeric-looking dimension (including custom ones) can have the same problem — seereferences/analysis-guide.mdfor the full list of what's safe. -
Do not sum rows to get a total. Metrics ending in
~Usersandsessionsare deduplicated values — summing per-dimension rows inflates them beyond the real total (measured on a live property: summing dailyactiveUsersover a month came out 13% above the true total). Rates and averages are unsafe to sum for a different reason: they were never deduplicated, the arithmetic of summing/averaging them across rows is simply invalid. If the response carriesnon_additive_metrics/note, this applies — the note explains which reason applies to which metric rather than blaming deduplication for all of them. If you need a total, query once more with the same period but nodimensions. Count-type metrics likeeventCountcan be safely summed. -
totalUsersandactiveUsersare not interchangeable.totalUserscounts any user who logged an event;activeUsersrequires an engagement signal, so the two commonly differ (measured live: 99 vs 124 over the same period, a 25% gap). GA4's own*PerUsermetrics (sessionsPerUser, etc.) divide byactiveUsers, nottotalUsers— don't hand-compute a "per user" figure againsttotalUsersand call it more correct; use the metric GA4 already provides, oractiveUsersif you must divide by hand. -
dauPerMau/dauPerWau/wauPerMaubreak the "no dimensions = safe total" rule above. These are rolling day-by-day ratios, and over a period longer than one day GA4's own no-dimension aggregate is a sum of the daily values, not a period ratio — measured live, exactly (a 6-day query's no-dimension total matched the same days summed by hand to double-precision). If the response carries a note naming one of these, request the metric for a single day instead, or break it out bydateand read each day on its own. -
A revenue-based expression metric can come back as the raw revenue, not the ratio. An expression like
totalRevenue/eventCount(average order value) requested on its own returnstotalRevenueitself unlesstotalRevenueis also listed inmetrics(measured live — GA4 evaluates the expression only once the revenue metric is present). If the response carriesexpression_revenue_operands, re-query with that revenue metric added; a "ratio" that reads like a large total is this trap. Expressions over non-revenue metrics (eventCount/sessions) are unaffected. The auto-check only catches metrics ending inRevenue— other money metrics (itemRefundAmount,advertiserAdCost) aren't flagged, so request them alongside the expression too. -
Always state the currency with revenue. The response's
metadata.currency_codeis the property's reporting currency — never assume it matches the country or the language you are speaking. A live property onAsia/Seoultime reports in USD; reading those figures as won is out by roughly 1300x, and nothing else in the response hints at it.metadata.time_zoneis the property's timezone, which is what "today" resolves against. -
metadatacan say the numbers aren't quotable.sampling: truemeans they are estimates from a subset — say so, and don't call small gaps between them real.subject_to_thresholding: truemeans GA4 withheld rows for privacy, so totals are a lower bound.data_loss_from_other_row: truemeans values were collapsed into(other)and the breakdown is missing an unknown tail. -
A sampled expression can corrupt its raw operand. If a sampled response carries
expression_sampling_overlap, an event-scope revenue metric (totalRevenue,purchaseRevenue) requested both raw and inside an expression (e.g.totalRevenue/eventCount) came back far smaller than its true total (measured ~13x off). Don't quote that raw column from this response — re-query it without the expression, or narrow the query so sampling doesn't trigger. (Item-scopeitemRevenueand count metrics don't drift and aren't flagged.) -
A daily (or monthly) series can be missing rows. GA4 drops a row when every requested metric is zero for it, so a zero-sales day vanishes from a revenue-by-
datequery — and the same happens one granularity up for ayearMonthbreakdown (measured live: a 12-month subscription-revenue query returned only 4 of 12 months). If the response carriesmissing_dates, those rows are real zeros, not missing data — include them when averaging (dividing by the returned rows alone runs high) and don't let a trend line join across them. -
(not set)is not a category. If the response carriesunset_dimensions, GA4 received no value for those rows. Never report it as the top item — on a live property "top coupons by revenue" ranks(not set)first, meaning "no coupon was used". When a dimension is listed underfully_unset, no row has a value at all: the event fires but the app or site never attaches that parameter, so report a tracking gap rather than a breakdown.Before calling
fully_unseta tracking gap, check the platform. A dimension that does not apply to this property's streams comes back entirely(not set)even when the implementation is perfect. The response already does this check itself when config.json'splatformsis on file, across 24 dimensions: the web-stream side is the 9-member page family,browser/browserVersion, and the whole 12-member Enhanced Measurement family (outbound click, file download, video, scroll); the app-stream side isappVersion. Those are listed separately underunset_dimensions.platform_mismatchedand thenotesays "expected, not a tracking gap" instead of blaming the app or site.Still apply the reasoning below yourself in the three cases the check stays silent on: when
platformsisn't on file at all; when it containsunknown(a stream that could not be classified means the list isn't a complete answer, so no web/app judgement is applied — see theplatformsnotes above); and for a fully-unset dimension outside those 24 (unifiedScreenNameincluded — it's cross-platform, so a real gap there stays unflagged, as dosearchTermand thecontentGroupfamily, which need manual instrumentation on either platform).There are three families here, not two.
pagePath,pageTitleandlandingPagePlusQueryStringare web-stream dimensions;appVersionis app-stream; and theunified*family —unifiedScreenName(its UI name is "Page title and screen name"),unifiedScreenClass,unifiedPagePathScreen— is cross-platform, carrying the page title/path on web streams and the screen name/class on app streams.screenPageViewsis the metric that counts both. There is noscreenNamedimension: requesting one fails outright with400 ... Field screenName is not a valid dimension.- App-only: the page family is
(not set)on every row — that is the wrong family, not a tracking gap. Use theunified*family instead. IfunifiedScreenNameis fully unset too, the app is not sending a screen-name parameter, andunifiedScreenClassstill carries the auto-collected class name (measured on a live app-only property:unifiedScreenNameentirely(not set)whileunifiedScreenClassreturned real screens). In a funnel, the first step isscreen_view, notpage_view. - Web-only: the
unified*family is populated here — those rows are genuine page titles and paths, so never discard them as an app artifact or steer around the family. What is genuinely absent is app-scoped:appVersionand the app screen parameters have no stream to come from. - Mixed:
pagePathis(not set)for the app half andappVersionis(not set)for the web half, and both are correct — report neither as a gap. Use theunified*family when you want one cross-platform view, and break down by theplatformdimension (or filter to one) when you need the halves apart, saying which platform the figure covers.
- App-only: the page family is
-
A zero can mean "never tracked". If the response carries
uninstrumented_metrics, the metric reads 0 because the event behind it has never been recorded on this property — not because nothing happened. Report it as a tracking gap ("the app or site never sends therefundevent"), never as a business result ("there were no refunds"). The second one ends the conversation with a false fact the marketer will act on. The key lists every event that could fill the metric — quote the list, since a purchase on an app arrives asin_app_purchase, notpurchase. If the event inventory on file is stale the note hedges ("may mean not tracked") and asks to refresh it; pass that hedge through rather than reporting a certainty the data cannot support. -
A metric can also read 0 because its stream type doesn't exist here — a third reading, distinct from "never tracked."
scrolledUsers(and any future metric like it) depends on a web-only GA4 Enhanced Measurement event with no app-stream counterpart, so on an app-only property it can never be anything but 0 — not a tracking gap someone could fix by adding an event, and not a business zero. If the response carriesplatform_mismatched_metrics, say so explicitly rather than reaching for either of the other two framings. -
Purchase revenue beside zero purchases is subscription revenue. If a
purchaseRevenue/grossPurchaseRevenueis non-zero while every requested purchase-count/purchaser metric is 0, the response carriessubscription_revenue. On app-store subscription apps GA4 counts theapp_store_subscription_*events into revenue and intotransactionsbut not into the purchase counts — so the zeros are by design, not a data gap. Report the revenue as recurring subscription revenue, and do not compute an average order value from it:purchaseRevenue/ecommercePurchasescomes back 0 for this shape and would read as zero-priced orders. -
An empty funnel may be data retention, not zero conversions. Retention limits funnels and explorations but not standard reports, so a period past it makes
ga4_funnel.pyreturn zero rows with no error whilega4_query.pyanswers the same period normally. Measured on a 14-month property: a January-2025 funnel gave nothing,runReportgave 783add_to_cartand 136purchase. If the response carriesbeyond_retention_days, say the funnel cannot reach that far and verify the events with a query — never report it as zero conversions. Thesteps_without_datanote itself is already softened when this applies (it no longer flatly says "usually means zero users"), but still lead with the retention explanation rather than the missing-steps one. The absence ofbeyond_retention_daysis not an all-clear — it is also absent when the retention setting was never recorded. An empty funnel then carries a note saying the check could not run; pass that on and verify withga4_query.pyrather than reporting a zero. Re-selecting the property with ga-auth records the setting. -
A funnel breakdown carries aggregate rows. If the response lists
reserved_total_row_indexes, those rows are not categories. For a real breakdown dimension (funnel_breakdown), aRESERVED_TOTALrow already equals the sum of the rows beside it, so adding everything up reports double the real total. For a period comparison (2+date_ranges,dateRangeheader) it's the opposite: a user active in more than one compared period is counted once in the total but once per period beside it, so the rows can sum to more than the total — do not expect them to match either way, just exclude the aggregate rows from any ranking or share-of-total you present. ARESERVED_(other)row is a third case — the collapsed long tail, which belongs in a total but cannot be broken down further. -
The last two days are still moving. GA4 keeps processing for 24-48 hours, so a period ending at
todayoryesterdaywill change. If the response carriesunsettled_ranges, never report a recent dip as a real decline — say the data is still settling, or end the period at2daysAgo. "Yesterday's revenue fell 30%" is the classic version of this: the marketer pauses a campaign over a number that will be higher tomorrow. -
Period comparison: give
date_rangestwo periods and adateRangecolumn shows the per-range comparison. Make the two periods the same length and end the recent one atyesterday, nottoday—7daysAgo..todayis 8 days against14daysAgo..8daysAgo's 7, and today is still in progress, so an "up/down X%" between them is partly just the extra day. The response carriesdate_range_lengthsand warns when they differ. If you also break down bydate, GA4 returns every date against every range and fills0outside each one — those zeros are "not in this period", so never average over all returned rows. -
Filters:
dimension_filter(compound and/or/not, partial match, lists),metric_filter(numeric conditions).
Realtime Snapshot (last 30 minutes)
Use ga4_realtime.py for realtime questions like "who's active on the app or site right now"
or "what just happened" (a separate endpoint):
echo '<request JSON>' | .ga4/venv/bin/python {SKILLS_DIR}/ga-use/scripts/ga4_realtime.py
minute_rangescan narrow the window (omit for the last 30 minutes).- Realtime has a limited set of valid dimensions/metrics (country, deviceCategory, eventName, minutesAgo, etc. / activeUsers, eventCount, keyEvents, screenPageViews). Most standard report dimensions/metrics aren't available in realtime.
- Of those metrics,
activeUserscombines with none of the others (eventCount, keyEvents, screenPageViews all fail paired with it, while they combine freely among themselves). The script rejects the pairing locally — split it into two realtime queries and report the numbers side by side. - Filter/order syntax is the same as
ga4_query.py(references/analysis-guide.md), butdate_ranges/offsetdon't exist — passing them is blocked as an error (usega4_query.pyfor past periods).
Crosstab (Pivot Report)
Use ga4_pivot.py for two-or-more-axis crosstab analysis, like "by country × device" or
"cross-tabulated by channel and device":
echo '<request JSON>' | .ga4/venv/bin/python {SKILLS_DIR}/ga-use/scripts/ga4_pivot.py
- Each item in the
pivotsarray is one axis (field_names,limit,order_bys,metric_aggregations). Axis dimensions must also be listed in the top-leveldimensions. GA4 requires alimiton every axis — omitting it is a 400, not an unbounded result — so a missinglimitis defaulted (single axis: GA4's recommended 10000; several axes: scaled down so the product of all limits stays under GA4's 250000 cap); set it explicitly to bound an axis (e.g.5for top-5). If an axis has more values than its limit,pivot_axes[].truncatedsays so. - Requesting
metric_aggregations(TOTAL,MINIMUM,MAXIMUM,COUNT) addsaggregatesrows (totals, etc.) to the response — but only whenpivotshas 2 or more entries. Confirmed in practice: with a single pivot, GA4 silently returns noaggregatesat all (no error). Add a second pivot over any dimension to unlock it. If you requestmetric_aggregationson a single pivot, the response now carries anotesaying the total was silently dropped and how to fix it — don't hand-sum the rows for the total instead. The note states why: a truncated axis (the common case — seepivot_axes[].truncated) means the rows don't cover every value in the first place, and a deduplicated metric (users, sessions) would come out too high even on an untruncated axis; it names whichever actually applies rather than always blaming deduplication. - The response's
pivot_axesis per-axis truncation info (returned_values,total_row_count,truncated, in the same order aspivots). If any axis was truncated, the top-leveltruncatedalso becomes true, so describe that axis as "based on the top N" rather than "the total."returned_valuesis the count of values on the axis, which differs from the top-levelrow_count(the cross-product row count). When an axis has multiple dimensions,total_row_countis the count of combinations — seereferences/analysis-guide.mdfor how to describe this. - Filter syntax is the same as
ga4_query.py, but ordering and row limits are per axis (pivots[].order_bys,pivots[].limit). Top-levelorder_bys/limitcannot be used — passing them is blocked as an error.
Cohort (Retention) Report
Use ga4_cohort.py for retention questions like "how many of the users who signed up in
the first week of June come back, week by week":
echo '<request JSON>' | .ga4/venv/bin/python {SKILLS_DIR}/ga-use/scripts/ga4_cohort.py
Shorthand (single-cohort) example:
{
"granularity": "WEEKLY",
"date_range": {"start_date": "2026-06-01", "end_date": "2026-06-28"},
"offsets": {"start": 0, "end": 4},
"dimension": "firstSessionDate",
"accumulate": false
}
granularity(DAILY/WEEKLY/MONTHLY),date_range(the cohort acquisition period), andoffsets(the elapsed window to observe,{start,end}) are required. Offsets count from the acquisition week (week 0), so they can't be negative, andstartmust be<= end— an inverted window matches no week and GA4 answers it with zero rows and no error, which reads as "no retention"; both are now caught locally.dimension(defaultfirstSessionDate) is optional.accumulatemust stay false —truerequiresrunPivotReport, which this script never uses, so GA4 always rejected it; it is now caught locally. Add upcohortActiveUsersacross offsets yourself for a cumulative total.- To compare multiple cohorts at once, use full control and specify
cohort_spec(cohorts,cohorts_range,cohort_report_settings) directly. - The
cohort/cohortNthDay/Week/Monthdimensions and the default metric (cohortActiveUsers) are auto-injected. Top-leveldate_rangesis forbidden — cohorts usedate_range/cohort_spec. - Filter/order syntax is the same as
ga4_query.py(references/analysis-guide.md). - Dates must be absolute (
2026-06-01) only. The relative dates other reports accept (28daysAgo,today) are rejected by GA4 for cohorts. - A requested offset whose window hasn't fully elapsed yet may come back with no row at
all — indistinguishable from zero return users. The response's
notesays so when this applies (e.g. offset 3 of a weekly cohort whose week 3 is still in progress); don't report a missing recent offset as "0% retention."
Funnel (Conversion/Drop-off) Report
Use ga4_funnel.py for ordered-step analysis like "step-by-step conversion/drop-off from
page_view → add_to_cart → purchase":
echo '<request JSON>' | .ga4/venv/bin/python {SKILLS_DIR}/ga-use/scripts/ga4_funnel.py
Shorthand example:
{
"steps": [
{"name": "Page view", "event_name": "page_view"},
{"name": "Add to cart", "event_name": "add_to_cart"},
{"name": "Purchase", "event_name": "purchase"}
],
"date_ranges": [{"start_date": "28daysAgo", "end_date": "yesterday"}],
"funnel_breakdown": {"dimension": "deviceCategory", "limit": 5}
}
steps(at least 2) each define exactly one ofevent_name/field(+value|in_list) /filter_expression(full control).funnel_breakdown(optional) splits step drop-off by a specific dimension (device, channel, etc.). A breakdown can come back entirely(not set)— the event fires but the app or site never attaches that parameter. If a sub-report carriesunset_dimensions.fully_unset(or the top-levelnotenames a tracking gap), that breakdown carries no information: report it as missing instrumentation, not as a conversion result. (The aggregateRESERVED_TOTALrows mixed into a breakdown are excluded from this check; they are listed underreserved_total_row_indexes.)funnel_visualization_type(defaultSTANDARD_FUNNEL; useTRENDED_FUNNELfor a time-series trend) decides whetherfunnel_visualizationcarries adateaxis — both sub-reports are filled either way — seereferences/analysis-guide.mdfor details. A TRENDED funnel's daily trend can be missing rows per step, the same "GA4 drops a zero row" issue as a plain daily series (see the bullet above) — checkfunnel_visualization.missing_dates_by_step(or the top-levelnote) before averaging or charting the trend.date_rangeswith 2 entries compares two periods, the same asga4_query.py— GA4 attaches adateRangedimension and treats it exactly likefunnel_breakdown(verified live), aggregate rows and all: expectRESERVED_TOTALrows and apply the same "exclude before totalling" guidance above. Combining this withfunnel_breakdownalso works (verified live) — the two axes cross-product (step × your breakdown × dateRange), andreserved_total_row_indexescorrectly catches a row that is RESERVED_TOTAL on either axis, no special handling needed.
File Locations
config.json(property_id) andtoken.jsonare created and owned by the ga-auth skill.taxonomy.mdis created by the ga-taxonomy skill.- The
GA4_DIRenvironment variable can change the.ga4location (default:<cwd>/.ga4).