Generating ClickHouse query performance reports
This skill is the methodology for investigating slow ClickHouse queries and writing up a
performance report. It pairs with querying-production-databases-via-metabase,
which is the mechanism (SSO-gated auth and hogli metabase:query). Run every query in this skill
through that one.
Reports themselves are not public. When it exists, the private PostHog/query-performance-analysis repo
holds the historical reports and example query IDs; this repo holds only the tooling and methodology.
That repo is usually checked out as a sibling folder to the posthog checkout (e.g.
../query-performance-analysis relative to the repo root, or alongside it under the same parent
directory). Look for a sibling directory named query-performance-analysis containing an analysis/
folder of dated reports. If you find it, add the new report there as a new markdown file under
analysis/, named <YYYY-MM-DD>-<topic>.md (match the existing naming, e.g.
2026-05-27-slow-queries-14d.md).
The sibling repo may not exist, and that is fine. If you cannot find it, do not write into the public
posthog repo and do not block on it: write the report to a temp folder instead (e.g.
/tmp/<YYYY-MM-DD>-<topic>.md), tell the user where you put it, and skip the previous-report comparison
in step 9 (there is no history to diff against).
Data source: posthog.query_log_archive (not system.query_log)
system.query_log on the production clusters retains only a few hours, so it cannot answer a
multi-day question. Use the Distributed archive table instead:
FROM posthog.query_log_archive
It retains roughly three weeks and exposes log_comment as typed columns, so you skip JSONExtract.
Query it directly (it already fans out across the cluster). Always filter is_initial_query so
distributed sub-queries are not double-counted. Confirm current retention with a per-day
count() before trusting a window (see references/query-patterns.md).
Key columns (full list via system.columns WHERE table='query_log_archive'):
| Column |
Meaning |
team_id (Int64) |
Tenant. 0 / empty means internal or unattributed. |
lc_kind |
How the query was issued: request (sync API/web), celery (async refresh), temporal, cohort_calculation, dagster. |
lc_product |
product_analytics, warehouse, experiments, messaging, web_analytics, replay, llm_analytics, cohorts, ... |
lc_access_method |
personal_api_key, oauth, sharing_token, or empty (logged-in web). |
lc_query__kind |
Product query type: TrendsQuery, FunnelsQuery, RetentionQuery, HogQLQuery, ... |
lc_workload |
Workload.OFFLINE / ONLINE. |
lc_feature, lc_temporal__workflow_type, lc_route_id, lc_api_key_label |
Origin detail for attribution. |
lc_dashboard_id, lc_insight_id, lc_experiment_id, lc_cohort_id |
Link a query back to the object that triggered it. |
query, query_duration_ms, read_bytes, read_rows, memory_usage, exception_code |
The query and its cost. |
Both regions have the archive. US and EU are separate clusters with different workloads and
materialized columns; run cross-region comparisons against both. Discover the current ClickHouse
database id per region with hogli metabase:databases (ids are not stable). Note that the ONLINE and
OFFLINE Metabase connections for a region fan out to the same logical cluster, so they return the same
query_log_archive data.
What counts as a slow query
query_duration_ms > 30000 OR exception_code IN (159, 160, 241)
| Code |
Meaning |
| 159 |
TIMEOUT_EXCEEDED |
| 160 |
TOO_SLOW |
| 241 |
MEMORY_LIMIT_EXCEEDED |
Do not add type = 'QueryFinish': OOM and timeout rows are type = 'ExceptionWhileProcessing',
so that filter silently drops every failure. The duration/exception predicate already excludes
QueryStart rows (duration 0). Exclude the cluster health-poll query by normalized_query_hash
(pattern in references/query-patterns.md).
Producing the report
The standard workflow, building from coarse to specific. Each step's SQL is in
references/query-patterns.md.
Do not read previous reports until step 9. Steps 1-8 should run against the raw data with fresh eyes,
so the analysis captures the largest surface area rather than re-walking last report's findings. Reading
the prior report early anchors you to its categories and makes it easy to miss a new problem it never
mentioned. Diff against history only after the independent pass is done.
- Confirm the window. Per-day
count() over the intended range to verify the archive actually
covers it (retention can be shorter than you expect).
- Headline summary. Total slow queries, total cluster query-hours, bytes read, teams touched,
and the split across succeeded-but-slow / timeouts / OOMs / other. Also capture the cluster-wide
totals across all queries (not just the slow set): total query-seconds, total CPU-seconds (typed
ProfileEvents_OSCPUVirtualTimeMicroseconds column, not the Map lookup), total bytes read, and
total OOMs (references/query-patterns.md §1b). The slow-set sums are a biased subset; the all-query
totals are the honest "busier / reading more this period?" denominator and the baseline future reports
diff against. They cannot be backfilled once a window ages past retention, so record them every run.
- Date distribution. Slow count, timeouts, and OOMs per day. This is where incidents announce
themselves: a multi-day OOM or timeout surge against a flat baseline.
- Categorize. Group by
lc_kind × lc_product × lc_access_method. This separates background
work (data modeling, dagster pre-aggregation, batch exports) from synchronous user-facing queries.
- Attribute. Drill into the worst categories by
team_id. Rank by total cluster-hours
(sum(query_duration_ms)) and by OOM count separately. Before calling anything systemic,
check whether one team or one API key dominates a metric: a single integration querying via a
personal_api_key can account for the large majority of cluster OOMs, and the "incident" is then
really one tenant. Attribute by team_id + lc_api_key_label first. Then add a top-consumers view
over all queries (not just the slow set): top teams, top API keys (lc_api_key_label), and top tools
(lc_product) ranked by bytes, CPU-seconds, and wall-time (references/query-patterns.md §4c).
This is where the heavy-but-fast consumers show up: a tenant or integration can dominate cluster CPU
or bytes through millions of cheap queries while never crossing the slow threshold, so it is invisible
to the slow-set ranking. The CPU:wall ratio per row separates compute-bound from wait/IO-bound load.
- Characterize user-facing slowness. For
lc_kind='request' AND lc_product='product_analytics'
with empty lc_access_method (logged-in web), break down by lc_query__kind and flag
breakdown_value usage and JSONExtract over person_properties. This is the product-actionable
bucket. Always include the JSON-extracted property breakdown (references/query-patterns.md §7):
the top event vs person property names pulled from JSON blobs in the slow set, and which teams use
each. These are the materialization candidates and a required report output. HogQLQuery (arbitrary
user- and AI-authored SQL) deserves its own deep dive, including how much is AI-written and why it is
slow; see references/hogql-deep-dive.md.
- Root-cause the worst offenders. For the top findings, do not stop at "team X is slow": pull the
full query and form a hypothesis for why, then test it with EXPLAIN. Root-causing an individual
query is the
optimizing-clickhouse-and-hogql-queries
skill's job; its references/investigation-playbook.md
is the playbook (pull the full query, bytes vs CPU vs duration, the runtime causes, origin tracing,
EXPLAIN). A useful finding includes a why ("scans full history because the time filter is
function-wrapped and can't prune granules"), even if stated as a hypothesis.
- Examples + write-up. Capture
query_id + event_date for the worst offenders in each finding,
then write the report (structure below). Because system.query_log retention is short, examples are
resolved from query_log_archive (WHERE query_id = '…' AND event_date = '…'), not the old Metabase
lookup card. Link each example to a shareable self-contained Metabase URL (the query_link recipe in
references/query-patterns.md) so a reader clicks straight through to the query. When you draft the
recommendations, ground the researchable ones in code by spawning background research agents (see
"Grounding recommendations in code" below) so a recommendation points at the actual file and change
rather than saying "audit X".
- Diff against the previous report (do this last, if there is one). If the sibling
query-performance-analysis repo is not present, skip this step entirely. Otherwise, only now, after
the independent pass above, read the most recent dated report in its analysis/ folder
(sort by filename date). Add a short delta section to the new report covering: what moved since
last time (new incidents, findings that grew or resolved, headline numbers up or down), and a
follow-up check on anything the previous report flagged as needing action (a materialization that
was recommended, a team to watch, a pipeline to make incremental). For each prior follow-up, state
whether it is resolved, still open, or regressed, with the current numbers as evidence. Doing this
last is deliberate: it keeps the fresh analysis unbiased while still closing the loop on history.
Make the windows comparable before quoting a delta: confirm the previous report used the same
window length (both reports here use a trailing now() - INTERVAL N DAY, so equal length but with
overlapping and partial edge days). Headline totals between two trailing windows are usually dominated
by whichever one-off incident sits inside one window and not the other, so a large drop is rarely a
structural improvement. Always also compare an incident-excluded baseline (e.g. OOMs/day with the
spike days removed) so the delta is not misread, and say explicitly when a total moved because an
incident aged into or out of the window. Remember the summed metrics (bytes read, cluster-hours) cover
the slow set only, not total cluster I/O, so they also move when a heavy background job's runs
cross or stop crossing the 30s threshold; attribute a big bytes/hours swing to specific categories
(it is usually one or two background pipelines) rather than reporting it as a cluster-wide change.
Grounding recommendations in code
A recommendation like "audit pipeline X" or "materialize property Y" is far more useful when it points at
the actual code. For each recommendation that maps to a concrete place in the PostHog codebase, spawn a
background research agent (the Agent tool, run_in_background: true, subagent_type: general-purpose
or Explore) to read the source and return: how the relevant code works today, the specific file /
function to change, any constraints, and whether a better mechanism already exists. Spawn one agent per
researchable recommendation, all in a single message so they run in parallel, as soon as the
recommendations are drafted. Let them run while you do the delta (step 9) and finalize the write-up, then
fold each finding into its recommendation: replace "audit X" with "X is implemented in <file> as
<current behavior>; the change is <specific>", and cite the file paths so the human can jump straight
in. The agents research and report only; they do not change code.
Not every recommendation is researchable this way. Spawn an agent only where source code is the source of
truth; skip operational / infra items:
| Recommendation shape |
Researchable? |
What the agent reads |
| Rewrite a slow insight / query shape |
yes |
the query runner under posthog/hogql_queries/, the HogQL it emits |
| Materialize property X |
yes |
the materialized-column registry (ee/clickhouse/materialized_columns/) |
| Make pipeline Y incremental |
yes |
the dagster / temporal job that builds it |
| Cap memory / add a query guard per key |
yes |
where ClickHouse SETTINGS and per-key throttling are applied |
| Add a breakdown cardinality guard |
yes |
the trends / breakdown query runner |
| Investigate an infra incident window |
no |
n/a (deploys, node health, cluster state) |
| Watch / confirm a tenant's intended load |
no |
n/a (a judgement call for a human) |
Give each agent a focused prompt: the recommendation, the specific question, and an instruction to return
file paths + current behavior + the precise change point and to change nothing. The agents read the
posthog repo (where this skill lives); the report itself is written to the separate
query-performance-analysis repo.
Interpreting the results
- Two populations live in "slow queries." Tight-timeout API noise (queries erroring at ~10s
against a low
max_execution_time, usually personal_api_key) inflates the raw count without
representing real compute. Genuinely expensive work is better measured by total cluster-hours and
OOM count. Always call this distinction out; do not let timeout volume masquerade as slowness.
- Bytes read is the truest cost signal, more than duration (which varies with cache and cluster
load). High bytes against low rows means heavy columns, almost always JSONExtract over a
properties
blob. For root-causing individual queries, see the
optimizing-clickhouse-and-hogql-queries skill.
- Background pipelines usually dominate raw cluster-time (data-modeling DAGs, web-analytics
pre-aggregation). That is expected; weigh them by whether their scan volume is necessary, separately
from user-facing latency.
Report structure
A report should contain, in order:
- One-line scope: region, window, and the slow definition / exclusions used.
- Headline numbers table + the cluster-wide totals (all queries) table (total query-seconds,
CPU-seconds, bytes read, OOMs) + the two-populations caveat.
- Daily distribution table (flag any incident window).
- Findings, worst first. Every finding needs at least one concrete
query_id + event_date,
linked via the shareable query_link URL (see references/query-patterns.md) so a reader clicks
straight through to the exact query, plus a hypothesis for why it is slow (from the
optimizing-clickhouse-and-hogql-queries
skill's investigation playbook). Group findings by what they are: a per-tenant incident, the
heaviest cluster-time consumers, user-facing insight slowness, and tight-timeout API noise.
- A top-consumers-by-resource section (all queries, not just slow): top teams, top API keys, and
top tools (
lc_product) ranked by bytes / CPU / wall-time (references/query-patterns.md §4c), calling
out consumers that never trip the slow threshold and the compute-bound vs wait-bound split.
- A JSON-extracted property table: the top event and person property names pulled from JSON blobs
in the slow set, with the teams using each (
references/query-patterns.md §7). These are the
materialization candidates.
- Concrete recommendations tied to each finding (materialize property X, cap memory per API key,
make pipeline Y incremental, ...). Ground the researchable ones in code (see "Grounding
recommendations in code"): cite the file / function and the specific change, not just "audit X".
- A delta vs the previous report (step 9): what changed since last time, plus a follow-up check on
each action the previous report recommended (resolved / still open / regressed, with numbers). Omit
this section when there is no previous report.
Save the finished report as analysis/<YYYY-MM-DD>-<topic>.md in the sibling
query-performance-analysis repo, never in the public posthog repo; if that repo is not present, save to
a temp folder (e.g. /tmp/<YYYY-MM-DD>-<topic>.md) and tell the user the path.
References
references/query-patterns.md: ready-to-run SQL for every step above, against query_log_archive.
references/materialization-analysis.md: finding properties to materialize and columns to drop,
run across both US and EU.
references/hogql-deep-dive.md: analyzing HogQLQuery (arbitrary user/AI SQL) specifically,
including how to identify AI-written HogQL (lc_product/lc_feature, not ai_query_source) and the
causes that make ad-hoc and AI queries slow.
Related skills
This skill is fleet-level: it finds and ranks slow queries across all teams and writes the report. Once a
finding points at one query you want to explain or fix, switch to
optimizing-clickhouse-and-hogql-queries — it
owns root-causing an individual query (its references/investigation-playbook.md) and applying the fix at
the right layer (printer, query runner, or ClickHouse migration).
1---2name: generating-clickhouse-query-performance-reports3description: Produce and structure slow-query performance reports for PostHog's production ClickHouse (US and EU). Use when asked for a slow query report, query performance analysis over the last N days, per-team query cost, OOM or timeout investigation, cluster cost/memory regressions, or materialization candidates. Covers the modern `query_log_archive` source (typed `lc_*` columns, multi-day retention), how to categorize and attribute slow queries, root-cause patterns (unmaterialized JSONExtract, high-cardinality breakdowns, heavy joins), and the report structure. Runs queries via the `querying-production-databases-via-metabase` skill.4---5
6# Generating ClickHouse query performance reports
7
8This skill is the _methodology_ for investigating slow ClickHouse queries and writing up a
9performance report. It pairs with [`querying-production-databases-via-metabase`](../querying-production-databases-via-metabase/SKILL.md),
10which is the _mechanism_ (SSO-gated auth and `hogli metabase:query`). Run every query in this skill
11through that one.
12
13Reports themselves are not public. When it exists, the private `PostHog/query-performance-analysis` repo
14holds the historical reports and example query IDs; this repo holds only the tooling and methodology.
15That repo is usually checked out as a **sibling folder** to the posthog checkout (e.g.
16`../query-performance-analysis` relative to the repo root, or alongside it under the same parent
17directory). Look for a sibling directory named `query-performance-analysis` containing an `analysis/`
18folder of dated reports. If you find it, **add the new report there as a new markdown file** under
19`analysis/`, named `<YYYY-MM-DD>-<topic>.md` (match the existing naming, e.g.
20`2026-05-27-slow-queries-14d.md`).
21
22**The sibling repo may not exist, and that is fine.** If you cannot find it, do not write into the public
23posthog repo and do not block on it: write the report to a temp folder instead (e.g.
24`/tmp/<YYYY-MM-DD>-<topic>.md`), tell the user where you put it, and skip the previous-report comparison
25in step 9 (there is no history to diff against).
26
27## Data source: `posthog.query_log_archive` (not `system.query_log`)
28
29`system.query_log` on the production clusters retains only a few **hours**, so it cannot answer a
30multi-day question. Use the Distributed archive table instead:
31
32```sql
33FROM posthog.query_log_archive
34```
35
36It retains roughly three weeks and exposes `log_comment` as typed columns, so you skip `JSONExtract`.
37Query it directly (it already fans out across the cluster). Always filter `is_initial_query` so
38distributed sub-queries are not double-counted. Confirm current retention with a per-day
39`count()` before trusting a window (see `references/query-patterns.md`).
40
41Key columns (full list via `system.columns WHERE table='query_log_archive'`):
42
43| Column | Meaning |
44| ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
45| `team_id` (Int64) | Tenant. `0` / empty means internal or unattributed. |
46| `lc_kind` | How the query was issued: `request` (sync API/web), `celery` (async refresh), `temporal`, `cohort_calculation`, `dagster`. |
47| `lc_product` | `product_analytics`, `warehouse`, `experiments`, `messaging`, `web_analytics`, `replay`, `llm_analytics`, `cohorts`, ... |
48| `lc_access_method` | `personal_api_key`, `oauth`, `sharing_token`, or empty (logged-in web). |
49| `lc_query__kind` | Product query type: `TrendsQuery`, `FunnelsQuery`, `RetentionQuery`, `HogQLQuery`, ... |
50| `lc_workload` | `Workload.OFFLINE` / `ONLINE`. |
51| `lc_feature`, `lc_temporal__workflow_type`, `lc_route_id`, `lc_api_key_label` | Origin detail for attribution. |
52| `lc_dashboard_id`, `lc_insight_id`, `lc_experiment_id`, `lc_cohort_id` | Link a query back to the object that triggered it. |
53| `query`, `query_duration_ms`, `read_bytes`, `read_rows`, `memory_usage`, `exception_code` | The query and its cost. |
54
55Both regions have the archive. US and EU are separate clusters with different workloads and
56materialized columns; run cross-region comparisons against both. Discover the current ClickHouse
57database id per region with `hogli metabase:databases` (ids are not stable). Note that the ONLINE and
58OFFLINE Metabase connections for a region fan out to the same logical cluster, so they return the same
59`query_log_archive` data.
60
61## What counts as a slow query
62
63```sql
64query_duration_ms > 30000 OR exception_code IN (159, 160, 241)
65```
66
67| Code | Meaning |
68| ---- | --------------------- |
69| 159 | TIMEOUT_EXCEEDED |
70| 160 | TOO_SLOW |
71| 241 | MEMORY_LIMIT_EXCEEDED |
72
73Do **not** add `type = 'QueryFinish'`: OOM and timeout rows are `type = 'ExceptionWhileProcessing'`,
74so that filter silently drops every failure. The duration/exception predicate already excludes
75`QueryStart` rows (duration 0). Exclude the cluster health-poll query by `normalized_query_hash`
76(pattern in `references/query-patterns.md`).
77
78## Producing the report
79
80The standard workflow, building from coarse to specific. Each step's SQL is in
81`references/query-patterns.md`.
82
83**Do not read previous reports until step 9.** Steps 1-8 should run against the raw data with fresh eyes,
84so the analysis captures the largest surface area rather than re-walking last report's findings. Reading
85the prior report early anchors you to its categories and makes it easy to miss a new problem it never
86mentioned. Diff against history only after the independent pass is done.
87
881. **Confirm the window.** Per-day `count()` over the intended range to verify the archive actually
89 covers it (retention can be shorter than you expect).
902. **Headline summary.** Total slow queries, total cluster query-hours, bytes read, teams touched,
91 and the split across succeeded-but-slow / timeouts / OOMs / other. Also capture the **cluster-wide
92 totals across all queries** (not just the slow set): total query-seconds, total CPU-seconds (typed
93 `ProfileEvents_OSCPUVirtualTimeMicroseconds` column, not the `Map` lookup), total bytes read, and
94 total OOMs (`references/query-patterns.md` §1b). The slow-set sums are a biased subset; the all-query
95 totals are the honest "busier / reading more this period?" denominator and the baseline future reports
96 diff against. They cannot be backfilled once a window ages past retention, so record them every run.
973. **Date distribution.** Slow count, timeouts, and OOMs per day. This is where incidents announce
98 themselves: a multi-day OOM or timeout surge against a flat baseline.
994. **Categorize.** Group by `lc_kind` × `lc_product` × `lc_access_method`. This separates background
100 work (data modeling, dagster pre-aggregation, batch exports) from synchronous user-facing queries.
1015. **Attribute.** Drill into the worst categories by `team_id`. Rank by **total cluster-hours**
102 (`sum(query_duration_ms)`) and by **OOM count** separately. Before calling anything systemic,
103 check whether one team or one API key dominates a metric: a single integration querying via a
104 `personal_api_key` can account for the large majority of cluster OOMs, and the "incident" is then
105 really one tenant. Attribute by `team_id` + `lc_api_key_label` first. Then add a **top-consumers view
106 over all queries** (not just the slow set): top teams, top API keys (`lc_api_key_label`), and top tools
107 (`lc_product`) ranked by **bytes, CPU-seconds, and wall-time** (`references/query-patterns.md` §4c).
108 This is where the heavy-but-fast consumers show up: a tenant or integration can dominate cluster CPU
109 or bytes through millions of cheap queries while never crossing the slow threshold, so it is invisible
110 to the slow-set ranking. The CPU:wall ratio per row separates compute-bound from wait/IO-bound load.
1116. **Characterize user-facing slowness.** For `lc_kind='request' AND lc_product='product_analytics'`
112 with empty `lc_access_method` (logged-in web), break down by `lc_query__kind` and flag
113 `breakdown_value` usage and JSONExtract over `person_properties`. This is the product-actionable
114 bucket. Always include the **JSON-extracted property breakdown** (`references/query-patterns.md` §7):
115 the top event vs person property names pulled from JSON blobs in the slow set, and which teams use
116 each. These are the materialization candidates and a required report output. `HogQLQuery` (arbitrary
117 user- and AI-authored SQL) deserves its own deep dive, including how much is AI-written and why it is
118 slow; see `references/hogql-deep-dive.md`.
1197. **Root-cause the worst offenders.** For the top findings, do not stop at "team X is slow": pull the
120 full query and form a hypothesis for _why_, then test it with EXPLAIN. Root-causing an individual
121 query is the [`optimizing-clickhouse-and-hogql-queries`](../optimizing-clickhouse-and-hogql-queries/SKILL.md)
122 skill's job; its [`references/investigation-playbook.md`](../optimizing-clickhouse-and-hogql-queries/references/investigation-playbook.md)
123 is the playbook (pull the full query, bytes vs CPU vs duration, the runtime causes, origin tracing,
124 EXPLAIN). A useful finding includes a why ("scans full history because the time filter is
125 function-wrapped and can't prune granules"), even if stated as a hypothesis.
1268. **Examples + write-up.** Capture `query_id` + `event_date` for the worst offenders in each finding,
127 then write the report (structure below). Because `system.query_log` retention is short, examples are
128 resolved from `query_log_archive` (`WHERE query_id = '…' AND event_date = '…'`), not the old Metabase
129 lookup card. Link each example to a shareable self-contained Metabase URL (the `query_link` recipe in
130 `references/query-patterns.md`) so a reader clicks straight through to the query. When you draft the
131 recommendations, **ground the researchable ones in code** by spawning background research agents (see
132 "Grounding recommendations in code" below) so a recommendation points at the actual file and change
133 rather than saying "audit X".
1349. **Diff against the previous report (do this last, if there is one).** If the sibling
135 `query-performance-analysis` repo is not present, skip this step entirely. Otherwise, only now, after
136 the independent pass above, read the most recent dated report in its `analysis/` folder
137 (sort by filename date). Add a short **delta** section to the new report covering: what moved since
138 last time (new incidents, findings that grew or resolved, headline numbers up or down), and a
139 **follow-up check** on anything the previous report flagged as needing action (a materialization that
140 was recommended, a team to watch, a pipeline to make incremental). For each prior follow-up, state
141 whether it is resolved, still open, or regressed, with the current numbers as evidence. Doing this
142 last is deliberate: it keeps the fresh analysis unbiased while still closing the loop on history.
143 **Make the windows comparable before quoting a delta:** confirm the previous report used the same
144 window length (both reports here use a trailing `now() - INTERVAL N DAY`, so equal length but with
145 overlapping and partial edge days). Headline totals between two trailing windows are usually dominated
146 by whichever one-off incident sits inside one window and not the other, so a large drop is rarely a
147 structural improvement. Always also compare an **incident-excluded baseline** (e.g. OOMs/day with the
148 spike days removed) so the delta is not misread, and say explicitly when a total moved because an
149 incident aged into or out of the window. Remember the summed metrics (bytes read, cluster-hours) cover
150 the **slow set only**, not total cluster I/O, so they also move when a heavy background job's runs
151 cross or stop crossing the 30s threshold; attribute a big bytes/hours swing to specific categories
152 (it is usually one or two background pipelines) rather than reporting it as a cluster-wide change.
153
154## Grounding recommendations in code
155
156A recommendation like "audit pipeline X" or "materialize property Y" is far more useful when it points at
157the actual code. For each recommendation that maps to a concrete place in the PostHog codebase, **spawn a
158background research agent** (the `Agent` tool, `run_in_background: true`, `subagent_type: general-purpose`
159or `Explore`) to read the source and return: how the relevant code works today, the specific file /
160function to change, any constraints, and whether a better mechanism already exists. Spawn **one agent per
161researchable recommendation**, all in a single message so they run in parallel, as soon as the
162recommendations are drafted. Let them run while you do the delta (step 9) and finalize the write-up, then
163fold each finding into its recommendation: replace "audit X" with "X is implemented in `<file>` as
164`<current behavior>`; the change is `<specific>`", and cite the file paths so the human can jump straight
165in. The agents research and report only; they do not change code.
166
167Not every recommendation is researchable this way. Spawn an agent only where source code is the source of
168truth; skip operational / infra items:
169
170| Recommendation shape | Researchable? | What the agent reads |
171| ---------------------------------------- | ------------- | ------------------------------------------------------------------------ |
172| Rewrite a slow insight / query shape | yes | the query runner under `posthog/hogql_queries/`, the HogQL it emits |
173| Materialize property X | yes | the materialized-column registry (`ee/clickhouse/materialized_columns/`) |
174| Make pipeline Y incremental | yes | the dagster / temporal job that builds it |
175| Cap memory / add a query guard per key | yes | where ClickHouse SETTINGS and per-key throttling are applied |
176| Add a breakdown cardinality guard | yes | the trends / breakdown query runner |
177| Investigate an infra incident window | no | n/a (deploys, node health, cluster state) |
178| Watch / confirm a tenant's intended load | no | n/a (a judgement call for a human) |
179
180Give each agent a focused prompt: the recommendation, the specific question, and an instruction to return
181file paths + current behavior + the precise change point and to change nothing. The agents read the
182posthog repo (where this skill lives); the report itself is written to the separate
183`query-performance-analysis` repo.
184
185## Interpreting the results
186
187- **Two populations live in "slow queries."** Tight-timeout API noise (queries erroring at ~10s
188 against a low `max_execution_time`, usually `personal_api_key`) inflates the raw count without
189 representing real compute. Genuinely expensive work is better measured by total cluster-hours and
190 OOM count. Always call this distinction out; do not let timeout volume masquerade as slowness.
191- **Bytes read is the truest cost signal**, more than duration (which varies with cache and cluster
192 load). High bytes against low rows means heavy columns, almost always JSONExtract over a `properties`
193 blob. For root-causing individual queries, see the
194 [`optimizing-clickhouse-and-hogql-queries`](../optimizing-clickhouse-and-hogql-queries/SKILL.md) skill.
195- **Background pipelines usually dominate raw cluster-time** (data-modeling DAGs, web-analytics
196 pre-aggregation). That is expected; weigh them by whether their scan volume is necessary, separately
197 from user-facing latency.
198
199## Report structure
200
201A report should contain, in order:
202
2031. One-line scope: region, window, and the slow definition / exclusions used.
2042. Headline numbers table + the **cluster-wide totals (all queries)** table (total query-seconds,
205 CPU-seconds, bytes read, OOMs) + the two-populations caveat.
2063. Daily distribution table (flag any incident window).
2074. Findings, worst first. **Every finding needs at least one concrete `query_id` + `event_date`,
208 linked via the shareable `query_link` URL** (see `references/query-patterns.md`) so a reader clicks
209 straight through to the exact query, plus a **hypothesis for why it is slow** (from the
210 [`optimizing-clickhouse-and-hogql-queries`](../optimizing-clickhouse-and-hogql-queries/SKILL.md)
211 skill's investigation playbook). Group findings by what they are: a per-tenant incident, the
212 heaviest cluster-time consumers, user-facing insight slowness, and tight-timeout API noise.
2135. A **top-consumers-by-resource section** (all queries, not just slow): top teams, top API keys, and
214 top tools (`lc_product`) ranked by bytes / CPU / wall-time (`references/query-patterns.md` §4c), calling
215 out consumers that never trip the slow threshold and the compute-bound vs wait-bound split.
2166. A **JSON-extracted property table**: the top event and person property names pulled from JSON blobs
217 in the slow set, with the teams using each (`references/query-patterns.md` §7). These are the
218 materialization candidates.
2197. Concrete recommendations tied to each finding (materialize property X, cap memory per API key,
220 make pipeline Y incremental, ...). Ground the researchable ones in code (see "Grounding
221 recommendations in code"): cite the file / function and the specific change, not just "audit X".
2228. A **delta vs the previous report** (step 9): what changed since last time, plus a follow-up check on
223 each action the previous report recommended (resolved / still open / regressed, with numbers). Omit
224 this section when there is no previous report.
225
226Save the finished report as `analysis/<YYYY-MM-DD>-<topic>.md` in the sibling
227`query-performance-analysis` repo, never in the public posthog repo; if that repo is not present, save to
228a temp folder (e.g. `/tmp/<YYYY-MM-DD>-<topic>.md`) and tell the user the path.
229
230## References
231
232- `references/query-patterns.md`: ready-to-run SQL for every step above, against `query_log_archive`.
233- `references/materialization-analysis.md`: finding properties to materialize and columns to drop,
234 run across both US and EU.
235- `references/hogql-deep-dive.md`: analyzing `HogQLQuery` (arbitrary user/AI SQL) specifically,
236 including how to identify AI-written HogQL (`lc_product`/`lc_feature`, not `ai_query_source`) and the
237 causes that make ad-hoc and AI queries slow.
238
239## Related skills
240
241This skill is fleet-level: it finds and ranks slow queries across all teams and writes the report. Once a
242finding points at one query you want to explain or fix, switch to
243[`optimizing-clickhouse-and-hogql-queries`](../optimizing-clickhouse-and-hogql-queries/SKILL.md) — it
244owns root-causing an individual query (its `references/investigation-playbook.md`) and applying the fix at
245the right layer (printer, query runner, or ClickHouse migration).