Files
zeekay 1b367e2b62 chore: rebase the tree onto upstream master
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.
2026-08-01 15:44:40 -07:00

17 KiB

Slow-query report: ready-to-run patterns

All queries run against insights.query_log_archive via insightscli metabase:query --region <us|eu> --database-id <id> (discover the id with insightscli metabase:databases). Save wide results with --save <path>. now() - INTERVAL 14 DAY = "last two weeks"; adjust the interval per request.

The health-poll exclusion below removes the cluster's own liveness probe so it does not show up as a "slow query"; keep it in every aggregate.

AND normalized_query_hash != normalizedQueryHash('SELECT hostname(), query_id FROM clusterAllReplicas(insights, system.processes) WHERE query_id LIKE ? SETTINGS max_execution_time = 2')

0. Confirm retention covers the window

Run this first. If the earliest day is later than your window start, the archive does not go back far enough and the report must say so.

SELECT toDate(event_time) AS day, count() AS rows
FROM insights.query_log_archive
WHERE event_time > now() - INTERVAL 21 DAY
GROUP BY day ORDER BY day

1. Headline summary

Do not wrap the slow predicate in a CTE that projects only some columns: Datastore's analyzer prunes columns and then cannot resolve a column used only in WHERE. Filter in the main query.

SELECT
    count() AS total_slow,
    round(sum(query_duration_ms)/3600000, 0) AS total_hours,
    round(avg(query_duration_ms)/1000) AS avg_s,
    round(max(query_duration_ms)/1000) AS max_s,
    countIf(exception_code = 0)   AS ok_slow,
    countIf(exception_code = 159) AS timeouts,
    countIf(exception_code = 241) AS ooms,
    countIf(exception_code NOT IN (0,159,160,241)) AS other_exc,
    countIf(lc_access_method = 'personal_api_key') AS via_api_key,
    uniqExact(team_id) AS teams,
    formatReadableSize(sum(read_bytes)) AS total_read
FROM insights.query_log_archive
WHERE event_time > now() - INTERVAL 14 DAY
    AND is_initial_query
    AND (query_duration_ms > 30000 OR exception_code IN (159,160,241))
    AND normalized_query_hash != normalizedQueryHash('SELECT hostname(), query_id FROM clusterAllReplicas(insights, system.processes) WHERE query_id LIKE ? SETTINGS max_execution_time = 2')

Quantiles (p90/p99) over the full set are expensive and can hit the Metabase ~60s cutoff. If the query is cancelled, drop the quantiles or shorten the window.

1b. Cluster-wide totals (all queries, the honest denominator)

The headline above is the slow set only, which is a biased subset (~0.2% of queries but a large share of bytes and query-time). Capture the totals across all is_initial_query queries too, so the report records the real denominator and a baseline future reports can diff against. Record total query-seconds, total CPU-seconds, total bytes read, and total OOMs.

Use the typed flattened ProfileEvents_OSCPUVirtualTimeMicroseconds column for CPU time. Do not use ProfileEvents['OSCPUVirtualTimeMicroseconds'] (the Map form): a map lookup over the full ~180M-row window times out even at a 300s client timeout, while the typed Int64 column sums in seconds.

SELECT
    count() AS all_queries,
    round(sum(query_duration_ms)/1000, 0)                          AS total_query_seconds,
    round(sum(ProfileEvents_OSCPUVirtualTimeMicroseconds)/1e6, 0)  AS total_cpu_seconds,
    formatReadableSize(sum(read_bytes))                            AS total_read_all,
    countIf(exception_code = 241)                                  AS total_ooms
FROM insights.query_log_archive
WHERE event_time > now() - INTERVAL 14 DAY AND is_initial_query

CPU-seconds exceed wall query-seconds because queries run multi-threaded; the ratio is rough average parallelism. total_ooms here equals the slow-set OOM count (every OOM is in the slow set by definition), so it is directly comparable across reports even when the all-query totals are not yet available historically. These all-query totals cannot be backfilled for an old report once its window ages past the archive's ~3-week retention.

2. Daily distribution

SELECT
    toDate(event_time) AS day,
    count() AS slow,
    countIf(exception_code = 159) AS timeouts,
    countIf(exception_code = 241) AS ooms,
    round(sum(query_duration_ms)/3600000, 1) AS total_hours
FROM insights.query_log_archive
WHERE event_time > now() - INTERVAL 14 DAY
    AND is_initial_query
    AND (query_duration_ms > 30000 OR exception_code IN (159,160,241))
    AND normalized_query_hash != normalizedQueryHash('SELECT hostname(), query_id FROM clusterAllReplicas(insights, system.processes) WHERE query_id LIKE ? SETTINGS max_execution_time = 2')
