querying-tophog

작성자: posthog

tophog를 쿼리하여 — ClickHouse의 수집 파이프라인에서 가장 많은 리소스를 사용하는 저장소 — 핫하거나 비용이 많이 드는 액터(team_id, distinct_id, session_id, partition)를 식별합니다…

npx skills add https://github.com/posthog/posthog-foss --skill querying-tophog

Querying tophog

tophog is the ingestion pipeline's heavy-hitter tracker: workers accumulate per-key aggregates (counts, timers) in memory and periodically flush them to the tophog ClickHouse table via Kafka (clickhouse_tophog topic). It answers "which actor is responsible" questions that fleet-level Prometheus metrics cannot — per-metric label cardinality is unbounded (distinct_id, session_id), so this data lives only in ClickHouse. Retention is 30 days.

The staff-only Django admin has a dashboard over it, but for agent-driven triage query it directly through the internal Metabase.

Access — internal Metabase, never Grafana

The production ClickHouse clusters hold customer data, so there is deliberately no ClickHouse datasource for agents in Grafana. The sanctioned path is the internal Metabase using the engineer's own SSO session — per-person identity, attributable in Metabase's query history, no standing credential. General mechanics live in the querying-production-databases-via-metabase skill; the short version:

  1. The user must run login themselves (the agent shell cannot access the Keychain): hogli metabase:login --region eu (or us). macOS will prompt about "Chrome Safe Storage" — that's browser_cookie3 decrypting the browser's cookie store to capture the SSO session; one-time Allow is the right choice.

  2. Discover the database id — it is not stable across Metabase rebuilds:

    hogli metabase:databases --region eu
    

    Pick "PostHog ClickHouse PROD Data Tier" (the data tier, not the query tier — tophog lives with the events data).

  3. Run queries; the cookie is read internally and never enters the transcript:

    hogli metabase:query --region eu --database-id <id> <<'SQL'
    SELECT ...
    SQL
    

Schema

Table tophog (Distributed over sharded_tophog), ordered by (pipeline, lane, metric, timestamp, key), partitioned by day:

ColumnTypeNotes
timestampDateTime64(6)Flush-window time; always bound it (daily partitions)
metricLowCardinality(String)See inventory below
typeLowCardinality(String)Aggregation semantics: sum (default), max, avg
keyMap(String, String)The actor: access as key['team_id'], key['distinct_id'], key['partition'], key['session_id']
valueFloat64The aggregated value for this flush window
countUInt64Observations in the window
pipelineLowCardinality(String)e.g. analytics
laneLowCardinality(String)main, overflow, historical, async, turbo
labelsMap(String, String)Extra non-key labels

One row is one worker's flush window for one (metric, key) — always aggregate on read. Read-side semantics per type (matches the admin dashboard):

CASE type
    WHEN 'max' THEN max(value)
    WHEN 'avg' THEN sum(value * count) / sum(count)
    ELSE sum(value)
END

Metric inventory — discover live, don't trust lists

