# PostHog Development Guide ## Codebase Structure - Key entry points: `posthog/api/__init__.py` (API URL routing skeleton; products register their own routes in `products//backend/routes.py` via `register_routes(routers)`), `posthog/settings/web.py` (Django settings, INSTALLED_APPS), `products/` (product apps) - [Monorepo layout](docs/internal/monorepo-layout.md) — read when you need to place a new directory, or to work out which tree owns something (products, services, common, tools) - [Products README](products/README.md) — read before creating a product or changing one's structure; covers the isolation rules `product:lint` enforces - [Products architecture](products/architecture.md) — read when designing a product's DTOs, facade, or contract with the rest of the repo - A directory may carry its own `AGENTS.md` that wins locally (`posthog/temporal/`, `rust/`, `frontend/src/`, most of `products/`). Check for one before working in an unfamiliar tree. ## Commands - Environment: - This is a full dev environment with `node`, `pnpm`, a package mirror and `apt`. A missing `node_modules`, browser binary or flox means setup has not run yet (`pnpm install`, `npx playwright install --with-deps chromium`), not that running things is impossible — install it and continue. Report "can't run" only for a specific, nameable failure (no network, no `apt`, out of memory), alongside whatever fallback you took. - Visual and UX work is not confirmed by reading the code. Render the affected surface (the running app, or Storybook via a headless browser) and compare before and after. Worth the setup cost. A story written only for this check is scratch: keep it out of the commit. - Use flox when available — prefer `flox activate -- bash -c ""` if commands fail - Never use `flox activate` in interactive sessions (it hangs if you try) - Tests: - Universal: `hogli test ` — auto-detects test type (Python, Jest, Playwright, Rust, Go) - Single test: `hogli test path/to/test.py::TestClass::test_method` - Watch mode: `hogli test path/to/test.py --watch` - Changed files only: `hogli test --changed` - Lint: - Python: - `ruff check . --fix` and `ruff format .` - Frontend: `pnpm --filter=@posthog/frontend fix` (safe Oxlint fixes + Oxfmt; suggestion fixes are not applied). `format` runs Oxfmt only; `lint` and `format:check` only verify. - TypeScript check: `pnpm --filter=@posthog/frontend typescript:check` - Build: - Frontend: `pnpm --filter=@posthog/frontend build` - Start dev: `./bin/start` or `hogli start` (interactive TUI). Detached mode: `hogli up -d` paired with `hogli wait` / `hogli down` - In a PostHog Tasks cloud run (`POSTHOG_TASK_RUN_ID` set), the boot sequence, prewarmed test database and scoped-test rules differ — read [Cloud task sandbox](docs/internal/cloud-task-sandbox.md) before starting the stack or running tests there - OpenAPI/types: `hogli build:openapi` (regenerate after changing serializers/viewsets) - Dev experience feedback: `hogli devex:feedback ""` sends feedback about repo tooling — hogli, the dev stack, tests, CI, migrations, this setup — straight to the devex team as a `hogli_feedback` event (add `-c bug|idea|praise|question`). **Local agents must use it too**: when a hogli command or local dev workflow is broken, slow, or confusing, run it — e.g. `hogli devex:feedback -c bug "migrations:run failed with "`. Do not run it from cloud tasks or agent-server sandboxes; the command is a no-op there. ## Commits and Pull Requests - Use [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) for all commit messages and PR titles. - When a change touches user-facing behavior, an API, a config/setting, or a documented workflow, update an existing doc under `docs/` **whose scope covers that behavior** in the same PR. If none exists, make no docs change and put PR-specific context in the PR description. `project-structure.md` is a high-level directory map; feature behavior, UI controls, command usage, and worktree notes do not belong there. - A new `docs/**` file requires a person to request that specific document in the current conversation. Existing related docs, PR checklists, and general docs requirements do not authorize one. Put PR-specific context in the PR description. - When triaging review bot findings, react to each PostHog Review finding comment (`posthog[bot]`) with 👍 if it is valid or 👎 if not, so review metrics can tell acted-on findings from noise. ### Commit types - `feat`: New feature or functionality (touches production code) - `fix`: Bug fix (touches production code) - `chore`: Non-production changes (docs, tests, config, CI, refactoring agents instructions, etc.) - Scope convention: use `aio` for AI observability changes (for example, `feat(aio): ...`) ### Format ```text (): ``` Examples: - `feat(insights): add retention graph export` - `fix(cohorts): handle empty cohort in query builder` - `chore(ci): update GitHub Actions workflow` - `chore: update AGENTS.md instructions` ### PR descriptions **Required:** Read `.github/pull_request_template.md` and use its exact section structure. Do not invent a different format. Invoke `/writing-pr-descriptions` before writing the body — it carries the shape rules. Always fill the `## 🤖 Agent context` section, including the exact model that wrote the code. NEVER put sensitive information in a PR description or comment. A user may share sensitive data in an agent session; none of it belongs on the PR. **Screenshots:** Show frontend/visual changes with a screenshot, or with a feature reel when `/writing-pr-descriptions` calls for one. Upload with `hogli pr:upload-image ` and embed the printed markdown. The first run only warns and uploads nothing; re-run with `--yes` to confirm. Only PostHog employees can upload, but the public can permanently view these assets, so only upload the image if you're certain it doesn't contain customer data (including customer names), secrets, or sensitive internal info. ### Rules - Scope is optional but encouraged when the change is specific to a feature area - Description should be lowercase and not end with a period - Keep the first line under 72 characters ### Pushing to remote Once a branch already has an open PR, push incremental changes and fixes to it without waiting for human guidance — keeping the PR current is part of the work. Pushes still trigger CI, which burns runner credits, so batch related commits and push once the increment is ready rather than after every change. A push to this repository cannot be taken back. Forks, clones, mirrors, and notification emails carry it within seconds, and a later fix commit does not retract what is already in the branch's history — recovering means abandoning the branch and respinning the PR. That makes the first push of a branch the decision point, not a step you correct afterwards. So if any part of the work drew on something from your session rather than from this repository — a customer conversation, a support ticket, a log, an internal thread — check what is actually in the diff before that first push. See [Public open source repo guidance](#public-open-source-repo-guidance) for what has to clear the bar. #### Before you push - **Never bypass the pre-push hooks** (`--no-verify`). They run `hogli ci:preflight --strict` and a merge-queue guard. When one blocks you, fix what it reports — `/running-ci-preflight` has the loop, `/merging-prs` explains the queue guard. - **Never force-push a branch that is in the merge queue** — it removes the PR from the queue. That includes restacking a stack whose base is queued. - Draft PRs run a narrowed CI matrix. To force the full one, see "Forcing the full matrix on a draft" in `/authoring-ci-workflows`. #### Stacked PRs GitHub's native stacked PRs are enabled here. Use the `gh stack` CLI and `/stacking-prs` rather than hand-managing branch chains. Keep stacks shallow and merge the base before extending: restacking force-pushes every branch, and a deep stack pushed at once can exceed GitHub's dispatch cap and fail unrelated runs with it. Never `gh stack merge` — it lands the chain through GitHub's API, outside the queue. ### Merging PRs All merges into `master` go through the Trunk merge queue. Never run `gh pr merge` or click the GitHub merge button — both are blocked by branch ruleset. **Agents must not enqueue, merge, re-enqueue, or otherwise cause a PR to land without explicit user approval in the current conversation for the identified PR or stack.** Do not infer that approval from requests to prepare a PR, move it toward merge, make it ready, monitor it, or resolve its blockers. Agents may inspect status, fix code and CI, request a stamphog review when a required approval is missing (MCP first, label fallback, see `/merging-prs`), and report that a PR is ready — then wait for a direct instruction. Once approved, follow `/merging-prs` for the enqueue, watch and failure loop. It also covers why the PR's own checks never show queue progress. ### Public open source repo guidance This repository is public, and everything you push is public with it: source, tests, fixtures and sample data, comments and docstrings, branch names, commit messages, PR titles, descriptions, and comments, and uploaded screenshots. Anything you were given as context that is not already in this repository — a customer conversation, a support ticket, a log, an internal thread — has to clear that bar before any of it reaches a file, a message, or a description. - Never mention internal-only systems, private incidents, customer data, Slack thread contents, unreleased roadmap details, or security-sensitive implementation details. Slack thread links and channel references are fine to include — they sit behind PostHog auth and are useful as origin context — but do not quote or paraphrase what was said in the thread. - **Derived is not synthetic.** Swapping out names, domains, and identifiers does not make real customer material publishable. The prose, the typos, the error IDs, and the order of events are still theirs, and still disclose what they told us. If you started from real material and edited it, it is derived, however much you changed. - **Sample data that has to read like the real thing gets invented, not transcribed.** List the properties a case must exercise, then write the case from that list with the real material closed. Use reserved domains (`example.com`), invented identifiers, and obviously fake tokens — customers paste credentials and cookies into support chats, and those must not survive the trip even in fragments. - **Do not claim a provenance you have not checked.** "Written fresh" in a commit message is a factual claim a reviewer will rely on. If you are unsure, compare your text against the source: any shared run of ~40 characters or more means derived, not fresh. - Use product-facing and code-facing context that a public OSS contributor could understand from this repository alone. - If context is sensitive, summarize it at a high level without naming internal tools, accounts, or people. - Avoid citing private operational scale or incident metrics (for example, exact affected team counts, internal row-volume anecdotes, or customer-specific performance numbers) unless that data is already public and linkable. Examples: - ✅ `fix(insights): handle missing series color in trend export` - ✅ A PR description that links to the originating Slack thread for context - ✅ A test fixture written from a list of the properties it has to exercise, with the real conversation closed - ❌ `fix: patch issue found in acme-co prod workspace after sales escalation` — references internal customer - ❌ `fix: will run fine on our 12 million rows there now` — leaks private operational scale - ❌ A PR description that quotes verbatim what a coworker said in a Slack thread - ❌ A test fixture adapted from a real support conversation with the names and domains replaced ## CI / GitHub Actions - `.nvmrc` controls the Node.js version for all CI workflows (via `actions/setup-node`) — changing it affects every CI job that runs Node - CI uploads test results to Trunk Flaky Tests; the `trunk` MCP server in `.mcp.json` queries per-test flakiness on a PR or `master` (authenticate via `/mcp`, or a `TRUNK_API_TOKEN` bearer header when headless) — see `/debugging-ci-failures` and `/fixing-flaky-tests` - **A workflow edit reaches every open PR before those branches rebase.** It runs against the PR merged with master, but a companion change — a new dependency, file, or config — only arrives when the branch rebases. A workflow that starts requiring something unrebased branches lack fails every in-flight PR before its tests run. Make the new behavior degrade gracefully, or gate it. This has broken CI repeatedly. - Mechanical workflow rules (`timeout-minutes`, concurrency, dispatch budget, path filters, gate hygiene) are enforced by `hogli lint:workflows` and actionlint, which are the source of truth. `/authoring-ci-workflows` explains the reasoning behind each. - **A directory of tests that read event properties belongs in `.github/new-events-schema-targets.txt`.** Backend CI reruns the listed paths against the native-JSON events table. A test outside the list runs on the legacy table only. Add the `test-new-events-schema` label when a diff needs the whole backend suite on both tables, such as an event ingestion change or event reads tested outside the list. Label while the PR is a draft, or push again after labeling, because label events do not start Backend CI. - **A pull request never publishes a package or release.** `[lint: github-actions-publish-on-pull-request]` A published version is public forever, and a pull request run executes code its author controls. Publish only from a `push` to `master`, a release tag, or a `workflow_dispatch` on `master`. A pull request run only builds and validates, for example with `--dry-run`. The semgrep rule cannot see a publisher inside a reusable workflow, so gate that job in the called workflow too. Never run a publish command by hand from a pull request branch. [`build-hogql-parser-npm.yml`](.github/workflows/build-hogql-parser-npm.yml) is the reference shape. ## Security **Every change lands in `PostHog/posthog`, security fixes included, also when the session starts in a `PostHog/posthog-private` checkout.** The only exception is a severe vulnerability that team-security agrees needs an embargo, because an embargo pauses the private sync and the merge queue. Never open anything in `posthog-private` without the user's confirmation in the current conversation. Hardening, validation, and low-impact access fixes go public with a neutral description. Read [Routing security fixes](.agents/security.md#routing-security-fixes) before you open a PR for any security fix: it defines "severe" and says what to leave out of a public PR. The private mirror carries this file, and its `origin` points at the private repo, so a default push or `gh pr create` opens the PR there. Before the first push of a branch, run `git remote get-url origin`. If it names `posthog-private` and the change is not an embargoed fix, open the PR from a `PostHog/posthog` checkout. Do not add new `INTERNAL_API_SECRET` callers. Read [.agents/security.md](.agents/security.md) before touching auth, secrets, service-to-service calls, raw SQL, or HogQL string building — it covers least privilege, the injection rules, and how to respond when semgrep flags your code. `.semgrep/rules/security/` is the enforced set; run `semgrep --config .semgrep/rules/security/ .` to check a change locally. **Treat the Content Security Policy in `CSPMiddleware` as enforced.** A resource from an origin the policy does not name is refused, and the page shows the user no error, so the feature fails silently. Loading a script, font, stylesheet, image, frame, or `fetch` target from a new external origin needs that directive widened in the same PR. `eval` and `new Function` never run, because the policy grants `wasm-unsafe-eval` and nothing more. In a Django template an inline `