GROUP BY day ORDER BY day

3. Category breakdown

The backbone of the report: it separates background work from synchronous user-facing queries.

SELECT
    lc_kind, lc_product, lc_access_method,
    count() AS slow,
    uniqExact(team_id) AS teams,
    countIf(exception_code = 0)   AS ok_slow,
    countIf(exception_code = 159) AS timeouts,
    countIf(exception_code = 241) AS ooms,
    countIf(exception_code NOT IN (0,159,160,241)) AS other_exc,
    round(sum(query_duration_ms)/3600000, 1) AS total_hours,
    round(avg(query_duration_ms)/1000) AS avg_s,
    round(quantile(0.95)(query_duration_ms)/1000) AS p95_s,
    formatReadableSize(sum(read_bytes)) AS total_read
FROM insights.query_log_archive
WHERE event_time > now() - INTERVAL 14 DAY
    AND is_initial_query
    AND (query_duration_ms > 30000 OR exception_code IN (159,160,241))
    AND normalized_query_hash != normalizedQueryHash('SELECT hostname(), query_id FROM clusterAllReplicas(insights, system.processes) WHERE query_id LIKE ? SETTINGS max_execution_time = 2')
GROUP BY lc_kind, lc_product, lc_access_method
ORDER BY slow DESC LIMIT 40

4a. Per-team offenders by total cluster-time

SELECT
    team_id, lc_product, lc_kind,
    count() AS slow,
    countIf(exception_code = 241) AS ooms,
    countIf(exception_code = 159) AS timeouts,
    round(sum(query_duration_ms)/3600000, 1) AS total_hours,
    round(avg(query_duration_ms)/1000) AS avg_s,
    round(max(query_duration_ms)/1000) AS max_s,
    formatReadableSize(sum(read_bytes)) AS total_read
FROM insights.query_log_archive
WHERE event_time > now() - INTERVAL 14 DAY
    AND is_initial_query
    AND (query_duration_ms > 30000 OR exception_code IN (159,160,241))
    AND team_id != 0
    AND normalized_query_hash != normalizedQueryHash('SELECT hostname(), query_id FROM clusterAllReplicas(insights, system.processes) WHERE query_id LIKE ? SETTINGS max_execution_time = 2')
GROUP BY team_id, lc_product, lc_kind
ORDER BY total_hours DESC LIMIT 30

4b. OOM attribution

Rank OOMs by team + product + API key. If one row dominates, the cluster OOM "spike" is one tenant.

SELECT
    team_id, lc_product, lc_access_method, lc_kind, lc_api_key_label,
    countIf(exception_code = 241) AS ooms,
    round(avg(query_duration_ms)/1000) AS avg_s,
    formatReadableSize(avg(memory_usage)) AS avg_mem,
    formatReadableSize(sum(read_bytes)) AS total_read
FROM insights.query_log_archive
WHERE event_time > now() - INTERVAL 14 DAY
    AND is_initial_query
    AND exception_code = 241
GROUP BY team_id, lc_product, lc_access_method, lc_kind, lc_api_key_label
ORDER BY ooms DESC LIMIT 25

To confirm an incident's timing and source, pin to the suspect team and watch it per day:

SELECT toDate(event_time) AS day, countIf(exception_code = 241) AS ooms, count() AS total,
    any(lc_query__kind) AS qkind, any(lc_route_id) AS route, any(lc_api_key_label) AS key_label
FROM insights.query_log_archive
WHERE event_time > now() - INTERVAL 14 DAY AND is_initial_query AND team_id = <TEAM_ID>
GROUP BY day ORDER BY day

4c. Top consumers by resource (all queries, not just slow)

Attribute total cluster resource use across all is_initial_query queries, not just the slow set: this catches consumers that cost the cluster through many cheap queries without ever crossing the slow threshold (and so are invisible to 4a/4b). Report top teams, top API keys, and top tools by all three of bytes / CPU / wall-time (the leader differs by metric). Use the typed ProfileEvents_OSCPUVirtualTimeMicroseconds column, not the Map lookup (§1b). Re-run each block with a different ORDER BY (cpu_hours, sum(read_bytes), query_hours) to get the per-metric leaders. The CPU:wall ratio per row separates compute-bound (ratio >> 1, highly parallel) from wait/IO-bound (ratio ~1 or below) consumers.