Metrics are defined inline in the ingestion pipelines (grep topHog( / timer( / sum( in nodejs/src/ingestion/pipelines/analytics/), so the set evolves. Always start with discovery:

SELECT metric, type, count() AS rows, sum(count) AS observations
FROM tophog
WHERE timestamp > now() - INTERVAL 1 HOUR
GROUP BY metric, type
ORDER BY metric

As of 2026-07-06 (master), the analytics metrics include process_persons_time (timer; key: team_id, distinct_id, partition), emitted_events[_per_distinct_id|_per_partition], transformations_run[_per_partition], events_dropped_by_transformation[_per_partition], merge_events_per_distinct_id (merge-intent events: $create_alias / $merge_dangerously with alias, $identify with $anon_distinct_id), group_identify_events_per_distinct_id, resolved_teams, and session-replay *_by_session_id timers.

Dimensions are deploy-gated and rows are immutable: the partition key on process_persons_time and the merge/group-identify metrics merged 2026-07-06 and only exist in data written after that deploy reaches the environment. Check before relying on them:

SELECT countIf(key['partition'] != '') AS with_partition, count() AS total
FROM tophog
WHERE timestamp > now() - INTERVAL 1 HOUR AND metric = 'process_persons_time'

The lens: cost vs volume

This is the load-bearing idea. Volume ranking finds busy actors; cost ranking finds slow ones — and a single lagging partition is usually a cost problem (a real incident: the top-cost actor was invisible in every volume view). Rank by summed timer value, and compute the per-event ratio to classify what you found:

PatternReading
High events, normal ms/eventHot key (volume) — overflow/rebalance is the lever
Low events, high ms/eventExpensive actor — fat person properties, merge-heavy, or contended writes; scaling out will not help

Canned queries

Top actors by person-processing cost (the incident query):

SELECT
    key['team_id'] AS team_id,
    key['distinct_id'] AS distinct_id,
    round(sum(value)) AS total_ms,
    sum(count) AS events,
    round(sum(value) / sum(count), 1) AS ms_per_event,
    arraySort(groupUniqArray(lane)) AS lanes
FROM tophog
WHERE timestamp > now() - INTERVAL 1 HOUR
  AND metric = 'process_persons_time'
GROUP BY team_id, distinct_id
ORDER BY total_ms DESC
LIMIT 10

Scoped to one lagging partition (data written after the partition dimension deployed):

-- add to WHERE:
  AND key['partition'] = '434'

Merge storms (merges are the classic person-processing cost driver):

SELECT key['team_id'] AS team_id, key['distinct_id'] AS distinct_id,
       key['partition'] AS partition, sum(value) AS merge_events
FROM tophog
WHERE timestamp > now() - INTERVAL 1 HOUR
  AND metric = 'merge_events_per_distinct_id'
GROUP BY team_id, distinct_id, partition
ORDER BY merge_events DESC
LIMIT 10

Generic top-10 per metric with correct type semantics (the admin dashboard's query shape) — filter by pipeline / lane as needed:

SELECT metric, type, key, total, obs
FROM (
    SELECT *, ROW_NUMBER() OVER (PARTITION BY metric, type ORDER BY total DESC) AS rn
    FROM (
        SELECT metric, type, key,
               CASE type
                   WHEN 'max' THEN max(value)
                   WHEN 'avg' THEN sum(value * count) / sum(count)
                   ELSE sum(value)
               END AS total,
               sum(count) AS obs
        FROM tophog
        WHERE timestamp > now() - INTERVAL 1 HOUR
          AND pipeline = 'analytics' AND lane = 'main'
        GROUP BY metric, type, key
    )
)
WHERE rn <= 10
ORDER BY metric, type, rn

Cautions

  • distinct_id and session_id values are customer PII (often emails). Internal triage use only — never paste them into public PRs, issues, or commit messages.
  • Always bound timestamp — the table is partitioned by day and holds 30 days.
  • Queries run under the engineer's Metabase identity and appear in their query history.

Related

  • monitoring-ingestion-pipeline — the Grafana-side diagnosis, including the single-partition-lag playbook that hands off to this skill for actor identification.
  • The pganalyze MCP — the next hop when person processing is implicated (query-level view of the persons Postgres).

posthog의 다른 스킬

error-tracking-hono
posthog
PostHog 오류 추적 for Hono
tuning-incremental-sync-config
posthog
동기화의 구성은 ExternalDataSchema에 저장되며, external-data-schemas-partial-update를 통해 언제든지 변경할 수 있습니다. 대부분의 변경은 비파괴적이며(다음 동기화에 적용됨), 일부 변경(sync_type 전환, 기본 키 변경)은 동기화된 데이터 손상을 방지하기 위해 신중한 처리가 필요합니다.
playwright-test
posthog
플레이라이트 테스트를 작성하고, 실행이 잘 되며, 불안정하지 않도록 하세요.
error-tracking-ruby
posthog
PostHog Ruby 오류 추적
authoring-log-alerts
posthog
PostHog 프로젝트의 서비스에 유용하고 노이즈가 적은 로그 알림을 작성합니다. 사용자가 로그에 대한 알림 설정을 요청하거나 추가해야 할 알림을 제안할 때 사용하세요.
making-scenes-tab-aware
posthog
Guides converting PostHog frontend scenes to be tab aware for internal scene tabs. Use when adding or refactoring a `SceneExport` scene, fixing state leaking…
posthog-survey-creator
posthog
PostHog에서 안내 대화를 통해 설문조사를 생성하고 구성합니다. 사용자가 설문조사를 만들거나, 사용자 피드백을 수집하거나, 실행하려 할 때 이 스킬을 사용하세요.
authoring-scouts
posthog
PostHog Signals 스카우트를 작성, 편집 및 조정하는 방법 — 프로젝트를 스캔하고 Signals 인박스에 보고서를 작성하는 예약된 에이전트입니다. 사용자가…