The tree was a debranded snapshot of upstream taken around 2026-02-20, and upstream has moved about five months since. This replaces it with upstream master, mechanically re-derived: bin/debrand does the rename, and the pipeline and squashed migrations are restored from the shipped tree. Not a merge. The previous import replaced the tree wholesale in a single commit, so there is no shared content history to merge against. Reproducing the rename on the new tree is what actually converges. What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints, compose files, LLM.md, and insights/migrations. The squashed initial migration is load-bearing — the live database records it by name, so upstream's ~918 individual migrations cannot come back. Nine products are dropped as unreferenced. Twenty-one more that we do not run are kept anyway because the core app still imports them; unpicking those is a refactor and does not belong in a rebase. This base does not build yet, and the reasons are not in the rename: upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against our 4.2, and the dependency manifests still name upstream packages that we republish under our own names. Those are decisions, not renames.
13 KiB
Root-causing an individual slow query
This is the deep-dive behind the main skill's Step 2 (smells) and Step 3 (EXPLAIN): once you have a specific slow query in front of you, this is how to explain why it is slow before you reach for a fix. Where Step 2 scans the SQL for known-bad shapes, this reference works the other way round, from the runtime cost back to the cause.
It is written for a query you pulled from production (via /query-datastore-via-metabase against
insights.query_log_archive, since the slowest real example beats a synthesized one), but the reasoning
applies to any slow query. You do not need certainty: a concrete, falsifiable hypothesis ("the sort key
is function-wrapped, so the date filter can't prune granules") is what drives the next step. Always state
one, then test it.
Pull the full query, never a substring
When drilling into a specific query, select the complete query text plus the exception message,
not a substring. The smoking gun is almost always at the tail: the ORDER BY, the WHERE time
bound, the LIMIT, and the SETTINGS clause (max_threads, max_execution_time, max_memory_usage).
A scan-list preview that truncates at 160 chars will hide all of it.
SELECT query, exception, query_duration_ms,
formatReadableSize(memory_usage) AS mem, formatReadableSize(read_bytes) AS read, read_rows
FROM insights.query_log_archive
WHERE query_id = '<id>' AND event_date = '<YYYY-MM-DD>' AND is_initial_query
The exception message for an OOM (code 241) states the configured ceiling and the column that blew
it, e.g. Query memory limit exceeded: would use 42.01 GiB, maximum: 42.00 GiB (while reading column elements_chain). That column name is a direct pointer to what to look at.
Read bytes, CPU time, and duration together
Three signals matter, and all three are worth pulling: bytes read, CPU time, and duration. None of them on its own tells the whole story.
-
Bytes read is the most stable measure of work done. Sort candidates by
read_bytes DESC. Compare bytes against rows across queries in the same batch: if two queries read similar row counts but one reads ~100x more bytes, the heavy one is decompressing a wide column (almost always apropertiesJSON blob).Query A: 210M rows, 19 GiB -- materialized columns only Query B: 210M rows, 2.65 TiB -- JSONExtractRaw(events.properties, '$some_prop') -
CPU time (
ProfileEvents['OSCPUVirtualTimeMicroseconds'], orquery_duration_msvs CPU to gauge parallelism) catches work that bytes read misses: expensive expressions, high-cardinality aggregation, and grouping that burns CPU without reading much more data. -
Duration still matters, but with a hard rule: never rely on the duration of the API request. That number includes queueing, serialization, network, and app overhead, and does not reflect the Datastore query itself. When you report duration, it must come from
query_duration_msinquery_log/query_log_archive, never from the API timing. Read against bytes and CPU, duration is most telling when it is high but bytes and CPU are not: that gap usually means the query spent its time waiting (on locks, on concurrency limits, on other queries), which is itself worth flagging.
Common causes, most frequent first
These are the causes that come up most often, but they are only a subset: frequently the real problem is something else entirely. Treat this list as a starting set of hypotheses to check, not an exhaustive diagnosis, and stay open to a cause that is not listed here.
- Unmaterialized property access (JSONExtract). The number-one cause of extreme byte reads.
JSONExtract*(events.properties, …)orJSONExtract*(person_properties, …)forces a read of the full JSON blob per matching row. Confirm by searching the query text forJSONExtract/JSONHas. The fix depends on where theJSONExtractcame from. If it's hand-written in a printer-path query (InsightsQL source that literally saysJSONExtractString(properties, 'X')), the fix is to replace it with property accessproperties.Xfor every such property and let the printer materialize; the column may already be materialized in prod, so no migration is needed (see the JSON-operations smell inSKILL.md). If the source already usesproperties.X(or it's raw SQL that bypasses the printer) and prod still emitsJSONExtract, the property genuinely isn't materialized, so the fix is to materialize it (the migration layer inSKILL.mdStep 5) or drop the property filter. This applies to both event and person property blobs: reading either as raw JSON can be up to ~100x slower than reading a directly materialized (mat_*/dmat_*) column, and ~10x slower than a property group read. - Session joins. Joining
raw_sessions/sharded_sessions(for$session_durationetc.) adds a full sessions scan. Look forraw_sessionsin the text. - High-cardinality breakdowns. A
breakdown_valueon something like a URL or an ID explodes the grouping. This is the dominant OOM driver for user-facingTrendsQuery. - High-volume event as a funnel/first step.
$pageviewas a step scans all pageviews; index pruning helps but the raw volume is large. - Ratio / multi-series metrics with double scans. Numerator and denominator each run their own scan and person-overrides join; doubles the work when both use high-volume events.
- All-time or multi-month date ranges. Scans proportionally more data; check the range.
- Function-wrapped sort/filter keys defeating index pruning. When the
WHEREtime bound orORDER BYwraps the sort column in a function (e.g.coalesce(toTimeZone(timestamp, …))instead of rawtimestamp), Datastore can't match it to the primary key, so the filter drops to a Prewhere row filter (no granule pruning) and the read scans far more than the requested window, often the team's full history. On a wide table (events), readingproperties/elements_chainfor that inflated row set under a highmax_threadsis a classic OOM, and where it does not OOM it hits themax_execution_timecap as a timeout. Watch for no-op wrappers like a single-argumentcoalesce()on a non-nullable column. Confirm with EXPLAIN by diffing granule counts (below).
Tracing a query back to its origin
The lc_* columns exist only on query_log_archive: they are the pre-parsed log_comment tags.
system.query_log carries the exact same information, but unparsed, inside its log_comment JSON blob,
so on query_log you read the field out of that JSON (e.g. JSONExtractString(log_comment, 'query_type'))
instead of selecting an lc_* column. The table below uses the query_log_archive column names.
These tags tell you what triggered the query without reverse-engineering the SQL:
| Column | Tells you |
|---|---|
lc_query__kind |
Product query type (TrendsQuery, ExperimentQuery, ...) |
lc_route_id |
API route that handled the request |
lc_dashboard_id / lc_insight_id / lc_experiment_id / lc_cohort_id |
The object that fired it |
lc_feature, lc_temporal__workflow_type, lc_dagster__job_name |
Background-job origin |
lc_access_method, lc_api_key_label |
Whether it came from the web, OAuth, or a named API key |
lc_user_id |
Who triggered it |
The full log_comment (raw, on system.query_log) additionally carries http_referer, scene, and
source_file/source_line. These tags are defined in insights/datastore/query_tagging.py
(QueryTags).
To reverse-engineer SQL, grep the Insights codebase for the lc_query__kind value (e.g. TrendsQuery)
to find the query runner that generates it.
EXPLAIN to test the hypothesis
Per the Datastore EXPLAIN docs, no
EXPLAIN variant executes the query or scans table data, so EXPLAIN is safe to run against prod at any
size. Two variants are not entirely free, though: EXPLAIN ESTIMATE and EXPLAIN indexes=1 read primary
index marks / part metadata to do their analysis. That is a small metadata read, not a data scan, but it
is not zero, so prefer the lighter plan-only variants (actions=1, PIPELINE) when you do not
specifically need index pruning numbers, and avoid hammering indexes=1 in a tight loop. If in doubt,
re-check the current docs before running a variant at scale. Pull the query text, strip the leading
/* … */ tag comment, and prepend the EXPLAIN modifier that answers your question:
EXPLAIN actions=1: the full plan, showing theSortingstep,ReadType(InOrdervsDefault), and whether a filter is a primary-key condition or only aPrewhererow filter.EXPLAIN indexes=1: per-index granule pruning (Granules: X/Y, which skip indexes fired).EXPLAIN PIPELINE: the executor pipeline (thread fan-out, merge stages).EXPLAIN json=1: machine-readable, handy for diffing two plans programmatically.
The strongest technique is to EXPLAIN the suspect query and a fixed variant side by side, then diff.
For the function-wrapped-key case, compare the generated ORDER BY coalesce(toTimeZone(timestamp, …))
against raw ORDER BY timestamp: a large drop in Granules (full history shrinks to just the date
window) and the time bound moving from Prewhere into the primary-key condition confirms the wrapper is
the cause.
Signals to read off the plan:
Granules: X: how many 8192-row blocks survive index pruning. A wrapped/opaque filter shows a much larger granule count than the equivalent raw-column filter; that delta is the wasted scan.ReadType: InOrdervsDefault: whetheroptimize_read_in_orderapplies. Note the events table is ordered bytoDate(timestamp), not sub-day, so ordering by fulltimestampstill sorts; do not infer read-in-order from a date sort alone.Prewhere filtervs primary-key condition: a time bound in Prewhere is evaluated row-by-row and does not prune granules; in the primary-key condition it does.- Skip-index effectiveness (e.g.
minmax_mat_*): how many granules each eliminates. - Bytes vs rows: similar granule counts but wildly different bytes means a column-width problem (the JSON blob again).
Concurrency and timing notes
- In
query_log,event_timeis the completion time forQueryFinishrows. Usequery_start_time_microsecondswhen reconstructing concurrency, or short queries that finish first will look like they ran first. - A single experiment page load fires one query per metric, dispatched via celery with limited
parallelism. Wall-clock ≈ dispatch overhead + the longest single query; total data scanned is the
sum across metric queries. Same query length does not mean same query; distinguish by
client_query_id. person_distinct_id_overridesjoins are expected and mandatory for correct person resolution. Do not flag them on their own; they are the cost of correctness. A query joining bothperson_distinct_id_overridesandperson_distinct_id2is the pattern worth a closer look.
Codebase map (in ../insights or the public repo)
| Path | What it explains |
|---|---|
insights/insightsql/printer/base.py |
InsightsQL → SQL; the materialization decision tree (mat_* → dmat_* → property groups → JSONExtract fallback) |
insights/insightsql/transforms/property_types.py |
Whether a property access uses a materialized column or JSON extraction |
insights/insightsql/property.py |
How property filters become AST (person properties → ["person","properties"] chain) |
insights/insightsql/database/schema/events.py |
Events table schema; lazy joins to person, pdi, sessions, groups |
insights/insightsql/database/database.py |
Person-on-events mode: how person_id resolves via overrides vs pdi2 |
insights/insightsql_queries/experiments/ |
How experiment queries build exposure CTEs and resolve persons |
ee/datastore/materialized_columns/analyze.py |
Auto-materialization logic (properties in 10+ slow queries reading >20GB or >5M rows) |
insights/datastore/query_tagging.py |
The QueryTags model behind the lc_* columns |