-- top teams (swap ORDER BY for the bytes / wall-time rankings)
SELECT team_id,
    round(sum(query_duration_ms)/3600000, 0)                       AS query_hours,
    round(sum(ProfileEvents_OSCPUVirtualTimeMicroseconds)/3.6e9, 0) AS cpu_hours,
    formatReadableSize(sum(read_bytes))                            AS bytes_read,
    countIf(exception_code = 241)                                  AS ooms,
    count()                                                        AS queries
FROM insights.query_log_archive
WHERE event_time > now() - INTERVAL 14 DAY AND is_initial_query AND team_id != 0
GROUP BY team_id ORDER BY cpu_hours DESC LIMIT 15
-- top named API keys / integrations (lc_api_key_label); swap ORDER BY as above
SELECT lc_api_key_label,
    round(sum(query_duration_ms)/3600000, 0)                       AS query_hours,
    round(sum(ProfileEvents_OSCPUVirtualTimeMicroseconds)/3.6e9, 0) AS cpu_hours,
    formatReadableSize(sum(read_bytes))                            AS bytes_read,
    countIf(exception_code = 241) AS ooms, uniqExact(team_id) AS teams, count() AS queries
FROM insights.query_log_archive
WHERE event_time > now() - INTERVAL 14 DAY AND is_initial_query AND lc_api_key_label != ''
GROUP BY lc_api_key_label ORDER BY cpu_hours DESC LIMIT 15
-- top tools (lc_product), cluster-wide (this is the all-query counterpart to the §3 slow-set categories)
SELECT lc_product,
    round(sum(query_duration_ms)/3600000, 0)                       AS query_hours,
    round(sum(ProfileEvents_OSCPUVirtualTimeMicroseconds)/3.6e9, 0) AS cpu_hours,
    formatReadableSize(sum(read_bytes))                            AS bytes_read,
    countIf(exception_code = 241) AS ooms, uniqExact(team_id) AS teams, count() AS queries
FROM insights.query_log_archive
WHERE event_time > now() - INTERVAL 14 DAY AND is_initial_query
GROUP BY lc_product ORDER BY cpu_hours DESC LIMIT 15

A grouped aggregation over the full ~180M-row window is heavier than the slow-set queries; if Metabase returns a 502 or times out, retry (transient) or raise --timeout. any(lc_product) per team is non-deterministic (a team spans products), so annotate notable teams by hand rather than trusting it.

5. User-facing insight characterization

Synchronous web product-analytics queries (the ones a logged-in user waits on), by query kind, with the two most common slowness signals.

SELECT
    lc_query__kind AS qkind,
    count() AS slow,
    uniqExact(team_id) AS teams,
    countIf(exception_code = 241) AS ooms,
    countIf(exception_code = 159) AS timeouts,
    round(avg(query_duration_ms)/1000) AS avg_s,
    countIf(query LIKE '%person_properties%' AND query LIKE '%JSONExtract%') AS json_person_props,
    countIf(query LIKE '%breakdown_value%') AS breakdowns,
    formatReadableSize(sum(read_bytes)) AS total_read
FROM insights.query_log_archive
WHERE event_time > now() - INTERVAL 14 DAY AND is_initial_query
    AND lc_kind = 'request' AND lc_product = 'product_analytics' AND lc_access_method = ''
    AND (query_duration_ms > 30000 OR exception_code IN (159,160,241))
GROUP BY qkind ORDER BY slow DESC LIMIT 15

6. Example queries for the write-up

Use --format json and pull query_id + event_date. Bound the duration to skip a single stuck outlier that would otherwise top the list. The substring(query, 1, 160) here is only for skimming the list; when you drill into one query, pull the full query text (see the optimizing-datastore-and-insightsql-queries investigation playbook — the ORDER BY / WHERE / SETTINGS tail is where the cause usually is).

SELECT query_id, event_date, team_id, lc_product, lc_kind, lc_query__kind,
    lc_temporal__workflow_type AS wf, lc_dashboard_id AS dash, lc_insight_id AS insight,
    query_duration_ms, exception_code,
    formatReadableSize(read_bytes) AS read, formatReadableSize(memory_usage) AS mem,
    substring(query, 1, 160) AS q
FROM insights.query_log_archive
WHERE event_time > now() - INTERVAL 14 DAY AND is_initial_query
    AND (query_duration_ms > 30000 OR exception_code IN (159,160,241))
    AND query_duration_ms BETWEEN 60000 AND 5000000
ORDER BY query_duration_ms DESC LIMIT 20

Resolve any example later from the archive (the short-retention system.query_log lookup card will miss anything older than a few hours):

SELECT * FROM insights.query_log_archive
WHERE query_id = '<id>' AND event_date = '<YYYY-MM-DD>' AND is_initial_query

For each example in a report, link to a self-contained Metabase URL that carries the SQL in its hash fragment, so a reader clicks straight through to the query in query_log_archive. This survives the short system.query_log retention (unlike the old 795-look-up-query-by-query-id card). Build it by base64-encoding the native-query payload:

import base64, json

BASE = {"us": "https://metabase.prod-us.insights.dev", "eu": "https://metabase.prod-eu.insights.dev"}

def _ch_literal(value: str) -> str:
    # query_id derives from the caller-supplied client_query_id, so it can contain a single quote.
    # Escape for a Datastore single-quoted literal by doubling quotes. Treat this as required, not
    # cosmetic: Metabase may execute the embedded native query on load, so an unescaped quote is a real
    # injection vector, not just malformed SQL in an editor.
    return str(value).replace("'", "''")

def query_link(query_id, event_date, region="us", database_id=43):
    # database_id is a Datastore connection on the cluster (US 43). IDs can change when Metabase's
    # metadata is rebuilt: rediscover with `insightscli metabase:databases` and any Datastore id works,
    # since query_log_archive is a Distributed table. EU needs its own id.
    sql = (f"  SELECT lc_query__query FROM insights.query_log_archive\n"
           f"  WHERE query_id = '{_ch_literal(query_id)}'\n"
           f"    AND event_date = '{_ch_literal(event_date)}' AND is_initial_query")
    payload = {
        "dataset_query": {"type": "native", "native": {"query": sql, "template-tags": {}}, "database": database_id},
        "display": "table", "parameters": [], "visualization_settings": {},
    }
    blob = base64.b64encode(json.dumps(payload, separators=(",", ":")).encode()).decode()
    return f"{BASE[region]}/question#{blob}"

SELECT lc_query__query shows the originating product query; widen the SELECT (e.g. *, or query for the executed Datastore SQL) if you want more detail in the linked view. Example output:

https://metabase.prod-us.insights.dev/question#eyJkYXRhc2V0X3F1ZXJ5Ijp7InR5cGUiOiJuYXRpdmUiLCJ...

7. JSON-extracted properties: which names, which teams

A core report output and the input to materialization. Pulls the property names that slow queries JSONExtract out of a JSON blob, split by which blob (properties = event, person_properties = person, group_properties = group), with the teams driving each. extractAllGroupsVertical returns one [alias, column, name] triple per match; ARRAY JOIN explodes them.

SELECT
    column,                                 -- properties (event) | person_properties | group_properties
    property,
    sum(c) AS slow_queries,
    uniqExact(team_id) AS teams,
    sum(failures) AS failures,              -- OOM + timeout among them
    arraySlice(arrayReverseSort(x -> x.2, groupArray((team_id, c))), 1, 5) AS top_teams_by_count
FROM (
    SELECT g[2] AS column, g[3] AS property, team_id,
        count() AS c,
        countIf(exception_code IN (159,160,241)) AS failures
    FROM insights.query_log_archive
    ARRAY JOIN extractAllGroupsVertical(query, 'JSONExtract\\w*\\((\\w+)\\.(\\w*properties),\\s*''([^'']+)''') AS g
    WHERE event_time > now() - INTERVAL 14 DAY
        AND is_initial_query
        AND (query_duration_ms > 30000 OR exception_code IN (159,160,241))
        AND query LIKE '%JSONExtract%'
    GROUP BY column, property, team_id
)
GROUP BY column, property
ORDER BY slow_queries DESC LIMIT 30

top_teams_by_count reads as [[team_id, query_count], ...]. Caveat: a query that extracts N properties contributes one row per property after the ARRAY JOIN, so slow_queries counts property-occurrences, not distinct queries (which is what you want for ranking materialization candidates). The regex captures the first key only, so a nested path JSONExtract(properties, 'a', 'b') shows as a.

To drill one property and list every team using it:

SELECT team_id, count() AS slow_queries,
    countIf(exception_code IN (159,160,241)) AS failures,
    formatReadableSize(avg(read_bytes)) AS avg_read
FROM insights.query_log_archive
WHERE event_time > now() - INTERVAL 14 DAY AND is_initial_query
    AND (query_duration_ms > 30000 OR exception_code IN (159,160,241))
    AND match(query, 'JSONExtract\\w*\\(\\w+\\.\\w*properties,\\s*''<PROPERTY>''')
GROUP BY team_id ORDER BY slow_queries DESC LIMIT 30