Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
872d5179df | ||
|
|
77ad2ec7af | ||
|
|
2fdd72162c | ||
|
|
9daefa19bf | ||
|
|
acdecfbef2 | ||
|
|
0825f9b328 | ||
|
|
9e7600529e | ||
|
|
dbd42ef40b | ||
|
|
f5a82a152d | ||
|
|
d30108ec04 | ||
|
|
cb6d3418ae | ||
|
|
cb4be5be13 | ||
|
|
773d1a6522 | ||
|
|
6307b49d3d | ||
|
|
d47424a5a7 | ||
|
|
7821fda0f1 | ||
|
|
84bebb264e | ||
|
|
4ad6691a4b | ||
|
|
c5c72cd8e9 | ||
|
|
b647d9832d | ||
|
|
9cff39e59f | ||
|
|
0e20f0322e | ||
|
|
e19aea466d | ||
|
|
897a0986c7 | ||
|
|
095c2250a6 | ||
|
|
99e600d5fd | ||
|
|
592bf16a03 | ||
|
|
896d6584f5 | ||
|
|
0fbcdad02b | ||
|
|
d758305ee3 | ||
|
|
089a58cba2 | ||
|
|
62644a072a | ||
|
|
a409e8a71b | ||
|
|
7983dfb24c | ||
|
|
b1096575f5 | ||
|
|
3c6a3dddd4 | ||
|
|
a7a50312e9 | ||
|
|
618c7339ff | ||
|
|
abb385b461 | ||
|
|
11ae61d541 | ||
|
|
25550dd595 | ||
|
|
85ef4cdfe6 | ||
|
|
a7d1938fd6 | ||
|
|
7f3ddfd709 | ||
|
|
647eeb6eee | ||
|
|
9bb582d8cf | ||
|
|
69a9af6d51 | ||
|
|
638baf7fb5 | ||
|
|
69eef199e7 | ||
|
|
16246565b7 | ||
|
|
a24a14e1ae | ||
|
|
c61e422c86 | ||
|
|
5339d28657 | ||
|
|
d4a87ddfee | ||
|
|
fddf5f3aec | ||
|
|
509daa10e9 | ||
|
|
54f8182467 | ||
|
|
23f12107e0 | ||
|
|
e23b9722a4 | ||
|
|
aa507916ad | ||
|
|
738ebee6e6 | ||
|
|
29b9587914 | ||
|
|
7d6b2c8e25 | ||
|
|
c72ee8d5f0 | ||
|
|
9ce9a78f14 | ||
|
|
f9e6c28e91 | ||
|
|
f3e32fa480 | ||
|
|
10c9dad2cd | ||
|
|
7e0683b4c1 | ||
|
|
6143c61c2f | ||
|
|
a2ac5244d5 | ||
|
|
4af523b87b | ||
|
|
6dabff3e77 | ||
|
|
493dba9d0f | ||
|
|
2da0f024f4 | ||
|
|
e135c81422 | ||
|
|
f9ced604dd | ||
|
|
373385b0b0 | ||
|
|
3cff611498 | ||
|
|
40001fb502 | ||
|
|
dbae61c06e | ||
|
|
0cb0763e9b | ||
|
|
4e6ba0fbce | ||
|
|
5817ff5c92 | ||
|
|
6adacc7e4e | ||
|
|
d13dc49e69 | ||
|
|
ec75af5859 | ||
|
|
8cc9baa514 | ||
|
|
10fef57400 | ||
|
|
bfad0b8bf6 | ||
|
|
b27f7165a0 | ||
|
|
4038efd556 | ||
|
|
1ca63e1836 | ||
|
|
865833e049 | ||
|
|
cbfe1c090b | ||
|
|
3e7db2c7e1 | ||
|
|
23e9df90aa | ||
|
|
c18cada283 | ||
|
|
087f35d0d0 | ||
|
|
c654b714b2 | ||
|
|
4a9ba2af8f | ||
|
|
098b04b75d | ||
|
|
9ea65311a5 | ||
|
|
987dee1e08 | ||
|
|
bcc6d41bba | ||
|
|
19536e947a | ||
|
|
078cebdcf1 | ||
|
|
755389b935 | ||
|
|
f44b575c45 | ||
|
|
bc5b35c0b8 | ||
|
|
a1e0f8e792 | ||
|
|
1608de5fd9 | ||
|
|
8f60abb1a6 | ||
|
|
3318ca32a9 | ||
|
|
03523a317c | ||
|
|
289d51f37f | ||
|
|
b736e97595 | ||
|
|
5e1ea787fe | ||
|
|
63acec3982 | ||
|
|
bd2641feb4 | ||
|
|
cc19ebb8db | ||
|
|
5eed0f5a2f | ||
|
|
e50efd1f43 | ||
|
|
d5e94b9a7a | ||
|
|
2c352a373b | ||
|
|
03a6167613 | ||
|
|
8ceffc76e8 | ||
|
|
4863c4f1e7 | ||
|
|
f472e6385c | ||
|
|
355112b403 | ||
|
|
864788b42f | ||
|
|
f6b8b51daa | ||
|
|
35627404fc | ||
|
|
5a0db2c6dd | ||
|
|
215354b47a | ||
|
|
f0ad2e0a91 | ||
|
|
81af20b738 | ||
|
|
48efb20cfa | ||
|
|
cfef594484 | ||
|
|
3b4a66999e | ||
|
|
41b5385f73 | ||
|
|
bdd27ec829 | ||
|
|
bd68c0ee23 | ||
|
|
20f1097810 | ||
|
|
f3cb76e35b | ||
|
|
1a8061bc34 | ||
|
|
c91a641fa9 | ||
|
|
0ca75cae70 | ||
|
|
cbffe1dde4 | ||
|
|
d1a537c32c | ||
|
|
2f1ae6b7c2 | ||
|
|
48f3c170a0 | ||
|
|
9144dc6aba | ||
|
|
277abb5e15 | ||
|
|
b7595715ff | ||
|
|
592e575ae3 | ||
|
|
68cfbc1cd5 | ||
|
|
fa52cc74e1 | ||
|
|
48e84ee58d | ||
|
|
11763882f8 | ||
|
|
6a74c141d5 | ||
|
|
df5bf34218 | ||
|
|
3c54be90f7 | ||
|
|
38c035022f | ||
|
|
b9e200f58a | ||
|
|
1552bb1874 | ||
|
|
ca8017f958 | ||
|
|
1d2b2b040d | ||
|
|
e3a400b1ac | ||
|
|
62fad7cb48 | ||
|
|
6470f8e41b | ||
|
|
7a6e3f3f62 | ||
|
|
890d98d32d | ||
|
|
ef8e8fbdf6 | ||
|
|
d78dd1ff52 | ||
|
|
cf4c66e977 | ||
|
|
15b2dedd42 | ||
|
|
117f19e6cf | ||
|
|
69c2b3535d | ||
|
|
e703c25bc6 | ||
|
|
8435eb6fa1 | ||
|
|
a5da0bd863 | ||
|
|
47f296b2fb | ||
|
|
584a73ff53 | ||
|
|
c4ba6846b8 | ||
|
|
286b1af431 | ||
|
|
884772402c | ||
|
|
53f188d131 | ||
|
|
7f0f7a683f | ||
|
|
ddd5aeda18 | ||
|
|
b78f48e0b3 | ||
|
|
c75da83217 | ||
|
|
4679d1af14 | ||
|
|
f084d89918 | ||
|
|
d284a8e076 | ||
|
|
55e14e85f3 | ||
|
|
44435ca291 | ||
|
|
39d88d0cfd | ||
|
|
1fc3c6eca6 | ||
|
|
86e26e5e54 | ||
|
|
a2c925ab7b | ||
|
|
a3f7412653 | ||
|
|
d3a47c44d6 | ||
|
|
937ef0f0d8 | ||
|
|
75064e8d42 | ||
|
|
a464a12efb | ||
|
|
1823b4e94f | ||
|
|
f5ff37fc34 | ||
|
|
39d690072f | ||
|
|
d4c361f3b5 | ||
|
|
330c247bfb | ||
|
|
43062cfa5e | ||
|
|
8c5d4e5902 | ||
|
|
a382542fd7 | ||
|
|
92c8155ee9 | ||
|
|
fb2a276496 | ||
|
|
9b59decccf | ||
|
|
9289698825 | ||
|
|
ad4d6ca8aa | ||
|
|
790c65b951 | ||
|
|
55819f81d4 | ||
|
|
76a4380d9e | ||
|
|
6c745bc4dc | ||
|
|
8da1f80314 | ||
|
|
55409ef473 | ||
|
|
ad5f082926 | ||
|
|
88d4c68c2a | ||
|
|
3e0325bb14 | ||
|
|
ce67bb16e8 | ||
|
|
75f78e1a17 | ||
|
|
98a7088be7 | ||
|
|
28c186b508 | ||
|
|
bba56046e4 | ||
|
|
948a627c72 | ||
|
|
bdda312f5e | ||
|
|
4cfcd1f779 | ||
|
|
b3fc76186f | ||
|
|
fecaf7d49d | ||
|
|
1d9c2a9225 | ||
|
|
7a3b13d55e | ||
|
|
bc7f92665e | ||
|
|
509e511b6e | ||
|
|
ab9f8751b7 | ||
|
|
5ea5cde253 | ||
|
|
f28d79673a | ||
|
|
40a6236aa3 | ||
|
|
97c385f5ca | ||
|
|
7cf8015c07 | ||
|
|
5874ec5acc | ||
|
|
01844deb23 | ||
|
|
f7a90ffe65 | ||
|
|
7f7a9341da | ||
|
|
13978c0ca9 | ||
|
|
d564d3c488 | ||
|
|
bd933e5984 | ||
|
|
c0da836191 | ||
|
|
abb45c9481 | ||
|
|
a09e9483c8 | ||
|
|
5a61a9571c | ||
|
|
7b9a02ab9a | ||
|
|
03d80356bf | ||
|
|
35e0ea4c30 | ||
|
|
cf44eff091 | ||
|
|
e3c4d52bb4 | ||
|
|
da83b67e89 | ||
|
|
a13de116e8 | ||
|
|
725beb1d98 | ||
|
|
21c1016292 | ||
|
|
bc76e6fc76 | ||
|
|
24f9b14e69 | ||
|
|
ac0ecfcb84 | ||
|
|
e547011c96 | ||
|
|
164f073b50 | ||
|
|
6df405adcd | ||
|
|
f50dbdd9a6 | ||
|
|
b4274bda4a | ||
|
|
c9adc16cad | ||
|
|
f30cdbbee5 | ||
|
|
a307d64b07 | ||
|
|
c32bb30154 | ||
|
|
c27a62d271 | ||
|
|
102a37a48c | ||
|
|
6beb670744 | ||
|
|
82e81ddf75 | ||
|
|
1ad8ff3fa0 | ||
|
|
631dc417b2 | ||
|
|
57371b1c9d | ||
|
|
0076f411b0 | ||
|
|
9b04ea848c | ||
|
|
e30e9c7b3e | ||
|
|
f057352fc3 | ||
|
|
a0db49f282 | ||
|
|
c09943eb08 | ||
|
|
0e2ee1f394 | ||
|
|
c6f55d8646 | ||
|
|
14519c41c5 | ||
|
|
1f80429f97 | ||
|
|
800b3481d7 | ||
|
|
7e3fa15425 | ||
|
|
07db3af7fe | ||
|
|
85e68e433e | ||
|
|
5527bb30dd | ||
|
|
1b03d838db | ||
|
|
42f591214c | ||
|
|
668f6b8951 | ||
|
|
7895e104b0 | ||
|
|
bfd2a2dd37 | ||
|
|
cf5d3094ae | ||
|
|
7a3dd59128 | ||
|
|
f117a01fac | ||
|
|
2b4d64c9cf |
@@ -1,178 +0,0 @@
|
||||
# Agent Guidelines for Langfuse
|
||||
|
||||
This is the canonical root agent guide for the repo. The root `AGENTS.md`
|
||||
should remain only as a discovery symlink so tools that require that filename
|
||||
continue to work while `.agents/` stays the source of truth.
|
||||
|
||||
Langfuse is an open source LLM engineering platform for developing, monitoring,
|
||||
evaluating, and debugging AI applications.
|
||||
|
||||
## Maintenance Contract
|
||||
|
||||
- `AGENTS.md` is a living document.
|
||||
- Keep this file concise and router-like. Push narrow or conditional workflows
|
||||
into package-local `AGENTS.md` files or shared skills under `skills/`.
|
||||
- Update this file in the same PR when monorepo-level architecture, workflows,
|
||||
dependency boundaries, mandatory verification commands, or release/security
|
||||
processes materially change.
|
||||
- Update this file and the relevant shared skills when user feedback introduces
|
||||
a durable repo-level default for future agents. Do not edit this file for
|
||||
one-off task preferences.
|
||||
- For package-local material changes, update the nearest package `AGENTS.md` in
|
||||
the same PR.
|
||||
|
||||
## Start Here By Task
|
||||
|
||||
- Repo-wide agent setup, `.agents/**`, provider shims, or MCP/bootstrap config:
|
||||
[`README.md`](README.md),
|
||||
[`skills/agent-setup-maintenance/SKILL.md`](skills/agent-setup-maintenance/SKILL.md)
|
||||
- Backend/API work in `web/src/server/**`, `web/src/pages/api/public/**`,
|
||||
`worker/src/**`, or `packages/shared/src/**`:
|
||||
[`skills/backend-dev-guidelines/SKILL.md`](skills/backend-dev-guidelines/SKILL.md)
|
||||
- Model pricing work in `worker/src/constants/default-model-prices.json`,
|
||||
`packages/shared/src/server/llm/types.ts`, or related pricing files:
|
||||
[`skills/add-model-price/SKILL.md`](skills/add-model-price/SKILL.md)
|
||||
- Code review tasks:
|
||||
[`skills/code-review/SKILL.md`](skills/code-review/SKILL.md)
|
||||
- Changelog drafting for completed feature branches:
|
||||
[`skills/changelog-writing/SKILL.md`](skills/changelog-writing/SKILL.md)
|
||||
- ClickHouse schema/query review:
|
||||
[`skills/clickhouse-best-practices/SKILL.md`](skills/clickhouse-best-practices/SKILL.md)
|
||||
- Monorepo/Turbo task graph changes:
|
||||
[`skills/turborepo/SKILL.md`](skills/turborepo/SKILL.md)
|
||||
- pnpm dependency upgrades, package-version bumps, or `minimumReleaseAgeExclude`
|
||||
decisions in `pnpm-workspace.yaml`:
|
||||
[`skills/pnpm-upgrade-package/SKILL.md`](skills/pnpm-upgrade-package/SKILL.md)
|
||||
- User-visible frontend changes, Playwright review, or browser signoff:
|
||||
[`skills/frontend-browser-review/SKILL.md`](skills/frontend-browser-review/SKILL.md)
|
||||
- Web UI and frontend entry points:
|
||||
`../web/AGENTS.md`
|
||||
- Worker queues and processors:
|
||||
`../worker/AGENTS.md`
|
||||
- Shared contracts, exports, schema, and migrations:
|
||||
`../packages/shared/AGENTS.md`
|
||||
- EE-only work:
|
||||
`../ee/AGENTS.md`
|
||||
|
||||
Read the minimal set required for the task. More-specific package guides and
|
||||
shared skills take precedence over this root file for their scoped areas.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
langfuse/
|
||||
├─ web/ # Next.js app (UI + tRPC + public REST)
|
||||
├─ worker/ # Queue consumers and background processing
|
||||
├─ packages/shared/ # Shared domain, DB, queue contracts, repositories
|
||||
├─ ee/ # Enterprise package consumed by web
|
||||
├─ generated/ # Generated API clients (do not hand-edit)
|
||||
├─ fern/ # API definition sources
|
||||
└─ scripts/ # Repo scripts
|
||||
```
|
||||
|
||||
- Dependency direction:
|
||||
- `web` -> `@langfuse/shared`, `@langfuse/ee`
|
||||
- `worker` -> `@langfuse/shared`
|
||||
- `@langfuse/ee` -> `@langfuse/shared`
|
||||
- `@langfuse/shared` -> no imports from `web`, `worker`, or `ee`
|
||||
- Queue payload schemas and queue-name contracts are owned by
|
||||
`packages/shared/src/server/queues.ts`.
|
||||
- High-signal shared entry points:
|
||||
- Domain models: `packages/shared/src/domain/{observations,traces,scores}.ts`
|
||||
- Postgres schema: `packages/shared/prisma/schema.prisma`
|
||||
- ClickHouse migrations:
|
||||
`packages/shared/clickhouse/migrations/{clustered,unclustered}/*.sql`
|
||||
- Architecture handbook:
|
||||
[langfuse.com/handbook/product-engineering/architecture](https://langfuse.com/handbook/product-engineering/architecture)
|
||||
with source markdown in
|
||||
`../langfuse-docs/content/handbook/product-engineering/architecture.mdx`
|
||||
|
||||
## Core Commands
|
||||
|
||||
- Install deps: `pnpm install`
|
||||
- Dev all packages: `pnpm run dev`
|
||||
- Dev web only: `pnpm run dev:web`
|
||||
- Dev worker only: `pnpm run dev:worker`
|
||||
- Lint all: `pnpm run lint`
|
||||
- Typecheck all: `pnpm run typecheck` / `pnpm tc`
|
||||
- Build check: `pnpm run build:check`
|
||||
- Full build: `pnpm run build`
|
||||
- Full reset/bootstrap (destructive): `pnpm run dx`
|
||||
- Codex environment bootstrap: `bash scripts/codex/setup.sh`
|
||||
- Codex environment maintenance: `bash scripts/codex/maintenance.sh`
|
||||
- Install Playwright Chromium for agent browser review: `pnpm run playwright:install`
|
||||
|
||||
Minimum verification matrix:
|
||||
|
||||
| Change scope | Minimum verification |
|
||||
| --- | --- |
|
||||
| `web/**` only | `pnpm --filter web run lint` + targeted web tests |
|
||||
| `worker/**` only | `pnpm --filter worker run lint` + targeted worker tests |
|
||||
| `packages/shared/**` (non-schema) | `pnpm --filter @langfuse/shared run lint` + one targeted web check + one targeted worker check |
|
||||
| `packages/shared/prisma/**` or `packages/shared/clickhouse/**` | `pnpm --filter @langfuse/shared run lint` + `pnpm run db:generate` + targeted web/worker regressions |
|
||||
| Public API contract (`web/src/pages/api/public/**`, `web/src/features/public-api/types/**`, `fern/apis/**`) | web lint + targeted server API tests + Fern update/regeneration; never hand-edit `generated/**` |
|
||||
| Cross-package refactor (`web` + `worker` + `shared`) | `pnpm run lint` + `pnpm run typecheck` + targeted tests per impacted package |
|
||||
|
||||
## Repo Rules
|
||||
|
||||
- Keep changes scoped; avoid unrelated refactors.
|
||||
- Prefer package-local implementation details in package `AGENTS.md` files.
|
||||
- Do not hand-edit generated/build artifacts:
|
||||
- `generated/*`
|
||||
- `web/.next/*`
|
||||
- `web/.next-check/*`
|
||||
- `*/dist/*`
|
||||
- `packages/shared/prisma/generated/*`
|
||||
- Public API contract changes must update Fern sources in `fern/apis/**` and
|
||||
regenerated outputs; never hand-edit `generated/**`.
|
||||
- Keep tests independent and parallel-safe.
|
||||
- For bug fixes, write the failing test first, confirm it fails, then fix the
|
||||
bug.
|
||||
- For user-visible frontend changes in `web/**`, review the affected flow in a
|
||||
real browser with the Playwright MCP server before signoff. Use
|
||||
`skills/frontend-browser-review/SKILL.md` and `../web/AGENTS.md` for the
|
||||
browser-review loop.
|
||||
- Never commit secrets or credentials. Keep `.env*.example` files in sync with
|
||||
required env vars.
|
||||
|
||||
## Shared Agent Setup
|
||||
|
||||
- `.agents/AGENTS.md` is the canonical root guide.
|
||||
- Root `AGENTS.md` is a symlink to `.agents/AGENTS.md`.
|
||||
- Root `CLAUDE.md` is a compatibility symlink to `AGENTS.md`.
|
||||
- Shared agent/tool config lives in `config.json` and shared skills live in
|
||||
`skills/`.
|
||||
- Project-scoped provider discovery files are generated local artifacts. Edit
|
||||
the canonical files under `.agents/` instead of editing generated tool
|
||||
directories by hand.
|
||||
- If you change `.agents/config.json`, `skills/**`, or the shim-generation
|
||||
workflow, run:
|
||||
- `pnpm run agents:sync`
|
||||
- `pnpm run agents:check`
|
||||
- Do not commit generated provider config or shim outputs under `.claude/`,
|
||||
`.cursor/`, `.codex/`, `.vscode/`, or `.mcp.json`.
|
||||
- Durable cross-tool guidance belongs in root/package `AGENTS.md` files or
|
||||
`skills/**`, not only in tool-specific config directories.
|
||||
|
||||
## Commit, PR, and Release Rules
|
||||
|
||||
- Commit messages and PR titles must follow Conventional Commits:
|
||||
`type(scope): description` or `type: description`.
|
||||
- PR titles are validated by `.github/workflows/validate-pr-title.yml`.
|
||||
- In PR descriptions, list impacted packages and executed verification commands.
|
||||
- Release workflow is managed at root with `pnpm run release`.
|
||||
- Promote `main` to `production` via
|
||||
`.github/workflows/promote-main-to-production.yml` or
|
||||
`pnpm run release:cloud`.
|
||||
- Do not change release/versioning flow without updating this file and impacted
|
||||
package guides.
|
||||
|
||||
## Git and Tooling Notes
|
||||
|
||||
- Use `gh search issues` for GitHub issue search.
|
||||
- Do not use destructive git commands such as `reset --hard` unless explicitly
|
||||
requested.
|
||||
- Do not revert unrelated working-tree changes.
|
||||
- Keep commits focused and atomic.
|
||||
- Remaining `.cursor/rules/*.mdc` files should stay thin wrappers around shared
|
||||
docs or skills rather than owning durable repo guidance directly.
|
||||
@@ -1,181 +0,0 @@
|
||||
# Shared Agent Setup
|
||||
|
||||
This directory is the neutral, repo-owned source of truth for agent behavior in
|
||||
Langfuse.
|
||||
|
||||
Use `.agents/` for configuration and guidance that should apply across tools.
|
||||
Do not put durable shared guidance only in `.claude/`, `.codex/`, `.cursor/`,
|
||||
or `.vscode/`.
|
||||
|
||||
## Layout
|
||||
|
||||
- `AGENTS.md`: canonical shared root instructions
|
||||
- `config.json`: shared bootstrap and MCP configuration used to generate
|
||||
tool-specific shims
|
||||
- `skills/`: shared, tool-neutral implementation guidance for recurring
|
||||
workflows
|
||||
|
||||
## `config.json`
|
||||
|
||||
`.agents/config.json` contains four kinds of data:
|
||||
|
||||
- `shared`: defaults used across tools
|
||||
- `mcpServers`: project MCP servers and how to connect to them
|
||||
- `claude`: Claude-specific generated settings inputs
|
||||
- `codex`: Codex-specific generated settings inputs
|
||||
- `cursor`: Cursor-specific generated settings inputs
|
||||
|
||||
Current shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"shared": {
|
||||
"setupScript": "bash scripts/codex/setup.sh",
|
||||
"devCommand": "pnpm run dev",
|
||||
"devTerminalDescription": "Main development terminal running the development server"
|
||||
},
|
||||
"mcpServers": {
|
||||
"playwright": {
|
||||
"transport": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@playwright/mcp@latest"]
|
||||
},
|
||||
"datadog": {
|
||||
"transport": "http",
|
||||
"url": "https://mcp.datadoghq.com/api/unstable/mcp-server/mcp"
|
||||
}
|
||||
},
|
||||
"claude": {
|
||||
"settings": {}
|
||||
},
|
||||
"codex": {
|
||||
"environment": {
|
||||
"version": 1,
|
||||
"name": "langfuse"
|
||||
}
|
||||
},
|
||||
"cursor": {
|
||||
"environment": {
|
||||
"agentCanUpdateSnapshot": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## How Shims Are Generated
|
||||
|
||||
`scripts/agents/sync-agent-shims.mjs` reads `.agents/config.json` and writes the
|
||||
tool discovery files that those products require.
|
||||
|
||||
Generated local artifacts:
|
||||
|
||||
- `.claude/settings.json`
|
||||
- `.claude/skills/*`
|
||||
- `.cursor/environment.json`
|
||||
- `.cursor/mcp.json`
|
||||
- `.vscode/mcp.json`
|
||||
- `.mcp.json`
|
||||
- `.codex/config.toml`
|
||||
- `.codex/environments/environment.toml`
|
||||
|
||||
The repo root discovery files remain committed as symlinks:
|
||||
|
||||
- `AGENTS.md` -> `.agents/AGENTS.md`
|
||||
- `CLAUDE.md` -> `AGENTS.md`
|
||||
|
||||
This keeps provider discovery stable while `.agents/` remains the source of
|
||||
truth.
|
||||
|
||||
## When To Edit `config.json`
|
||||
|
||||
Edit `.agents/config.json` when you need to:
|
||||
|
||||
- add, remove, or update a shared MCP server
|
||||
- change the shared setup/bootstrap command
|
||||
- change the default dev command or terminal label used by generated shims
|
||||
- adjust generated Claude, Cursor, or Codex settings that are intentionally
|
||||
modeled in the shared config
|
||||
|
||||
Do not edit generated shim files by hand. Edit the canonical files in
|
||||
`.agents/` instead.
|
||||
|
||||
## How To Extend `config.json`
|
||||
|
||||
### Add an MCP server
|
||||
|
||||
Add a new entry under `mcpServers`.
|
||||
|
||||
For `stdio` servers:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"example": {
|
||||
"transport": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "some-package"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For HTTP servers:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"example": {
|
||||
"transport": "http",
|
||||
"url": "https://example.com/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Optional fields:
|
||||
|
||||
- `env` for `stdio` servers
|
||||
- `headers` for HTTP servers
|
||||
|
||||
### Change bootstrap or default dev command
|
||||
|
||||
Update values in `shared`:
|
||||
|
||||
- `setupScript`
|
||||
- `devCommand`
|
||||
- `devTerminalDescription`
|
||||
|
||||
### Add tool-specific generated inputs
|
||||
|
||||
Only add tool-specific fields when they are required to generate a discovery
|
||||
file for a supported tool. Keep the shared config minimal and neutral.
|
||||
|
||||
## Workflow
|
||||
|
||||
After editing `.agents/config.json`:
|
||||
|
||||
1. Run `pnpm run agents:sync`
|
||||
2. Run `pnpm run agents:check`
|
||||
3. Verify you did not stage any generated files under `.claude/skills/` or the
|
||||
generated MCP/runtime config paths
|
||||
4. Update `AGENTS.md` or `CONTRIBUTING.md` if the shared workflow materially
|
||||
changed
|
||||
|
||||
`pnpm install` also runs the sync/check flow via `postinstall`.
|
||||
|
||||
## Adding Shared Skills
|
||||
|
||||
Shared skills live under `.agents/skills/`.
|
||||
|
||||
Use them for durable, reusable guidance such as:
|
||||
|
||||
- backend implementation patterns
|
||||
- provider-specific maintenance workflows
|
||||
- repeated repo-specific review checklists
|
||||
|
||||
Do not use skills for one-off task notes or tool runtime configuration.
|
||||
|
||||
`pnpm run agents:sync` projects the shared skills into `.claude/skills/` so
|
||||
Claude can discover the same repo-owned skills.
|
||||
|
||||
For the skill authoring workflow, see [skills/README.md](skills/README.md).
|
||||
@@ -1,59 +0,0 @@
|
||||
{
|
||||
"shared": {
|
||||
"setupScript": "bash scripts/codex/setup.sh",
|
||||
"devCommand": "pnpm run dev",
|
||||
"devTerminalDescription": "Main development terminal running the development server"
|
||||
},
|
||||
"mcpServers": {
|
||||
"playwright": {
|
||||
"transport": "stdio",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@playwright/mcp@latest",
|
||||
"--isolated",
|
||||
"--save-trace",
|
||||
"--output-dir",
|
||||
".playwright-mcp",
|
||||
"--test-id-attribute",
|
||||
"data-testid"
|
||||
]
|
||||
},
|
||||
"langfuse-docs": {
|
||||
"transport": "http",
|
||||
"url": "https://langfuse.com/api/mcp"
|
||||
},
|
||||
"linear": {
|
||||
"transport": "http",
|
||||
"url": "https://mcp.linear.app/mcp"
|
||||
}
|
||||
},
|
||||
"claude": {
|
||||
"settings": {
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(find:*)",
|
||||
"Bash(rg:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(head:*)",
|
||||
"Bash(tail:*)"
|
||||
],
|
||||
"deny": []
|
||||
},
|
||||
"enableAllProjectMcpServers": true
|
||||
}
|
||||
},
|
||||
"codex": {
|
||||
"environment": {
|
||||
"version": 1,
|
||||
"name": "langfuse"
|
||||
}
|
||||
},
|
||||
"cursor": {
|
||||
"environment": {
|
||||
"agentCanUpdateSnapshot": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
# Shared Skills
|
||||
|
||||
Shared repo skills for any coding agent working in Langfuse.
|
||||
|
||||
Use these from `AGENTS.md`. Claude Code reaches the same shared instructions via
|
||||
the root `CLAUDE.md` compatibility symlink. Shared skills should stay focused on
|
||||
reusable implementation guidance rather than runtime automation.
|
||||
|
||||
For the shared agent config and generated shim model, start with
|
||||
[`../README.md`](../README.md).
|
||||
|
||||
Claude discovers these shared skills through symlinks under `.claude/skills/`.
|
||||
Those discovery links are created and verified by `pnpm run agents:sync` and
|
||||
`pnpm run agents:check`.
|
||||
|
||||
Shared skills should use progressive disclosure:
|
||||
|
||||
- `SKILL.md` is the short entrypoint with trigger guidance and navigation.
|
||||
- `AGENTS.md` is optional and should stay concise when it exists.
|
||||
- `references/` holds focused prose references that agents should open only
|
||||
when the task needs them.
|
||||
- `scripts/` holds deterministic helpers for repetitive or fragile steps.
|
||||
|
||||
## Available Skills
|
||||
|
||||
### agent-setup-maintenance
|
||||
|
||||
Use for:
|
||||
- `.agents/config.json`, `.agents/AGENTS.md`, or `.agents/README.md`
|
||||
- shared skill additions or shared skill routing changes
|
||||
- generated shim behavior in `scripts/agents/sync-agent-shims.mjs`
|
||||
- install-time agent sync behavior and provider discovery paths
|
||||
|
||||
Open: [agent-setup-maintenance/SKILL.md](agent-setup-maintenance/SKILL.md)
|
||||
|
||||
### frontend-browser-review
|
||||
|
||||
Use for:
|
||||
- user-visible changes in `web/**`
|
||||
- Playwright MCP browser review before signoff
|
||||
- checking visible regressions in layout, styling, navigation, or responsive behavior
|
||||
|
||||
Open: [frontend-browser-review/SKILL.md](frontend-browser-review/SKILL.md)
|
||||
|
||||
### backend-dev-guidelines
|
||||
|
||||
Use for:
|
||||
- tRPC routers and procedures
|
||||
- public API endpoints
|
||||
- worker queue processors
|
||||
- Prisma and ClickHouse backed services
|
||||
- backend auth, validation, observability, and tests
|
||||
|
||||
Open: [backend-dev-guidelines/SKILL.md](backend-dev-guidelines/SKILL.md)
|
||||
|
||||
### add-model-price
|
||||
|
||||
Use for:
|
||||
- `worker/src/constants/default-model-prices.json`
|
||||
- `packages/shared/src/server/llm/types.ts`
|
||||
- pricing tiers, tokenizer IDs, and model `matchPattern` changes
|
||||
|
||||
Open: [add-model-price/SKILL.md](add-model-price/SKILL.md)
|
||||
|
||||
### code-review
|
||||
|
||||
Use for:
|
||||
- PR or branch review
|
||||
- correctness, regression, and risk-focused review tasks
|
||||
- applying the repo-specific review policy in
|
||||
`code-review/references/review-checklist.md`
|
||||
|
||||
Open: [code-review/SKILL.md](code-review/SKILL.md)
|
||||
|
||||
### changelog-writing
|
||||
|
||||
Use for:
|
||||
- changelog entries for completed features
|
||||
- drafting user-facing release notes
|
||||
- checking related docs links for changelog posts
|
||||
|
||||
Open: [changelog-writing/SKILL.md](changelog-writing/SKILL.md)
|
||||
|
||||
### pnpm-upgrade-package
|
||||
|
||||
Use for:
|
||||
- pnpm dependency bumps that need a specific target version
|
||||
- interactive upgrades where the package name or version may be missing
|
||||
- checking whether `pnpm-workspace.yaml` `minimumReleaseAgeExclude` must change
|
||||
- comparing registry latest with the latest version installable under the
|
||||
current release-age gate
|
||||
|
||||
Open: [pnpm-upgrade-package/SKILL.md](pnpm-upgrade-package/SKILL.md)
|
||||
|
||||
## Adding a New Shared Skill
|
||||
|
||||
1. Codex may create or refine shared skills under `.agents/skills/` when a
|
||||
repo-specific workflow becomes repeated enough to justify durable guidance.
|
||||
2. Create a concise `.agents/skills/<skill-name>/SKILL.md`.
|
||||
3. Add `.agents/skills/<skill-name>/AGENTS.md` only when the skill benefits
|
||||
from a short router or checklist on top of `SKILL.md`.
|
||||
4. Prefer `references/` for detailed prose and `scripts/` for deterministic
|
||||
execution helpers.
|
||||
5. Keep the skill tightly scoped to one domain or workflow.
|
||||
6. Link the skill from `AGENTS.md` if it is relevant across the repo.
|
||||
7. Run `pnpm run agents:sync` and `pnpm run agents:check` so Claude's projected
|
||||
`.claude/skills/` view stays in sync.
|
||||
8. Update `AGENTS.md` or package-local `AGENTS.md` if the new skill changes the
|
||||
default reusable workflow for future agents.
|
||||
9. Run the relevant verification for the package or workflow the skill affects.
|
||||
|
||||
## Skill Design Rules
|
||||
|
||||
- Keep the skill tool-neutral.
|
||||
- Use `SKILL.md` as the short entrypoint, not the full knowledge dump.
|
||||
- Prefer `references/` for deeper docs and `scripts/` for deterministic helpers.
|
||||
- Avoid copying large sections of repo docs into the skill when a stable link is
|
||||
enough.
|
||||
- If the skill is web- or package-specific, link the nearest package
|
||||
`AGENTS.md` or package docs instead of restating them.
|
||||
@@ -1,544 +0,0 @@
|
||||
# Add Model Price
|
||||
|
||||
Guide for adding or updating model pricing entries in Langfuse. Use this when
|
||||
editing `worker/src/constants/default-model-prices.json`,
|
||||
`packages/shared/src/server/llm/types.ts`, model `matchPattern` values,
|
||||
tokenizer IDs, or pricing tiers.
|
||||
|
||||
## Purpose
|
||||
|
||||
This guide keeps model pricing changes consistent across providers and runtime
|
||||
surfaces so Langfuse can calculate token costs accurately.
|
||||
|
||||
## How to Use This Skill
|
||||
|
||||
1. Read [references/schema-and-tiers.md](references/schema-and-tiers.md) for
|
||||
the JSON shape and pricing-tier rules.
|
||||
2. Read
|
||||
[references/provider-sources-and-price-keys.md](references/provider-sources-and-price-keys.md)
|
||||
for official pricing URLs, per-token conversion, and provider-specific usage
|
||||
keys.
|
||||
3. Read [references/match-patterns.md](references/match-patterns.md) when you
|
||||
need to add or expand regex coverage.
|
||||
4. Read
|
||||
[references/workflow-and-validation.md](references/workflow-and-validation.md)
|
||||
for the end-to-end edit workflow, validation rules, and common mistakes.
|
||||
|
||||
## Deterministic Helpers
|
||||
|
||||
- Validate the pricing file:
|
||||
`node .agents/skills/add-model-price/scripts/validate-pricing-file.mjs`
|
||||
- Test a regex directly:
|
||||
`node .agents/skills/add-model-price/scripts/test-match-pattern.mjs --pattern '(?i)^(openai/)?(gpt-4o)$' --accept gpt-4o openai/gpt-4o --reject gpt-4o-mini`
|
||||
- Test the regex for an existing model entry:
|
||||
`node .agents/skills/add-model-price/scripts/test-match-pattern.mjs --model gpt-4o --accept gpt-4o openai/gpt-4o --reject gpt-4o-mini`
|
||||
|
||||
## Quick Start Checklist
|
||||
|
||||
### Adding a New Model
|
||||
|
||||
- [ ] Gather official pricing from the provider documentation
|
||||
- [ ] Generate a lowercase UUID for the model entry
|
||||
- [ ] Create a `matchPattern` that covers supported provider formats
|
||||
- [ ] Add at least one default pricing tier
|
||||
- [ ] Insert the pricing entry into
|
||||
`worker/src/constants/default-model-prices.json`
|
||||
- [ ] Update `packages/shared/src/server/llm/types.ts` if the model should be
|
||||
selectable in playground or evaluation flows
|
||||
- [ ] Validate the JSON after editing
|
||||
|
||||
### Updating an Existing Model
|
||||
|
||||
- [ ] Update the relevant prices, keys, tiers, or regexes
|
||||
- [ ] Refresh `updatedAt` to today's ISO-8601 timestamp
|
||||
- [ ] Validate the JSON after editing
|
||||
|
||||
## Target Files
|
||||
|
||||
- Pricing data:
|
||||
`worker/src/constants/default-model-prices.json`
|
||||
- Shared model types:
|
||||
`packages/shared/src/server/llm/types.ts`
|
||||
- Validation logic:
|
||||
`packages/shared/src/features/model-pricing/validation.ts`
|
||||
- Matching logic:
|
||||
`packages/shared/src/server/pricing-tiers/matcher.ts`
|
||||
- Tests:
|
||||
`worker/src/__tests__/pricing-tier-matcher.test.ts`
|
||||
|
||||
## Data Structure
|
||||
|
||||
### Complete Model Entry Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid-generated-with-uuidgen",
|
||||
"modelName": "model-name-identifier",
|
||||
"matchPattern": "(?i)^regex-pattern$",
|
||||
"createdAt": "ISO-8601-timestamp",
|
||||
"updatedAt": "ISO-8601-timestamp",
|
||||
"tokenizerConfig": null,
|
||||
"tokenizerId": "claude|openai|null",
|
||||
"pricingTiers": [
|
||||
{
|
||||
"id": "model-uuid_tier_default",
|
||||
"name": "Standard",
|
||||
"isDefault": true,
|
||||
"priority": 0,
|
||||
"conditions": [],
|
||||
"prices": {
|
||||
"input": 0.000005,
|
||||
"output": 0.000025
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Required Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `id` | string | Unique lowercase UUID |
|
||||
| `modelName` | string | Primary model identifier |
|
||||
| `matchPattern` | string | Regex for matching model names |
|
||||
| `createdAt` | string | ISO-8601 timestamp set on creation |
|
||||
| `updatedAt` | string | ISO-8601 timestamp refreshed whenever the entry changes |
|
||||
| `pricingTiers` | array | At least one pricing tier |
|
||||
|
||||
### Optional Fields
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `tokenizerId` | string | `null` | `"claude"`, `"openai"`, or `null` |
|
||||
| `tokenizerConfig` | object | `null` | Custom tokenizer settings |
|
||||
|
||||
## Pricing Tier Structure
|
||||
|
||||
### Default Tier
|
||||
|
||||
Every model must have exactly one default tier:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "{model-id}_tier_default",
|
||||
"name": "Standard",
|
||||
"isDefault": true,
|
||||
"priority": 0,
|
||||
"conditions": [],
|
||||
"prices": {}
|
||||
}
|
||||
```
|
||||
|
||||
Rules for the default tier:
|
||||
|
||||
- `isDefault` must be `true`
|
||||
- `priority` must be `0`
|
||||
- `conditions` must be `[]`
|
||||
|
||||
### Additional Tiers
|
||||
|
||||
Use extra tiers for context-window or usage-based pricing:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid-for-tier",
|
||||
"name": "Large Context (>200K)",
|
||||
"isDefault": false,
|
||||
"priority": 1,
|
||||
"conditions": [
|
||||
{
|
||||
"usageDetailPattern": "(input|prompt|cached)",
|
||||
"operator": "gt",
|
||||
"value": 200000,
|
||||
"caseSensitive": false
|
||||
}
|
||||
],
|
||||
"prices": {}
|
||||
}
|
||||
```
|
||||
|
||||
Supported condition operators: `gt`, `gte`, `lt`, `lte`, `eq`, `neq`
|
||||
|
||||
## Official Pricing Sources
|
||||
|
||||
Always fetch pricing from the provider's official docs before editing. Do not
|
||||
infer or estimate missing values.
|
||||
|
||||
| Provider | Source |
|
||||
| --- | --- |
|
||||
| Anthropic Claude | `https://platform.claude.com/docs/en/about-claude/pricing` |
|
||||
| OpenAI | `https://openai.com/api/pricing/` |
|
||||
| Google Gemini | `https://ai.google.dev/pricing` |
|
||||
| AWS Bedrock | `https://aws.amazon.com/bedrock/pricing/` |
|
||||
| Azure OpenAI | `https://azure.microsoft.com/pricing/details/cognitive-services/openai-service/` |
|
||||
|
||||
Gather:
|
||||
|
||||
1. Base input token price per million tokens
|
||||
2. Output token price per million tokens
|
||||
3. Cache write price when supported
|
||||
4. Cache read price when supported
|
||||
5. Any long-context pricing tiers
|
||||
6. All model ID formats that Langfuse should match
|
||||
|
||||
## Price Conversion
|
||||
|
||||
Values in `default-model-prices.json` are per token, not per million tokens.
|
||||
|
||||
| Provider Price | JSON Value |
|
||||
| --- | --- |
|
||||
| `$5 / MTok` | `5e-6` |
|
||||
| `$25 / MTok` | `25e-6` |
|
||||
| `$0.50 / MTok` | `0.5e-6` |
|
||||
| `$6.25 / MTok` | `6.25e-6` |
|
||||
|
||||
Formula:
|
||||
|
||||
```text
|
||||
price_per_token = price_per_mtok / 1_000_000
|
||||
```
|
||||
|
||||
## Common Price Keys by Provider
|
||||
|
||||
### Anthropic Claude Models
|
||||
|
||||
```json
|
||||
{
|
||||
"input": "<base_input_price>",
|
||||
"input_tokens": "<base_input_price>",
|
||||
"output": "<output_price>",
|
||||
"output_tokens": "<output_price>",
|
||||
"cache_creation_input_tokens": "<cache_write_price>",
|
||||
"input_cache_creation": "<cache_write_price>",
|
||||
"cache_read_input_tokens": "<cache_read_price>",
|
||||
"input_cache_read": "<cache_read_price>"
|
||||
}
|
||||
```
|
||||
|
||||
### OpenAI Models
|
||||
|
||||
```json
|
||||
{
|
||||
"input": "<input_price>",
|
||||
"input_cached_tokens": "<cached_input_price>",
|
||||
"input_cache_read": "<cached_input_price>",
|
||||
"output": "<output_price>"
|
||||
}
|
||||
```
|
||||
|
||||
### Google Gemini Models
|
||||
|
||||
```json
|
||||
{
|
||||
"input": "<input_price>",
|
||||
"input_modality_1": "<input_price>",
|
||||
"prompt_token_count": "<input_price>",
|
||||
"promptTokenCount": "<input_price>",
|
||||
"input_cached_tokens": "<cached_price>",
|
||||
"cached_content_token_count": "<cached_price>",
|
||||
"output": "<output_price>",
|
||||
"output_modality_1": "<output_price>",
|
||||
"candidates_token_count": "<output_price>",
|
||||
"candidatesTokenCount": "<output_price>"
|
||||
}
|
||||
```
|
||||
|
||||
## Match Pattern Examples
|
||||
|
||||
### Anthropic Claude: API + Bedrock + Vertex
|
||||
|
||||
```regex
|
||||
(?i)^(anthropic\/)?(claude-opus-4-6|(eu\\.|us\\.|apac\\.)?anthropic\\.claude-opus-4-6-v1(:0)?|claude-opus-4-6)$
|
||||
```
|
||||
|
||||
Matches:
|
||||
|
||||
- `claude-opus-4-6`
|
||||
- `anthropic/claude-opus-4-6`
|
||||
- `anthropic.claude-opus-4-6-v1:0`
|
||||
- `us.anthropic.claude-opus-4-6-v1:0`
|
||||
- `claude-opus-4-6`
|
||||
|
||||
### With Version Date
|
||||
|
||||
```regex
|
||||
(?i)^(anthropic\/)?(claude-opus-4-5-20251101|(eu\\.|us\\.|apac\\.)?anthropic\\.claude-opus-4-5-20251101-v1:0|claude-opus-4-5@20251101)$
|
||||
```
|
||||
|
||||
### OpenAI
|
||||
|
||||
```regex
|
||||
(?i)^(openai\/)?(gpt-4o)$
|
||||
```
|
||||
|
||||
### Google Gemini
|
||||
|
||||
```regex
|
||||
(?i)^(google\/)?(gemini-2.5-pro)$
|
||||
```
|
||||
|
||||
### Pattern Components
|
||||
|
||||
| Component | Purpose | Example |
|
||||
| --- | --- | --- |
|
||||
| `(?i)` | Case-insensitive match | `gpt-4o` and `GPT-4O` |
|
||||
| `^...$` | Full-string match | Avoids partial matches |
|
||||
| `(provider\/)?` | Optional provider prefix | `openai/gpt-4o` |
|
||||
| `(eu\\.|us\\.|apac\\.)?` | Optional AWS region prefix | `us.anthropic.model` |
|
||||
| `(:0)?` | Optional version suffix | Bedrock model versions |
|
||||
| `@date` | Vertex AI version format | `claude-3-5-sonnet@20240620` |
|
||||
|
||||
## Step-by-Step Workflow
|
||||
|
||||
### 1. Fetch Official Pricing
|
||||
|
||||
Open the official provider pricing page and capture the model's input, output,
|
||||
cache write, and cache read prices.
|
||||
|
||||
### 2. Generate a Lowercase UUID
|
||||
|
||||
```bash
|
||||
uuidgen
|
||||
```
|
||||
|
||||
Convert the output to lowercase before using it.
|
||||
|
||||
### 3. Create the JSON Entry
|
||||
|
||||
Example for a model with $5 input, $25 output, $6.25 cache write, and
|
||||
$0.50 cache read:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "13458bc0-1c20-44c2-8753-172f54b67647",
|
||||
"modelName": "claude-opus-4-6",
|
||||
"matchPattern": "(?i)^(anthropic\/)?(claude-opus-4-6|(eu\\.|us\\.|apac\\.)?anthropic\\.claude-opus-4-6-v1(:0)?|claude-opus-4-6)$",
|
||||
"createdAt": "2026-03-09T00:00:00.000Z",
|
||||
"updatedAt": "2026-03-09T00:00:00.000Z",
|
||||
"tokenizerConfig": null,
|
||||
"tokenizerId": "claude",
|
||||
"pricingTiers": [
|
||||
{
|
||||
"id": "13458bc0-1c20-44c2-8753-172f54b67647_tier_default",
|
||||
"name": "Standard",
|
||||
"isDefault": true,
|
||||
"priority": 0,
|
||||
"conditions": [],
|
||||
"prices": {
|
||||
"input": 5e-6,
|
||||
"input_tokens": 5e-6,
|
||||
"output": 25e-6,
|
||||
"output_tokens": 25e-6,
|
||||
"cache_creation_input_tokens": 6.25e-6,
|
||||
"input_cache_creation": 6.25e-6,
|
||||
"cache_read_input_tokens": 0.5e-6,
|
||||
"input_cache_read": 0.5e-6
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Insert the Entry
|
||||
|
||||
Add the entry to the JSON array in
|
||||
`worker/src/constants/default-model-prices.json`. Keep related models grouped
|
||||
together.
|
||||
|
||||
### 5. Update Shared Model Types When Needed
|
||||
|
||||
If the model should be available in the playground or LLM-as-judge flows, add
|
||||
it to the correct array in `packages/shared/src/server/llm/types.ts`.
|
||||
|
||||
Model arrays include:
|
||||
|
||||
- `anthropicModels`
|
||||
- `openAIModels`
|
||||
- `vertexAIModels`
|
||||
- `googleAIStudioModels`
|
||||
|
||||
Do not add a new model as the first entry in one of these arrays. The first
|
||||
entry is used as a default model in some test or evaluation paths and newer
|
||||
models may not be available to all users yet.
|
||||
|
||||
### 6. Validate the Change
|
||||
|
||||
```bash
|
||||
jq . worker/src/constants/default-model-prices.json > /dev/null
|
||||
```
|
||||
|
||||
You can also inspect a specific entry:
|
||||
|
||||
```bash
|
||||
jq '.[] | select(.modelName == "claude-opus-4-6")' worker/src/constants/default-model-prices.json
|
||||
```
|
||||
|
||||
## Multi-Tier Example
|
||||
|
||||
For models with long-context pricing:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid-here",
|
||||
"modelName": "model-name",
|
||||
"matchPattern": "...",
|
||||
"pricingTiers": [
|
||||
{
|
||||
"id": "uuid-here_tier_default",
|
||||
"name": "Standard",
|
||||
"isDefault": true,
|
||||
"priority": 0,
|
||||
"conditions": [],
|
||||
"prices": {
|
||||
"input": 5e-6,
|
||||
"output": 25e-6
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "uuid-for-large-context-tier",
|
||||
"name": "Large Context (>200K)",
|
||||
"isDefault": false,
|
||||
"priority": 1,
|
||||
"conditions": [
|
||||
{
|
||||
"usageDetailPattern": "(input|prompt|cached)",
|
||||
"operator": "gt",
|
||||
"value": 200000,
|
||||
"caseSensitive": false
|
||||
}
|
||||
],
|
||||
"prices": {
|
||||
"input": 10e-6,
|
||||
"output": 37.5e-6
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Validation Rules
|
||||
|
||||
1. Exactly one default tier must have `isDefault: true`
|
||||
2. The default tier must have `priority: 0`
|
||||
3. The default tier must have `conditions: []`
|
||||
4. Non-default tiers must have `priority > 0`
|
||||
5. Non-default tiers must have at least one condition
|
||||
6. Priorities must be unique within a model
|
||||
7. Tier names must be unique within a model
|
||||
8. Each tier must contain at least one price
|
||||
9. All tiers must expose the same usage-type keys
|
||||
10. Regex patterns must be valid and safe
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### Guessing Instead of Using Official Pricing
|
||||
|
||||
Wrong:
|
||||
|
||||
```json
|
||||
{
|
||||
"cache_creation_input_tokens": "input_price * 1.25"
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```json
|
||||
{
|
||||
"cache_creation_input_tokens": 6.25e-6
|
||||
}
|
||||
```
|
||||
|
||||
### Using MTok Values Directly
|
||||
|
||||
Wrong:
|
||||
|
||||
```json
|
||||
{
|
||||
"input": 5
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```json
|
||||
{
|
||||
"input": 5e-6
|
||||
}
|
||||
```
|
||||
|
||||
### Missing the Default Tier Suffix
|
||||
|
||||
Wrong:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "some-uuid"
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "model-uuid_tier_default"
|
||||
}
|
||||
```
|
||||
|
||||
### Invalid Regex Escaping
|
||||
|
||||
Wrong:
|
||||
|
||||
```json
|
||||
{
|
||||
"matchPattern": "anthropic.claude"
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```json
|
||||
{
|
||||
"matchPattern": "anthropic\\.claude"
|
||||
}
|
||||
```
|
||||
|
||||
### Forgetting to Update `updatedAt`
|
||||
|
||||
Wrong:
|
||||
|
||||
```json
|
||||
{
|
||||
"updatedAt": "2025-12-12T15:00:06.513Z"
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```json
|
||||
{
|
||||
"updatedAt": "2026-03-09T00:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Model Matching
|
||||
|
||||
After adding a model, verify that the regex matches the intended provider
|
||||
variants:
|
||||
|
||||
```javascript
|
||||
const pattern = new RegExp(matchPattern);
|
||||
console.log(pattern.test("claude-opus-4-6")); // true
|
||||
console.log(pattern.test("anthropic/claude-opus-4-6")); // true
|
||||
console.log(pattern.test("anthropic.claude-opus-4-6-v1:0")); // true
|
||||
console.log(pattern.test("us.anthropic.claude-opus-4-6-v1:0")); // true
|
||||
```
|
||||
|
||||
## Existing Model Templates
|
||||
|
||||
Use nearby entries as templates:
|
||||
|
||||
- `claude-opus-4-5-20251101` for Anthropic multi-provider patterns
|
||||
- `gpt-4o` for a simple OpenAI pattern
|
||||
- `gemini-2.5-pro` for a multi-tier Gemini entry
|
||||
@@ -1,40 +0,0 @@
|
||||
---
|
||||
name: add-model-price
|
||||
description: Use when editing worker/src/constants/default-model-prices.json, packages/shared/src/server/llm/types.ts, pricing tiers, tokenizer IDs, or matchPattern regexes for OpenAI, Anthropic, Bedrock, Vertex, Azure, or Gemini model pricing.
|
||||
---
|
||||
|
||||
# Add Model Price
|
||||
|
||||
Use this skill for model pricing changes in `worker/` and shared LLM type
|
||||
updates in `packages/shared/`.
|
||||
|
||||
## When to Apply
|
||||
|
||||
- Editing `worker/src/constants/default-model-prices.json`
|
||||
- Editing `packages/shared/src/server/llm/types.ts`
|
||||
- Adding a new priced model
|
||||
- Updating provider prices, cache pricing, or tier conditions
|
||||
- Expanding regex coverage for Bedrock, Vertex, Azure, or provider-prefixed
|
||||
model names
|
||||
|
||||
## How to Read This Skill
|
||||
|
||||
- Start with [AGENTS.md](AGENTS.md) for the high-level workflow and helper
|
||||
scripts.
|
||||
- Then open only the specific reference file that matches the task.
|
||||
|
||||
## Reference Map
|
||||
|
||||
| Topic | Read this when | File |
|
||||
| --- | --- | --- |
|
||||
| Schema and tier rules | You need the entry shape or pricing-tier invariants | [references/schema-and-tiers.md](references/schema-and-tiers.md) |
|
||||
| Provider sources and price keys | You need official pricing URLs, per-token conversion, or provider-specific usage keys | [references/provider-sources-and-price-keys.md](references/provider-sources-and-price-keys.md) |
|
||||
| Match patterns | You are editing `matchPattern` regexes or provider coverage | [references/match-patterns.md](references/match-patterns.md) |
|
||||
| Workflow and validation | You are applying the end-to-end edit process or checking common mistakes | [references/workflow-and-validation.md](references/workflow-and-validation.md) |
|
||||
|
||||
## Deterministic Helpers
|
||||
|
||||
- Pricing file validator:
|
||||
`node .agents/skills/add-model-price/scripts/validate-pricing-file.mjs`
|
||||
- Match-pattern tester:
|
||||
`node .agents/skills/add-model-price/scripts/test-match-pattern.mjs --model <modelName> --accept <sample...> --reject <sample...>`
|
||||
@@ -1,51 +0,0 @@
|
||||
# Match Patterns
|
||||
|
||||
## Anthropic Claude: API + Bedrock + Vertex
|
||||
|
||||
```regex
|
||||
(?i)^(anthropic\/)?(claude-opus-4-6|(eu\\.|us\\.|apac\\.)?anthropic\\.claude-opus-4-6-v1(:0)?|claude-opus-4-6)$
|
||||
```
|
||||
|
||||
Matches:
|
||||
|
||||
- `claude-opus-4-6`
|
||||
- `anthropic/claude-opus-4-6`
|
||||
- `anthropic.claude-opus-4-6-v1:0`
|
||||
- `us.anthropic.claude-opus-4-6-v1:0`
|
||||
|
||||
## With Version Date
|
||||
|
||||
```regex
|
||||
(?i)^(anthropic\/)?(claude-opus-4-5-20251101|(eu\\.|us\\.|apac\\.)?anthropic\\.claude-opus-4-5-20251101-v1:0|claude-opus-4-5@20251101)$
|
||||
```
|
||||
|
||||
## OpenAI
|
||||
|
||||
```regex
|
||||
(?i)^(openai\/)?(gpt-4o)$
|
||||
```
|
||||
|
||||
## Google Gemini
|
||||
|
||||
```regex
|
||||
(?i)^(google\/)?(gemini-2.5-pro)$
|
||||
```
|
||||
|
||||
## Pattern Components
|
||||
|
||||
| Component | Purpose | Example |
|
||||
| --- | --- | --- |
|
||||
| `(?i)` | Case-insensitive match | `gpt-4o` and `GPT-4O` |
|
||||
| `^...$` | Full-string match | Avoids partial matches |
|
||||
| `(provider\/)?` | Optional provider prefix | `openai/gpt-4o` |
|
||||
| `(eu\\.|us\\.|apac\\.)?` | Optional AWS region prefix | `us.anthropic.model` |
|
||||
| `(:0)?` | Optional version suffix | Bedrock model versions |
|
||||
| `@date` | Vertex AI version format | `claude-3-5-sonnet@20240620` |
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
Use the bundled helper script:
|
||||
|
||||
```bash
|
||||
node .agents/skills/add-model-price/scripts/test-match-pattern.mjs --model gpt-4o --accept gpt-4o openai/gpt-4o --reject gpt-4o-mini
|
||||
```
|
||||
@@ -1,84 +0,0 @@
|
||||
# Provider Sources and Price Keys
|
||||
|
||||
## Official Pricing Sources
|
||||
|
||||
Always fetch pricing from the provider's official docs before editing.
|
||||
|
||||
| Provider | Source |
|
||||
| --- | --- |
|
||||
| Anthropic Claude | `https://platform.claude.com/docs/en/about-claude/pricing` |
|
||||
| OpenAI | `https://openai.com/api/pricing/` |
|
||||
| Google Gemini | `https://ai.google.dev/pricing` |
|
||||
| AWS Bedrock | `https://aws.amazon.com/bedrock/pricing/` |
|
||||
| Azure OpenAI | `https://azure.microsoft.com/pricing/details/cognitive-services/openai-service/` |
|
||||
|
||||
Capture:
|
||||
|
||||
1. Base input token price per million tokens
|
||||
2. Output token price per million tokens
|
||||
3. Cache write price when supported
|
||||
4. Cache read price when supported
|
||||
5. Any long-context or conditional pricing
|
||||
6. All model ID variants that Langfuse should match
|
||||
|
||||
## Price Conversion
|
||||
|
||||
Values in `default-model-prices.json` are per token, not per million tokens.
|
||||
|
||||
| Provider Price | JSON Value |
|
||||
| --- | --- |
|
||||
| `$5 / MTok` | `5e-6` |
|
||||
| `$25 / MTok` | `25e-6` |
|
||||
| `$0.50 / MTok` | `0.5e-6` |
|
||||
| `$6.25 / MTok` | `6.25e-6` |
|
||||
|
||||
Formula:
|
||||
|
||||
```text
|
||||
price_per_token = price_per_mtok / 1_000_000
|
||||
```
|
||||
|
||||
## Common Price Keys by Provider
|
||||
|
||||
### Anthropic Claude
|
||||
|
||||
```json
|
||||
{
|
||||
"input": "<base_input_price>",
|
||||
"input_tokens": "<base_input_price>",
|
||||
"output": "<output_price>",
|
||||
"output_tokens": "<output_price>",
|
||||
"cache_creation_input_tokens": "<cache_write_price>",
|
||||
"input_cache_creation": "<cache_write_price>",
|
||||
"cache_read_input_tokens": "<cache_read_price>",
|
||||
"input_cache_read": "<cache_read_price>"
|
||||
}
|
||||
```
|
||||
|
||||
### OpenAI
|
||||
|
||||
```json
|
||||
{
|
||||
"input": "<input_price>",
|
||||
"input_cached_tokens": "<cached_input_price>",
|
||||
"input_cache_read": "<cached_input_price>",
|
||||
"output": "<output_price>"
|
||||
}
|
||||
```
|
||||
|
||||
### Google Gemini
|
||||
|
||||
```json
|
||||
{
|
||||
"input": "<input_price>",
|
||||
"input_modality_1": "<input_price>",
|
||||
"prompt_token_count": "<input_price>",
|
||||
"promptTokenCount": "<input_price>",
|
||||
"input_cached_tokens": "<cached_price>",
|
||||
"cached_content_token_count": "<cached_price>",
|
||||
"output": "<output_price>",
|
||||
"output_modality_1": "<output_price>",
|
||||
"candidates_token_count": "<output_price>",
|
||||
"candidatesTokenCount": "<output_price>"
|
||||
}
|
||||
```
|
||||
@@ -1,96 +0,0 @@
|
||||
# Schema and Tiers
|
||||
|
||||
## Target Files
|
||||
|
||||
- Pricing data: `worker/src/constants/default-model-prices.json`
|
||||
- Shared model types: `packages/shared/src/server/llm/types.ts`
|
||||
|
||||
## Complete Model Entry Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid-generated-with-uuidgen",
|
||||
"modelName": "model-name-identifier",
|
||||
"matchPattern": "(?i)^regex-pattern$",
|
||||
"createdAt": "ISO-8601-timestamp",
|
||||
"updatedAt": "ISO-8601-timestamp",
|
||||
"tokenizerConfig": null,
|
||||
"tokenizerId": "claude|openai|null",
|
||||
"pricingTiers": [
|
||||
{
|
||||
"id": "model-uuid_tier_default",
|
||||
"name": "Standard",
|
||||
"isDefault": true,
|
||||
"priority": 0,
|
||||
"conditions": [],
|
||||
"prices": {
|
||||
"input": 0.000005,
|
||||
"output": 0.000025
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Required Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `id` | string | Unique lowercase ID used by the pricing file |
|
||||
| `modelName` | string | Primary model identifier |
|
||||
| `matchPattern` | string | Regex used to match provider model names |
|
||||
| `createdAt` | string | ISO-8601 timestamp set on creation |
|
||||
| `updatedAt` | string | ISO-8601 timestamp refreshed whenever the entry changes |
|
||||
| `pricingTiers` | array | At least one pricing tier |
|
||||
|
||||
## Optional Fields
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `tokenizerId` | string | `null` | Usually `"claude"`, `"openai"`, or `null` |
|
||||
| `tokenizerConfig` | object | `null` | Custom tokenizer settings |
|
||||
|
||||
## Default Tier
|
||||
|
||||
Every model must have exactly one default tier:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "{model-id}_tier_default",
|
||||
"name": "Standard",
|
||||
"isDefault": true,
|
||||
"priority": 0,
|
||||
"conditions": [],
|
||||
"prices": {}
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `isDefault` must be `true`
|
||||
- `priority` must be `0`
|
||||
- `conditions` must be `[]`
|
||||
|
||||
## Additional Tiers
|
||||
|
||||
Use extra tiers for context-window or usage-based pricing:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid-for-tier",
|
||||
"name": "Large Context (>200K)",
|
||||
"isDefault": false,
|
||||
"priority": 1,
|
||||
"conditions": [
|
||||
{
|
||||
"usageDetailPattern": "(input|prompt|cached)",
|
||||
"operator": "gt",
|
||||
"value": 200000,
|
||||
"caseSensitive": false
|
||||
}
|
||||
],
|
||||
"prices": {}
|
||||
}
|
||||
```
|
||||
|
||||
Supported operators: `gt`, `gte`, `lt`, `lte`, `eq`, `neq`
|
||||
@@ -1,63 +0,0 @@
|
||||
# Workflow and Validation
|
||||
|
||||
## Step-by-Step Workflow
|
||||
|
||||
### 1. Fetch Official Pricing
|
||||
|
||||
Open the provider's official pricing page and collect input, output, cache
|
||||
write, and cache read prices.
|
||||
|
||||
### 2. Generate a Lowercase ID
|
||||
|
||||
```bash
|
||||
uuidgen
|
||||
```
|
||||
|
||||
Convert the output to lowercase before using it.
|
||||
|
||||
### 3. Create or Update the Entry
|
||||
|
||||
Use nearby models in `worker/src/constants/default-model-prices.json` as the
|
||||
template, then:
|
||||
|
||||
- add the new entry near related models
|
||||
- refresh `updatedAt` when editing an existing entry
|
||||
- update `packages/shared/src/server/llm/types.ts` when the model should be
|
||||
selectable in product flows
|
||||
|
||||
### 4. Validate the Result
|
||||
|
||||
Run the bundled validator:
|
||||
|
||||
```bash
|
||||
node .agents/skills/add-model-price/scripts/validate-pricing-file.mjs
|
||||
```
|
||||
|
||||
## Validation Rules
|
||||
|
||||
1. Exactly one default tier must have `isDefault: true`
|
||||
2. The default tier must have `priority: 0`
|
||||
3. The default tier must have `conditions: []`
|
||||
4. Non-default tiers must have `priority > 0`
|
||||
5. Non-default tiers must have at least one condition
|
||||
6. Priorities must be unique within a model
|
||||
7. Tier names must be unique within a model
|
||||
8. Each tier must contain at least one price
|
||||
9. All tiers must expose the same usage-type keys
|
||||
10. Regex patterns must be valid
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
- Guessing prices instead of using official provider docs
|
||||
- Using MTok values directly instead of per-token values
|
||||
- Forgetting the `_tier_default` suffix on the default tier ID
|
||||
- Forgetting to escape regex metacharacters such as `.`
|
||||
- Forgetting to refresh `updatedAt`
|
||||
|
||||
## Existing Model Templates
|
||||
|
||||
Use nearby entries as templates:
|
||||
|
||||
- `claude-opus-4-5-20251101` for Anthropic multi-provider patterns
|
||||
- `gpt-4o` for a simple OpenAI pattern
|
||||
- `gemini-2.5-pro` for a multi-tier Gemini entry
|
||||
@@ -1,115 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const defaultFile = path.resolve(
|
||||
repoRoot,
|
||||
"worker/src/constants/default-model-prices.json",
|
||||
);
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
function readOption(name) {
|
||||
const index = args.indexOf(name);
|
||||
if (index === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return args[index + 1] ?? null;
|
||||
}
|
||||
|
||||
function readListOption(name) {
|
||||
const index = args.indexOf(name);
|
||||
if (index === -1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const values = [];
|
||||
for (let i = index + 1; i < args.length; i += 1) {
|
||||
if (args[i].startsWith("--")) {
|
||||
break;
|
||||
}
|
||||
values.push(args[i]);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function compilePattern(rawPattern) {
|
||||
let source = rawPattern;
|
||||
let flags = "";
|
||||
const inlineFlags = rawPattern.match(/^\(\?([dgimsuvy]*)\)/);
|
||||
|
||||
if (inlineFlags) {
|
||||
flags = inlineFlags[1];
|
||||
source = rawPattern.slice(inlineFlags[0].length);
|
||||
}
|
||||
|
||||
return new RegExp(source, flags);
|
||||
}
|
||||
|
||||
let pattern = readOption("--pattern");
|
||||
const modelName = readOption("--model");
|
||||
const accepted = readListOption("--accept");
|
||||
const rejected = readListOption("--reject");
|
||||
|
||||
if (!pattern && !modelName) {
|
||||
console.error("Pass either --pattern <regex> or --model <modelName>.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (accepted.length === 0 && rejected.length === 0) {
|
||||
console.error("Provide samples with --accept and/or --reject.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!pattern && modelName) {
|
||||
const models = JSON.parse(await fs.readFile(defaultFile, "utf8"));
|
||||
const model = models.find((entry) => entry.modelName === modelName);
|
||||
|
||||
if (!model) {
|
||||
console.error(`Model not found in pricing file: ${modelName}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
pattern = model.matchPattern;
|
||||
}
|
||||
|
||||
let regex;
|
||||
try {
|
||||
regex = compilePattern(pattern);
|
||||
} catch (error) {
|
||||
console.error(`Invalid pattern: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const failures = [];
|
||||
|
||||
for (const sample of accepted) {
|
||||
const matched = regex.test(sample);
|
||||
console.log(`${matched ? "PASS" : "FAIL"} accept ${sample}`);
|
||||
if (!matched) {
|
||||
failures.push(`Expected pattern to match: ${sample}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const sample of rejected) {
|
||||
const matched = regex.test(sample);
|
||||
console.log(`${!matched ? "PASS" : "FAIL"} reject ${sample}`);
|
||||
if (matched) {
|
||||
failures.push(`Expected pattern to reject: ${sample}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error("");
|
||||
for (const failure of failures) {
|
||||
console.error(`- ${failure}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log(
|
||||
`Pattern is valid for ${accepted.length + rejected.length} sample(s).`,
|
||||
);
|
||||
@@ -1,150 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const defaultFile = "worker/src/constants/default-model-prices.json";
|
||||
const repoRoot = process.cwd();
|
||||
const filePath = path.resolve(repoRoot, process.argv[2] ?? defaultFile);
|
||||
const failures = [];
|
||||
|
||||
function compileMatchPattern(rawPattern, label) {
|
||||
let source = rawPattern;
|
||||
let flags = "";
|
||||
const inlineFlags = rawPattern.match(/^\(\?([dgimsuvy]*)\)/);
|
||||
|
||||
if (inlineFlags) {
|
||||
flags = inlineFlags[1];
|
||||
source = rawPattern.slice(inlineFlags[0].length);
|
||||
}
|
||||
|
||||
try {
|
||||
return new RegExp(source, flags);
|
||||
} catch (error) {
|
||||
failures.push(`${label}: invalid matchPattern (${error.message})`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function keysOfPrices(prices) {
|
||||
return Object.keys(prices).sort();
|
||||
}
|
||||
|
||||
const raw = await fs.readFile(filePath, "utf8");
|
||||
const models = JSON.parse(raw);
|
||||
|
||||
if (!Array.isArray(models)) {
|
||||
throw new Error("Expected the pricing file to be a JSON array.");
|
||||
}
|
||||
|
||||
for (const model of models) {
|
||||
const label = model.modelName ?? model.id ?? "<unknown-model>";
|
||||
|
||||
if (!model.id || typeof model.id !== "string") {
|
||||
failures.push(`${label}: missing string id`);
|
||||
}
|
||||
|
||||
if (!model.modelName || typeof model.modelName !== "string") {
|
||||
failures.push(`${label}: missing string modelName`);
|
||||
}
|
||||
|
||||
if (!model.matchPattern || typeof model.matchPattern !== "string") {
|
||||
failures.push(`${label}: missing string matchPattern`);
|
||||
} else {
|
||||
compileMatchPattern(model.matchPattern, label);
|
||||
}
|
||||
|
||||
if (Number.isNaN(Date.parse(model.createdAt ?? ""))) {
|
||||
failures.push(`${label}: invalid createdAt timestamp`);
|
||||
}
|
||||
|
||||
if (Number.isNaN(Date.parse(model.updatedAt ?? ""))) {
|
||||
failures.push(`${label}: invalid updatedAt timestamp`);
|
||||
}
|
||||
|
||||
if (!Array.isArray(model.pricingTiers) || model.pricingTiers.length === 0) {
|
||||
failures.push(`${label}: pricingTiers must be a non-empty array`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const defaultTiers = model.pricingTiers.filter((tier) => tier.isDefault);
|
||||
if (defaultTiers.length !== 1) {
|
||||
failures.push(`${label}: must have exactly one default tier`);
|
||||
}
|
||||
|
||||
const seenPriorities = new Set();
|
||||
const seenNames = new Set();
|
||||
let expectedPriceKeys = null;
|
||||
|
||||
for (const tier of model.pricingTiers) {
|
||||
const tierLabel = `${label}/${tier.name ?? tier.id ?? "<unknown-tier>"}`;
|
||||
|
||||
if (seenPriorities.has(tier.priority)) {
|
||||
failures.push(`${tierLabel}: duplicate tier priority ${tier.priority}`);
|
||||
} else {
|
||||
seenPriorities.add(tier.priority);
|
||||
}
|
||||
|
||||
if (seenNames.has(tier.name)) {
|
||||
failures.push(`${tierLabel}: duplicate tier name ${tier.name}`);
|
||||
} else {
|
||||
seenNames.add(tier.name);
|
||||
}
|
||||
|
||||
if (!tier.prices || typeof tier.prices !== "object") {
|
||||
failures.push(`${tierLabel}: missing prices object`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const priceKeys = keysOfPrices(tier.prices);
|
||||
if (priceKeys.length === 0) {
|
||||
failures.push(`${tierLabel}: prices object must not be empty`);
|
||||
}
|
||||
|
||||
for (const [usageType, price] of Object.entries(tier.prices)) {
|
||||
if (typeof price !== "number" || Number.isNaN(price) || price < 0) {
|
||||
failures.push(`${tierLabel}: invalid price for ${usageType}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (tier.isDefault) {
|
||||
if (tier.priority !== 0) {
|
||||
failures.push(`${tierLabel}: default tier priority must be 0`);
|
||||
}
|
||||
|
||||
if (!Array.isArray(tier.conditions) || tier.conditions.length !== 0) {
|
||||
failures.push(`${tierLabel}: default tier conditions must be []`);
|
||||
}
|
||||
} else {
|
||||
if (!(tier.priority > 0)) {
|
||||
failures.push(`${tierLabel}: non-default tier priority must be > 0`);
|
||||
}
|
||||
|
||||
if (!Array.isArray(tier.conditions) || tier.conditions.length === 0) {
|
||||
failures.push(
|
||||
`${tierLabel}: non-default tiers must define at least one condition`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!expectedPriceKeys) {
|
||||
expectedPriceKeys = priceKeys.join(",");
|
||||
} else if (expectedPriceKeys !== priceKeys.join(",")) {
|
||||
failures.push(
|
||||
`${tierLabel}: price keys must match the other tiers for ${label}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error("Pricing validation failed:\n");
|
||||
for (const failure of failures) {
|
||||
console.error(`- ${failure}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Validated ${models.length} pricing entries in ${path.relative(repoRoot, filePath)}.`,
|
||||
);
|
||||
@@ -1,65 +0,0 @@
|
||||
---
|
||||
name: agent-setup-maintenance
|
||||
description: |
|
||||
Shared workflow for editing Langfuse's repo-owned agent setup under `.agents/`.
|
||||
Use when changing AGENTS files, shared skills, `.agents/config.json`,
|
||||
generated shim behavior, provider discovery paths, or install-time agent sync.
|
||||
---
|
||||
|
||||
# Agent Setup Maintenance
|
||||
|
||||
Use this skill when changing the shared agent setup for the repository.
|
||||
|
||||
## Start Here
|
||||
|
||||
- Read [`../../README.md`](../../README.md) for the shared config and shim model.
|
||||
- Read root [`../../AGENTS.md`](../../AGENTS.md) for repo-level expectations.
|
||||
- Inspect [`../../../scripts/agents/sync-agent-shims.mjs`](../../../scripts/agents/sync-agent-shims.mjs)
|
||||
before changing generated outputs or provider discovery behavior.
|
||||
- Inspect [`../../../scripts/postinstall.sh`](../../../scripts/postinstall.sh)
|
||||
and [`../../../package.json`](../../../package.json) when changing install-time
|
||||
sync behavior.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Edit the canonical files under `.agents/`, not generated provider outputs.
|
||||
2. Keep root `AGENTS.md` and `CLAUDE.md` as discovery symlinks; do not turn
|
||||
them back into manually maintained copies.
|
||||
3. Treat tool-specific directories such as `.claude/`, `.cursor/`, `.codex/`,
|
||||
`.vscode/`, and `.mcp.json` as generated discovery surfaces unless the tool
|
||||
requires a truly tool-specific feature.
|
||||
4. Keep root `AGENTS.md` concise and router-like. Move detailed or conditional
|
||||
workflows into shared skills or package `AGENTS.md` files.
|
||||
5. When adding or changing a shared skill, update `skills/README.md` and link
|
||||
it from root `AGENTS.md` if it changes the default reusable workflow.
|
||||
6. When shared setup behavior changes materially, update `README.md` and
|
||||
contributor-facing docs in the same PR.
|
||||
|
||||
## Docker / Install-Time Constraint
|
||||
|
||||
- `pnpm install` runs in environments that may not contain the full repo source
|
||||
tree.
|
||||
- In Docker builds, Turbo's pruned install stage can run root `postinstall`
|
||||
before `scripts/` and `.agents/` are available in the image.
|
||||
- Keep install-time agent setup logic robust in those pruned contexts: skip
|
||||
cleanly when the required repo-owned files are not present.
|
||||
|
||||
## Required Verification
|
||||
|
||||
Run after changing shared agent setup:
|
||||
|
||||
- `pnpm run agents:sync`
|
||||
- `pnpm run agents:check`
|
||||
|
||||
Run additional verification when relevant:
|
||||
|
||||
- `pnpm run postinstall` when install-time behavior changes
|
||||
- targeted tests for any scripts you changed
|
||||
|
||||
## Design Rules
|
||||
|
||||
- Prefer one repo-owned source of truth over duplicated provider-specific files.
|
||||
- Keep shared setup tool-neutral where possible.
|
||||
- Only keep provider-specific files in source control when the provider requires
|
||||
a fixed discovery path or feature that cannot be expressed through the shared
|
||||
setup model.
|
||||
@@ -1,577 +0,0 @@
|
||||
# Backend Development Guidelines
|
||||
|
||||
## Purpose
|
||||
|
||||
Establish consistency and best practices across Langfuse's backend packages (web, worker, packages/shared) using Next.js 14, tRPC, BullMQ, and TypeScript patterns.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Use this guide when working on:
|
||||
|
||||
- Creating or modifying tRPC routers and procedures
|
||||
- Creating or modifying public API endpoints (REST)
|
||||
- Creating or modifying BullMQ queue consumers and producers
|
||||
- Building services with business logic
|
||||
- Authenticating API requests
|
||||
- Accessing resources based on entitlements
|
||||
- Implementing middleware (tRPC, NextAuth, public API)
|
||||
- Database operations with Prisma (PostgreSQL) or ClickHouse
|
||||
- Observability with OpenTelemetry, DataDog, logger, and traceException
|
||||
- Input validation with Zod v4
|
||||
- Environment configuration from env variables
|
||||
- Backend testing and refactoring
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### UI: New tRPC Feature Checklist (Web)
|
||||
|
||||
- [ ] **Router**: Define in `features/[feature]/server/*Router.ts`
|
||||
- [ ] **Procedures**: Use appropriate procedure type (protected, public)
|
||||
- [ ] **Authentication**: Use JWT authorization via middlewares.
|
||||
- [ ] **Entitlement check**: Access resources based on resource and role
|
||||
- [ ] **Validation**: Zod v4 schema for input
|
||||
- [ ] **Service**: Business logic in service file
|
||||
- [ ] **Error handling**: Use traceException wrapper
|
||||
- [ ] **Tests**: Unit + integration tests in `__tests__/`
|
||||
- [ ] **Config**: Access via env.mjs
|
||||
|
||||
### SDKs: New Public API Endpoint Checklist (Web)
|
||||
|
||||
- [ ] **Route file**: Create in `pages/api/public/`
|
||||
- [ ] **Wrapper**: Use `withMiddlewares` + `createAuthedProjectAPIRoute`
|
||||
- [ ] **Types**: Define in `features/public-api/types/`
|
||||
- [ ] **Authentication**: Authorization via basic auth
|
||||
- [ ] **Validation**: Zod schemas for query/body/response
|
||||
- [ ] **Versioning**: Versioning in API path and Zod schemas for query/body/response
|
||||
- [ ] **Fern API Docs**: Update `fern/apis/server/definition/` to match TypeScript types
|
||||
- [ ] **Tests**: Add end-to-end test in `__tests__/async/`
|
||||
|
||||
### New Queue Processor Checklist (Worker)
|
||||
|
||||
- [ ] **Processor**: Create in `worker/src/queues/`
|
||||
- [ ] **Queue types**: Create queue types in `packages/shared/src/server/queues`
|
||||
- [ ] **Service**: Business logic in `features/` or `worker/src/features/`
|
||||
- [ ] **Error handling**: Distinguish between errors which should fail queue processing and errors which should result in a succeeded event.
|
||||
- [ ] **Queue registration**: Add to WorkerManager in app.ts
|
||||
- [ ] **Tests**: Add vitest tests in worker
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Layered Architecture
|
||||
|
||||
```
|
||||
# Web Package (Next.js 14)
|
||||
|
||||
┌─ tRPC API ──────────────────┐ ┌── Public REST API ──────────┐
|
||||
│ │ │ │
|
||||
│ HTTP Request │ │ HTTP Request │
|
||||
│ ↓ │ │ ↓ │
|
||||
│ tRPC Procedure │ │ withMiddlewares + │
|
||||
│ (protectedProjectProcedure)│ │ createAuthedProjectAPIRoute│
|
||||
│ ↓ │ │ ↓ │
|
||||
│ Service (business logic) │ │ Service (business logic) │
|
||||
│ ↓ │ │ ↓ │
|
||||
│ Prisma / ClickHouse │ │ Prisma / ClickHouse │
|
||||
│ │ │ │
|
||||
└─────────────────────────────┘ └─────────────────────────────┘
|
||||
↓
|
||||
[optional]: Publish to Redis BullMQ queue
|
||||
↓
|
||||
┌─ Worker Package (Express) ──────────────────────────────────┐
|
||||
│ │
|
||||
│ BullMQ Queue Job │
|
||||
│ ↓ │
|
||||
│ Queue Processor (handles job) │
|
||||
│ ↓ │
|
||||
│ Service (business logic) │
|
||||
│ ↓ │
|
||||
│ Prisma / ClickHouse │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Key Principles:**
|
||||
|
||||
- **Web**: tRPC procedures for UI OR public API routes for SDKs → Services → Database
|
||||
- **Worker**: Queue processors → Services → Database
|
||||
- **packages/shared**: Shared code for Web and Worker
|
||||
|
||||
See [references/architecture-overview.md](references/architecture-overview.md)
|
||||
for complete details.
|
||||
|
||||
---
|
||||
|
||||
## Directory Structure
|
||||
|
||||
### Web Package (`/web/`)
|
||||
|
||||
```
|
||||
web/src/
|
||||
├── features/ # Feature-organized code
|
||||
│ ├── [feature-name]/
|
||||
│ │ ├── server/ # Backend logic
|
||||
│ │ │ ├── *Router.ts # tRPC router
|
||||
│ │ │ └── service.ts # Business logic
|
||||
│ │ ├── components/ # React components
|
||||
│ │ └── types/ # Feature types
|
||||
├── server/
|
||||
│ ├── api/
|
||||
│ │ ├── routers/ # tRPC routers
|
||||
│ │ ├── trpc.ts # tRPC setup & middleware
|
||||
│ │ └── root.ts # Main router
|
||||
│ ├── auth.ts # NextAuth.js config
|
||||
│ └── db.ts # Database client
|
||||
├── pages/
|
||||
│ ├── api/
|
||||
│ │ ├── public/ # Public REST APIs
|
||||
│ │ └── trpc/ # tRPC endpoint
|
||||
│ └── [routes].tsx # Next.js pages
|
||||
├── __tests__/ # Jest tests
|
||||
│ └── async/ # Integration tests
|
||||
├── instrumentation.ts # OpenTelemetry (FIRST IMPORT)
|
||||
└── env.mjs # Environment config
|
||||
```
|
||||
|
||||
### Worker Package (`/worker/`)
|
||||
|
||||
```
|
||||
worker/src/
|
||||
├── queues/ # BullMQ processors
|
||||
│ ├── evalQueue.ts
|
||||
│ ├── ingestionQueue.ts
|
||||
│ └── workerManager.ts
|
||||
├── features/ # Business logic
|
||||
│ └── [feature]/
|
||||
│ └── service.ts
|
||||
├── instrumentation.ts # OpenTelemetry (FIRST IMPORT)
|
||||
├── app.ts # Express setup + queue registration
|
||||
├── env.ts # Environment config
|
||||
└── index.ts # Server start
|
||||
```
|
||||
|
||||
### Shared Package (`/packages/shared/`)
|
||||
|
||||
```
|
||||
shared/src/
|
||||
├── server/ # Server utilities
|
||||
│ ├── auth/ # Authentication helpers
|
||||
│ ├── clickhouse/ # ClickHouse client & schema
|
||||
│ ├── instrumentation/ # OpenTelemetry helpers
|
||||
│ ├── llm/ # LLM integration utilities
|
||||
│ ├── redis/ # Redis queues & cache
|
||||
│ ├── repositories/ # Data repositories
|
||||
│ ├── services/ # Shared services
|
||||
│ ├── utils/ # Server utilities
|
||||
│ ├── logger.ts
|
||||
│ └── queues.ts
|
||||
├── encryption/ # Encryption utilities
|
||||
├── features/ # Feature-specific code
|
||||
├── tableDefinitions/ # Table schemas
|
||||
├── utils/ # Shared utilities
|
||||
├── constants.ts
|
||||
├── db.ts # Prisma client
|
||||
├── env.ts # Environment config
|
||||
└── index.ts # Main exports
|
||||
```
|
||||
|
||||
**Import Paths (package.json exports):**
|
||||
|
||||
The shared package exposes specific import paths for different use cases:
|
||||
|
||||
| Import Path | Maps To | Use For |
|
||||
| ------------------------------------------ | --------------------------------- | --------------------------------------------------------------- |
|
||||
| `@langfuse/shared` | `dist/src/index.js` | General types, schemas, utilities, constants |
|
||||
| `@langfuse/shared/src/db` | `dist/src/db.js` | Prisma client and database types |
|
||||
| `@langfuse/shared/src/server` | `dist/src/server/index.js` | Server-side utilities (queues, auth, services, instrumentation) |
|
||||
| `@langfuse/shared/src/server/auth/apiKeys` | `dist/src/server/auth/apiKeys.js` | API key management utilities |
|
||||
| `@langfuse/shared/encryption` | `dist/src/encryption/index.js` | Encryption and signature utilities |
|
||||
|
||||
**Usage Examples:**
|
||||
|
||||
```typescript
|
||||
// General imports - types, schemas, constants, interfaces
|
||||
import {
|
||||
CloudConfigSchema,
|
||||
StringNoHTML,
|
||||
AnnotationQueueObjectType,
|
||||
type APIScoreV2,
|
||||
type ColumnDefinition,
|
||||
Role,
|
||||
} from "@langfuse/shared";
|
||||
|
||||
// Database - Prisma client and types
|
||||
import { prisma, Prisma, JobExecutionStatus } from "@langfuse/shared/src/db";
|
||||
import { type DB as Database } from "@langfuse/shared";
|
||||
|
||||
// Server utilities - queues, services, auth, instrumentation
|
||||
import {
|
||||
logger,
|
||||
instrumentAsync,
|
||||
traceException,
|
||||
redis,
|
||||
getTracesTable,
|
||||
StorageService,
|
||||
sendMembershipInvitationEmail,
|
||||
invalidateApiKeysForProject,
|
||||
recordIncrement,
|
||||
recordHistogram,
|
||||
} from "@langfuse/shared/src/server";
|
||||
|
||||
// API key management (specific path)
|
||||
import { createAndAddApiKeysToDb } from "@langfuse/shared/src/server/auth/apiKeys";
|
||||
|
||||
// Encryption utilities
|
||||
import { encrypt, decrypt, sign, verify } from "@langfuse/shared/encryption";
|
||||
```
|
||||
|
||||
**What Goes Where:**
|
||||
|
||||
The shared package provides types, utilities, and server code used by both web and worker packages. It has **5 export paths** that control frontend vs backend access:
|
||||
|
||||
| Import Path | Usage | What's Included |
|
||||
| ------------------------------------------ | --------------------- | ---------------------------------------------------------------------------------- |
|
||||
| `@langfuse/shared` | ✅ Frontend + Backend | Prisma types, Zod schemas, constants, table definitions, domain models, utilities |
|
||||
| `@langfuse/shared/src/db` | 🔒 Backend only | Prisma client instance |
|
||||
| `@langfuse/shared/src/server` | 🔒 Backend only | Services, repositories, queues, auth, ClickHouse, LLM integration, instrumentation |
|
||||
| `@langfuse/shared/src/server/auth/apiKeys` | 🔒 Backend only | API key management (separated to avoid circular deps) |
|
||||
| `@langfuse/shared/encryption` | 🔒 Backend only | Database field encryption/decryption |
|
||||
|
||||
**Naming Conventions:**
|
||||
|
||||
- tRPC Routers: `camelCaseRouter.ts` - `datasetRouter.ts`
|
||||
- Services: `service.ts` in feature directory
|
||||
- Queue Processors: `camelCaseQueue.ts` - `evalQueue.ts`
|
||||
- Public APIs: `kebab-case.ts` - `dataset-items.ts`
|
||||
|
||||
---
|
||||
|
||||
## Core Principles
|
||||
|
||||
### 1. tRPC Procedures Delegate to Services
|
||||
|
||||
```typescript
|
||||
// ❌ NEVER: Business logic in procedures
|
||||
export const traceRouter = createTRPCRouter({
|
||||
byId: protectedProjectProcedure
|
||||
.input(z.object({ traceId: z.string() }))
|
||||
.query(async ({ input, ctx }) => {
|
||||
// 200 lines of logic here
|
||||
}),
|
||||
});
|
||||
|
||||
// ✅ ALWAYS: Delegate to service
|
||||
export const traceRouter = createTRPCRouter({
|
||||
byId: protectedProjectProcedure
|
||||
.input(z.object({ traceId: z.string() }))
|
||||
.query(async ({ input, ctx }) => {
|
||||
return await getTraceById(input.traceId);
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Access Config via env.mjs, NEVER process.env
|
||||
|
||||
```typescript
|
||||
// ❌ NEVER (except in env.mjs itself)
|
||||
const dbUrl = process.env.DATABASE_URL;
|
||||
|
||||
// ✅ ALWAYS
|
||||
import { env } from "@/src/env.mjs";
|
||||
const dbUrl = env.DATABASE_URL;
|
||||
```
|
||||
|
||||
### 3. Validate ALL Input with Zod v4
|
||||
|
||||
```typescript
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email(),
|
||||
projectId: z.string(),
|
||||
});
|
||||
const validated = schema.parse(input);
|
||||
```
|
||||
|
||||
### 4. Services Use Prisma Directly for Simple CRUD or Repositories for Complex Queries
|
||||
|
||||
```typescript
|
||||
// Services use Prisma directly for simple CRUD
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
|
||||
const dataset = await prisma.dataset.findUnique({
|
||||
where: { id: datasetId, projectId }, // Always filter by projectId for tenant isolation
|
||||
});
|
||||
|
||||
// Or use repositories for complex queries (traces, observations, scores)
|
||||
import { getTracesTable } from "@langfuse/shared/src/server";
|
||||
|
||||
const traces = await getTracesTable({
|
||||
projectId,
|
||||
filter: [...],
|
||||
limit: 1000,
|
||||
});
|
||||
```
|
||||
|
||||
### 6. Observability: OpenTelemetry + DataDog (Not Sentry for Backend)
|
||||
|
||||
**Langfuse uses OpenTelemetry for backend observability, with traces and logs sent to DataDog.**
|
||||
|
||||
```typescript
|
||||
// Import observability utilities
|
||||
import {
|
||||
logger, // Winston logger with OpenTelemetry/DataDog context
|
||||
traceException, // Record exceptions to OpenTelemetry spans
|
||||
instrumentAsync, // Create instrumented spans
|
||||
} from "@langfuse/shared/src/server";
|
||||
|
||||
// Structured logging (includes trace_id, span_id, dd.trace_id)
|
||||
logger.info("Processing dataset", { datasetId, projectId });
|
||||
logger.error("Failed to create dataset", { error: err.message });
|
||||
|
||||
// Record exceptions to OpenTelemetry (sent to DataDog)
|
||||
try {
|
||||
await operation();
|
||||
} catch (error) {
|
||||
traceException(error); // Records to current span
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Instrument critical operations (all API routes auto-instrumented)
|
||||
const result = await instrumentAsync(
|
||||
{ name: "dataset.create" },
|
||||
async (span) => {
|
||||
span.setAttributes({ datasetId, projectId });
|
||||
// Operation here
|
||||
return dataset;
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
**Note**: Frontend uses Sentry, but backend (tRPC, API routes, services, worker) uses OpenTelemetry + DataDog.
|
||||
|
||||
### 7. Comprehensive Testing Required
|
||||
|
||||
Write tests for all new features and bug fixes. See [testing-guide.md](references/testing-guide.md) for detailed examples.
|
||||
|
||||
**Test Types:**
|
||||
|
||||
| Type | Framework | Location | Purpose |
|
||||
| ----------- | --------- | --------------------------------------- | ---------------------------- |
|
||||
| Integration | Jest | `web/src/__tests__/async/` | Full API endpoint testing |
|
||||
| tRPC | Jest | `web/src/__tests__/async/` | tRPC procedures with auth |
|
||||
| Service | Jest | `web/src/__tests__/async/repositories/` | Repository/service functions |
|
||||
| Worker | Vitest | `worker/src/__tests__/` | Queue processors & streams |
|
||||
|
||||
**Quick Examples:**
|
||||
|
||||
```typescript
|
||||
// Integration Test (Public API)
|
||||
const res = await makeZodVerifiedAPICall(
|
||||
PostDatasetsV1Response, "POST", "/api/public/datasets",
|
||||
{ name: "test-dataset" }, auth
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// tRPC Test
|
||||
const { caller } = await prepare(); // Creates session + caller
|
||||
const response = await caller.automations.getAutomations({ projectId });
|
||||
expect(response).toHaveLength(1);
|
||||
|
||||
// Service Test
|
||||
const result = await getObservationsWithModelDataFromEventsTable({
|
||||
projectId, filter: [...], limit: 1000, offset: 0
|
||||
});
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
|
||||
// Worker Test (vitest)
|
||||
const stream = await getObservationStream({ projectId, filter: [] });
|
||||
const rows = [];
|
||||
for await (const chunk of stream) rows.push(chunk);
|
||||
expect(rows).toHaveLength(2);
|
||||
```
|
||||
|
||||
**Key Principles:**
|
||||
|
||||
- Use unique IDs (`randomUUID()`) to avoid test interference
|
||||
- Clean up test data or use unique project IDs
|
||||
- Tests must be independent and runnable in any order
|
||||
- Prefer scoped cleanup or unique project IDs over global reset helpers
|
||||
|
||||
### 8. Always Filter by projectId for Tenant Isolation
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT: Filter by projectId for tenant isolation
|
||||
const trace = await prisma.trace.findUnique({
|
||||
where: { id: traceId, projectId }, // Required for multi-tenant data isolation
|
||||
});
|
||||
|
||||
// ✅ CORRECT: ClickHouse queries also require projectId
|
||||
const traces = await queryClickhouse({
|
||||
query: `
|
||||
SELECT * FROM traces
|
||||
WHERE project_id = {projectId: String}
|
||||
AND timestamp >= {startTime: DateTime64(3)}
|
||||
`,
|
||||
params: { projectId, startTime },
|
||||
});
|
||||
```
|
||||
|
||||
### 9. Keep Fern API Definitions in Sync with TypeScript Types
|
||||
|
||||
When modifying public API types in `web/src/features/public-api/types/`, the corresponding Fern API definitions in `fern/apis/server/definition/` must be updated to match.
|
||||
|
||||
**Zod to Fern Type Mapping:**
|
||||
|
||||
| Zod Type | Fern Type | Example |
|
||||
| -------- | --------- | ------- |
|
||||
| `.nullish()` | `optional<nullable<T>>` | `z.string().nullish()` → `optional<nullable<string>>` |
|
||||
| `.nullable()` | `nullable<T>` | `z.string().nullable()` → `nullable<string>` |
|
||||
| `.optional()` | `optional<T>` | `z.string().optional()` → `optional<string>` |
|
||||
| Always present | `T` | `z.string()` → `string` |
|
||||
|
||||
**Source References:**
|
||||
|
||||
Add a comment at the top of each Fern type referencing the TypeScript source file:
|
||||
|
||||
```yaml
|
||||
# Source: web/src/features/public-api/types/traces.ts - APITrace
|
||||
Trace:
|
||||
properties:
|
||||
id: string
|
||||
name:
|
||||
type: nullable<string>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Imports
|
||||
|
||||
```typescript
|
||||
// tRPC (Web)
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
createTRPCRouter,
|
||||
protectedProjectProcedure,
|
||||
} from "@/src/server/api/trpc";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
// Database
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
// ClickHouse
|
||||
import {
|
||||
queryClickhouse,
|
||||
queryClickhouseStream,
|
||||
upsertClickhouse,
|
||||
} from "@langfuse/shared/src/server";
|
||||
|
||||
// Observability - OpenTelemetry + DataDog (NOT Sentry for backend)
|
||||
import {
|
||||
logger, // Winston logger with OTEL/DataDog trace context
|
||||
traceException, // Record exceptions to OpenTelemetry spans
|
||||
instrumentAsync, // Create instrumented spans for operations
|
||||
} from "@langfuse/shared/src/server";
|
||||
|
||||
// Config
|
||||
import { env } from "@/src/env.mjs"; // web
|
||||
// or
|
||||
import { env } from "./env"; // worker
|
||||
|
||||
// Public API (Web)
|
||||
import { withMiddlewares } from "@/src/features/public-api/server/withMiddlewares";
|
||||
import { createAuthedProjectAPIRoute } from "@/src/features/public-api/server/createAuthedProjectAPIRoute";
|
||||
|
||||
// Queue Processing (Worker)
|
||||
import { Job } from "bullmq";
|
||||
import { QueueName, TQueueJobTypes } from "@langfuse/shared/src/server";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### HTTP Status Codes
|
||||
|
||||
| Code | Use Case |
|
||||
| ---- | ------------ |
|
||||
| 200 | Success |
|
||||
| 201 | Created |
|
||||
| 400 | Bad Request |
|
||||
| 401 | Unauthorized |
|
||||
| 403 | Forbidden |
|
||||
| 404 | Not Found |
|
||||
| 500 | Server Error |
|
||||
|
||||
### Example Features to Reference
|
||||
|
||||
Reference existing Langfuse features for implementation patterns:
|
||||
- **Datasets** (`web/src/features/datasets/`) - Complete feature with tRPC router, public API, and service
|
||||
- **Prompts** (`web/src/features/prompts/`) - Feature with versioning and templates
|
||||
- **Evaluations** (`web/src/features/evals/`) - Complex feature with worker integration
|
||||
- **Public API** (`web/src/features/public-api/`) - Middleware and route patterns
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
❌ Business logic in routes/procedures
|
||||
❌ Direct process.env usage (always use env.mjs/env.ts)
|
||||
❌ Missing error handling
|
||||
❌ No input validation (always use Zod v4)
|
||||
❌ Missing projectId filter on tenant-scoped queries
|
||||
❌ console.log instead of logger/traceException (OpenTelemetry)
|
||||
|
||||
---
|
||||
|
||||
## Navigation Guide
|
||||
|
||||
| Need to... | Read this |
|
||||
| ------------------------- | ------------------------------------------------------------ |
|
||||
| Understand architecture | [architecture-overview.md](references/architecture-overview.md) |
|
||||
| Create routes/controllers | [routing-and-controllers.md](references/routing-and-controllers.md) |
|
||||
| Organize business logic | [services-and-repositories.md](references/services-and-repositories.md) |
|
||||
| Create middleware | [middleware-guide.md](references/middleware-guide.md) |
|
||||
| Database access | [database-patterns.md](references/database-patterns.md) |
|
||||
| Manage config | [configuration.md](references/configuration.md) |
|
||||
| Write tests | [testing-guide.md](references/testing-guide.md) |
|
||||
|
||||
---
|
||||
|
||||
## Reference Files
|
||||
|
||||
### [architecture-overview.md](references/architecture-overview.md)
|
||||
|
||||
Three-layer architecture (tRPC/Public API → Services → Data Access), request lifecycle for tRPC/Public API/Worker, Next.js 14 directory structure, dual database system (PostgreSQL + ClickHouse), separation of concerns, repository pattern for complex queries
|
||||
|
||||
### [routing-and-controllers.md](references/routing-and-controllers.md)
|
||||
|
||||
Next.js file-based routing, tRPC router patterns, Public REST API routes, layered architecture (Entry Points → Services → Repositories → Database), service layer organization, anti-patterns to avoid
|
||||
|
||||
### [services-and-repositories.md](references/services-and-repositories.md)
|
||||
|
||||
Service layer overview, dependency injection patterns, singleton patterns, repository pattern for data access, service design principles, caching strategies, testing services
|
||||
|
||||
### [middleware-guide.md](references/middleware-guide.md)
|
||||
|
||||
tRPC middleware (withErrorHandling, withOtelInstrumentation, enforceUserIsAuthed), seven tRPC procedure types (publicProcedure, authenticatedProcedure, protectedProjectProcedure, etc.), Public API middleware (withMiddlewares, createAuthedProjectAPIRoute), authentication patterns (NextAuth for tRPC, Basic Auth for Public API)
|
||||
|
||||
### [database-patterns.md](references/database-patterns.md)
|
||||
|
||||
Dual database architecture (PostgreSQL via Prisma + ClickHouse via direct client), PostgreSQL CRUD operations, ClickHouse query patterns (queryClickhouse, queryClickhouseStream, upsertClickhouse), repository pattern for complex queries, tenant isolation with projectId filtering, when to use which database
|
||||
|
||||
### [configuration.md](references/configuration.md)
|
||||
|
||||
Environment variable validation with Zod, package-specific configs (web/env.mjs with t3-oss/env-nextjs, worker/env.ts, shared/env.ts), NEXT_PUBLIC_LANGFUSE_CLOUD_REGION usage, LANGFUSE_EE_LICENSE_KEY for enterprise features, best practices for env management
|
||||
|
||||
### [testing-guide.md](references/testing-guide.md)
|
||||
|
||||
Integration tests (Public API with makeZodVerifiedAPICall), tRPC tests (createInnerTRPCContext, appRouter.createCaller), service-level tests (repository/service functions), worker tests (vitest with streams), test isolation principles, running tests (Jest for web, vitest for worker)
|
||||
|
||||
**Skill Status**: COMPLETE ✅
|
||||
**Line Count**: ~540 lines
|
||||
**Progressive Disclosure**: 7 reference files ✅
|
||||
@@ -1,43 +0,0 @@
|
||||
---
|
||||
name: backend-dev-guidelines
|
||||
description: Shared backend guide for Langfuse's Next.js 14, tRPC, BullMQ, and TypeScript monorepo. Use when creating or reviewing tRPC routers, public REST endpoints, BullMQ queue processors, backend services, middleware, Prisma or ClickHouse data access, OpenTelemetry instrumentation, Zod validation, env configuration, or backend tests across web, worker, or packages/shared.
|
||||
---
|
||||
|
||||
# Backend Development Guidelines
|
||||
|
||||
Use this skill for backend and API work across `web/`, `worker/`, and
|
||||
`packages/shared/`.
|
||||
|
||||
## When to Apply
|
||||
|
||||
- Creating or modifying tRPC routers and procedures
|
||||
- Creating or modifying public API endpoints
|
||||
- Creating or modifying queue processors, producers, or queue-backed workflows
|
||||
- Building or refactoring backend services and repositories
|
||||
- Working on backend auth, middleware, validation, or observability
|
||||
- Updating Prisma or ClickHouse access patterns
|
||||
- Adding or fixing backend tests
|
||||
|
||||
## How to Read This Skill
|
||||
|
||||
- Start with [AGENTS.md](AGENTS.md) when the task spans multiple backend areas
|
||||
or you need the end-to-end checklists.
|
||||
- Read only the specific reference file that matches the work when the scope is
|
||||
narrower.
|
||||
|
||||
## Reference Map
|
||||
|
||||
| Topic | Read this when | File |
|
||||
| --- | --- | --- |
|
||||
| Architecture and package boundaries | You need the web/worker/shared split, request flow, or queue lifecycle | [references/architecture-overview.md](references/architecture-overview.md) |
|
||||
| Routing and controllers | You are writing tRPC procedures, public API routes, or queue entrypoints | [references/routing-and-controllers.md](references/routing-and-controllers.md) |
|
||||
| Middleware and auth | You are changing request auth, permissions, or middleware composition | [references/middleware-guide.md](references/middleware-guide.md) |
|
||||
| Services and repositories | You are placing business logic, repository code, or DI patterns | [references/services-and-repositories.md](references/services-and-repositories.md) |
|
||||
| Database access | You are touching Prisma, ClickHouse, tenant filters, or query patterns | [references/database-patterns.md](references/database-patterns.md) |
|
||||
| Configuration | You are adding env vars, startup config, or runtime toggles | [references/configuration.md](references/configuration.md) |
|
||||
| Testing | You are adding or updating backend tests | [references/testing-guide.md](references/testing-guide.md) |
|
||||
|
||||
## Full Compiled Guide
|
||||
|
||||
Read [AGENTS.md](AGENTS.md) for the complete backend guide with checklists,
|
||||
directory conventions, imports, architecture, and cross-cutting practices.
|
||||
@@ -1,870 +0,0 @@
|
||||
# Architecture Overview - Langfuse Backend
|
||||
|
||||
Complete guide to the layered architecture pattern used in Langfuse's Next.js 14/tRPC/Express monorepo.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Layered Architecture Pattern](#layered-architecture-pattern)
|
||||
- [Request Lifecycle](#request-lifecycle)
|
||||
- [Directory Structure](#directory-structure)
|
||||
- [Module Organization](#module-organization)
|
||||
- [Separation of Concerns](#separation-of-concerns)
|
||||
- [Database Architecture](#database-architecture)
|
||||
|
||||
---
|
||||
|
||||
## Layered Architecture Pattern
|
||||
|
||||
Langfuse uses a **three-layer architecture** with two primary entry points (tRPC and Public API) plus async processing via Worker.
|
||||
|
||||
### The Three Layers
|
||||
|
||||
```
|
||||
# Web Package (Next.js 14)
|
||||
|
||||
┌─ tRPC API ──────────────────┐ ┌── Public REST API ──────────┐
|
||||
│ │ │ │
|
||||
│ HTTP Request │ │ HTTP Request │
|
||||
│ ↓ │ │ ↓ │
|
||||
│ tRPC Procedure │ │ withMiddlewares + │
|
||||
│ (protectedProjectProcedure)│ │ createAuthedProjectAPIRoute│
|
||||
│ ↓ │ │ ↓ │
|
||||
│ Service (business logic) │ │ Service (business logic) │
|
||||
│ ↓ │ │ ↓ │
|
||||
│ Prisma / ClickHouse │ │ Prisma / ClickHouse │
|
||||
│ │ │ │
|
||||
└─────────────────────────────┘ └─────────────────────────────┘
|
||||
↓
|
||||
[optional]: Publish to Redis BullMQ queue
|
||||
↓
|
||||
┌─ Worker Package (Express) ──────────────────────────────────┐
|
||||
│ │
|
||||
│ BullMQ Queue Job │
|
||||
│ ↓ │
|
||||
│ Queue Processor (handles job) │
|
||||
│ ↓ │
|
||||
│ Service (business logic) │
|
||||
│ ↓ │
|
||||
│ Prisma / ClickHouse │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Layer Breakdown
|
||||
|
||||
**Layer 1: API Entry Points**
|
||||
|
||||
Two types of entry points:
|
||||
|
||||
- **tRPC Procedures** - Type-safe RPC for UI
|
||||
- Located in `features/[feature]/server/*Router.ts`
|
||||
- Uses middleware for auth/validation
|
||||
- Types shared between client/server
|
||||
|
||||
- **Public REST APIs** - REST endpoints for SDKs
|
||||
- Located in `pages/api/public/`
|
||||
- Uses `withMiddlewares` + `createAuthedProjectAPIRoute`
|
||||
- Versioned with Zod schemas
|
||||
|
||||
**Layer 2: Services**
|
||||
|
||||
- Business logic and orchestration
|
||||
- Shared between tRPC, Public API, and Worker
|
||||
- Located in `features/[feature]/server/service.ts`
|
||||
- No HTTP/Request/Response knowledge
|
||||
- Use repositories for complex queries or Prisma directly for simple CRUD
|
||||
|
||||
**Layer 3: Data Access**
|
||||
|
||||
- **Repositories** for complex data access patterns (traces, observations, scores, events)
|
||||
- **Direct Prisma** for simple CRUD operations in services
|
||||
- PostgreSQL for transactional data
|
||||
- ClickHouse for analytics/traces (accessed via repositories)
|
||||
- Redis for caching/queues
|
||||
|
||||
**Async Processing Layer: Worker**
|
||||
|
||||
- BullMQ queue processors
|
||||
- Same service layer as Web
|
||||
- Handles long-running operations
|
||||
|
||||
### Why This Architecture?
|
||||
|
||||
**Testability:**
|
||||
|
||||
- tRPC procedures easily testable with type-safe callers
|
||||
- Services tested independently with mocked DB
|
||||
- Queue processors tested with vitest
|
||||
- Clear test boundaries
|
||||
|
||||
**Maintainability:**
|
||||
|
||||
- Business logic isolated in services
|
||||
- tRPC provides type safety end-to-end
|
||||
- Changes to API don't affect service layer
|
||||
- Easy to locate and fix bugs
|
||||
|
||||
**Reusability:**
|
||||
|
||||
- Services used by tRPC, Public API, Worker, and scripts
|
||||
- Business logic not tied to HTTP or tRPC
|
||||
- Consistent patterns across packages
|
||||
|
||||
**Scalability:**
|
||||
|
||||
- Worker handles async operations separately
|
||||
- Easy to add new tRPC procedures
|
||||
- Clear patterns to follow
|
||||
- Shared code in packages/shared
|
||||
|
||||
---
|
||||
|
||||
## Request Lifecycle
|
||||
|
||||
### tRPC Request Flow (UI)
|
||||
|
||||
```typescript
|
||||
1. HTTP POST /api/trpc/datasets.create
|
||||
↓
|
||||
2. Next.js API route catches request (pages/api/trpc/[trpc].ts)
|
||||
↓
|
||||
3. tRPC router resolves procedure:
|
||||
- Match route to procedure in datasetRouter.ts
|
||||
↓
|
||||
4. tRPC middleware chain executes:
|
||||
- protectedProjectProcedure (authentication)
|
||||
- hasEntitlement checks
|
||||
- Input validation with Zod v4
|
||||
↓
|
||||
5. Procedure handler calls service:
|
||||
export const datasetRouter = createTRPCRouter({
|
||||
create: protectedProjectProcedure
|
||||
.input(createDatasetSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
return await createDataset(input, ctx.session);
|
||||
}),
|
||||
})
|
||||
↓
|
||||
6. Service executes business logic:
|
||||
- Validate business rules
|
||||
- Use repositories for complex queries or Prisma directly
|
||||
- ClickHouse queries via repositories if needed
|
||||
↓
|
||||
7. Database operations:
|
||||
- prisma.dataset.create({ data })
|
||||
- clickhouse queries via getTracesTable()
|
||||
↓
|
||||
8. Response flows back:
|
||||
Database → Service → Procedure → tRPC → Client
|
||||
```
|
||||
|
||||
### Public API Request Flow (SDKs)
|
||||
|
||||
```typescript
|
||||
1. HTTP POST /api/public/datasets
|
||||
↓
|
||||
2. Next.js API route handler (pages/api/public/datasets.ts)
|
||||
↓
|
||||
3. withMiddlewares wrapper executes:
|
||||
- Basic auth verification
|
||||
- Rate limiting
|
||||
- CORS handling
|
||||
↓
|
||||
4. createAuthedProjectAPIRoute handler:
|
||||
- Parse and validate request with Zod v4
|
||||
- Extract auth context (project, user)
|
||||
↓
|
||||
5. Handler calls service function:
|
||||
const dataset = await createDataset({
|
||||
name: req.body.name,
|
||||
projectId: req.auth.projectId,
|
||||
});
|
||||
↓
|
||||
6. Service executes (same as tRPC path)
|
||||
↓
|
||||
7. Response formatted and returned:
|
||||
res.status(201).json(dataset);
|
||||
```
|
||||
|
||||
### Worker/Queue Processing Flow
|
||||
|
||||
```typescript
|
||||
1. Job added to Redis BullMQ queue:
|
||||
await evalQueue.add("eval-job", {
|
||||
evalId, projectId
|
||||
});
|
||||
↓
|
||||
2. Worker picks up job from Redis
|
||||
↓
|
||||
3. Queue processor handles job:
|
||||
// worker/src/queues/evalQueue.ts
|
||||
async process(job: Job<EvalJobType>) {
|
||||
await processEvaluation(job.data);
|
||||
}
|
||||
↓
|
||||
4. Processor calls service:
|
||||
- Same service layer as Web
|
||||
- Business logic execution
|
||||
↓
|
||||
5. Service performs operations:
|
||||
- Prisma transactions
|
||||
- ClickHouse queries
|
||||
- External API calls (LLMs)
|
||||
↓
|
||||
6. Job completes or fails:
|
||||
- Success: job.updateProgress(100)
|
||||
- Failure: throw error for retry
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Directory Structure
|
||||
|
||||
### Web Package (`/web/src/`)
|
||||
|
||||
```
|
||||
web/src/
|
||||
├── features/ # Feature-organized code
|
||||
│ ├── datasets/
|
||||
│ │ ├── server/ # Backend logic
|
||||
│ │ │ ├── datasetRouter.ts # tRPC router
|
||||
│ │ │ └── datasetService.ts # Business logic
|
||||
│ │ ├── components/ # React components
|
||||
│ │ └── types/ # Feature types
|
||||
│ │
|
||||
│ ├── public-api/
|
||||
│ │ ├── server/
|
||||
│ │ │ ├── withMiddlewares.ts
|
||||
│ │ │ └── createAuthedProjectAPIRoute.ts
|
||||
│ │ └── types/ # API schemas
|
||||
│ │
|
||||
│ └── [feature-name]/
|
||||
│ ├── server/
|
||||
│ │ ├── *Router.ts # tRPC router
|
||||
│ │ └── service.ts # Business logic
|
||||
│ ├── components/
|
||||
│ └── types/
|
||||
│
|
||||
├── server/
|
||||
│ ├── api/
|
||||
│ │ ├── routers/ # tRPC routers
|
||||
│ │ ├── trpc.ts # tRPC setup & middleware
|
||||
│ │ └── root.ts # Main router combining all
|
||||
│ ├── auth.ts # NextAuth.js config
|
||||
│ └── db.ts # Database utilities
|
||||
│
|
||||
├── pages/
|
||||
│ ├── api/
|
||||
│ │ ├── public/ # Public REST APIs
|
||||
│ │ │ ├── datasets.ts
|
||||
│ │ │ └── traces.ts
|
||||
│ │ └── trpc/
|
||||
│ │ └── [trpc].ts # tRPC endpoint
|
||||
│ └── [routes].tsx # Next.js pages
|
||||
│
|
||||
├── __tests__/ # Jest tests
|
||||
│ ├── async/ # Integration tests
|
||||
│ └── sync/ # Unit tests
|
||||
│
|
||||
├── instrumentation.ts # OpenTelemetry (FIRST IMPORT)
|
||||
└── env.mjs # Environment config
|
||||
```
|
||||
|
||||
### Worker Package (`/worker/src/`)
|
||||
|
||||
```
|
||||
worker/src/
|
||||
├── queues/ # BullMQ processors
|
||||
│ ├── evalQueue.ts # Evaluation jobs
|
||||
│ ├── ingestionQueue.ts # Data ingestion
|
||||
│ ├── batchExportQueue.ts # Batch exports
|
||||
│ └── workerManager.ts # Queue registration
|
||||
│
|
||||
├── features/ # Business logic
|
||||
│ └── [feature]/
|
||||
│ └── service.ts
|
||||
│
|
||||
├── __tests__/ # Vitest tests
|
||||
│
|
||||
├── instrumentation.ts # OpenTelemetry (FIRST IMPORT)
|
||||
├── app.ts # Express setup + queue registration
|
||||
├── env.ts # Environment config
|
||||
└── index.ts # Server start
|
||||
```
|
||||
|
||||
### Shared Package (`/packages/shared/`)
|
||||
|
||||
The shared package provides types, utilities, and server code used by both web and worker packages. It has **5 export paths** that control frontend vs backend access:
|
||||
|
||||
| Import Path | Usage | What's Included |
|
||||
| ------------------------------------------ | --------------------- | ---------------------------------------------------------------------------------- |
|
||||
| `@langfuse/shared` | ✅ Frontend + Backend | Prisma types, Zod schemas, constants, table definitions, domain models, utilities |
|
||||
| `@langfuse/shared/src/db` | 🔒 Backend only | Prisma client instance |
|
||||
| `@langfuse/shared/src/server` | 🔒 Backend only | Services, repositories, queues, auth, ClickHouse, LLM integration, instrumentation |
|
||||
| `@langfuse/shared/src/server/auth/apiKeys` | 🔒 Backend only | API key management (separated to avoid circular deps) |
|
||||
| `@langfuse/shared/encryption` | 🔒 Backend only | Database field encryption/decryption |
|
||||
|
||||
**Key Structure:**
|
||||
|
||||
```
|
||||
packages/shared/src/
|
||||
├── server/ # 🔒 All server-only code
|
||||
│ ├── auth/ # Authentication & authorization
|
||||
│ ├── clickhouse/ # ClickHouse client & queries
|
||||
│ ├── redis/ # Redis client & 30+ queue types
|
||||
│ ├── repositories/ # Data access (traces, observations, scores, events)
|
||||
│ ├── services/ # Business services (Storage, Email, Slack, etc.)
|
||||
│ ├── llm/ # LLM integration
|
||||
│ ├── instrumentation/ # OpenTelemetry
|
||||
│ └── queues.ts, logger.ts, filterToPrisma.ts, etc.
|
||||
│
|
||||
├── features/ # ✅ Feature types (evals, scores, prompts, datasets)
|
||||
├── domain/ # ✅ Domain models (automations, webhooks, etc.)
|
||||
├── tableDefinitions/ # ✅ Table schemas
|
||||
├── interfaces/ # ✅ Shared interfaces (filters, orderBy)
|
||||
├── utils/ # ✅ Utilities (JSON, Zod, string checks)
|
||||
├── encryption/ # 🔒 Encryption utilities
|
||||
└── db.ts, constants.ts, types.ts, etc.
|
||||
```
|
||||
|
||||
**Common Import Patterns:**
|
||||
|
||||
```typescript
|
||||
// ✅ Main export - Safe for frontend + backend
|
||||
import {
|
||||
Prisma,
|
||||
Role,
|
||||
type Dataset,
|
||||
CloudConfigSchema,
|
||||
} from "@langfuse/shared";
|
||||
|
||||
// 🔒 Database - Backend only
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
|
||||
// 🔒 Server utilities - Backend only
|
||||
import {
|
||||
logger,
|
||||
instrumentAsync,
|
||||
traceException,
|
||||
redis,
|
||||
clickhouseClient,
|
||||
StorageService,
|
||||
fetchLLMCompletion,
|
||||
filterToPrisma,
|
||||
} from "@langfuse/shared/src/server";
|
||||
|
||||
// 🔒 API keys - Backend only
|
||||
import { createAndAddApiKeysToDb } from "@langfuse/shared/src/server/auth/apiKeys";
|
||||
|
||||
// 🔒 Encryption - Backend only
|
||||
import { encrypt, decrypt } from "@langfuse/shared/encryption";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Module Organization
|
||||
|
||||
### Feature-Based Organization (Recommended)
|
||||
|
||||
For most features, organize by domain within `features/`:
|
||||
|
||||
```
|
||||
src/features/datasets/
|
||||
├── server/ # Backend code
|
||||
│ ├── datasetRouter.ts # tRPC procedures
|
||||
│ └── service.ts # Business logic
|
||||
├── components/ # React components
|
||||
│ ├── DatasetTable.tsx
|
||||
│ └── DatasetForm.tsx
|
||||
├── types/ # Feature types
|
||||
│ └── index.ts
|
||||
└── utils/ # Feature utilities
|
||||
```
|
||||
|
||||
**When to use:**
|
||||
|
||||
- Any feature with UI + API
|
||||
- Clear domain boundary
|
||||
- Multiple related procedures
|
||||
|
||||
### Subdomain Organization
|
||||
|
||||
For complex features with multiple subdomains:
|
||||
|
||||
```
|
||||
src/features/evaluations/
|
||||
├── server/
|
||||
│ ├── evalRouter.ts # Main router
|
||||
│ ├── evalService.ts # Core service
|
||||
│ ├── templates/ # Template subdomain
|
||||
│ │ ├── templateRouter.ts
|
||||
│ │ └── templateService.ts
|
||||
│ └── configs/ # Config subdomain
|
||||
│ ├── configRouter.ts
|
||||
│ └── configService.ts
|
||||
├── components/
|
||||
│ ├── templates/
|
||||
│ └── configs/
|
||||
└── types/
|
||||
```
|
||||
|
||||
**When to use:**
|
||||
|
||||
- Feature has 10+ files
|
||||
- Clear subdomains exist
|
||||
- Logical grouping improves clarity
|
||||
|
||||
### Flat Organization (Rare)
|
||||
|
||||
For small, standalone features:
|
||||
|
||||
```
|
||||
src/server/api/routers/
|
||||
├── healthRouter.ts # Simple health check
|
||||
└── versionRouter.ts # Version info
|
||||
```
|
||||
|
||||
**When to use:**
|
||||
|
||||
- Simple features (1-2 procedures)
|
||||
- No UI components
|
||||
- Standalone utilities
|
||||
|
||||
---
|
||||
|
||||
## Separation of Concerns
|
||||
|
||||
### What Goes Where
|
||||
|
||||
**tRPC Procedures (Entry Layer):**
|
||||
|
||||
- ✅ Procedure definitions (query/mutation)
|
||||
- ✅ Middleware application (auth, validation)
|
||||
- ✅ Input schemas (Zod v4)
|
||||
- ✅ Service delegation
|
||||
- ✅ Error transformation (TRPCError)
|
||||
- ❌ Business logic (belongs in services)
|
||||
- ❌ Database operations (belongs in services)
|
||||
- ❌ Complex validation (belongs in services)
|
||||
|
||||
**Public API Routes (Entry Layer):**
|
||||
|
||||
- ✅ Route registration
|
||||
- ✅ Middleware wrapper application
|
||||
- ✅ Input validation (Zod v4)
|
||||
- ✅ Service delegation
|
||||
- ✅ Response formatting
|
||||
- ✅ HTTP status codes
|
||||
- ❌ Business logic (belongs in services)
|
||||
- ❌ Database operations (belongs in services)
|
||||
|
||||
**Services Layer:**
|
||||
|
||||
- ✅ Business logic
|
||||
- ✅ Business rules enforcement
|
||||
- ✅ Transaction orchestration
|
||||
- ✅ Repository calls for complex queries
|
||||
- ✅ Direct Prisma operations for simple CRUD
|
||||
- ✅ ClickHouse queries (via repositories)
|
||||
- ✅ Redis cache access
|
||||
- ✅ External API calls (LLMs, etc.)
|
||||
- ❌ HTTP concerns (Request/Response)
|
||||
- ❌ tRPC-specific types (TRPCError in entry layer)
|
||||
- ❌ NextAuth session handling (passed as parameter)
|
||||
|
||||
**Queue Processors (Worker):**
|
||||
|
||||
- ✅ Job registration and configuration
|
||||
- ✅ Job data extraction
|
||||
- ✅ Service delegation
|
||||
- ✅ Progress updates
|
||||
- ✅ Error handling (retry logic)
|
||||
- ❌ Business logic (belongs in services)
|
||||
- ❌ Database operations (belongs in services)
|
||||
|
||||
### Example: Dataset Creation
|
||||
|
||||
**tRPC Procedure (Entry Point):**
|
||||
|
||||
```typescript
|
||||
// web/src/features/datasets/server/datasetRouter.ts
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
createTRPCRouter,
|
||||
protectedProjectProcedure,
|
||||
} from "@/src/server/api/trpc";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { createDataset } from "./service";
|
||||
|
||||
export const datasetRouter = createTRPCRouter({
|
||||
create: protectedProjectProcedure
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
projectId: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
return await createDataset({
|
||||
...input,
|
||||
userId: ctx.session.user.id,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to create dataset",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
**Service (Business Logic):**
|
||||
|
||||
```typescript
|
||||
// web/src/features/datasets/server/service.ts
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { instrumentAsync, traceException } from "@langfuse/shared/src/server";
|
||||
|
||||
export async function createDataset(data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
projectId: string;
|
||||
userId: string;
|
||||
}) {
|
||||
return await instrumentAsync({ name: "dataset.create" }, async (span) => {
|
||||
// Business rule: Check for duplicate names in project
|
||||
const existing = await prisma.dataset.findFirst({
|
||||
where: {
|
||||
name: data.name,
|
||||
projectId: data.projectId,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new Error(`Dataset with name "${data.name}" already exists`);
|
||||
}
|
||||
|
||||
// Create dataset
|
||||
const dataset = await prisma.dataset.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
projectId: data.projectId,
|
||||
createdById: data.userId,
|
||||
},
|
||||
});
|
||||
|
||||
span.setAttributes({
|
||||
datasetId: dataset.id,
|
||||
projectId: dataset.projectId,
|
||||
});
|
||||
|
||||
return dataset;
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Public API (Alternative Entry Point):**
|
||||
|
||||
```typescript
|
||||
// web/src/pages/api/public/datasets.ts
|
||||
import { withMiddlewares } from "@/src/features/public-api/server/withMiddlewares";
|
||||
import { createAuthedProjectAPIRoute } from "@/src/features/public-api/server/createAuthedProjectAPIRoute";
|
||||
import { createDataset } from "@/src/features/datasets/server/service";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const createDatasetSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export default withMiddlewares({
|
||||
POST: createAuthedProjectAPIRoute({
|
||||
name: "Create Dataset",
|
||||
bodySchema: createDatasetSchema,
|
||||
fn: async ({ body, auth, res }) => {
|
||||
const dataset = await createDataset({
|
||||
name: body.name,
|
||||
description: body.description,
|
||||
projectId: auth.scope.projectId,
|
||||
userId: auth.scope.userId,
|
||||
});
|
||||
|
||||
return res.status(201).json(dataset);
|
||||
},
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
**Queue Processor (Async Processing):**
|
||||
|
||||
```typescript
|
||||
// worker/src/queues/datasetExportQueue.ts
|
||||
import { Job } from "bullmq";
|
||||
import { exportDataset } from "../features/datasets/exportService";
|
||||
|
||||
export async function processDatasetExport(
|
||||
job: Job<{ datasetId: string; projectId: string; format: string }>,
|
||||
) {
|
||||
const { datasetId, projectId, format } = job.data;
|
||||
|
||||
await job.updateProgress(10);
|
||||
|
||||
// Delegate to service
|
||||
const exportUrl = await exportDataset({
|
||||
datasetId,
|
||||
projectId,
|
||||
format,
|
||||
onProgress: (percent) => job.updateProgress(percent),
|
||||
});
|
||||
|
||||
await job.updateProgress(100);
|
||||
|
||||
return { exportUrl };
|
||||
}
|
||||
```
|
||||
|
||||
**Notice:** Each layer has clear, distinct responsibilities!
|
||||
|
||||
- **Entry layers** (tRPC/Public API/Queue) handle protocol concerns
|
||||
- **Service layer** contains all business logic
|
||||
- **Data layer** accessed via repositories (complex queries) or Prisma directly (simple CRUD)
|
||||
|
||||
---
|
||||
|
||||
## Database Architecture
|
||||
|
||||
### Dual Database System
|
||||
|
||||
Langfuse uses two databases with different purposes:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Application │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ PostgreSQL │ │ ClickHouse │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ Transactional│ │ Analytics │ │
|
||||
│ │ Data │ │ Data │ │
|
||||
│ └──────────────┘ └──────────────┘ │
|
||||
│ ↑ ↑ │
|
||||
│ │ │ │
|
||||
│ Prisma ORM Direct SQL │
|
||||
│ (schema migrations) (via client) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**PostgreSQL (Primary Database):**
|
||||
|
||||
- Accessed via Prisma ORM
|
||||
- Transactional data (users, projects, datasets, etc.)
|
||||
- ACID guarantees
|
||||
- Schema managed via `prisma migrate`
|
||||
- Located in `packages/shared/prisma/`
|
||||
|
||||
**ClickHouse (Analytics Database):**
|
||||
|
||||
- Accessed via direct SQL queries
|
||||
- High-volume trace/observation data
|
||||
- Columnar storage for analytics
|
||||
- Optimized for aggregations
|
||||
- Schema in `packages/shared/src/server/clickhouse/`
|
||||
- Schema managed via `golang-migrate`
|
||||
|
||||
**Redis (Cache & Queues):**
|
||||
|
||||
- BullMQ job queues
|
||||
- Caching layer
|
||||
- Session storage
|
||||
- Rate limiting
|
||||
|
||||
### Data Access Pattern
|
||||
|
||||
**Services access databases directly:**
|
||||
|
||||
```typescript
|
||||
// PostgreSQL via Prisma
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
|
||||
const dataset = await prisma.dataset.create({ data });
|
||||
|
||||
// ClickHouse via helper functions
|
||||
import { getTracesTable } from "@langfuse/shared/src/server";
|
||||
|
||||
const traces = await getTracesTable({
|
||||
projectId,
|
||||
filter: [...],
|
||||
limit: 1000,
|
||||
});
|
||||
|
||||
// Redis via queue/cache utilities
|
||||
import { redis } from "@langfuse/shared/src/server";
|
||||
|
||||
await redis.set(`cache:${key}`, value, "EX", 3600);
|
||||
```
|
||||
|
||||
**Repository Pattern:**
|
||||
|
||||
Langfuse uses repositories in `packages/shared/src/server/repositories/` for complex data access patterns. Repositories provide:
|
||||
|
||||
- Abstraction over complex queries (traces, observations, scores, events)
|
||||
- Data converters for transforming database models to application models
|
||||
- ClickHouse query builders and stream processing
|
||||
- Reusable query logic across services
|
||||
|
||||
Services can use repositories for complex operations OR Prisma directly for simple CRUD operations.
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Keep Procedures Thin
|
||||
|
||||
tRPC procedures should only handle protocol concerns:
|
||||
|
||||
```typescript
|
||||
// ❌ BAD: Business logic in procedure
|
||||
export const datasetRouter = createTRPCRouter({
|
||||
create: protectedProjectProcedure
|
||||
.input(createSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
// 200 lines of business logic here
|
||||
const existing = await prisma.dataset.findFirst(...);
|
||||
if (existing) throw new Error(...);
|
||||
const dataset = await prisma.dataset.create(...);
|
||||
await sendNotification(...);
|
||||
return dataset;
|
||||
}),
|
||||
});
|
||||
|
||||
// ✅ GOOD: Delegate to service
|
||||
export const datasetRouter = createTRPCRouter({
|
||||
create: protectedProjectProcedure
|
||||
.input(createSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
return await createDataset(input, ctx.session);
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Services Should Be Protocol-Agnostic
|
||||
|
||||
Services should work regardless of entry point:
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD: No HTTP/tRPC knowledge
|
||||
export async function createDataset(data: CreateDatasetInput) {
|
||||
// Pure business logic
|
||||
return await prisma.dataset.create({ data });
|
||||
}
|
||||
|
||||
// ❌ BAD: tRPC-specific
|
||||
export async function createDataset(ctx: TRPCContext) {
|
||||
// Coupled to tRPC
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Observability with OpenTelemetry + DataDog
|
||||
|
||||
**Langfuse uses OpenTelemetry for backend observability, with traces and logs sent to DataDog.**
|
||||
|
||||
Use structured logging and instrumentation:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
logger,
|
||||
traceException,
|
||||
instrumentAsync,
|
||||
} from "@langfuse/shared/src/server";
|
||||
|
||||
export async function processEvaluation(evalId: string) {
|
||||
return await instrumentAsync(
|
||||
{ name: "evaluation.process", attributes: { evalId } },
|
||||
async (span) => {
|
||||
// Structured logging (includes trace_id, span_id, dd.trace_id)
|
||||
logger.info("Starting evaluation", { evalId });
|
||||
|
||||
try {
|
||||
// Operation here
|
||||
const result = await runEvaluation(evalId);
|
||||
|
||||
span.setAttributes({
|
||||
score: result.score,
|
||||
status: "success",
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
// Record exception to OpenTelemetry span (sent to DataDog)
|
||||
traceException(error, span);
|
||||
logger.error("Evaluation failed", { evalId, error: error.message });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: Frontend uses Sentry for error tracking, but backend (tRPC, API routes, services, worker) uses OpenTelemetry + DataDog.
|
||||
|
||||
### 4. Use Proper Error Handling
|
||||
|
||||
Transform errors at entry points:
|
||||
|
||||
```typescript
|
||||
// tRPC procedure
|
||||
try {
|
||||
return await service();
|
||||
} catch (error) {
|
||||
traceException(error); // Record to OpenTelemetry span (sent to DataDog)
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "User-friendly message",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
// Public API
|
||||
try {
|
||||
return await service();
|
||||
} catch (error) {
|
||||
traceException(error); // Record to OpenTelemetry span (sent to DataDog)
|
||||
return res.status(500).json({
|
||||
error: "User-friendly message",
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Validate at Entry Points
|
||||
|
||||
Use Zod v4 for all input validation:
|
||||
|
||||
```typescript
|
||||
import { z } from "zod/v4";
|
||||
|
||||
// tRPC
|
||||
.input(z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
projectId: z.string(),
|
||||
}))
|
||||
|
||||
// Public API
|
||||
const bodySchema = z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
});
|
||||
const validated = bodySchema.parse(req.body);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Related Files:**
|
||||
|
||||
- [../AGENTS.md](../AGENTS.md) - Main guide
|
||||
- [routing-and-controllers.md](routing-and-controllers.md) - tRPC and Public API details
|
||||
- [services-and-repositories.md](services-and-repositories.md) - Service patterns
|
||||
- [testing-guide.md](testing-guide.md) - Testing strategies
|
||||
@@ -1,563 +0,0 @@
|
||||
# Configuration Management - Environment Variables
|
||||
|
||||
Complete guide to managing configuration across Langfuse's monorepo packages.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Environment Variable Pattern](#environment-variable-pattern)
|
||||
- [Package-Specific Configuration](#package-specific-configuration)
|
||||
- [Special Environment Variables](#special-environment-variables)
|
||||
- [Best Practices](#best-practices)
|
||||
|
||||
---
|
||||
|
||||
## Environment Variable Pattern
|
||||
|
||||
### Why Zod-Validated Environment Variables?
|
||||
|
||||
**Problems with raw process.env:**
|
||||
|
||||
- ❌ No type safety
|
||||
- ❌ No validation
|
||||
- ❌ Hard to test
|
||||
- ❌ Runtime errors for typos
|
||||
- ❌ No default values
|
||||
|
||||
**Benefits of Zod validation:**
|
||||
|
||||
- ✅ Type-safe configuration
|
||||
- ✅ Validated at startup
|
||||
- ✅ Clear error messages
|
||||
- ✅ Default values
|
||||
- ✅ Environment-specific transformation
|
||||
|
||||
---
|
||||
|
||||
## Package-Specific Configuration
|
||||
|
||||
Each package has its own `env.ts` or `env.mjs` file that validates and exports environment variables:
|
||||
|
||||
```
|
||||
langfuse/
|
||||
├── web/src/env.mjs # Next.js app (t3-env pattern)
|
||||
├── worker/src/env.ts # Worker service (Zod schema)
|
||||
├── packages/shared/src/env.ts # Shared config (Zod schema)
|
||||
└── ee/src/env.ts # Enterprise Edition (Zod schema)
|
||||
```
|
||||
|
||||
### Web Package (`web/src/env.mjs`)
|
||||
|
||||
Uses **t3-oss/env-nextjs** for Next.js-specific validation with server/client separation.
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- Separates server-side and client-side environment variables
|
||||
- Client variables must be prefixed with `NEXT_PUBLIC_`
|
||||
- Validates at build time (unless `DOCKER_BUILD=1`)
|
||||
- `runtimeEnv` section manually maps all variables
|
||||
|
||||
**Structure:**
|
||||
|
||||
```typescript
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod";
|
||||
|
||||
export const env = createEnv({
|
||||
// Server-side only variables (never exposed to client)
|
||||
server: {
|
||||
DATABASE_URL: z.string().url(),
|
||||
NEXTAUTH_SECRET: z.string().min(1),
|
||||
SALT: z.string(),
|
||||
CLICKHOUSE_URL: z.string().url(),
|
||||
// ... 100+ server variables
|
||||
},
|
||||
|
||||
// Client-side variables (exposed to browser)
|
||||
client: {
|
||||
NEXT_PUBLIC_LANGFUSE_CLOUD_REGION: z
|
||||
.enum(["US", "EU", "STAGING", "DEV", "HIPAA", "JP"])
|
||||
.optional(),
|
||||
NEXT_PUBLIC_SIGN_UP_DISABLED: z.enum(["true", "false"]).default("false"),
|
||||
// ... client variables
|
||||
},
|
||||
|
||||
// Runtime mapping (required for Next.js edge runtime)
|
||||
runtimeEnv: {
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
|
||||
NEXT_PUBLIC_LANGFUSE_CLOUD_REGION:
|
||||
process.env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION,
|
||||
// ... must map ALL variables
|
||||
},
|
||||
|
||||
// Skip validation in Docker builds
|
||||
skipValidation: process.env.DOCKER_BUILD === "1",
|
||||
emptyStringAsUndefined: true,
|
||||
});
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
```typescript
|
||||
// In server-side code (tRPC, API routes)
|
||||
import { env } from "@/src/env.mjs";
|
||||
|
||||
const dbUrl = env.DATABASE_URL;
|
||||
const salt = env.SALT;
|
||||
|
||||
// In client-side code (React components)
|
||||
import { env } from "@/src/env.mjs";
|
||||
|
||||
const region = env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION;
|
||||
```
|
||||
|
||||
### Worker Package (`worker/src/env.ts`)
|
||||
|
||||
Uses **plain Zod schema** for Express.js worker service.
|
||||
|
||||
**Structure:**
|
||||
|
||||
```typescript
|
||||
import { z } from "zod/v4";
|
||||
import { removeEmptyEnvVariables } from "@langfuse/shared";
|
||||
|
||||
const EnvSchema = z.object({
|
||||
BUILD_ID: z.string().optional(),
|
||||
NODE_ENV: z
|
||||
.enum(["development", "test", "production"])
|
||||
.default("development"),
|
||||
DATABASE_URL: z.string(),
|
||||
PORT: z.coerce.number().positive().max(65536).default(3030),
|
||||
|
||||
// ClickHouse
|
||||
CLICKHOUSE_URL: z.string().url(),
|
||||
CLICKHOUSE_USER: z.string(),
|
||||
CLICKHOUSE_PASSWORD: z.string(),
|
||||
|
||||
// S3 Event Upload (required)
|
||||
LANGFUSE_S3_EVENT_UPLOAD_BUCKET: z.string({
|
||||
error: "Langfuse requires a bucket name for S3 Event Uploads.",
|
||||
}),
|
||||
|
||||
// Queue concurrency settings
|
||||
LANGFUSE_INGESTION_QUEUE_PROCESSING_CONCURRENCY: z.coerce
|
||||
.number()
|
||||
.positive()
|
||||
.default(20),
|
||||
LANGFUSE_EVAL_EXECUTION_WORKER_CONCURRENCY: z.coerce
|
||||
.number()
|
||||
.positive()
|
||||
.default(5),
|
||||
|
||||
// Queue consumer toggles
|
||||
QUEUE_CONSUMER_INGESTION_QUEUE_IS_ENABLED: z
|
||||
.enum(["true", "false"])
|
||||
.default("true"),
|
||||
QUEUE_CONSUMER_BATCH_EXPORT_QUEUE_IS_ENABLED: z
|
||||
.enum(["true", "false"])
|
||||
.default("true"),
|
||||
|
||||
// ... 150+ worker-specific variables
|
||||
});
|
||||
|
||||
export const env: z.infer<typeof EnvSchema> =
|
||||
process.env.DOCKER_BUILD === "1"
|
||||
? (process.env as any)
|
||||
: EnvSchema.parse(removeEmptyEnvVariables(process.env));
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
```typescript
|
||||
import { env } from "./env";
|
||||
|
||||
const concurrency = env.LANGFUSE_INGESTION_QUEUE_PROCESSING_CONCURRENCY;
|
||||
const s3Bucket = env.LANGFUSE_S3_EVENT_UPLOAD_BUCKET;
|
||||
```
|
||||
|
||||
### Shared Package (`packages/shared/src/env.ts`)
|
||||
|
||||
Uses **plain Zod schema** for configuration shared between web and worker.
|
||||
|
||||
**Structure:**
|
||||
|
||||
```typescript
|
||||
import { z } from "zod/v4";
|
||||
import { removeEmptyEnvVariables } from "./utils/environment";
|
||||
|
||||
const EnvSchema = z.object({
|
||||
NODE_ENV: z
|
||||
.enum(["development", "test", "production"])
|
||||
.default("development"),
|
||||
|
||||
// Redis configuration
|
||||
REDIS_HOST: z.string().nullish(),
|
||||
REDIS_PORT: z.coerce.number().positive().max(65536).default(6379).nullable(),
|
||||
REDIS_AUTH: z.string().nullish(),
|
||||
REDIS_CONNECTION_STRING: z.string().nullish(),
|
||||
REDIS_CLUSTER_ENABLED: z.enum(["true", "false"]).default("false"),
|
||||
|
||||
// ClickHouse
|
||||
CLICKHOUSE_URL: z.string().url(),
|
||||
CLICKHOUSE_USER: z.string(),
|
||||
CLICKHOUSE_PASSWORD: z.string(),
|
||||
CLICKHOUSE_MAX_OPEN_CONNECTIONS: z.coerce.number().int().default(25),
|
||||
|
||||
// S3 Event Upload
|
||||
LANGFUSE_S3_EVENT_UPLOAD_BUCKET: z.string(),
|
||||
LANGFUSE_S3_EVENT_UPLOAD_REGION: z.string().optional(),
|
||||
|
||||
// Logging
|
||||
LANGFUSE_LOG_LEVEL: z
|
||||
.enum(["trace", "debug", "info", "warn", "error", "fatal"])
|
||||
.optional(),
|
||||
LANGFUSE_LOG_FORMAT: z.enum(["text", "json"]).default("text"),
|
||||
|
||||
// Encryption
|
||||
ENCRYPTION_KEY: z
|
||||
.string()
|
||||
.length(
|
||||
64,
|
||||
"ENCRYPTION_KEY must be 256 bits, 64 string characters in hex format, generate via: openssl rand -hex 32",
|
||||
)
|
||||
.optional(),
|
||||
|
||||
// ... 80+ shared variables
|
||||
});
|
||||
|
||||
export const env: z.infer<typeof EnvSchema> =
|
||||
process.env.DOCKER_BUILD === "1"
|
||||
? (process.env as any)
|
||||
: EnvSchema.parse(removeEmptyEnvVariables(process.env));
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
```typescript
|
||||
import { env } from "@langfuse/shared/src/env";
|
||||
|
||||
const redisHost = env.REDIS_HOST;
|
||||
const clickhouseUrl = env.CLICKHOUSE_URL;
|
||||
```
|
||||
|
||||
### Enterprise Edition Package (`ee/src/env.ts`)
|
||||
|
||||
Minimal Zod schema for EE-specific variables.
|
||||
|
||||
**Structure:**
|
||||
|
||||
```typescript
|
||||
import { z } from "zod/v4";
|
||||
import { removeEmptyEnvVariables } from "@langfuse/shared";
|
||||
|
||||
const EnvSchema = z.object({
|
||||
NEXT_PUBLIC_LANGFUSE_CLOUD_REGION: z.string().optional(),
|
||||
LANGFUSE_EE_LICENSE_KEY: z.string().optional(),
|
||||
});
|
||||
|
||||
export const env = EnvSchema.parse(removeEmptyEnvVariables(process.env));
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
```typescript
|
||||
import { env } from "@langfuse/ee/src/env";
|
||||
|
||||
const licenseKey = env.LANGFUSE_EE_LICENSE_KEY;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Special Environment Variables
|
||||
|
||||
### NEXT_PUBLIC_LANGFUSE_CLOUD_REGION
|
||||
|
||||
**Purpose:** Identifies the cloud deployment region for Langfuse Cloud.
|
||||
|
||||
**Type:** `"US" | "EU" | "STAGING" | "DEV" | "HIPAA" | "JP" | undefined`
|
||||
|
||||
**Where Used:**
|
||||
|
||||
- **web/src/env.mjs** - Client-side accessible (prefixed with `NEXT_PUBLIC_`)
|
||||
- **ee/src/env.ts** - Enterprise features
|
||||
- **packages/shared/src/env.ts** - Shared logic
|
||||
- **worker/src/env.ts** - Worker processing
|
||||
|
||||
**When Set:**
|
||||
|
||||
| Environment | Value | Purpose |
|
||||
|--------------------------|------------------------|------------------------------------------------|
|
||||
| **Developer Laptop** | `"DEV"` or `"STAGING"` | Local development against cloud infrastructure |
|
||||
| **Langfuse Cloud US** | `"US"` | Production US region |
|
||||
| **Langfuse Cloud EU** | `"EU"` | Production EU region |
|
||||
| **Langfuse Cloud HIPAA** | `"HIPAA"` | HIPAA-compliant region |
|
||||
| **Langfuse Cloud JP** | `"JP"` | Production JP region |
|
||||
| **OSS Self-Hosted** | `undefined` (not set) | Self-hosted deployments don't have region |
|
||||
|
||||
**Use Cases:**
|
||||
|
||||
```typescript
|
||||
// Check if running in cloud
|
||||
if (env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION) {
|
||||
// Enable cloud-specific features
|
||||
- Usage metering and billing
|
||||
- Cloud spend alerts
|
||||
- Free tier enforcement
|
||||
- Stripe integration
|
||||
- PostHog analytics
|
||||
}
|
||||
|
||||
// Region-specific behavior
|
||||
if (env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "HIPAA") {
|
||||
// HIPAA compliance features
|
||||
}
|
||||
|
||||
// Development/staging checks
|
||||
if (env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "DEV") {
|
||||
// Enable debug features
|
||||
}
|
||||
```
|
||||
|
||||
**Example Configuration:**
|
||||
|
||||
```bash
|
||||
# .env file on developer laptop
|
||||
NEXT_PUBLIC_LANGFUSE_CLOUD_REGION=DEV
|
||||
|
||||
# Cloud US deployment
|
||||
NEXT_PUBLIC_LANGFUSE_CLOUD_REGION=US
|
||||
|
||||
# Self-hosted OSS deployment
|
||||
# (variable not set)
|
||||
```
|
||||
|
||||
### LANGFUSE_EE_LICENSE_KEY
|
||||
|
||||
**Purpose:** Enables Enterprise Edition features in self-hosted deployments.
|
||||
|
||||
**Type:** `string | undefined`
|
||||
|
||||
**Where Used:**
|
||||
|
||||
- **web/src/env.mjs** - Web app EE features
|
||||
- **ee/src/env.ts** - EE package
|
||||
|
||||
**When Set:**
|
||||
|
||||
| Deployment | Value | Features Enabled |
|
||||
| ------------------- | ------------------ | ---------------------------------------------------------------- |
|
||||
| **Langfuse Cloud** | Not set | Cloud features controlled by `NEXT_PUBLIC_LANGFUSE_CLOUD_REGION` |
|
||||
| **OSS Self-Hosted** | Not set | Core open-source features only |
|
||||
| **EE Self-Hosted** | License key string | Enterprise features enabled |
|
||||
|
||||
**Enterprise Features Controlled:**
|
||||
|
||||
When `LANGFUSE_EE_LICENSE_KEY` is set and valid:
|
||||
|
||||
- SSO integrations (custom OIDC, SAML)
|
||||
- Advanced RBAC
|
||||
- Audit logging
|
||||
- Custom branding
|
||||
- SLA support
|
||||
- Advanced security features
|
||||
|
||||
**Usage Pattern:**
|
||||
|
||||
```typescript
|
||||
import { env } from "@/src/env.mjs";
|
||||
|
||||
// Check if EE license is present
|
||||
if (env.LANGFUSE_EE_LICENSE_KEY) {
|
||||
// Validate license
|
||||
const isValidLicense = await validateEELicense(env.LANGFUSE_EE_LICENSE_KEY);
|
||||
|
||||
if (isValidLicense) {
|
||||
// Enable EE features
|
||||
enableCustomSSO();
|
||||
enableAdvancedRBAC();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Example Configuration:**
|
||||
|
||||
```bash
|
||||
# OSS self-hosted (no license)
|
||||
# LANGFUSE_EE_LICENSE_KEY not set
|
||||
|
||||
# EE self-hosted
|
||||
LANGFUSE_EE_LICENSE_KEY=ee_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# Langfuse Cloud (uses region instead)
|
||||
NEXT_PUBLIC_LANGFUSE_CLOUD_REGION=US
|
||||
# LANGFUSE_EE_LICENSE_KEY not used
|
||||
```
|
||||
|
||||
### Other Important Variables
|
||||
|
||||
**DOCKER_BUILD**
|
||||
|
||||
```typescript
|
||||
// Skip validation during Docker builds
|
||||
skipValidation: process.env.DOCKER_BUILD === "1";
|
||||
```
|
||||
|
||||
**Purpose:** Docker builds happen before runtime env vars are available, so validation must be skipped.
|
||||
|
||||
**SALT**
|
||||
|
||||
```typescript
|
||||
SALT: z.string({
|
||||
required_error: "A strong Salt is required to encrypt API keys securely.",
|
||||
});
|
||||
```
|
||||
|
||||
**Purpose:** Required for encrypting API keys in database. Must be set in production.
|
||||
|
||||
**ENCRYPTION_KEY**
|
||||
|
||||
```typescript
|
||||
ENCRYPTION_KEY: z.string().length(64, "Must be 256 bits, 64 hex characters");
|
||||
```
|
||||
|
||||
**Purpose:** Optional 256-bit key for encrypting sensitive database fields.
|
||||
|
||||
**Generate:** `openssl rand -hex 32`
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Always Import from env.mjs/env.ts
|
||||
|
||||
```typescript
|
||||
// ❌ NEVER DO THIS
|
||||
const dbUrl = process.env.DATABASE_URL;
|
||||
|
||||
// ✅ ALWAYS DO THIS
|
||||
import { env } from "@/src/env.mjs";
|
||||
const dbUrl = env.DATABASE_URL; // Type-safe, validated
|
||||
```
|
||||
|
||||
### 2. Use Appropriate Import Path
|
||||
|
||||
```typescript
|
||||
// In web package
|
||||
import { env } from "@/src/env.mjs";
|
||||
|
||||
// In worker package
|
||||
import { env } from "./env";
|
||||
|
||||
// In shared package
|
||||
import { env } from "@langfuse/shared/src/env";
|
||||
```
|
||||
|
||||
### 3. Client Variables Must Start with NEXT*PUBLIC*
|
||||
|
||||
```typescript
|
||||
// ❌ Won't work in browser
|
||||
API_KEY: z.string(); // in server config
|
||||
|
||||
// ✅ Accessible in browser
|
||||
NEXT_PUBLIC_API_KEY: z.string(); // in client config
|
||||
```
|
||||
|
||||
### 4. Provide Sensible Defaults for Development
|
||||
|
||||
```typescript
|
||||
PORT: z.coerce.number().positive().default(3030),
|
||||
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||
REDIS_PORT: z.coerce.number().positive().default(6379),
|
||||
```
|
||||
|
||||
### 5. Use Coercion for Numbers
|
||||
|
||||
```typescript
|
||||
// .env files are always strings
|
||||
PORT: z.coerce.number(); // Converts "3000" to 3000
|
||||
```
|
||||
|
||||
### 6. Transform Complex Values
|
||||
|
||||
```typescript
|
||||
// Split comma-separated values
|
||||
LANGFUSE_LOG_PROPAGATED_HEADERS: z.string().optional().transform((s) =>
|
||||
s ? s.split(",").map((s) => s.toLowerCase().trim()) : []
|
||||
),
|
||||
|
||||
// Parse project:rate pairs
|
||||
LANGFUSE_INGESTION_PROCESSING_SAMPLED_PROJECTS: z.string().optional().transform((val) => {
|
||||
const map = new Map<string, number>();
|
||||
val?.split(",").forEach(part => {
|
||||
const [projectId, rate] = part.split(":");
|
||||
map.set(projectId, parseFloat(rate));
|
||||
});
|
||||
return map;
|
||||
}),
|
||||
```
|
||||
|
||||
### 7. Validation at Startup
|
||||
|
||||
All environment variables are validated when the application starts. Invalid configuration will cause immediate failure with clear error messages:
|
||||
|
||||
```bash
|
||||
❌ Validation error:
|
||||
- SALT: Required
|
||||
- CLICKHOUSE_URL: Invalid url
|
||||
- PORT: Number must be less than or equal to 65536
|
||||
```
|
||||
|
||||
### 8. Skip Validation in Docker Builds
|
||||
|
||||
Always include the Docker build escape hatch:
|
||||
|
||||
```typescript
|
||||
export const env =
|
||||
process.env.DOCKER_BUILD === "1"
|
||||
? (process.env as any)
|
||||
: EnvSchema.parse(removeEmptyEnvVariables(process.env));
|
||||
```
|
||||
|
||||
### 9. Use removeEmptyEnvVariables Helper
|
||||
|
||||
Treats empty strings as undefined:
|
||||
|
||||
```typescript
|
||||
import { removeEmptyEnvVariables } from "@langfuse/shared";
|
||||
|
||||
EnvSchema.parse(removeEmptyEnvVariables(process.env));
|
||||
```
|
||||
|
||||
This prevents errors from `.env` files with empty values:
|
||||
|
||||
```bash
|
||||
# .env
|
||||
OPTIONAL_VAR= # Treated as undefined, not empty string
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration File Locations
|
||||
|
||||
```
|
||||
langfuse/
|
||||
├── .env # Local development overrides
|
||||
├── .env.dev.example # Example dev configuration
|
||||
├── web/src/env.mjs # Web app env validation
|
||||
├── worker/src/env.ts # Worker env validation
|
||||
├── packages/shared/src/env.ts # Shared env validation
|
||||
└── ee/src/env.ts # EE env validation
|
||||
```
|
||||
|
||||
**DO NOT commit:**
|
||||
|
||||
- `.env`
|
||||
- `.env.local`
|
||||
- `.env.production`
|
||||
|
||||
---
|
||||
|
||||
**Related Files:**
|
||||
|
||||
- [../AGENTS.md](../AGENTS.md) - Main guide
|
||||
- [architecture-overview.md](architecture-overview.md) - Architecture patterns
|
||||
@@ -1,660 +0,0 @@
|
||||
# Database Patterns - PostgreSQL & ClickHouse
|
||||
|
||||
Complete guide to database access patterns in Langfuse using PostgreSQL (Prisma ORM) and ClickHouse (direct client).
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Database Architecture Overview](#database-architecture-overview)
|
||||
- [PostgreSQL with Prisma](#postgresql-with-prisma)
|
||||
- [ClickHouse with Direct Client](#clickhouse-with-direct-client)
|
||||
- [Repository Pattern](#repository-pattern)
|
||||
- [When to Use Which Database](#when-to-use-which-database)
|
||||
- [Error Handling](#error-handling)
|
||||
|
||||
---
|
||||
|
||||
## Database Architecture Overview
|
||||
|
||||
Langfuse uses a **dual database architecture**:
|
||||
|
||||
| Database | Technology | Purpose | Access Pattern |
|
||||
| -------------- | ----------------- | ------------------------------------------------------------- | -------------------------------------- |
|
||||
| **PostgreSQL** | Prisma ORM | Transactional data, relational data, CRUD operations | Type-safe ORM with migrations |
|
||||
| **ClickHouse** | Direct SQL client | Analytics data, high-volume traces/observations, aggregations | Raw SQL queries with streaming support |
|
||||
| **Redis** | ioredis | Queues (BullMQ), caching, rate limiting | Direct client access |
|
||||
|
||||
**Key Principle**: Use PostgreSQL for transactional data and relationships. Use ClickHouse for high-volume analytics and time-series data.
|
||||
|
||||
**⚠️ Important**: All queries must filter by `project_id` (or `projectId`) to ensure proper data isolation between tenants. This is essential for the multi-tenant architecture.
|
||||
|
||||
---
|
||||
|
||||
## PostgreSQL with Prisma
|
||||
|
||||
### Import Pattern
|
||||
|
||||
```typescript
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
|
||||
// Direct access to Prisma client
|
||||
const user = await prisma.user.findUnique({ where: { id } });
|
||||
```
|
||||
|
||||
**Important**: Always import from `@langfuse/shared/src/db`, not `@prisma/client` directly.
|
||||
|
||||
### Common CRUD Operations
|
||||
|
||||
**⚠️ ALWAYS include `projectId` in WHERE clauses** for project-scoped data:
|
||||
|
||||
```typescript
|
||||
// Create
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name: "My Project",
|
||||
orgId: organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
// ✅ GOOD: Read with projectId filter
|
||||
const trace = await prisma.trace.findUnique({
|
||||
where: { id: traceId, projectId }, // ← Always include projectId for tenant isolation
|
||||
include: {
|
||||
scores: true,
|
||||
project: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// ❌ BAD: Missing projectId filter
|
||||
// const trace = await prisma.trace.findUnique({
|
||||
// where: { id: traceId }, // ← Missing projectId!
|
||||
// });
|
||||
|
||||
// Update
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { lastLogin: new Date() },
|
||||
});
|
||||
|
||||
// ✅ GOOD: Delete with projectId
|
||||
await prisma.apiKey.delete({
|
||||
where: { id: apiKeyId, projectId }, // ← Always include projectId
|
||||
});
|
||||
|
||||
// ✅ GOOD: Count with projectId
|
||||
const traceCount = await prisma.trace.count({
|
||||
where: { projectId, userId }, // ← Always include projectId
|
||||
});
|
||||
```
|
||||
|
||||
### Transactions
|
||||
|
||||
Use Prisma interactive transactions for operations that must be atomic:
|
||||
|
||||
```typescript
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const user = await tx.user.create({ data: userData });
|
||||
|
||||
const project = await tx.project.create({
|
||||
data: {
|
||||
name: "Default Project",
|
||||
orgId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.projectMembership.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
projectId: project.id,
|
||||
role: "OWNER",
|
||||
},
|
||||
});
|
||||
|
||||
return { user, project };
|
||||
});
|
||||
```
|
||||
|
||||
**Transaction options:**
|
||||
|
||||
```typescript
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
// Transaction logic
|
||||
},
|
||||
{
|
||||
maxWait: 5000, // Max time to wait for transaction to start (ms)
|
||||
timeout: 10000, // Max time transaction can run (ms)
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### Query Optimization
|
||||
|
||||
**Use `select` to limit fields:**
|
||||
|
||||
```typescript
|
||||
// ❌ Fetches all fields (including large JSON columns)
|
||||
const traces = await prisma.trace.findMany({ where: { projectId } });
|
||||
|
||||
// ✅ Only fetch needed fields
|
||||
const traces = await prisma.trace.findMany({
|
||||
where: { projectId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
timestamp: true,
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Prevent N+1 queries with `include`:**
|
||||
|
||||
```typescript
|
||||
// ❌ N+1 Query Problem
|
||||
const projects = await prisma.project.findMany();
|
||||
for (const project of projects) {
|
||||
// N additional queries
|
||||
const memberCount = await prisma.projectMembership.count({
|
||||
where: { projectId: project.id },
|
||||
});
|
||||
}
|
||||
|
||||
// ✅ Use include or aggregation
|
||||
const projects = await prisma.project.findMany({
|
||||
include: {
|
||||
members: { select: { userId: true, role: true } },
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Pagination:**
|
||||
|
||||
```typescript
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const traces = await prisma.trace.findMany({
|
||||
where: { projectId },
|
||||
orderBy: { timestamp: "desc" },
|
||||
take: PAGE_SIZE,
|
||||
skip: page * PAGE_SIZE,
|
||||
});
|
||||
```
|
||||
|
||||
## ClickHouse with Direct Client
|
||||
|
||||
### Import Pattern
|
||||
|
||||
```typescript
|
||||
import { queryClickhouse } from "@langfuse/shared/src/server/repositories/clickhouse";
|
||||
import { clickhouseClient } from "@langfuse/shared/src/server/clickhouse/client";
|
||||
```
|
||||
|
||||
### ClickHouse Client Singleton
|
||||
|
||||
ClickHouse uses a singleton client manager that reuses connections:
|
||||
|
||||
```typescript
|
||||
import { clickhouseClient } from "@langfuse/shared/src/server/clickhouse/client";
|
||||
|
||||
// Get client (automatically reuses existing connection)
|
||||
const client = clickhouseClient();
|
||||
|
||||
// For read-only queries (uses read replica if configured)
|
||||
const client = clickhouseClient(undefined, "ReadOnly");
|
||||
```
|
||||
|
||||
### Query Patterns
|
||||
|
||||
ClickHouse queries use **raw SQL** with parameterized queries. Parameters use `{paramName: Type}` syntax:
|
||||
|
||||
**⚠️ Important**: All ClickHouse queries must include `project_id` filter to ensure proper tenant isolation.
|
||||
|
||||
**Simple query:**
|
||||
|
||||
```typescript
|
||||
import { queryClickhouse } from "@langfuse/shared/src/server/repositories/clickhouse";
|
||||
|
||||
// ✅ GOOD: Always filter by project_id
|
||||
const rows = await queryClickhouse<{ id: string; name: string }>({
|
||||
query: `
|
||||
SELECT id, name, timestamp
|
||||
FROM traces
|
||||
WHERE project_id = {projectId: String} -- ← REQUIRED: Always filter by project_id
|
||||
AND timestamp >= {startTime: DateTime64(3)}
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT {limit: UInt32}
|
||||
`,
|
||||
params: {
|
||||
projectId, // ← Required for tenant isolation
|
||||
startTime: convertDateToClickhouseDateTime(startDate),
|
||||
limit: 100,
|
||||
},
|
||||
tags: { feature: "tracing", type: "trace" },
|
||||
});
|
||||
|
||||
// ❌ BAD: Missing project_id filter
|
||||
// const rows = await queryClickhouse({
|
||||
// query: `SELECT * FROM traces WHERE timestamp >= {startTime: DateTime64(3)}`,
|
||||
// params: { startTime },
|
||||
// });
|
||||
```
|
||||
|
||||
**Streaming query (for large result sets):**
|
||||
|
||||
```typescript
|
||||
import { queryClickhouseStream } from "@langfuse/shared/src/server/repositories/clickhouse";
|
||||
|
||||
// Stream results to avoid loading all rows in memory
|
||||
for await (const row of queryClickhouseStream<ObservationRecordReadType>({
|
||||
query: `
|
||||
SELECT *
|
||||
FROM observations
|
||||
WHERE project_id = {projectId: String}
|
||||
AND start_time >= {startTime: DateTime64(3)}
|
||||
`,
|
||||
params: { projectId, startTime },
|
||||
})) {
|
||||
// Process row by row
|
||||
await processObservation(row);
|
||||
}
|
||||
```
|
||||
|
||||
**Upsert (insert) operation:**
|
||||
|
||||
```typescript
|
||||
import { upsertClickhouse } from "@langfuse/shared/src/server/repositories/clickhouse";
|
||||
|
||||
await upsertClickhouse({
|
||||
table: "traces",
|
||||
records: [
|
||||
{
|
||||
id: traceId,
|
||||
project_id: projectId,
|
||||
timestamp: new Date(),
|
||||
name: "API Call",
|
||||
user_id: userId,
|
||||
// ... other fields
|
||||
},
|
||||
],
|
||||
eventBodyMapper: (record) => ({
|
||||
// Transform record for event log
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
// ... other fields
|
||||
}),
|
||||
tags: { feature: "ingestion", type: "trace" },
|
||||
});
|
||||
```
|
||||
|
||||
**DDL/Administrative commands:**
|
||||
|
||||
```typescript
|
||||
import { commandClickhouse } from "@langfuse/shared/src/server/repositories/clickhouse";
|
||||
|
||||
// Create table, alter schema, etc.
|
||||
await commandClickhouse({
|
||||
query: `
|
||||
ALTER TABLE traces
|
||||
ADD COLUMN IF NOT EXISTS new_field String
|
||||
`,
|
||||
tags: { feature: "migration" },
|
||||
});
|
||||
```
|
||||
|
||||
### ClickHouse Type Mapping
|
||||
|
||||
| JavaScript Type | ClickHouse Param Type |
|
||||
| --------------- | --------------------------------------------------------- |
|
||||
| `string` | `String` |
|
||||
| `number` | `UInt32`, `Int64`, `Float64` |
|
||||
| `Date` | `DateTime64(3)` (use `convertDateToClickhouseDateTime()`) |
|
||||
| `boolean` | `UInt8` (0 or 1) |
|
||||
| `string[]` | `Array(String)` |
|
||||
|
||||
**Date handling:**
|
||||
|
||||
```typescript
|
||||
import { convertDateToClickhouseDateTime } from "@langfuse/shared/src/server/clickhouse/client";
|
||||
|
||||
const params = {
|
||||
startTime: convertDateToClickhouseDateTime(new Date()),
|
||||
};
|
||||
```
|
||||
|
||||
### ClickHouse Query Best Practices
|
||||
|
||||
**1. Always filter by `project_id` for tenant isolation:**
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT: project_id filter is required
|
||||
const query = `
|
||||
SELECT *
|
||||
FROM traces
|
||||
WHERE project_id = {projectId: String} -- ← Required for tenant isolation
|
||||
AND timestamp >= {startTime: DateTime64(3)}
|
||||
`;
|
||||
|
||||
// ❌ WRONG: Missing project_id filter
|
||||
// const query = `
|
||||
// SELECT * FROM traces WHERE timestamp >= {startTime: DateTime64(3)}
|
||||
// `;
|
||||
```
|
||||
|
||||
**Why this is important:**
|
||||
- Langfuse is multi-tenant - each project's data must be isolated
|
||||
- The `project_id` filter ensures queries only access data from the intended tenant
|
||||
- All queries on project-scoped tables (traces, observations, scores, sessions, etc.) must filter by `project_id`
|
||||
|
||||
**2. Use LIMIT BY for deduplication:**
|
||||
|
||||
```typescript
|
||||
// Get latest version of each trace
|
||||
const query = `
|
||||
SELECT *
|
||||
FROM traces
|
||||
WHERE project_id = {projectId: String} -- ← Always include project_id
|
||||
ORDER BY event_ts DESC
|
||||
LIMIT 1 BY id, project_id
|
||||
`;
|
||||
```
|
||||
|
||||
**3. Use time-based filtering for performance:**
|
||||
|
||||
```typescript
|
||||
// Combine project_id filter with timestamp for optimal performance
|
||||
const query = `
|
||||
SELECT *
|
||||
FROM observations
|
||||
WHERE project_id = {projectId: String} -- ← Required for tenant isolation
|
||||
AND start_time >= {startTime: DateTime64(3)} -- ← Improves performance
|
||||
AND start_time < {endTime: DateTime64(3)}
|
||||
`;
|
||||
```
|
||||
|
||||
**4. Use CTEs for complex queries (still require `project_id`):**
|
||||
|
||||
```typescript
|
||||
const query = `
|
||||
WITH observations_agg AS (
|
||||
SELECT
|
||||
trace_id,
|
||||
count() as observation_count,
|
||||
sum(total_cost) as total_cost
|
||||
FROM observations
|
||||
WHERE project_id = {projectId: String} -- ← Filter in CTE
|
||||
GROUP BY trace_id
|
||||
)
|
||||
SELECT
|
||||
t.id,
|
||||
t.name,
|
||||
o.observation_count,
|
||||
o.total_cost
|
||||
FROM traces t
|
||||
LEFT JOIN observations_agg o ON t.id = o.trace_id
|
||||
WHERE t.project_id = {projectId: String} -- ← Filter in main query
|
||||
`;
|
||||
```
|
||||
|
||||
**Note**: When using CTEs or subqueries, ensure `project_id` filter is applied at each level.
|
||||
|
||||
**Error handling with retries:**
|
||||
|
||||
ClickHouse queries automatically retry on network errors (socket hang up). Custom error handling for resource limits:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
queryClickhouse,
|
||||
ClickHouseResourceError,
|
||||
} from "@langfuse/shared/src/server/repositories/clickhouse";
|
||||
|
||||
try {
|
||||
const rows = await queryClickhouse({ query, params });
|
||||
} catch (error) {
|
||||
if (error instanceof ClickHouseResourceError) {
|
||||
// Memory limit, timeout, or overcommit error
|
||||
throw new Error(ClickHouseResourceError.ERROR_ADVICE_MESSAGE);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Repository Pattern
|
||||
|
||||
Langfuse uses repositories in `packages/shared/src/server/repositories/` for complex data access patterns.
|
||||
|
||||
### When to Use Repositories
|
||||
|
||||
✅ **Use repositories when:**
|
||||
|
||||
- Complex ClickHouse queries with CTEs, aggregations, or joins
|
||||
- Query used in multiple places (DRY principle)
|
||||
- Need data transformation/converters (DB → domain models)
|
||||
- Building reusable query logic with filters
|
||||
|
||||
❌ **Use direct Prisma/ClickHouse for:**
|
||||
|
||||
- Simple CRUD operations
|
||||
- One-off queries
|
||||
- Prototyping (refactor to repository later)
|
||||
|
||||
### Repository Examples
|
||||
|
||||
**Trace repository (ClickHouse):**
|
||||
|
||||
```typescript
|
||||
// packages/shared/src/server/repositories/traces.ts
|
||||
export const getTracesByIds = async (
|
||||
projectId: string,
|
||||
traceIds: string[],
|
||||
): Promise<TraceRecordReadType[]> => {
|
||||
const rows = await queryClickhouse<TraceRecordReadType>({
|
||||
query: `
|
||||
SELECT *
|
||||
FROM traces
|
||||
WHERE project_id = {projectId: String}
|
||||
AND id IN ({traceIds: Array(String)})
|
||||
ORDER BY event_ts DESC
|
||||
LIMIT 1 BY id, project_id
|
||||
`,
|
||||
params: { projectId, traceIds },
|
||||
tags: { feature: "tracing", type: "trace" },
|
||||
});
|
||||
|
||||
return rows.map(convertClickhouseToDomain);
|
||||
};
|
||||
```
|
||||
|
||||
**Score repository (PostgreSQL + ClickHouse):**
|
||||
|
||||
```typescript
|
||||
// Repositories can query both databases
|
||||
export const getScoresByTraceId = async (
|
||||
projectId: string,
|
||||
traceId: string,
|
||||
) => {
|
||||
// Use ClickHouse for analytics
|
||||
const clickhouseScores = await queryClickhouse<ScoreRecordReadType>({
|
||||
query: `
|
||||
SELECT *
|
||||
FROM scores
|
||||
WHERE project_id = {projectId: String}
|
||||
AND trace_id = {traceId: String}
|
||||
`,
|
||||
params: { projectId, traceId },
|
||||
});
|
||||
|
||||
// Use Prisma for config data
|
||||
const scoreConfigs = await prisma.scoreConfig.findMany({
|
||||
where: { projectId },
|
||||
});
|
||||
|
||||
return enrichScoresWithConfigs(clickhouseScores, scoreConfigs);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## When to Use Which Database
|
||||
|
||||
| Use Case | Database | Reasoning |
|
||||
| -------------------------------------- | ---------- | ------------------------------------------ |
|
||||
| User accounts, projects, API keys | PostgreSQL | Transactional data with strong consistency |
|
||||
| Prompt management, dataset definitions | PostgreSQL | Configuration data with relations |
|
||||
| Project settings, RBAC permissions | PostgreSQL | Small, frequently updated data |
|
||||
| Traces, observations, events | ClickHouse | High-volume time-series data |
|
||||
| Score aggregations, analytics queries | ClickHouse | Fast aggregations over millions of rows |
|
||||
| Usage metrics, cost calculations | ClickHouse | Analytical queries with GROUP BY |
|
||||
| Exports, large dataset queries | ClickHouse | Streaming support for large result sets |
|
||||
|
||||
**Decision flow:**
|
||||
|
||||
1. Is it high-volume time-series data? → **ClickHouse**
|
||||
2. Does it need aggregation over millions of rows? → **ClickHouse**
|
||||
3. Is it transactional data with relationships? → **PostgreSQL**
|
||||
4. Is it configuration or user data? → **PostgreSQL**
|
||||
5. Is it frequently updated? → **PostgreSQL**
|
||||
6. Is it append-only analytics data? → **ClickHouse**
|
||||
|
||||
### Project-Scoped vs Global Tables
|
||||
|
||||
**Project-scoped tables (MUST filter by `project_id`):**
|
||||
- `traces` - All trace queries require `project_id`
|
||||
- `observations` - All observation queries require `project_id`
|
||||
- `scores` - All score queries require `project_id`
|
||||
- `events` - All event queries require `project_id`
|
||||
- `dataset_run_items_rmt` - All dataset run queries require `project_id`
|
||||
|
||||
**Global tables (no `project_id` filter needed):**
|
||||
- `users` - User management (use `id` for filtering)
|
||||
- `organizations` - Organization data (use `id` for filtering)
|
||||
- System configuration tables
|
||||
|
||||
**Example of correct filtering:**
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT: Project-scoped query
|
||||
const traces = await queryClickhouse({
|
||||
query: `
|
||||
SELECT * FROM traces
|
||||
WHERE project_id = {projectId: String}
|
||||
AND timestamp >= {startTime: DateTime64(3)}
|
||||
`,
|
||||
params: { projectId, startTime },
|
||||
});
|
||||
|
||||
// ✅ CORRECT: Global table query (no project_id needed)
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
// ❌ WRONG: Project-scoped query without project_id filter
|
||||
// const traces = await queryClickhouse({
|
||||
// query: `SELECT * FROM traces WHERE timestamp >= {startTime: DateTime64(3)}`,
|
||||
// });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### PostgreSQL (Prisma) Errors
|
||||
|
||||
```typescript
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
|
||||
try {
|
||||
await prisma.user.create({ data: userData });
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
// Unique constraint violation
|
||||
if (error.code === "P2002") {
|
||||
const target = error.meta?.target as string[];
|
||||
throw new ConflictError(`${target?.join(", ")} already exists`);
|
||||
}
|
||||
|
||||
// Foreign key constraint
|
||||
if (error.code === "P2003") {
|
||||
throw new ValidationError("Invalid reference");
|
||||
}
|
||||
|
||||
// Record not found
|
||||
if (error.code === "P2025") {
|
||||
throw new NotFoundError("Record not found");
|
||||
}
|
||||
|
||||
// Record required to connect not found
|
||||
if (error.code === "P2018") {
|
||||
throw new ValidationError("Related record not found");
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown error
|
||||
logger.error("Prisma error", { error });
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
|
||||
**Common Prisma error codes:**
|
||||
|
||||
| Code | Meaning | Typical Cause |
|
||||
| -------- | ---------------------------- | ------------------------------------- |
|
||||
| `P2002` | Unique constraint violation | Duplicate email, API key, etc. |
|
||||
| `P2003` | Foreign key constraint | Referenced record doesn't exist |
|
||||
| `P2025` | Record not found | Update/delete of non-existent record |
|
||||
| `P2018` | Required relation not found | Connect to non-existent related record |
|
||||
|
||||
### ClickHouse Errors
|
||||
|
||||
```typescript
|
||||
import {
|
||||
queryClickhouse,
|
||||
ClickHouseResourceError,
|
||||
} from "@langfuse/shared/src/server/repositories/clickhouse";
|
||||
|
||||
try {
|
||||
const rows = await queryClickhouse({ query, params });
|
||||
} catch (error) {
|
||||
// ClickHouse resource errors (memory limit, timeout, overcommit)
|
||||
if (error instanceof ClickHouseResourceError) {
|
||||
logger.warn("ClickHouse resource error", {
|
||||
errorType: error.errorType, // "MEMORY_LIMIT" | "OVERCOMMIT" | "TIMEOUT"
|
||||
message: error.message,
|
||||
});
|
||||
|
||||
// User-friendly error message
|
||||
throw new BadRequestError(ClickHouseResourceError.ERROR_ADVICE_MESSAGE);
|
||||
}
|
||||
|
||||
// Network/connection errors are automatically retried
|
||||
logger.error("ClickHouse error", { error });
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
|
||||
**ClickHouse error types:**
|
||||
|
||||
| Error Type | Discriminator | Meaning | Solution |
|
||||
| --------------- | ----------------------- | ---------------------------- | -------------------------------------------------- |
|
||||
| `MEMORY_LIMIT` | "memory limit exceeded" | Query used too much memory | Use more specific filters or shorter time range |
|
||||
| `OVERCOMMIT` | "OvercommitTracker" | Memory overcommit limit hit | Reduce query complexity or result set size |
|
||||
| `TIMEOUT` | "Timeout", "timed out" | Query took too long | Add filters, reduce time range, or optimize query |
|
||||
|
||||
**ClickHouse retries:**
|
||||
|
||||
ClickHouse queries automatically retry network errors (socket hang up) with exponential backoff. Configure retry behavior:
|
||||
|
||||
```typescript
|
||||
// In packages/shared/src/env.ts
|
||||
LANGFUSE_CLICKHOUSE_QUERY_MAX_ATTEMPTS: z.coerce.number().positive().default(3)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Related Files:**
|
||||
|
||||
- [../AGENTS.md](../AGENTS.md) - Main backend development guidelines
|
||||
- [architecture-overview.md](architecture-overview.md) - System architecture
|
||||
- [configuration.md](configuration.md) - Environment variable configuration
|
||||
@@ -1,765 +0,0 @@
|
||||
# Middleware Guide - tRPC & Public API Patterns
|
||||
|
||||
Complete guide to middleware patterns in Langfuse's Next.js + tRPC architecture.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [tRPC Middleware](#trpc-middleware)
|
||||
- [Public API Middleware](#public-api-middleware)
|
||||
- [Authentication Patterns](#authentication-patterns)
|
||||
- [Error Handling Middleware](#error-handling-middleware)
|
||||
- [OpenTelemetry Instrumentation](#opentelemetry-instrumentation)
|
||||
- [Composable Procedures](#composable-procedures)
|
||||
|
||||
---
|
||||
|
||||
## tRPC Middleware
|
||||
|
||||
**File:** `web/src/server/api/trpc.ts`
|
||||
|
||||
tRPC middleware in Langfuse is composable and type-safe. Each middleware enriches the context and provides guarantees to subsequent middleware.
|
||||
|
||||
### Core tRPC Middlewares
|
||||
|
||||
**1. Error Handling Middleware (`withErrorHandling`)**
|
||||
|
||||
Intercepts all errors and transforms them into user-friendly tRPC errors:
|
||||
|
||||
```typescript
|
||||
const withErrorHandling = t.middleware(async ({ ctx, next }) => {
|
||||
const res = await next({ ctx });
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.error.cause instanceof ClickHouseResourceError) {
|
||||
// Surface ClickHouse resource errors with advice message
|
||||
res.error = new TRPCError({
|
||||
code: "SERVICE_UNAVAILABLE",
|
||||
message: ClickHouseResourceError.ERROR_ADVICE_MESSAGE,
|
||||
});
|
||||
} else {
|
||||
// Transform 5xx errors to not expose internals
|
||||
const { code, httpStatus } = resolveError(res.error);
|
||||
const isSafeToExpose = httpStatus >= 400 && httpStatus < 500;
|
||||
|
||||
res.error = new TRPCError({
|
||||
code,
|
||||
cause: null, // do not expose stack traces
|
||||
message: isSafeToExpose
|
||||
? res.error.message
|
||||
: "Internal error. We have been notified and are working on it.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
});
|
||||
```
|
||||
|
||||
**2. OpenTelemetry Instrumentation (`withOtelInstrumentation`)**
|
||||
|
||||
Propagates OpenTelemetry context with Langfuse-specific baggage:
|
||||
|
||||
```typescript
|
||||
const withOtelInstrumentation = t.middleware(async (opts) => {
|
||||
const actualInput = await opts.getRawInput();
|
||||
|
||||
const baggageCtx = contextWithLangfuseProps({
|
||||
headers: opts.ctx.headers,
|
||||
userId: opts.ctx.session?.user?.id,
|
||||
projectId: (actualInput as Record<string, string>)?.projectId,
|
||||
});
|
||||
|
||||
return opentelemetry.context.with(baggageCtx, () => opts.next());
|
||||
});
|
||||
```
|
||||
|
||||
**3. Authentication Middleware (`enforceUserIsAuthed`)**
|
||||
|
||||
Ensures user is logged in via NextAuth session:
|
||||
|
||||
```typescript
|
||||
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
|
||||
if (!ctx.session || !ctx.session.user) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
// infers the `session` as non-nullable
|
||||
session: { ...ctx.session, user: ctx.session.user },
|
||||
},
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**4. Project Membership Middleware (`enforceUserIsAuthedAndProjectMember`)**
|
||||
|
||||
Validates that the user is a member of the project specified in input:
|
||||
|
||||
```typescript
|
||||
const enforceUserIsAuthedAndProjectMember = t.middleware(async (opts) => {
|
||||
const { ctx, next } = opts;
|
||||
|
||||
if (!ctx.session || !ctx.session.user) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
const actualInput = await opts.getRawInput();
|
||||
const parsedInput = inputProjectSchema.safeParse(actualInput);
|
||||
|
||||
if (!parsedInput.success) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Invalid input, projectId is required",
|
||||
});
|
||||
}
|
||||
|
||||
const projectId = parsedInput.data.projectId;
|
||||
const sessionProject = ctx.session.user.organizations
|
||||
.flatMap((org) => org.projects.map((project) => ({ ...project, organization: org })))
|
||||
.find((project) => project.id === projectId);
|
||||
|
||||
if (!sessionProject) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "User is not a member of this project",
|
||||
});
|
||||
}
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
session: {
|
||||
...ctx.session,
|
||||
user: ctx.session.user,
|
||||
orgId: sessionProject.organization.id,
|
||||
orgRole: sessionProject.organization.role,
|
||||
projectId: projectId,
|
||||
projectRole: sessionProject.role,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**5. Organization Membership Middleware (`enforceIsAuthedAndOrgMember`)**
|
||||
|
||||
Validates organization membership:
|
||||
|
||||
```typescript
|
||||
const enforceIsAuthedAndOrgMember = t.middleware(async (opts) => {
|
||||
const { ctx, next } = opts;
|
||||
|
||||
if (!ctx.session || !ctx.session.user) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
const actualInput = await opts.getRawInput();
|
||||
const result = inputOrganizationSchema.safeParse(actualInput);
|
||||
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Invalid input, orgId is required",
|
||||
});
|
||||
}
|
||||
|
||||
const orgId = result.data.orgId;
|
||||
const sessionOrg = ctx.session.user.organizations.find((org) => org.id === orgId);
|
||||
|
||||
if (!sessionOrg && ctx.session.user.admin !== true) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "User is not a member of this organization",
|
||||
});
|
||||
}
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
session: {
|
||||
...ctx.session,
|
||||
user: ctx.session.user,
|
||||
orgId: orgId,
|
||||
orgRole: ctx.session.user.admin === true ? Role.OWNER : sessionOrg!.role,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**6. Trace Access Middleware (`enforceTraceAccess`)**
|
||||
|
||||
Special middleware for trace-level routes that supports public traces:
|
||||
|
||||
```typescript
|
||||
const enforceTraceAccess = t.middleware(async (opts) => {
|
||||
const { ctx, next } = opts;
|
||||
const actualInput = await opts.getRawInput();
|
||||
const result = inputTraceSchema.safeParse(actualInput);
|
||||
|
||||
if (!result.success) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid input" });
|
||||
}
|
||||
|
||||
const trace = await getTraceById({
|
||||
traceId: result.data.traceId,
|
||||
projectId: result.data.projectId,
|
||||
timestamp: result.data.timestamp ?? undefined,
|
||||
});
|
||||
|
||||
if (!trace) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Trace not found" });
|
||||
}
|
||||
|
||||
const sessionProject = ctx.session?.user?.organizations
|
||||
.flatMap((org) => org.projects)
|
||||
.find(({ id }) => id === result.data.projectId);
|
||||
|
||||
// Allow access if:
|
||||
// 1. User is a project member
|
||||
// 2. Trace is public
|
||||
// 3. User is admin
|
||||
if (!trace.public && !sessionProject && ctx.session?.user?.admin !== true) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "User is not a member of this project and this trace is not public",
|
||||
});
|
||||
}
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
session: { ...ctx.session, projectRole: sessionProject?.role },
|
||||
trace: trace, // pass the trace to avoid refetching
|
||||
},
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### tRPC Procedure Types
|
||||
|
||||
Langfuse exports composed procedures with middleware chains:
|
||||
|
||||
```typescript
|
||||
// 1. Public procedure (no auth required)
|
||||
export const publicProcedure = withOtelTracingProcedure.use(withErrorHandling);
|
||||
|
||||
// 2. Authenticated procedure (NextAuth session required)
|
||||
export const authenticatedProcedure = withOtelTracingProcedure
|
||||
.use(withErrorHandling)
|
||||
.use(enforceUserIsAuthed);
|
||||
|
||||
// 3. Project-scoped procedure (project membership required)
|
||||
export const protectedProjectProcedure = withOtelTracingProcedure
|
||||
.use(withErrorHandling)
|
||||
.use(enforceUserIsAuthedAndProjectMember);
|
||||
|
||||
// 4. Organization-scoped procedure
|
||||
export const protectedOrganizationProcedure = withOtelTracingProcedure
|
||||
.use(withErrorHandling)
|
||||
.use(enforceIsAuthedAndOrgMember);
|
||||
|
||||
// 5. Trace access procedure (public traces supported)
|
||||
export const protectedGetTraceProcedure = withOtelTracingProcedure
|
||||
.use(withErrorHandling)
|
||||
.use(enforceTraceAccess);
|
||||
|
||||
// 6. Session access procedure
|
||||
export const protectedGetSessionProcedure = withOtelTracingProcedure
|
||||
.use(withErrorHandling)
|
||||
.use(enforceSessionAccess);
|
||||
|
||||
// 7. Admin API key procedure (for admin operations)
|
||||
export const adminProcedure = withOtelTracingProcedure
|
||||
.use(withErrorHandling)
|
||||
.use(enforceAdminAuth);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Public API Middleware
|
||||
|
||||
**Files:** `web/src/features/public-api/server/`
|
||||
|
||||
### withMiddlewares Pattern
|
||||
|
||||
Wraps all public API routes with CORS, error handling, and OpenTelemetry:
|
||||
|
||||
```typescript
|
||||
export function withMiddlewares(handlers: Handlers) {
|
||||
return async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const ctx = contextWithLangfuseProps({ headers: req.headers });
|
||||
|
||||
return opentelemetry.context.with(ctx, async () => {
|
||||
try {
|
||||
// 1. CORS middleware
|
||||
await runMiddleware(req, res, cors);
|
||||
|
||||
// 2. HTTP method routing
|
||||
const method = req.method as HttpMethod;
|
||||
if (!handlers[method]) throw new MethodNotAllowedError();
|
||||
|
||||
// 3. Execute handler
|
||||
return await handlers[method](req, res);
|
||||
} catch (error) {
|
||||
// 4. Error handling
|
||||
if (error instanceof BaseError) {
|
||||
if (error.httpCode >= 500) traceException(error);
|
||||
|
||||
return res.status(error.httpCode).json({
|
||||
message: error.message,
|
||||
error: error.name,
|
||||
});
|
||||
}
|
||||
|
||||
if (error instanceof ClickHouseResourceError) {
|
||||
return res.status(524).json({
|
||||
message: ClickHouseResourceError.ERROR_ADVICE_MESSAGE,
|
||||
error: "Request is taking too long to process.",
|
||||
});
|
||||
}
|
||||
|
||||
if (isZodError(error)) {
|
||||
return res.status(400).json({
|
||||
message: "Invalid request data",
|
||||
error: error.issues,
|
||||
});
|
||||
}
|
||||
|
||||
traceException(error);
|
||||
return res.status(500).json({
|
||||
message: "Internal Server Error",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
```typescript
|
||||
// web/src/pages/api/public/datasets/[datasetName]/items.ts
|
||||
export default withMiddlewares({
|
||||
GET: getDatasetItemsHandler,
|
||||
POST: createDatasetItemHandler,
|
||||
});
|
||||
```
|
||||
|
||||
### createAuthedProjectAPIRoute Pattern
|
||||
|
||||
Factory function for authenticated public API routes with:
|
||||
- Authentication (Basic auth or Admin API key)
|
||||
- Rate limiting
|
||||
- Input/output validation (Zod)
|
||||
- OpenTelemetry context
|
||||
|
||||
```typescript
|
||||
export const createAuthedProjectAPIRoute = <TQuery, TBody, TResponse>(
|
||||
routeConfig: RouteConfig<TQuery, TBody, TResponse>
|
||||
) => {
|
||||
return async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
// 1. Authentication (verifyAuth)
|
||||
const auth = await verifyAuth(req, routeConfig.isAdminApiKeyAuthAllowed || false);
|
||||
|
||||
// 2. Rate limiting
|
||||
const rateLimitResponse = await RateLimitService.getInstance().rateLimitRequest(
|
||||
auth.scope,
|
||||
routeConfig.rateLimitResource || "public-api"
|
||||
);
|
||||
|
||||
if (rateLimitResponse?.isRateLimited()) {
|
||||
return rateLimitResponse.sendRestResponseIfLimited(res);
|
||||
}
|
||||
|
||||
// 3. Input validation
|
||||
const query = routeConfig.querySchema
|
||||
? routeConfig.querySchema.parse(req.query)
|
||||
: {};
|
||||
const body = routeConfig.bodySchema
|
||||
? routeConfig.bodySchema.parse(req.body)
|
||||
: {};
|
||||
|
||||
// 4. Execute with OpenTelemetry context
|
||||
const ctx = contextWithLangfuseProps({
|
||||
headers: req.headers,
|
||||
projectId: auth.scope.projectId,
|
||||
});
|
||||
|
||||
return opentelemetry.context.with(ctx, async () => {
|
||||
const response = await routeConfig.fn({ query, body, req, res, auth });
|
||||
|
||||
// 5. Response validation (dev only)
|
||||
if (env.NODE_ENV === "development" && routeConfig.responseSchema) {
|
||||
const parsingResult = routeConfig.responseSchema.safeParse(response);
|
||||
if (!parsingResult.success) {
|
||||
logger.error("Response validation failed:", parsingResult.error);
|
||||
}
|
||||
}
|
||||
|
||||
res.status(routeConfig.successStatusCode || 200).json(response);
|
||||
});
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
```typescript
|
||||
// web/src/pages/api/public/traces/[traceId].ts
|
||||
export default createAuthedProjectAPIRoute({
|
||||
name: "Get Trace",
|
||||
querySchema: GetTraceV1Query,
|
||||
responseSchema: GetTraceV1Response,
|
||||
fn: async ({ query, auth }) => {
|
||||
const trace = await getTraceById({
|
||||
traceId: query.traceId,
|
||||
projectId: auth.scope.projectId,
|
||||
});
|
||||
|
||||
return transformTraceToApiResponse(trace);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Authentication Patterns
|
||||
|
||||
### tRPC Authentication (NextAuth)
|
||||
|
||||
tRPC uses NextAuth sessions stored in JWT cookies:
|
||||
|
||||
```typescript
|
||||
// Context creation with session
|
||||
export const createTRPCContext = async (opts: CreateNextContextOptions) => {
|
||||
const { req, res } = opts;
|
||||
const session = await getServerAuthSession({ req, res });
|
||||
|
||||
addUserToSpan({
|
||||
userId: session?.user?.id,
|
||||
email: session?.user?.email ?? undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
session,
|
||||
headers: req.headers,
|
||||
prisma,
|
||||
DB,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
**Session types:**
|
||||
|
||||
```typescript
|
||||
// Base authenticated context
|
||||
export type AuthedContext = {
|
||||
session: { user: NonNullable<Session["user"]> };
|
||||
};
|
||||
|
||||
// Project-scoped context
|
||||
export type ProjectAuthedContext = {
|
||||
session: AuthedContext["session"] & {
|
||||
orgId: string;
|
||||
orgRole: Role;
|
||||
projectId: string;
|
||||
projectRole: Role;
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### Public API Authentication
|
||||
|
||||
Public APIs use **Basic Auth** with API keys:
|
||||
|
||||
```typescript
|
||||
async function verifyBasicAuth(authHeader: string | undefined) {
|
||||
const regularAuth = await new ApiAuthService(prisma, redis)
|
||||
.verifyAuthHeaderAndReturnScope(authHeader);
|
||||
|
||||
if (!regularAuth.validKey) {
|
||||
throw { status: 401, message: regularAuth.error };
|
||||
}
|
||||
|
||||
if (regularAuth.scope.accessLevel !== "project") {
|
||||
throw { status: 401, message: "Access denied - need basic auth with secret key" };
|
||||
}
|
||||
|
||||
return regularAuth;
|
||||
}
|
||||
```
|
||||
|
||||
**Admin API Key Authentication** (self-hosted only):
|
||||
|
||||
```typescript
|
||||
async function verifyAdminApiKeyAuth(req: NextApiRequest) {
|
||||
// Requires:
|
||||
// 1. Authorization: Bearer <ADMIN_API_KEY>
|
||||
// 2. x-langfuse-admin-api-key: <ADMIN_API_KEY>
|
||||
// 3. x-langfuse-project-id: <project-id>
|
||||
|
||||
if (env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION) {
|
||||
throw { status: 403, message: "Admin API key auth not available on Langfuse Cloud" };
|
||||
}
|
||||
|
||||
const adminApiKey = env.ADMIN_API_KEY;
|
||||
const bearerToken = req.headers.authorization?.replace("Bearer ", "");
|
||||
const adminApiKeyHeader = req.headers["x-langfuse-admin-api-key"];
|
||||
|
||||
// Timing-safe comparison
|
||||
const isValid =
|
||||
crypto.timingSafeEqual(Buffer.from(bearerToken), Buffer.from(adminApiKey)) &&
|
||||
crypto.timingSafeEqual(Buffer.from(adminApiKeyHeader), Buffer.from(adminApiKey));
|
||||
|
||||
if (!isValid) throw { status: 401, message: "Invalid admin API key" };
|
||||
|
||||
const projectId = req.headers["x-langfuse-project-id"];
|
||||
const project = await prisma.project.findUnique({ where: { id: projectId } });
|
||||
|
||||
if (!project) throw { status: 404, message: "Project not found" };
|
||||
|
||||
return { validKey: true, scope: { projectId, accessLevel: "project" } };
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling Middleware
|
||||
|
||||
### tRPC Error Transformation
|
||||
|
||||
All tRPC errors go through `withErrorHandling` middleware:
|
||||
|
||||
**Error types handled:**
|
||||
|
||||
1. **ClickHouseResourceError** → `SERVICE_UNAVAILABLE` (524)
|
||||
2. **BaseError** → Preserves httpCode and message
|
||||
3. **5xx errors** → Sanitized as "Internal error" (hides stack traces)
|
||||
4. **4xx errors** → Original error message preserved
|
||||
|
||||
**Example:**
|
||||
|
||||
```typescript
|
||||
if (!res.ok) {
|
||||
if (res.error.cause instanceof ClickHouseResourceError) {
|
||||
res.error = new TRPCError({
|
||||
code: "SERVICE_UNAVAILABLE",
|
||||
message: ClickHouseResourceError.ERROR_ADVICE_MESSAGE,
|
||||
});
|
||||
} else {
|
||||
const { code, httpStatus } = resolveError(res.error);
|
||||
const isSafeToExpose = httpStatus >= 400 && httpStatus < 500;
|
||||
|
||||
res.error = new TRPCError({
|
||||
code,
|
||||
cause: null,
|
||||
message: isSafeToExpose ? res.error.message : "Internal error.",
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Public API Error Handling
|
||||
|
||||
Public API uses `withMiddlewares` for error handling:
|
||||
|
||||
```typescript
|
||||
catch (error) {
|
||||
// 1. BaseError (custom application errors)
|
||||
if (error instanceof BaseError) {
|
||||
if (error.httpCode >= 500) traceException(error);
|
||||
return res.status(error.httpCode).json({
|
||||
message: error.message,
|
||||
error: error.name,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. ClickHouseResourceError (query timeouts, memory limits)
|
||||
if (error instanceof ClickHouseResourceError) {
|
||||
return res.status(524).json({
|
||||
message: ClickHouseResourceError.ERROR_ADVICE_MESSAGE,
|
||||
error: "Request is taking too long to process.",
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Zod validation errors
|
||||
if (isZodError(error)) {
|
||||
return res.status(400).json({
|
||||
message: "Invalid request data",
|
||||
error: error.issues,
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Prisma errors
|
||||
if (isPrismaException(error)) {
|
||||
traceException(error);
|
||||
return res.status(500).json({
|
||||
message: "Internal Server Error",
|
||||
error: "An unknown error occurred",
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Unknown errors
|
||||
traceException(error);
|
||||
return res.status(500).json({
|
||||
message: "Internal Server Error",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## OpenTelemetry Instrumentation
|
||||
|
||||
All requests (tRPC and public API) propagate OpenTelemetry context with Langfuse-specific baggage.
|
||||
|
||||
### Context Propagation Pattern
|
||||
|
||||
```typescript
|
||||
import { contextWithLangfuseProps } from "@langfuse/shared/src/server";
|
||||
import * as opentelemetry from "@opentelemetry/api";
|
||||
|
||||
// Create context with Langfuse baggage
|
||||
const ctx = contextWithLangfuseProps({
|
||||
headers: req.headers,
|
||||
userId: session?.user?.id,
|
||||
projectId: input?.projectId,
|
||||
});
|
||||
|
||||
// Execute with context
|
||||
return opentelemetry.context.with(ctx, async () => {
|
||||
// All instrumented code inside here will have access to baggage
|
||||
return await handler();
|
||||
});
|
||||
```
|
||||
|
||||
**Baggage includes:**
|
||||
- `userId` - User ID from session
|
||||
- `projectId` - Project ID from input
|
||||
- `headers` - Request headers for trace propagation
|
||||
|
||||
### tRPC Instrumentation
|
||||
|
||||
```typescript
|
||||
const withOtelInstrumentation = t.middleware(async (opts) => {
|
||||
const actualInput = await opts.getRawInput();
|
||||
|
||||
const baggageCtx = contextWithLangfuseProps({
|
||||
headers: opts.ctx.headers,
|
||||
userId: opts.ctx.session?.user?.id,
|
||||
projectId: (actualInput as Record<string, string>)?.projectId,
|
||||
});
|
||||
|
||||
return opentelemetry.context.with(baggageCtx, () => opts.next());
|
||||
});
|
||||
|
||||
// Used with Baselime tracing
|
||||
const withOtelTracingProcedure = t.procedure
|
||||
.use(withOtelInstrumentation)
|
||||
.use(tracing({ collectInput: true, collectResult: true }));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Composable Procedures
|
||||
|
||||
tRPC procedures are composed by chaining middleware:
|
||||
|
||||
### Composition Pattern
|
||||
|
||||
```typescript
|
||||
// Base procedure with tracing + error handling
|
||||
const baseProcedure = withOtelTracingProcedure.use(withErrorHandling);
|
||||
|
||||
// Add authentication
|
||||
const authedProcedure = baseProcedure.use(enforceUserIsAuthed);
|
||||
|
||||
// Add project scoping
|
||||
const projectProcedure = authedProcedure.use(enforceUserIsAuthedAndProjectMember);
|
||||
```
|
||||
|
||||
### Using Procedures in Routers
|
||||
|
||||
```typescript
|
||||
import { protectedProjectProcedure } from "@/src/server/api/trpc";
|
||||
|
||||
export const tracesRouter = createTRPCRouter({
|
||||
// Input automatically validated against Zod schema
|
||||
all: protectedProjectProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
page: z.number().optional(),
|
||||
limit: z.number().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
// ctx.session.projectId is guaranteed to exist
|
||||
// ctx.session.projectRole contains user's role
|
||||
|
||||
const traces = await getTraces({
|
||||
projectId: input.projectId,
|
||||
page: input.page ?? 0,
|
||||
limit: input.limit ?? 50,
|
||||
});
|
||||
|
||||
return traces;
|
||||
}),
|
||||
|
||||
byId: protectedGetTraceProcedure
|
||||
.input(
|
||||
z.object({
|
||||
traceId: z.string(),
|
||||
projectId: z.string(),
|
||||
timestamp: z.date().nullish(),
|
||||
})
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
// ctx.trace is guaranteed to exist (fetched by middleware)
|
||||
// No need to refetch
|
||||
return ctx.trace;
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
### Middleware Execution Order
|
||||
|
||||
Middleware executes in the order it's chained:
|
||||
|
||||
```typescript
|
||||
protectedProjectProcedure
|
||||
.use(withErrorHandling) // 1. Wraps entire execution
|
||||
.use(enforceUserIsAuthed) // 2. Validates session exists
|
||||
.use(enforceUserIsAuthedAndProjectMember) // 3. Validates project membership
|
||||
.input(schema) // 4. Validates input
|
||||
.query(async ({ input, ctx }) => { // 5. Executes query
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
**Context enrichment:**
|
||||
|
||||
Each middleware can enrich the context:
|
||||
|
||||
```typescript
|
||||
// After enforceUserIsAuthed:
|
||||
ctx.session.user // NonNullable<User>
|
||||
|
||||
// After enforceUserIsAuthedAndProjectMember:
|
||||
ctx.session.projectId // string
|
||||
ctx.session.projectRole // Role (OWNER | ADMIN | MEMBER | VIEWER)
|
||||
ctx.session.orgId // string
|
||||
ctx.session.orgRole // Role
|
||||
|
||||
// After enforceTraceAccess:
|
||||
ctx.trace // TraceRecord (pre-fetched)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Related Files:**
|
||||
|
||||
- [../AGENTS.md](../AGENTS.md) - Main backend development guidelines
|
||||
- [architecture-overview.md](architecture-overview.md) - System architecture
|
||||
- [../AGENTS.md](../AGENTS.md) - Error handling patterns and traceException guidance
|
||||
@@ -1,844 +0,0 @@
|
||||
# Routing Patterns - Next.js & tRPC
|
||||
|
||||
Complete guide to routing and separation of concerns in Langfuse's Next.js + tRPC architecture.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Architecture Overview](#architecture-overview)
|
||||
- [tRPC Routers](#trpc-routers)
|
||||
- [Public REST API Routes](#public-rest-api-routes)
|
||||
- [Service Layer](#service-layer)
|
||||
- [Repository Layer](#repository-layer)
|
||||
- [Separation of Concerns](#separation-of-concerns)
|
||||
- [Anti-Patterns](#anti-patterns)
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
Langfuse uses a **layered architecture** with clear separation of concerns:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ENTRY POINTS │
|
||||
│ ┌──────────────────────┐ ┌─────────────────────────┐ │
|
||||
│ │ tRPC Procedures │ │ Public REST API Routes │ │
|
||||
│ │ (Internal UI API) │ │ (SDK/External API) │ │
|
||||
│ └──────────────────────┘ └─────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ SERVICE LAYER │
|
||||
│ Business logic, orchestration, validation │
|
||||
│ web/src/features/*/server/ or packages/shared/services/ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ REPOSITORY LAYER │
|
||||
│ Complex queries, data transformation │
|
||||
│ packages/shared/src/server/repositories/ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ DATABASE LAYER │
|
||||
│ PostgreSQL (Prisma) + ClickHouse (Direct Client) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Key Principles
|
||||
|
||||
**Entry Points (Routes/Procedures):**
|
||||
- ✅ Define routing and procedure signatures
|
||||
- ✅ Handle authentication/authorization (via middleware)
|
||||
- ✅ Validate input (Zod schemas)
|
||||
- ✅ Delegate to services
|
||||
- ✅ Return responses
|
||||
|
||||
**Entry Points should NEVER:**
|
||||
- ❌ Contain business logic
|
||||
- ❌ Access database directly
|
||||
- ❌ Perform complex data transformations
|
||||
- ❌ Make direct repository calls (use services)
|
||||
|
||||
**Services:**
|
||||
- ✅ Contain business logic
|
||||
- ✅ Orchestrate multiple operations
|
||||
- ✅ Call repositories or Prisma/ClickHouse
|
||||
- ✅ Handle complex workflows
|
||||
- ❌ Should NOT know about HTTP, tRPC, or request/response objects
|
||||
|
||||
**Repositories:**
|
||||
- ✅ Complex database queries
|
||||
- ✅ Data transformation (DB → domain models)
|
||||
- ✅ ClickHouse query builders
|
||||
- ✅ Reusable query logic
|
||||
- ❌ Should NOT contain business logic
|
||||
|
||||
---
|
||||
|
||||
## tRPC Routers
|
||||
|
||||
**Location:** `web/src/server/api/routers/`
|
||||
|
||||
tRPC routers define type-safe procedures for the internal UI. Each router groups related operations.
|
||||
|
||||
### Router Structure
|
||||
|
||||
**File:** `web/src/server/api/routers/scores.ts`
|
||||
|
||||
```typescript
|
||||
import { z } from "zod/v4";
|
||||
import { createTRPCRouter, protectedProjectProcedure } from "@/src/server/api/trpc";
|
||||
import { paginationZod, singleFilter, orderBy } from "@langfuse/shared";
|
||||
import {
|
||||
getScoresUiTable,
|
||||
getScoresUiCount,
|
||||
upsertScore,
|
||||
} from "@langfuse/shared/src/server";
|
||||
|
||||
const ScoreAllOptions = z.object({
|
||||
projectId: z.string(),
|
||||
filter: z.array(singleFilter),
|
||||
orderBy: orderBy,
|
||||
...paginationZod,
|
||||
});
|
||||
|
||||
export const scoresRouter = createTRPCRouter({
|
||||
/**
|
||||
* Get all scores for a project
|
||||
*/
|
||||
all: protectedProjectProcedure
|
||||
.input(ScoreAllOptions)
|
||||
.query(async ({ input, ctx }) => {
|
||||
// Delegate to repository for data fetching
|
||||
const clickhouseScoreData = await getScoresUiTable({
|
||||
projectId: input.projectId,
|
||||
filter: input.filter ?? [],
|
||||
orderBy: input.orderBy,
|
||||
limit: input.limit,
|
||||
offset: input.page * input.limit,
|
||||
});
|
||||
|
||||
// Delegate to Prisma for related data
|
||||
const [jobExecutions, users] = await Promise.all([
|
||||
ctx.prisma.jobExecution.findMany({
|
||||
where: {
|
||||
jobOutputScoreId: {
|
||||
in: clickhouseScoreData.map((score) => score.id),
|
||||
},
|
||||
},
|
||||
}),
|
||||
ctx.prisma.user.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: clickhouseScoreData
|
||||
.map((s) => s.authorUserId)
|
||||
.filter((id): id is string => id !== null),
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// Transform and combine data
|
||||
return clickhouseScoreData.map((score) => ({
|
||||
...score,
|
||||
jobConfigurationId:
|
||||
jobExecutions.find((j) => j.jobOutputScoreId === score.id)
|
||||
?.jobConfigurationId ?? null,
|
||||
authorUserImage:
|
||||
users.find((u) => u.id === score.authorUserId)?.image ?? null,
|
||||
authorUserName:
|
||||
users.find((u) => u.id === score.authorUserId)?.name ?? null,
|
||||
}));
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create or update score
|
||||
*/
|
||||
createAnnotationScore: protectedProjectProcedure
|
||||
.input(CreateAnnotationScoreData)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
// Validation
|
||||
validateConfigAgainstBody(input);
|
||||
|
||||
// Delegate to repository
|
||||
await upsertScore({
|
||||
id: input.id ?? randomUUID(),
|
||||
traceId: input.traceId,
|
||||
projectId: input.projectId,
|
||||
name: input.name,
|
||||
value: input.value,
|
||||
source: ScoreSource.ANNOTATION,
|
||||
authorUserId: ctx.session.user.id,
|
||||
comment: input.comment,
|
||||
});
|
||||
|
||||
// Audit log
|
||||
await auditLog({
|
||||
session: ctx.session,
|
||||
resourceType: "score",
|
||||
resourceId: input.id,
|
||||
action: "create",
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- Use appropriate procedure type (`protectedProjectProcedure`, `authenticatedProcedure`, etc.)
|
||||
- Define input schema with Zod (`.input()`)
|
||||
- Use `.query()` for reads, `.mutation()` for writes
|
||||
- Delegate to services/repositories for data access
|
||||
- Keep procedures thin - no business logic
|
||||
- Type-safe throughout (TypeScript infers types from Zod schemas)
|
||||
|
||||
### Registering Routers
|
||||
|
||||
**File:** `web/src/server/api/root.ts`
|
||||
|
||||
```typescript
|
||||
import { createTRPCRouter } from "@/src/server/api/trpc";
|
||||
import { scoresRouter } from "./routers/scores";
|
||||
import { tracesRouter } from "./routers/traces";
|
||||
import { dashboardRouter } from "@/src/features/dashboard/server/dashboard-router";
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
scores: scoresRouter,
|
||||
traces: tracesRouter,
|
||||
dashboard: dashboardRouter,
|
||||
// ... other routers
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
```
|
||||
|
||||
**Calling from frontend:**
|
||||
|
||||
```typescript
|
||||
// Type-safe client call
|
||||
const { data, isLoading } = api.scores.all.useQuery({
|
||||
projectId: "proj_123",
|
||||
page: 0,
|
||||
limit: 50,
|
||||
filter: [],
|
||||
orderBy: null,
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Public REST API Routes
|
||||
|
||||
**Location:** `web/src/pages/api/public/`
|
||||
|
||||
Public API routes use **Next.js file-based routing** and provide REST endpoints for SDKs and external integrations.
|
||||
|
||||
### File-based Routing
|
||||
|
||||
Next.js uses file system for routing:
|
||||
|
||||
```
|
||||
web/src/pages/api/public/
|
||||
├── scores/
|
||||
│ ├── index.ts → GET/POST /api/public/scores
|
||||
│ └── [scoreId].ts → GET/PATCH/DELETE /api/public/scores/:scoreId
|
||||
├── traces/
|
||||
│ ├── index.ts → GET /api/public/traces
|
||||
│ └── [traceId].ts → GET /api/public/traces/:traceId
|
||||
└── datasets/
|
||||
└── [name]/
|
||||
├── index.ts → GET/POST /api/public/datasets/:name
|
||||
└── items/
|
||||
└── index.ts → GET /api/public/datasets/:name/items
|
||||
```
|
||||
|
||||
**Dynamic routes:**
|
||||
- `[param].ts` → Single dynamic segment (e.g., `/api/public/scores/[scoreId].ts`)
|
||||
- `[...param].ts` → Catch-all route (e.g., `/api/public/[...path].ts`)
|
||||
|
||||
### REST API Pattern
|
||||
|
||||
**File:** `web/src/pages/api/public/scores/index.ts`
|
||||
|
||||
```typescript
|
||||
import { v4 } from "uuid";
|
||||
import { createAuthedProjectAPIRoute } from "@/src/features/public-api/server/createAuthedProjectAPIRoute";
|
||||
import { withMiddlewares } from "@/src/features/public-api/server/withMiddlewares";
|
||||
import {
|
||||
GetScoresQueryV1,
|
||||
GetScoresResponseV1,
|
||||
PostScoresBodyV1,
|
||||
PostScoresResponseV1,
|
||||
} from "@langfuse/shared";
|
||||
import { eventTypes, processEventBatch } from "@langfuse/shared/src/server";
|
||||
import { ScoresApiService } from "@/src/features/public-api/server/scores-api-service";
|
||||
|
||||
export default withMiddlewares({
|
||||
// POST /api/public/scores
|
||||
POST: createAuthedProjectAPIRoute({
|
||||
name: "Create Score",
|
||||
bodySchema: PostScoresBodyV1,
|
||||
responseSchema: PostScoresResponseV1,
|
||||
fn: async ({ body, auth, res }) => {
|
||||
const event = {
|
||||
id: v4(),
|
||||
type: eventTypes.SCORE_CREATE,
|
||||
timestamp: new Date().toISOString(),
|
||||
body,
|
||||
};
|
||||
|
||||
if (!event.body.id) {
|
||||
event.body.id = v4();
|
||||
}
|
||||
|
||||
const result = await processEventBatch([event], auth);
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
const error = result.errors[0];
|
||||
res.status(error.status).json({
|
||||
message: error.error ?? error.message,
|
||||
});
|
||||
return { id: "" };
|
||||
}
|
||||
|
||||
return { id: event.body.id };
|
||||
},
|
||||
}),
|
||||
|
||||
// GET /api/public/scores
|
||||
GET: createAuthedProjectAPIRoute({
|
||||
name: "Get Scores",
|
||||
querySchema: GetScoresQueryV1,
|
||||
responseSchema: GetScoresResponseV1,
|
||||
fn: async ({ query, auth }) => {
|
||||
const scoresApiService = new ScoresApiService("v1");
|
||||
|
||||
const [items, count] = await Promise.all([
|
||||
scoresApiService.generateScoresForPublicApi({
|
||||
projectId: auth.scope.projectId,
|
||||
page: query.page,
|
||||
limit: query.limit,
|
||||
userId: query.userId,
|
||||
name: query.name,
|
||||
}),
|
||||
scoresApiService.getScoresCountForPublicApi({
|
||||
projectId: auth.scope.projectId,
|
||||
userId: query.userId,
|
||||
name: query.name,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
data: items,
|
||||
meta: {
|
||||
page: query.page,
|
||||
limit: query.limit,
|
||||
totalItems: count,
|
||||
totalPages: Math.ceil(count / query.limit),
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- Use `withMiddlewares` for all public API routes (provides CORS, error handling, OpenTelemetry)
|
||||
- Use `createAuthedProjectAPIRoute` for authenticated endpoints (handles auth, rate limiting, validation)
|
||||
- Define separate handlers for each HTTP method
|
||||
- Input/output validated with Zod schemas
|
||||
- Delegate to services for business logic
|
||||
|
||||
### Simple Public Routes
|
||||
|
||||
For routes that don't need authentication:
|
||||
|
||||
```typescript
|
||||
// web/src/pages/api/public/health.ts
|
||||
import { withMiddlewares } from "@/src/features/public-api/server/withMiddlewares";
|
||||
|
||||
export default withMiddlewares({
|
||||
GET: async (req, res) => {
|
||||
res.status(200).json({ status: "ok" });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Service Layer
|
||||
|
||||
**Location:** `web/src/features/*/server/` or `packages/shared/src/server/services/`
|
||||
|
||||
Services contain business logic and orchestrate operations. They're called by tRPC procedures and API routes.
|
||||
|
||||
### Service Pattern
|
||||
|
||||
**File:** `web/src/features/public-api/server/scores-api-service.ts`
|
||||
|
||||
```typescript
|
||||
import {
|
||||
_handleGenerateScoresForPublicApi,
|
||||
_handleGetScoresCountForPublicApi,
|
||||
type ScoreQueryType,
|
||||
} from "@/src/features/public-api/server/scores";
|
||||
import { _handleGetScoreById } from "@langfuse/shared/src/server";
|
||||
|
||||
export class ScoresApiService {
|
||||
constructor(private readonly apiVersion: "v1" | "v2") {}
|
||||
|
||||
/**
|
||||
* Get a specific score by ID
|
||||
*/
|
||||
async getScoreById({
|
||||
projectId,
|
||||
scoreId,
|
||||
source,
|
||||
}: {
|
||||
projectId: string;
|
||||
scoreId: string;
|
||||
source?: ScoreSourceType;
|
||||
}) {
|
||||
return _handleGetScoreById({
|
||||
projectId,
|
||||
scoreId,
|
||||
source,
|
||||
scoreScope: this.apiVersion === "v1" ? "traces_only" : "all",
|
||||
preferredClickhouseService: "ReadOnly",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of scores with version-aware filtering
|
||||
*/
|
||||
async generateScoresForPublicApi(props: ScoreQueryType) {
|
||||
return _handleGenerateScoresForPublicApi({
|
||||
props,
|
||||
scoreScope: this.apiVersion === "v1" ? "traces_only" : "all",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of scores with version-aware filtering
|
||||
*/
|
||||
async getScoresCountForPublicApi(props: ScoreQueryType) {
|
||||
return _handleGetScoresCountForPublicApi({
|
||||
props,
|
||||
scoreScope: this.apiVersion === "v1" ? "traces_only" : "all",
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- Services contain business logic, not routing logic
|
||||
- Services should NOT import tRPC or Next.js types
|
||||
- Services can call repositories, Prisma, ClickHouse directly
|
||||
- Services orchestrate multiple operations
|
||||
- Services are reusable across tRPC and public API
|
||||
|
||||
### Where to Put Services
|
||||
|
||||
**Feature-specific services:**
|
||||
```
|
||||
web/src/features/
|
||||
├── datasets/
|
||||
│ └── server/
|
||||
│ └── dataset-service.ts
|
||||
├── evals/
|
||||
│ └── server/
|
||||
│ └── eval-service.ts
|
||||
└── public-api/
|
||||
└── server/
|
||||
└── scores-api-service.ts
|
||||
```
|
||||
|
||||
**Shared services:**
|
||||
```
|
||||
packages/shared/src/server/services/
|
||||
├── SlackService.ts
|
||||
├── DashboardService/
|
||||
├── StorageService.ts
|
||||
└── DefaultEvaluationModelService/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Repository Layer
|
||||
|
||||
**Location:** `packages/shared/src/server/repositories/`
|
||||
|
||||
Repositories handle complex database queries, data transformation, and provide reusable query logic.
|
||||
|
||||
### Repository Structure
|
||||
|
||||
```
|
||||
packages/shared/src/server/repositories/
|
||||
├── traces.ts # Trace queries (ClickHouse)
|
||||
├── observations.ts # Observation queries (ClickHouse)
|
||||
├── scores.ts # Score queries (ClickHouse)
|
||||
├── clickhouse.ts # Core ClickHouse helpers
|
||||
└── definitions.ts # Type definitions
|
||||
```
|
||||
|
||||
### Repository Pattern
|
||||
|
||||
**File:** `packages/shared/src/server/repositories/traces.ts`
|
||||
|
||||
```typescript
|
||||
import { queryClickhouse, upsertClickhouse } from "./clickhouse";
|
||||
import { TraceRecordReadType } from "./definitions";
|
||||
import { convertClickhouseToDomain } from "./traces_converters";
|
||||
|
||||
/**
|
||||
* Get traces by IDs
|
||||
*/
|
||||
export const getTracesByIds = async (
|
||||
projectId: string,
|
||||
traceIds: string[]
|
||||
): Promise<TraceRecordReadType[]> => {
|
||||
const rows = await queryClickhouse<TraceRecordReadType>({
|
||||
query: `
|
||||
SELECT *
|
||||
FROM traces
|
||||
WHERE project_id = {projectId: String}
|
||||
AND id IN ({traceIds: Array(String)})
|
||||
ORDER BY event_ts DESC
|
||||
LIMIT 1 BY id, project_id
|
||||
`,
|
||||
params: { projectId, traceIds },
|
||||
tags: { feature: "tracing", type: "trace" },
|
||||
});
|
||||
|
||||
return rows.map(convertClickhouseToDomain);
|
||||
};
|
||||
|
||||
/**
|
||||
* Upsert trace to ClickHouse
|
||||
*/
|
||||
export const upsertTrace = async (
|
||||
trace: TraceRecordInsertType
|
||||
): Promise<void> => {
|
||||
await upsertClickhouse({
|
||||
table: "traces",
|
||||
records: [trace],
|
||||
eventBodyMapper: (body) => ({
|
||||
id: body.id,
|
||||
name: body.name,
|
||||
user_id: body.user_id,
|
||||
// ... map fields
|
||||
}),
|
||||
tags: { feature: "ingestion", type: "trace" },
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- Use `queryClickhouse` for SELECT queries
|
||||
- Use `upsertClickhouse` for INSERT/UPDATE
|
||||
- Use `commandClickhouse` for DDL (ALTER TABLE, etc.)
|
||||
- Include data converters (`convertClickhouseToDomain`)
|
||||
- Add OpenTelemetry tags for observability
|
||||
- Repositories should NOT contain business logic
|
||||
|
||||
### When to Use Repositories
|
||||
|
||||
✅ **Use repositories for:**
|
||||
- Complex ClickHouse queries with CTEs, joins, aggregations
|
||||
- Queries used in multiple places (DRY principle)
|
||||
- Data transformation from DB types to domain models
|
||||
- Streaming large result sets
|
||||
|
||||
❌ **Use direct Prisma/ClickHouse for:**
|
||||
- Simple CRUD operations
|
||||
- One-off queries
|
||||
- Prototyping (can refactor to repository later)
|
||||
|
||||
---
|
||||
|
||||
## Separation of Concerns
|
||||
|
||||
### ✅ Good Example: Proper Layering
|
||||
|
||||
**tRPC Procedure (Entry Point):**
|
||||
|
||||
```typescript
|
||||
// web/src/server/api/routers/scores.ts
|
||||
export const scoresRouter = createTRPCRouter({
|
||||
all: protectedProjectProcedure
|
||||
.input(ScoreFilterOptions)
|
||||
.query(async ({ input }) => {
|
||||
// ✅ Thin procedure - delegates to repository
|
||||
return await getScoresUiTable({
|
||||
projectId: input.projectId,
|
||||
filter: input.filter,
|
||||
orderBy: input.orderBy,
|
||||
});
|
||||
}),
|
||||
|
||||
create: protectedProjectProcedure
|
||||
.input(CreateScoreInput)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
// ✅ Delegates to service for orchestration
|
||||
return await createScoreWithValidation({
|
||||
scoreData: input,
|
||||
userId: ctx.session.user.id,
|
||||
projectId: ctx.session.projectId,
|
||||
});
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
**Service (Business Logic):**
|
||||
|
||||
```typescript
|
||||
// web/src/features/scores/server/score-service.ts
|
||||
export async function createScoreWithValidation({
|
||||
scoreData,
|
||||
userId,
|
||||
projectId,
|
||||
}: {
|
||||
scoreData: CreateScoreInput;
|
||||
userId: string;
|
||||
projectId: string;
|
||||
}) {
|
||||
// ✅ Business logic: validation
|
||||
const config = await prisma.scoreConfig.findUnique({
|
||||
where: { id: scoreData.configId },
|
||||
});
|
||||
|
||||
if (!config) {
|
||||
throw new LangfuseNotFoundError("Score config not found");
|
||||
}
|
||||
|
||||
validateConfigAgainstBody(config, scoreData);
|
||||
|
||||
// ✅ Business logic: orchestration
|
||||
const scoreId = randomUUID();
|
||||
|
||||
await Promise.all([
|
||||
// Create score in ClickHouse
|
||||
upsertScore({
|
||||
id: scoreId,
|
||||
projectId,
|
||||
traceId: scoreData.traceId,
|
||||
name: scoreData.name,
|
||||
value: scoreData.value,
|
||||
authorUserId: userId,
|
||||
}),
|
||||
// Audit log in PostgreSQL
|
||||
auditLog({
|
||||
userId,
|
||||
resourceType: "score",
|
||||
resourceId: scoreId,
|
||||
action: "create",
|
||||
}),
|
||||
]);
|
||||
|
||||
return { id: scoreId };
|
||||
}
|
||||
```
|
||||
|
||||
**Repository (Data Access):**
|
||||
|
||||
```typescript
|
||||
// packages/shared/src/server/repositories/scores.ts
|
||||
export const upsertScore = async (
|
||||
score: ScoreInsertType
|
||||
): Promise<void> => {
|
||||
// ✅ Pure data access - no business logic
|
||||
await upsertClickhouse({
|
||||
table: "scores",
|
||||
records: [score],
|
||||
eventBodyMapper: (body) => ({
|
||||
id: body.id,
|
||||
trace_id: body.traceId,
|
||||
name: body.name,
|
||||
value: body.value,
|
||||
author_user_id: body.authorUserId,
|
||||
}),
|
||||
tags: { feature: "scoring" },
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
### Why This Works
|
||||
|
||||
1. **tRPC Procedure**: Thin, delegates to service
|
||||
2. **Service**: Contains all business logic (validation, orchestration)
|
||||
3. **Repository**: Pure data access, reusable
|
||||
4. **Service is protocol-agnostic**: Can be called from tRPC, public API, or worker
|
||||
5. **Clear separation**: Easy to test, maintain, extend
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
### ❌ Anti-Pattern 1: Business Logic in Routes
|
||||
|
||||
**Bad:**
|
||||
|
||||
```typescript
|
||||
// ❌ BAD: Business logic in tRPC procedure
|
||||
export const scoresRouter = createTRPCRouter({
|
||||
create: protectedProjectProcedure
|
||||
.input(CreateScoreInput)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
// ❌ Validation logic in route
|
||||
const config = await ctx.prisma.scoreConfig.findUnique({
|
||||
where: { id: input.configId },
|
||||
});
|
||||
|
||||
if (!config) {
|
||||
throw new TRPCError({ code: "NOT_FOUND" });
|
||||
}
|
||||
|
||||
if (config.dataType === "NUMERIC" && typeof input.value !== "number") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST" });
|
||||
}
|
||||
|
||||
// ❌ Direct database access
|
||||
await ctx.prisma.score.create({
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
projectId: ctx.session.projectId,
|
||||
traceId: input.traceId,
|
||||
name: input.name,
|
||||
value: input.value,
|
||||
},
|
||||
});
|
||||
|
||||
// ❌ More business logic
|
||||
await auditLog({ ... });
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
**Why it's bad:**
|
||||
- Business logic tied to tRPC (can't reuse in public API)
|
||||
- Hard to test (need to mock tRPC context)
|
||||
- No separation of concerns
|
||||
- Difficult to maintain
|
||||
|
||||
**Good:**
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD: Thin procedure, delegates to service
|
||||
export const scoresRouter = createTRPCRouter({
|
||||
create: protectedProjectProcedure
|
||||
.input(CreateScoreInput)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
return await createScoreWithValidation({
|
||||
scoreData: input,
|
||||
userId: ctx.session.user.id,
|
||||
projectId: ctx.session.projectId,
|
||||
});
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
### ❌ Anti-Pattern 2: Database Calls in Routes
|
||||
|
||||
**Bad:**
|
||||
|
||||
```typescript
|
||||
// ❌ BAD: Direct database access in route
|
||||
export default withMiddlewares({
|
||||
GET: createAuthedProjectAPIRoute({
|
||||
name: "Get Scores",
|
||||
fn: async ({ auth }) => {
|
||||
// ❌ Direct ClickHouse query in route
|
||||
const scores = await queryClickhouse({
|
||||
query: "SELECT * FROM scores WHERE project_id = {projectId: String}",
|
||||
params: { projectId: auth.scope.projectId },
|
||||
});
|
||||
|
||||
return { data: scores };
|
||||
},
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
**Good:**
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD: Delegates to service or repository
|
||||
export default withMiddlewares({
|
||||
GET: createAuthedProjectAPIRoute({
|
||||
name: "Get Scores",
|
||||
fn: async ({ auth, query }) => {
|
||||
const scoresService = new ScoresApiService("v1");
|
||||
|
||||
return await scoresService.generateScoresForPublicApi({
|
||||
projectId: auth.scope.projectId,
|
||||
page: query.page,
|
||||
limit: query.limit,
|
||||
});
|
||||
},
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
### ❌ Anti-Pattern 3: Business Logic in Repositories
|
||||
|
||||
**Bad:**
|
||||
|
||||
```typescript
|
||||
// ❌ BAD: Business logic in repository
|
||||
export const upsertScore = async (
|
||||
score: ScoreInsertType
|
||||
): Promise<void> => {
|
||||
// ❌ Validation in repository
|
||||
if (!score.name) {
|
||||
throw new Error("Score name is required");
|
||||
}
|
||||
|
||||
// ❌ Authorization check in repository
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: score.projectId },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new Error("Project not found");
|
||||
}
|
||||
|
||||
// ❌ Side effects in repository
|
||||
await auditLog({ ... });
|
||||
|
||||
await upsertClickhouse({ ... });
|
||||
};
|
||||
```
|
||||
|
||||
**Good:**
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD: Pure data access, no business logic
|
||||
export const upsertScore = async (
|
||||
score: ScoreInsertType
|
||||
): Promise<void> => {
|
||||
await upsertClickhouse({
|
||||
table: "scores",
|
||||
records: [score],
|
||||
eventBodyMapper: (body) => ({
|
||||
id: body.id,
|
||||
trace_id: body.traceId,
|
||||
name: body.name,
|
||||
value: body.value,
|
||||
}),
|
||||
tags: { feature: "scoring" },
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Related Files:**
|
||||
|
||||
- [../AGENTS.md](../AGENTS.md) - Main backend development guidelines
|
||||
- [architecture-overview.md](architecture-overview.md) - System architecture
|
||||
- [middleware-guide.md](middleware-guide.md) - Middleware patterns
|
||||
- [database-patterns.md](database-patterns.md) - Database access patterns
|
||||
@@ -1,877 +0,0 @@
|
||||
# Services and Repositories - Business Logic Layer
|
||||
|
||||
Complete guide to organizing business logic with services and data access with repositories.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Service Layer Overview](#service-layer-overview)
|
||||
- [Dependency Injection Pattern](#dependency-injection-pattern)
|
||||
- [Singleton Pattern](#singleton-pattern)
|
||||
- [Repository Pattern](#repository-pattern)
|
||||
- [Service Design Principles](#service-design-principles)
|
||||
- [Caching Strategies](#caching-strategies)
|
||||
- [Testing Services](#testing-services)
|
||||
|
||||
---
|
||||
|
||||
## Service Layer Overview
|
||||
|
||||
### Purpose of Services
|
||||
|
||||
**Services contain business logic** - the 'what' and 'why' of your application:
|
||||
|
||||
```
|
||||
Controller asks: "Should I do this?"
|
||||
Service answers: "Yes/No, here's why, and here's what happens"
|
||||
Repository executes: "Here's the data you requested"
|
||||
```
|
||||
|
||||
**Services are responsible for:**
|
||||
|
||||
- ✅ Business rules enforcement
|
||||
- ✅ Orchestrating multiple repositories
|
||||
- ✅ Transaction management
|
||||
- ✅ Complex calculations
|
||||
- ✅ External service integration
|
||||
- ✅ Business validations
|
||||
|
||||
**Services should NOT:**
|
||||
|
||||
- ❌ Know about HTTP (Request/Response)
|
||||
- ❌ Direct Prisma access (use repositories)
|
||||
- ❌ Handle route-specific logic
|
||||
- ❌ Format HTTP responses
|
||||
|
||||
---
|
||||
|
||||
## Dependency Injection Pattern
|
||||
|
||||
### Why Dependency Injection?
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Easy to test (inject mocks)
|
||||
- Clear dependencies
|
||||
- Flexible configuration
|
||||
- Promotes loose coupling
|
||||
|
||||
### Excellent Example: NotificationService
|
||||
|
||||
**File:** `/blog-api/src/services/NotificationService.ts`
|
||||
|
||||
```typescript
|
||||
// Define dependencies interface for clarity
|
||||
export interface NotificationServiceDependencies {
|
||||
prisma: PrismaClient;
|
||||
batchingService: BatchingService;
|
||||
emailComposer: EmailComposer;
|
||||
}
|
||||
|
||||
// Service with dependency injection
|
||||
export class NotificationService {
|
||||
private prisma: PrismaClient;
|
||||
private batchingService: BatchingService;
|
||||
private emailComposer: EmailComposer;
|
||||
private preferencesCache: Map<
|
||||
string,
|
||||
{ preferences: UserPreference; timestamp: number }
|
||||
> = new Map();
|
||||
private CACHE_TTL =
|
||||
(notificationConfig.preferenceCacheTTLMinutes || 5) * 60 * 1000;
|
||||
|
||||
// Dependencies injected via constructor
|
||||
constructor(dependencies: NotificationServiceDependencies) {
|
||||
this.prisma = dependencies.prisma;
|
||||
this.batchingService = dependencies.batchingService;
|
||||
this.emailComposer = dependencies.emailComposer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a notification and route it appropriately
|
||||
*/
|
||||
async createNotification(params: CreateNotificationParams) {
|
||||
const {
|
||||
recipientID,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
link,
|
||||
context = {},
|
||||
channel = "both",
|
||||
priority = NotificationPriority.NORMAL,
|
||||
} = params;
|
||||
|
||||
try {
|
||||
// Get template and render content
|
||||
const template = getNotificationTemplate(type);
|
||||
const rendered = renderNotificationContent(template, context);
|
||||
|
||||
// Create in-app notification record
|
||||
const notificationId = await createNotificationRecord({
|
||||
instanceId: parseInt(context.instanceId || "0", 10),
|
||||
template: type,
|
||||
recipientUserId: recipientID,
|
||||
channel: channel === "email" ? "email" : "inApp",
|
||||
contextData: context,
|
||||
title: finalTitle,
|
||||
message: finalMessage,
|
||||
link: finalLink,
|
||||
});
|
||||
|
||||
// Route notification based on channel
|
||||
if (channel === "email" || channel === "both") {
|
||||
await this.routeNotification({
|
||||
notificationId,
|
||||
userId: recipientID,
|
||||
type,
|
||||
priority,
|
||||
title: finalTitle,
|
||||
message: finalMessage,
|
||||
link: finalLink,
|
||||
context,
|
||||
});
|
||||
}
|
||||
|
||||
return notification;
|
||||
} catch (error) {
|
||||
ErrorLogger.log(error, {
|
||||
context: {
|
||||
"[NotificationService] createNotification": {
|
||||
type: params.type,
|
||||
recipientID: params.recipientID,
|
||||
},
|
||||
},
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Route notification based on user preferences
|
||||
*/
|
||||
private async routeNotification(params: {
|
||||
notificationId: number;
|
||||
userId: string;
|
||||
type: string;
|
||||
priority: NotificationPriority;
|
||||
title: string;
|
||||
message: string;
|
||||
link?: string;
|
||||
context?: Record<string, any>;
|
||||
}) {
|
||||
// Get user preferences with caching
|
||||
const preferences = await this.getUserPreferences(params.userId);
|
||||
|
||||
// Check if we should batch or send immediately
|
||||
if (this.shouldBatchEmail(preferences, params.type, params.priority)) {
|
||||
await this.batchingService.queueNotificationForBatch({
|
||||
notificationId: params.notificationId,
|
||||
userId: params.userId,
|
||||
userPreference: preferences,
|
||||
priority: params.priority,
|
||||
});
|
||||
} else {
|
||||
// Send immediately via EmailComposer
|
||||
await this.sendImmediateEmail({
|
||||
userId: params.userId,
|
||||
title: params.title,
|
||||
message: params.message,
|
||||
link: params.link,
|
||||
context: params.context,
|
||||
type: params.type,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if email should be batched
|
||||
*/
|
||||
shouldBatchEmail(
|
||||
preferences: UserPreference,
|
||||
notificationType: string,
|
||||
priority: NotificationPriority,
|
||||
): boolean {
|
||||
// HIGH priority always immediate
|
||||
if (priority === NotificationPriority.HIGH) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check batch mode
|
||||
const batchMode = preferences.emailBatchMode || BatchMode.IMMEDIATE;
|
||||
return batchMode !== BatchMode.IMMEDIATE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user preferences with caching
|
||||
*/
|
||||
async getUserPreferences(userId: string): Promise<UserPreference> {
|
||||
// Check cache first
|
||||
const cached = this.preferencesCache.get(userId);
|
||||
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
|
||||
return cached.preferences;
|
||||
}
|
||||
|
||||
const preference = await this.prisma.userPreference.findUnique({
|
||||
where: { userID: userId },
|
||||
});
|
||||
|
||||
const finalPreferences = preference || DEFAULT_PREFERENCES;
|
||||
|
||||
// Update cache
|
||||
this.preferencesCache.set(userId, {
|
||||
preferences: finalPreferences,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
return finalPreferences;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Usage in Controller:**
|
||||
|
||||
```typescript
|
||||
// Instantiate with dependencies
|
||||
const notificationService = new NotificationService({
|
||||
prisma: PrismaService.main,
|
||||
batchingService: new BatchingService(PrismaService.main),
|
||||
emailComposer: new EmailComposer(),
|
||||
});
|
||||
|
||||
// Use in controller
|
||||
const notification = await notificationService.createNotification({
|
||||
recipientID: "user-123",
|
||||
type: "AFRLWorkflowNotification",
|
||||
context: { workflowName: "AFRL Monthly Report" },
|
||||
});
|
||||
```
|
||||
|
||||
**Key Takeaways:**
|
||||
|
||||
- Dependencies passed via constructor
|
||||
- Clear interface defines required dependencies
|
||||
- Easy to test (inject mocks)
|
||||
- Encapsulated caching logic
|
||||
- Business rules isolated from HTTP
|
||||
|
||||
---
|
||||
|
||||
## Singleton Pattern
|
||||
|
||||
### When to Use Singletons
|
||||
|
||||
**Use for:**
|
||||
|
||||
- Services with expensive initialization
|
||||
- Services with shared state (caching)
|
||||
- Services accessed from many places
|
||||
- Permission services
|
||||
- Configuration services
|
||||
|
||||
### Example: PermissionService (Singleton)
|
||||
|
||||
**File:** `/blog-api/src/services/permissionService.ts`
|
||||
|
||||
```typescript
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
class PermissionService {
|
||||
private static instance: PermissionService;
|
||||
private prisma: PrismaClient;
|
||||
private permissionCache: Map<
|
||||
string,
|
||||
{ canAccess: boolean; timestamp: number }
|
||||
> = new Map();
|
||||
private CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
// Private constructor prevents direct instantiation
|
||||
private constructor() {
|
||||
this.prisma = PrismaService.main;
|
||||
}
|
||||
|
||||
// Get singleton instance
|
||||
public static getInstance(): PermissionService {
|
||||
if (!PermissionService.instance) {
|
||||
PermissionService.instance = new PermissionService();
|
||||
}
|
||||
return PermissionService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can complete a workflow step
|
||||
*/
|
||||
async canCompleteStep(
|
||||
userId: string,
|
||||
stepInstanceId: number,
|
||||
): Promise<boolean> {
|
||||
const cacheKey = `${userId}:${stepInstanceId}`;
|
||||
|
||||
// Check cache
|
||||
const cached = this.permissionCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
|
||||
return cached.canAccess;
|
||||
}
|
||||
|
||||
try {
|
||||
const post = await this.prisma.post.findUnique({
|
||||
where: { id: postId },
|
||||
include: {
|
||||
author: true,
|
||||
comments: {
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if user has permission
|
||||
const canEdit =
|
||||
post.authorId === userId || (await this.isUserAdmin(userId));
|
||||
|
||||
// Cache result
|
||||
this.permissionCache.set(cacheKey, {
|
||||
canAccess: isAssigned,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
return isAssigned;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[PermissionService] Error checking step permission:",
|
||||
error,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache for user
|
||||
*/
|
||||
clearUserCache(userId: string): void {
|
||||
for (const [key] of this.permissionCache) {
|
||||
if (key.startsWith(`${userId}:`)) {
|
||||
this.permissionCache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cache
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.permissionCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const permissionService = PermissionService.getInstance();
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
```typescript
|
||||
import { permissionService } from "../services/permissionService";
|
||||
|
||||
// Use anywhere in the codebase
|
||||
const canComplete = await permissionService.canCompleteStep(userId, stepId);
|
||||
|
||||
if (!canComplete) {
|
||||
throw new ForbiddenError("You do not have permission to complete this step");
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Repository Pattern
|
||||
|
||||
### Purpose of Repositories
|
||||
|
||||
**Repositories abstract data access** - the 'how' of data operations:
|
||||
|
||||
```
|
||||
Service: "Get me all active users sorted by name"
|
||||
Repository: "Here's the Prisma query that does that"
|
||||
```
|
||||
|
||||
**Repositories are responsible for:**
|
||||
|
||||
- ✅ All Prisma operations
|
||||
- ✅ Query construction
|
||||
- ✅ Query optimization (select, include)
|
||||
- ✅ Database error handling
|
||||
- ✅ Caching database results
|
||||
|
||||
**Repositories should NOT:**
|
||||
|
||||
- ❌ Contain business logic
|
||||
- ❌ Know about HTTP
|
||||
- ❌ Make decisions (that's service layer)
|
||||
|
||||
### Repository Template
|
||||
|
||||
```typescript
|
||||
// repositories/UserRepository.ts
|
||||
import { PrismaService } from "@project-lifecycle-portal/database";
|
||||
import type { User, Prisma } from "@project-lifecycle-portal/database";
|
||||
|
||||
export class UserRepository {
|
||||
/**
|
||||
* Find user by ID with optimized query
|
||||
*/
|
||||
async findById(userId: string): Promise<User | null> {
|
||||
try {
|
||||
return await PrismaService.main.user.findUnique({
|
||||
where: { userID: userId },
|
||||
select: {
|
||||
userID: true,
|
||||
email: true,
|
||||
name: true,
|
||||
isActive: true,
|
||||
roles: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[UserRepository] Error finding user by ID:", error);
|
||||
throw new Error(`Failed to find user: ${userId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all active users
|
||||
*/
|
||||
async findActive(options?: {
|
||||
orderBy?: Prisma.UserOrderByWithRelationInput;
|
||||
}): Promise<User[]> {
|
||||
try {
|
||||
return await PrismaService.main.user.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: options?.orderBy || { name: "asc" },
|
||||
select: {
|
||||
userID: true,
|
||||
email: true,
|
||||
name: true,
|
||||
roles: true,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[UserRepository] Error finding active users:", error);
|
||||
throw new Error("Failed to find active users");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find user by email
|
||||
*/
|
||||
async findByEmail(email: string): Promise<User | null> {
|
||||
try {
|
||||
return await PrismaService.main.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[UserRepository] Error finding user by email:", error);
|
||||
throw new Error(`Failed to find user with email: ${email}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new user
|
||||
*/
|
||||
async create(data: Prisma.UserCreateInput): Promise<User> {
|
||||
try {
|
||||
return await PrismaService.main.user.create({ data });
|
||||
} catch (error) {
|
||||
console.error("[UserRepository] Error creating user:", error);
|
||||
throw new Error("Failed to create user");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user
|
||||
*/
|
||||
async update(userId: string, data: Prisma.UserUpdateInput): Promise<User> {
|
||||
try {
|
||||
return await PrismaService.main.user.update({
|
||||
where: { userID: userId },
|
||||
data,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[UserRepository] Error updating user:", error);
|
||||
throw new Error(`Failed to update user: ${userId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete user (soft delete by setting isActive = false)
|
||||
*/
|
||||
async delete(userId: string): Promise<User> {
|
||||
try {
|
||||
return await PrismaService.main.user.update({
|
||||
where: { userID: userId },
|
||||
data: { isActive: false },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[UserRepository] Error deleting user:", error);
|
||||
throw new Error(`Failed to delete user: ${userId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if email exists
|
||||
*/
|
||||
async emailExists(email: string): Promise<boolean> {
|
||||
try {
|
||||
const count = await PrismaService.main.user.count({
|
||||
where: { email },
|
||||
});
|
||||
return count > 0;
|
||||
} catch (error) {
|
||||
console.error("[UserRepository] Error checking email exists:", error);
|
||||
throw new Error("Failed to check if email exists");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const userRepository = new UserRepository();
|
||||
```
|
||||
|
||||
**Using Repository in Service:**
|
||||
|
||||
```typescript
|
||||
// services/userService.ts
|
||||
import { userRepository } from "../repositories/UserRepository";
|
||||
import { ConflictError, NotFoundError } from "../utils/errors";
|
||||
|
||||
export class UserService {
|
||||
/**
|
||||
* Create new user with business rules
|
||||
*/
|
||||
async createUser(data: {
|
||||
email: string;
|
||||
name: string;
|
||||
roles: string[];
|
||||
}): Promise<User> {
|
||||
// Business rule: Check if email already exists
|
||||
const emailExists = await userRepository.emailExists(data.email);
|
||||
if (emailExists) {
|
||||
throw new ConflictError("Email already exists");
|
||||
}
|
||||
|
||||
// Business rule: Validate roles
|
||||
const validRoles = ["admin", "operations", "user"];
|
||||
const invalidRoles = data.roles.filter(
|
||||
(role) => !validRoles.includes(role),
|
||||
);
|
||||
if (invalidRoles.length > 0) {
|
||||
throw new ValidationError(`Invalid roles: ${invalidRoles.join(", ")}`);
|
||||
}
|
||||
|
||||
// Create user via repository
|
||||
return await userRepository.create({
|
||||
email: data.email,
|
||||
name: data.name,
|
||||
roles: data.roles,
|
||||
isActive: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by ID
|
||||
*/
|
||||
async getUser(userId: string): Promise<User> {
|
||||
const user = await userRepository.findById(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundError(`User not found: ${userId}`);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Service Design Principles
|
||||
|
||||
### 1. Single Responsibility
|
||||
|
||||
Each service should have ONE clear purpose:
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD - Single responsibility
|
||||
class UserService {
|
||||
async createUser() {}
|
||||
async updateUser() {}
|
||||
async deleteUser() {}
|
||||
}
|
||||
|
||||
class EmailService {
|
||||
async sendEmail() {}
|
||||
async sendBulkEmails() {}
|
||||
}
|
||||
|
||||
// ❌ BAD - Too many responsibilities
|
||||
class UserService {
|
||||
async createUser() {}
|
||||
async sendWelcomeEmail() {} // Should be EmailService
|
||||
async logUserActivity() {} // Should be AuditService
|
||||
async processPayment() {} // Should be PaymentService
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Clear Method Names
|
||||
|
||||
Method names should describe WHAT they do:
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD - Clear intent
|
||||
async createNotification()
|
||||
async getUserPreferences()
|
||||
async shouldBatchEmail()
|
||||
async routeNotification()
|
||||
|
||||
// ❌ BAD - Vague or misleading
|
||||
async process()
|
||||
async handle()
|
||||
async doIt()
|
||||
async execute()
|
||||
```
|
||||
|
||||
### 3. Use Params Objects for Multiple Arguments
|
||||
|
||||
When a function receives multiple arguments, use a single params object instead of positional arguments:
|
||||
|
||||
```typescript
|
||||
// ❌ BAD - Positional arguments are unclear and can be swapped
|
||||
async function createTrace(
|
||||
projectId: string,
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
name: string,
|
||||
) {}
|
||||
|
||||
// Call site - which string is which?
|
||||
await createTrace(projectId, userId, sessionId, name);
|
||||
|
||||
// ✅ GOOD - Params object makes intent clear
|
||||
async function createTrace(params: {
|
||||
projectId: string;
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
name: string;
|
||||
}) {}
|
||||
|
||||
// Call site - clear and prevents argument swapping bugs
|
||||
await createTrace({ projectId, userId, sessionId, name });
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- More readable at call sites
|
||||
- Prevents bugs when positional arguments of the same type are accidentally swapped
|
||||
- Easier to add optional parameters later
|
||||
- Self-documenting code
|
||||
|
||||
### 4. Return Types
|
||||
|
||||
Always use explicit return types:
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD - Explicit types
|
||||
async createUser(data: CreateUserDTO): Promise<User> {}
|
||||
async findUsers(): Promise<User[]> {}
|
||||
async deleteUser(id: string): Promise<void> {}
|
||||
|
||||
// ❌ BAD - Implicit any
|
||||
async createUser(data) {} // No types!
|
||||
```
|
||||
|
||||
### 5. Error Handling
|
||||
|
||||
Services should throw meaningful errors:
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD - Meaningful errors
|
||||
if (!user) {
|
||||
throw new NotFoundError(`User not found: ${userId}`);
|
||||
}
|
||||
|
||||
if (emailExists) {
|
||||
throw new ConflictError("Email already exists");
|
||||
}
|
||||
|
||||
// ❌ BAD - Generic errors
|
||||
if (!user) {
|
||||
throw new Error("Error"); // What error?
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Avoid God Services
|
||||
|
||||
Don't create services that do everything:
|
||||
|
||||
```typescript
|
||||
// ❌ BAD - God service
|
||||
class WorkflowService {
|
||||
async startWorkflow() {}
|
||||
async completeStep() {}
|
||||
async assignRoles() {}
|
||||
async sendNotifications() {} // Should be NotificationService
|
||||
async validatePermissions() {} // Should be PermissionService
|
||||
async logAuditTrail() {} // Should be AuditService
|
||||
// ... 50 more methods
|
||||
}
|
||||
|
||||
// ✅ GOOD - Focused services
|
||||
class WorkflowService {
|
||||
constructor(
|
||||
private notificationService: NotificationService,
|
||||
private permissionService: PermissionService,
|
||||
private auditService: AuditService,
|
||||
) {}
|
||||
|
||||
async startWorkflow() {
|
||||
// Orchestrate other services
|
||||
await this.permissionService.checkPermission();
|
||||
await this.workflowRepository.create();
|
||||
await this.notificationService.notify();
|
||||
await this.auditService.log();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Caching Strategies
|
||||
|
||||
### 1. In-Memory Caching
|
||||
|
||||
```typescript
|
||||
class UserService {
|
||||
private cache: Map<string, { user: User; timestamp: number }> = new Map();
|
||||
private CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
async getUser(userId: string): Promise<User> {
|
||||
// Check cache
|
||||
const cached = this.cache.get(userId);
|
||||
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
|
||||
return cached.user;
|
||||
}
|
||||
|
||||
// Fetch from database
|
||||
const user = await userRepository.findById(userId);
|
||||
|
||||
// Update cache
|
||||
if (user) {
|
||||
this.cache.set(userId, { user, timestamp: Date.now() });
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
clearUserCache(userId: string): void {
|
||||
this.cache.delete(userId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Cache Invalidation
|
||||
|
||||
```typescript
|
||||
class UserService {
|
||||
async updateUser(userId: string, data: UpdateUserDTO): Promise<User> {
|
||||
// Update in database
|
||||
const user = await userRepository.update(userId, data);
|
||||
|
||||
// Invalidate cache
|
||||
this.clearUserCache(userId);
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Services
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```typescript
|
||||
// tests/userService.test.ts
|
||||
import { UserService } from "../services/userService";
|
||||
import { userRepository } from "../repositories/UserRepository";
|
||||
import { ConflictError } from "../utils/errors";
|
||||
|
||||
// Mock repository
|
||||
jest.mock("../repositories/UserRepository");
|
||||
|
||||
describe("UserService", () => {
|
||||
let userService: UserService;
|
||||
|
||||
beforeEach(() => {
|
||||
userService = new UserService();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("createUser", () => {
|
||||
it("should create user when email does not exist", async () => {
|
||||
// Arrange
|
||||
const userData = {
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
roles: ["user"],
|
||||
};
|
||||
|
||||
(userRepository.emailExists as jest.Mock).mockResolvedValue(false);
|
||||
(userRepository.create as jest.Mock).mockResolvedValue({
|
||||
userID: "123",
|
||||
...userData,
|
||||
});
|
||||
|
||||
// Act
|
||||
const user = await userService.createUser(userData);
|
||||
|
||||
// Assert
|
||||
expect(user).toBeDefined();
|
||||
expect(user.email).toBe(userData.email);
|
||||
expect(userRepository.emailExists).toHaveBeenCalledWith(userData.email);
|
||||
expect(userRepository.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throw ConflictError when email exists", async () => {
|
||||
// Arrange
|
||||
const userData = {
|
||||
email: "existing@example.com",
|
||||
name: "Test User",
|
||||
roles: ["user"],
|
||||
};
|
||||
|
||||
(userRepository.emailExists as jest.Mock).mockResolvedValue(true);
|
||||
|
||||
// Act & Assert
|
||||
await expect(userService.createUser(userData)).rejects.toThrow(
|
||||
ConflictError,
|
||||
);
|
||||
expect(userRepository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Related Files:**
|
||||
|
||||
- [../AGENTS.md](../AGENTS.md) - Main guide
|
||||
- [routing-and-controllers.md](routing-and-controllers.md) - Controllers that use services
|
||||
- [database-patterns.md](database-patterns.md) - Prisma and repository patterns
|
||||
- [testing-guide.md](testing-guide.md) - Testing service and repository code
|
||||
@@ -1,555 +0,0 @@
|
||||
# Testing Guide - Backend Testing Strategies
|
||||
|
||||
Complete guide to testing Langfuse backend services across web, worker, and shared packages.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Test Types Overview](#test-types-overview)
|
||||
- [Integration Tests (Public API)](#integration-tests-public-api)
|
||||
- [Service-Level Tests (Repository/Service)](#service-level-tests-repositoryservice)
|
||||
- [tRPC Tests (Procedure Testing)](#trpc-tests-procedure-testing)
|
||||
- [Worker Tests (Queue Processing)](#worker-tests-queue-processing)
|
||||
- [Key Testing Principles](#key-testing-principles)
|
||||
- [Running Tests](#running-tests)
|
||||
|
||||
---
|
||||
|
||||
## Test Types Overview
|
||||
|
||||
Langfuse uses multiple testing strategies for different layers:
|
||||
|
||||
| Test Type | Framework | Location | Purpose |
|
||||
|-----------|-----------|----------|---------|
|
||||
| Integration | Jest | `web/src/__tests__/async/` | Full API endpoint testing |
|
||||
| tRPC | Jest | `web/src/__tests__/async/` | tRPC procedure testing with auth |
|
||||
| Service | Jest | `web/src/__tests__/async/repositories/` | Repository/service function testing |
|
||||
| Worker | Vitest | `worker/src/__tests__/` | Queue processors and streams |
|
||||
|
||||
---
|
||||
|
||||
## Integration Tests (Public API)
|
||||
|
||||
Test full REST API endpoints end-to-end using HTTP requests.
|
||||
|
||||
**File location:** `web/src/__tests__/async/datasets-api.servertest.ts`
|
||||
|
||||
```typescript
|
||||
import { makeZodVerifiedAPICall } from "../helpers";
|
||||
import { PostDatasetsV1Response } from "@/src/features/public-api/types/datasets";
|
||||
|
||||
describe("Dataset API", () => {
|
||||
it("should create dataset", async () => {
|
||||
const res = await makeZodVerifiedAPICall(
|
||||
PostDatasetsV1Response,
|
||||
"POST",
|
||||
"/api/public/datasets",
|
||||
{ name: "test-dataset" },
|
||||
auth,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("should validate input", async () => {
|
||||
const res = await makeZodVerifiedAPICall(
|
||||
PostDatasetsV1Response,
|
||||
"POST",
|
||||
"/api/public/datasets",
|
||||
{ name: "" }, // Invalid empty name
|
||||
auth,
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- Uses `makeZodVerifiedAPICall` for type-safe API testing
|
||||
- Tests HTTP status codes and response validation
|
||||
- Tests both success and error cases
|
||||
|
||||
---
|
||||
|
||||
## Service-Level Tests (Repository/Service)
|
||||
|
||||
Test individual repository/service functions with isolated data.
|
||||
|
||||
**File location:** `web/src/__tests__/async/repositories/event-repository.servertest.ts`
|
||||
|
||||
```typescript
|
||||
import {
|
||||
createEvent,
|
||||
createEventsCh,
|
||||
getObservationsWithModelDataFromEventsTable,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
describe("Event Repository Tests", () => {
|
||||
it("should return observations with model data", async () => {
|
||||
const traceId = randomUUID();
|
||||
const generationId = randomUUID();
|
||||
const modelId = randomUUID();
|
||||
|
||||
// Create test data
|
||||
await prisma.model.create({
|
||||
data: {
|
||||
id: modelId,
|
||||
projectId,
|
||||
modelName: `gpt-4-${modelId}`,
|
||||
matchPattern: `(?i)^(gpt-?4-${modelId})$`,
|
||||
startDate: new Date("2023-01-01"),
|
||||
unit: "TOKENS",
|
||||
Price: {
|
||||
create: [
|
||||
{ usageType: "input", price: 0.03 },
|
||||
{ usageType: "output", price: 0.06 },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const event = createEvent({
|
||||
id: generationId,
|
||||
span_id: generationId,
|
||||
project_id: projectId,
|
||||
trace_id: traceId,
|
||||
type: "GENERATION",
|
||||
name: `test-generation-${generationId}`,
|
||||
model_id: modelId,
|
||||
});
|
||||
|
||||
await createEventsCh([event]);
|
||||
|
||||
// Test the service function
|
||||
const result = await getObservationsWithModelDataFromEventsTable({
|
||||
projectId,
|
||||
filter: [{ type: "string", column: "id", operator: "=", value: generationId }],
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
const observation = result.find((o) => o.id === generationId);
|
||||
expect(observation?.internalModelId).toBe(modelId);
|
||||
expect(Number(observation?.inputPrice)).toBeCloseTo(0.03, 5);
|
||||
|
||||
// Cleanup
|
||||
await prisma.model.delete({ where: { id: modelId } });
|
||||
});
|
||||
|
||||
it("should handle filters correctly", async () => {
|
||||
const projectId = randomUUID();
|
||||
const traceId = randomUUID();
|
||||
|
||||
const observations = [
|
||||
createEvent({
|
||||
id: randomUUID(),
|
||||
project_id: projectId,
|
||||
trace_id: traceId,
|
||||
type: "GENERATION",
|
||||
name: "test1",
|
||||
}),
|
||||
createEvent({
|
||||
id: randomUUID(),
|
||||
project_id: projectId,
|
||||
trace_id: traceId,
|
||||
type: "SPAN",
|
||||
name: "test2",
|
||||
}),
|
||||
];
|
||||
|
||||
await createEventsCh(observations);
|
||||
|
||||
const result = await getObservationsWithModelDataFromEventsTable({
|
||||
projectId,
|
||||
filter: [
|
||||
{ type: "stringOptions", column: "type", operator: "any of", value: ["GENERATION"] }
|
||||
],
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
expect(result.every(o => o.type === "GENERATION")).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- Tests service/repository functions directly
|
||||
- Uses ClickHouse and Prisma test data
|
||||
- Always cleanup test data after tests
|
||||
- Use unique IDs to avoid test interference
|
||||
|
||||
---
|
||||
|
||||
## tRPC Tests (Procedure Testing)
|
||||
|
||||
Test tRPC procedures with caller pattern and auth context.
|
||||
|
||||
**File location:** `web/src/__tests__/async/automations-trpc.servertest.ts`
|
||||
|
||||
```typescript
|
||||
import { appRouter } from "@/src/server/api/root";
|
||||
import { createInnerTRPCContext } from "@/src/server/api/trpc";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { createOrgProjectAndApiKey } from "@langfuse/shared/src/server";
|
||||
import type { Session } from "next-auth";
|
||||
import { v4 } from "uuid";
|
||||
import { JobConfigState } from "@langfuse/shared";
|
||||
|
||||
async function prepare() {
|
||||
const { project, org } = await createOrgProjectAndApiKey();
|
||||
|
||||
const session: Session = {
|
||||
expires: "1",
|
||||
user: {
|
||||
id: "user-1",
|
||||
name: "Demo User",
|
||||
organizations: [{
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
role: "OWNER",
|
||||
projects: [{
|
||||
id: project.id,
|
||||
role: "ADMIN",
|
||||
name: project.name,
|
||||
}],
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
const ctx = createInnerTRPCContext({ session, headers: {} });
|
||||
const caller = appRouter.createCaller({ ...ctx, prisma });
|
||||
|
||||
return { project, org, session, ctx, caller };
|
||||
}
|
||||
|
||||
describe("automations trpc", () => {
|
||||
it("should retrieve all automations for a project", async () => {
|
||||
const { project, caller } = await prepare();
|
||||
|
||||
// Create test trigger
|
||||
const trigger = await prisma.trigger.create({
|
||||
data: {
|
||||
id: v4(),
|
||||
projectId: project.id,
|
||||
eventSource: "prompt",
|
||||
eventActions: ["created"],
|
||||
filter: [],
|
||||
status: JobConfigState.ACTIVE,
|
||||
},
|
||||
});
|
||||
|
||||
// Create test action
|
||||
const action = await prisma.action.create({
|
||||
data: {
|
||||
id: v4(),
|
||||
projectId: project.id,
|
||||
type: "WEBHOOK",
|
||||
config: {
|
||||
type: "WEBHOOK",
|
||||
url: "https://example.com/webhook",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Link trigger to action
|
||||
await prisma.automation.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
triggerId: trigger.id,
|
||||
actionId: action.id,
|
||||
name: "Test Automation",
|
||||
},
|
||||
});
|
||||
|
||||
// Call tRPC procedure
|
||||
const response = await caller.automations.getAutomations({
|
||||
projectId: project.id,
|
||||
});
|
||||
|
||||
expect(response).toHaveLength(1);
|
||||
expect(response[0]).toMatchObject({
|
||||
name: "Test Automation",
|
||||
trigger: expect.objectContaining({
|
||||
id: trigger.id,
|
||||
eventSource: "prompt",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw error when user lacks permissions", async () => {
|
||||
const { project, session } = await prepare();
|
||||
|
||||
// Create limited session
|
||||
const limitedSession: Session = {
|
||||
...session,
|
||||
user: {
|
||||
...session.user!,
|
||||
organizations: [{
|
||||
...session.user!.organizations[0],
|
||||
projects: [{
|
||||
...session.user!.organizations[0].projects[0],
|
||||
role: "VIEWER", // VIEWER can't create automations
|
||||
}],
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
const limitedCtx = createInnerTRPCContext({
|
||||
session: limitedSession,
|
||||
headers: {},
|
||||
});
|
||||
const limitedCaller = appRouter.createCaller({ ...limitedCtx, prisma });
|
||||
|
||||
await expect(
|
||||
limitedCaller.automations.createAutomation({
|
||||
projectId: project.id,
|
||||
name: "Unauthorized",
|
||||
eventSource: "prompt",
|
||||
eventAction: ["created"],
|
||||
filter: [],
|
||||
status: JobConfigState.ACTIVE,
|
||||
actionType: "WEBHOOK",
|
||||
actionConfig: {
|
||||
type: "WEBHOOK",
|
||||
url: "https://example.com/webhook",
|
||||
requestHeaders: {},
|
||||
apiVersion: { prompt: "v1" },
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("User does not have access");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- Uses `prepare()` helper to set up test context
|
||||
- Creates authenticated caller with `appRouter.createCaller`
|
||||
- Tests both success and permission error cases
|
||||
- Can test different user roles and permissions
|
||||
|
||||
---
|
||||
|
||||
## Worker Tests (Queue Processing)
|
||||
|
||||
Test queue processors and stream functions using vitest.
|
||||
|
||||
**File location:** `worker/src/__tests__/batchExport.test.ts`
|
||||
|
||||
```typescript
|
||||
import { randomUUID } from "crypto";
|
||||
import { expect, describe, it } from "vitest";
|
||||
import {
|
||||
createObservation,
|
||||
createObservationsCh,
|
||||
createOrgProjectAndApiKey,
|
||||
createTraceScore,
|
||||
createScoresCh,
|
||||
createTrace,
|
||||
createTracesCh,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { getObservationStream } from "../features/database-read-stream/observation-stream";
|
||||
|
||||
describe("batch export test suite", () => {
|
||||
it("should export observations", async () => {
|
||||
const { projectId } = await createOrgProjectAndApiKey();
|
||||
|
||||
const traceId = randomUUID();
|
||||
const trace = createTrace({
|
||||
project_id: projectId,
|
||||
id: traceId,
|
||||
});
|
||||
|
||||
await createTracesCh([trace]);
|
||||
|
||||
const observations = [
|
||||
createObservation({
|
||||
project_id: projectId,
|
||||
trace_id: traceId,
|
||||
type: "SPAN",
|
||||
}),
|
||||
createObservation({
|
||||
project_id: projectId,
|
||||
trace_id: randomUUID(),
|
||||
type: "GENERATION",
|
||||
}),
|
||||
];
|
||||
|
||||
const score = createTraceScore({
|
||||
project_id: projectId,
|
||||
trace_id: traceId,
|
||||
observation_id: observations[0].id,
|
||||
name: "test",
|
||||
value: 123,
|
||||
});
|
||||
|
||||
await createScoresCh([score]);
|
||||
await createObservationsCh(observations);
|
||||
|
||||
// Test the stream function
|
||||
const stream = await getObservationStream({
|
||||
projectId: projectId,
|
||||
cutoffCreatedAt: new Date(Date.now() + 1000 * 60 * 60 * 24),
|
||||
filter: [],
|
||||
});
|
||||
|
||||
const rows: any[] = [];
|
||||
for await (const chunk of stream) {
|
||||
rows.push(chunk);
|
||||
}
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: observations[0].id,
|
||||
type: observations[0].type,
|
||||
test: [score.value],
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("should export with filters", async () => {
|
||||
const { projectId } = await createOrgProjectAndApiKey();
|
||||
|
||||
const observations = [
|
||||
createObservation({
|
||||
project_id: projectId,
|
||||
trace_id: randomUUID(),
|
||||
type: "GENERATION",
|
||||
name: "test1",
|
||||
}),
|
||||
createObservation({
|
||||
project_id: projectId,
|
||||
trace_id: randomUUID(),
|
||||
type: "SPAN",
|
||||
name: "test2",
|
||||
}),
|
||||
];
|
||||
|
||||
await createObservationsCh(observations);
|
||||
|
||||
const stream = await getObservationStream({
|
||||
projectId: projectId,
|
||||
cutoffCreatedAt: new Date(Date.now() + 1000 * 60 * 60 * 24),
|
||||
filter: [
|
||||
{
|
||||
type: "stringOptions",
|
||||
operator: "any of",
|
||||
column: "name",
|
||||
value: ["test1"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const rows: any[] = [];
|
||||
for await (const chunk of stream) {
|
||||
rows.push(chunk);
|
||||
}
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].name).toBe("test1");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- Uses vitest (not Jest) for worker tests
|
||||
- Tests stream functions with async iteration
|
||||
- Creates isolated test data per test
|
||||
- Use unique project IDs to avoid interference
|
||||
|
||||
---
|
||||
|
||||
## Key Testing Principles
|
||||
|
||||
### General Principles
|
||||
|
||||
1. **Test Isolation**: Each test should be independent and runnable in any order
|
||||
2. **Unique IDs**: Use `randomUUID()` or unique project IDs to avoid test interference
|
||||
3. **Cleanup**: Always clean up test data in service tests (or use unique project IDs)
|
||||
4. **Avoid Global Resets**: Prefer scoped cleanup or unique project IDs over global reset helpers
|
||||
|
||||
### By Test Type
|
||||
|
||||
| Test Type | Key Principles |
|
||||
|-----------|----------------|
|
||||
| **Integration** | Test HTTP endpoints, validate status codes and response shapes |
|
||||
| **tRPC** | Use `createInnerTRPCContext` and `appRouter.createCaller`, test auth/permissions |
|
||||
| **Service** | Test individual functions with isolated data, always cleanup |
|
||||
| **Worker** | Use vitest, test streams with async iteration, test filtering logic |
|
||||
|
||||
### Test Data Management
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD: Use unique IDs
|
||||
const projectId = randomUUID();
|
||||
const traceId = randomUUID();
|
||||
|
||||
// ✅ GOOD: Cleanup in service tests
|
||||
afterAll(async () => {
|
||||
await prisma.model.delete({ where: { id: modelId } });
|
||||
});
|
||||
|
||||
// ✅ GOOD: Use unique projects (no cleanup needed)
|
||||
const { projectId } = await createOrgProjectAndApiKey();
|
||||
|
||||
// ❌ BAD: Shared test data between tests
|
||||
const projectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Web Tests (Jest)
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pnpm test
|
||||
|
||||
# Run sync tests
|
||||
pnpm test-sync
|
||||
|
||||
# Run async tests
|
||||
pnpm test -- --testPathPattern="async"
|
||||
|
||||
# Run specific test file
|
||||
pnpm test -- --testPathPattern="datasets-api"
|
||||
|
||||
# Run specific test
|
||||
pnpm test -- --testPathPattern="datasets-api" --testNamePattern="should create dataset"
|
||||
```
|
||||
|
||||
### Worker Tests (Vitest)
|
||||
|
||||
```bash
|
||||
# Run all worker tests
|
||||
pnpm run test --filter=worker
|
||||
|
||||
# Run specific test file
|
||||
pnpm run test --filter=worker -- batchExport
|
||||
|
||||
# Run specific test
|
||||
pnpm run test --filter=worker -- batchExport -t "should export observations"
|
||||
```
|
||||
|
||||
### Coverage
|
||||
|
||||
```bash
|
||||
# Web coverage
|
||||
pnpm test -- --coverage
|
||||
|
||||
# Worker coverage
|
||||
pnpm run test --filter=worker -- --coverage
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Related Files:**
|
||||
- [../AGENTS.md](../AGENTS.md) - Main backend guidelines
|
||||
- [architecture-overview.md](architecture-overview.md) - Architecture patterns
|
||||
- [services-and-repositories.md](services-and-repositories.md) - Service and repository examples
|
||||
@@ -1,48 +0,0 @@
|
||||
---
|
||||
name: changelog-writing
|
||||
description: |
|
||||
Shared workflow for writing Langfuse changelog entries after a feature is complete.
|
||||
Use when a branch is ready for merge and a changelog entry or changelog draft is needed.
|
||||
---
|
||||
|
||||
# Changelog Writing
|
||||
|
||||
Use this skill when a completed feature branch needs a changelog entry.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Understand the change set.
|
||||
2. Study recent changelog patterns in `../langfuse-docs/pages/changelog`.
|
||||
3. Find related documentation links in `../langfuse-docs/pages`.
|
||||
4. Draft a user-focused changelog entry.
|
||||
5. Recommend whether an image or screenshot should be added.
|
||||
|
||||
## What To Gather
|
||||
|
||||
- The branch diff relative to `main`
|
||||
- The Linear issue, if the branch name includes an `lfe-XXXX` identifier
|
||||
- The affected product areas
|
||||
- Relevant docs pages to link or create
|
||||
|
||||
## Writing Rules
|
||||
|
||||
- Write for users, not internal implementation detail
|
||||
- Prefer second person: "you can now..."
|
||||
- Focus on what changed, why it matters, and how to use it
|
||||
- Match the structure and tone of recent changelog posts
|
||||
- Keep technical detail only where it improves user understanding
|
||||
|
||||
## Output Format
|
||||
|
||||
Provide:
|
||||
|
||||
1. A short summary of what changed
|
||||
2. The complete changelog post content
|
||||
3. Whether an image should be added and what it should show
|
||||
4. Any docs pages that should be linked or created
|
||||
|
||||
## Reference Files
|
||||
|
||||
- Changelog destination: `../langfuse-docs/pages/changelog`
|
||||
- Recent changelog examples: inspect 3-5 recent files in that directory
|
||||
- Existing docs: `../langfuse-docs/pages`
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,50 +0,0 @@
|
||||
# ClickHouse Best Practices
|
||||
|
||||
Agent skill providing comprehensive ClickHouse guidance for schema design, query optimization, and data ingestion.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npx skills add ClickHouse/clickhouse-agent-skills
|
||||
```
|
||||
|
||||
## What's Included
|
||||
|
||||
**28 atomic rules** organized by prefix:
|
||||
|
||||
| Prefix | Count | Coverage |
|
||||
|--------|-------|----------|
|
||||
| `schema-pk-*` | 4 | PRIMARY KEY selection, cardinality ordering |
|
||||
| `schema-types-*` | 5 | Data types, LowCardinality, Nullable |
|
||||
| `schema-partition-*` | 4 | Partitioning strategy, lifecycle management |
|
||||
| `schema-json-*` | 1 | JSON type usage |
|
||||
| `query-join-*` | 5 | JOIN algorithms, filtering, alternatives |
|
||||
| `query-index-*` | 1 | Data skipping indices |
|
||||
| `query-mv-*` | 2 | Incremental and refreshable MVs |
|
||||
| `insert-batch-*` | 1 | Batch sizing (10K-100K rows) |
|
||||
| `insert-async-*` | 2 | Async inserts, data formats |
|
||||
| `insert-mutation-*` | 2 | Mutation avoidance |
|
||||
| `insert-optimize-*` | 1 | OPTIMIZE FINAL avoidance |
|
||||
|
||||
## Trigger Phrases
|
||||
|
||||
This skill activates when you:
|
||||
- "Create a table for..."
|
||||
- "Optimize this query..."
|
||||
- "Design a schema for..."
|
||||
- "Why is this query slow?"
|
||||
- "How should I insert data into..."
|
||||
- "Should I use UPDATE or..."
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `SKILL.md` | Quick reference and decision frameworks |
|
||||
| `AGENTS.md` | Complete rule reference (auto-generated) |
|
||||
| `rules/*.md` | Individual rule definitions |
|
||||
|
||||
## Related Documentation
|
||||
|
||||
All rules link to official ClickHouse documentation:
|
||||
- [ClickHouse Best Practices](https://clickhouse.com/docs/best-practices)
|
||||
@@ -1,234 +0,0 @@
|
||||
---
|
||||
name: clickhouse-best-practices
|
||||
description: MUST USE when reviewing ClickHouse schemas, queries, or configurations. Contains 28 rules that MUST be checked before providing recommendations. Always read relevant rule files and cite specific rules in responses.
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
author: ClickHouse Inc
|
||||
version: "0.3.0"
|
||||
---
|
||||
|
||||
# ClickHouse Best Practices
|
||||
|
||||
Comprehensive guidance for ClickHouse covering schema design, query optimization, and data ingestion. Contains 28 rules across 3 main categories (schema, query, insert), prioritized by impact.
|
||||
|
||||
> **Official docs:** [ClickHouse Best Practices](https://clickhouse.com/docs/best-practices)
|
||||
|
||||
## IMPORTANT: How to Apply This Skill
|
||||
|
||||
**Before answering ClickHouse questions, follow this priority order:**
|
||||
|
||||
1. **Check for applicable rules** in the `rules/` directory
|
||||
2. **If rules exist:** Apply them and cite them in your response using "Per `rule-name`..."
|
||||
3. **If no rule exists:** Use the LLM's ClickHouse knowledge or search documentation
|
||||
4. **If uncertain:** Use web search for current best practices
|
||||
5. **Always cite your source:** rule name, "general ClickHouse guidance", or URL
|
||||
|
||||
**Why rules take priority:** ClickHouse has specific behaviors (columnar storage, sparse indexes, merge tree mechanics) where general database intuition can be misleading. The rules encode validated, ClickHouse-specific guidance.
|
||||
|
||||
### For Formal Reviews
|
||||
|
||||
When performing a formal review of schemas, queries, or data ingestion:
|
||||
|
||||
---
|
||||
|
||||
## Review Procedures
|
||||
|
||||
### For Schema Reviews (CREATE TABLE, ALTER TABLE)
|
||||
|
||||
**Read these rule files in order:**
|
||||
|
||||
1. `rules/schema-pk-plan-before-creation.md` - ORDER BY is immutable
|
||||
2. `rules/schema-pk-cardinality-order.md` - Column ordering in keys
|
||||
3. `rules/schema-pk-prioritize-filters.md` - Filter column inclusion
|
||||
4. `rules/schema-types-native-types.md` - Proper type selection
|
||||
5. `rules/schema-types-minimize-bitwidth.md` - Numeric type sizing
|
||||
6. `rules/schema-types-lowcardinality.md` - LowCardinality usage
|
||||
7. `rules/schema-types-avoid-nullable.md` - Nullable vs DEFAULT
|
||||
8. `rules/schema-partition-low-cardinality.md` - Partition count limits
|
||||
9. `rules/schema-partition-lifecycle.md` - Partitioning purpose
|
||||
|
||||
**Check for:**
|
||||
- [ ] PRIMARY KEY / ORDER BY column order (low-to-high cardinality)
|
||||
- [ ] Data types match actual data ranges
|
||||
- [ ] LowCardinality applied to appropriate string columns
|
||||
- [ ] Partition key cardinality bounded (100-1,000 values)
|
||||
- [ ] ReplacingMergeTree has version column if used
|
||||
|
||||
### For Query Reviews (SELECT, JOIN, aggregations)
|
||||
|
||||
**Read these rule files:**
|
||||
|
||||
1. `rules/query-join-choose-algorithm.md` - Algorithm selection
|
||||
2. `rules/query-join-filter-before.md` - Pre-join filtering
|
||||
3. `rules/query-join-use-any.md` - ANY vs regular JOIN
|
||||
4. `rules/query-index-skipping-indices.md` - Secondary index usage
|
||||
5. `rules/schema-pk-filter-on-orderby.md` - Filter alignment with ORDER BY
|
||||
|
||||
**Check for:**
|
||||
- [ ] Filters use ORDER BY prefix columns
|
||||
- [ ] JOINs filter tables before joining (not after)
|
||||
- [ ] Correct JOIN algorithm for table sizes
|
||||
- [ ] Skipping indices for non-ORDER BY filter columns
|
||||
|
||||
### For Insert Strategy Reviews (data ingestion, updates, deletes)
|
||||
|
||||
**Read these rule files:**
|
||||
|
||||
1. `rules/insert-batch-size.md` - Batch sizing requirements
|
||||
2. `rules/insert-mutation-avoid-update.md` - UPDATE alternatives
|
||||
3. `rules/insert-mutation-avoid-delete.md` - DELETE alternatives
|
||||
4. `rules/insert-async-small-batches.md` - Async insert usage
|
||||
5. `rules/insert-optimize-avoid-final.md` - OPTIMIZE TABLE risks
|
||||
|
||||
**Check for:**
|
||||
- [ ] Batch size 10K-100K rows per INSERT
|
||||
- [ ] No ALTER TABLE UPDATE for frequent changes
|
||||
- [ ] ReplacingMergeTree or CollapsingMergeTree for update patterns
|
||||
- [ ] Async inserts enabled for high-frequency small batches
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
Structure your response as follows:
|
||||
|
||||
```
|
||||
## Rules Checked
|
||||
- `rule-name-1` - Compliant / Violation found
|
||||
- `rule-name-2` - Compliant / Violation found
|
||||
...
|
||||
|
||||
## Findings
|
||||
|
||||
### Violations
|
||||
- **`rule-name`**: Description of the issue
|
||||
- Current: [what the code does]
|
||||
- Required: [what it should do]
|
||||
- Fix: [specific correction]
|
||||
|
||||
### Compliant
|
||||
- `rule-name`: Brief note on why it's correct
|
||||
|
||||
## Recommendations
|
||||
[Prioritized list of changes, citing rules]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rule Categories by Priority
|
||||
|
||||
| Priority | Category | Impact | Prefix | Rule Count |
|
||||
|----------|----------|--------|--------|------------|
|
||||
| 1 | Primary Key Selection | CRITICAL | `schema-pk-` | 4 |
|
||||
| 2 | Data Type Selection | CRITICAL | `schema-types-` | 5 |
|
||||
| 3 | JOIN Optimization | CRITICAL | `query-join-` | 5 |
|
||||
| 4 | Insert Batching | CRITICAL | `insert-batch-` | 1 |
|
||||
| 5 | Mutation Avoidance | CRITICAL | `insert-mutation-` | 2 |
|
||||
| 6 | Partitioning Strategy | HIGH | `schema-partition-` | 4 |
|
||||
| 7 | Skipping Indices | HIGH | `query-index-` | 1 |
|
||||
| 8 | Materialized Views | HIGH | `query-mv-` | 2 |
|
||||
| 9 | Async Inserts | HIGH | `insert-async-` | 2 |
|
||||
| 10 | OPTIMIZE Avoidance | HIGH | `insert-optimize-` | 1 |
|
||||
| 11 | JSON Usage | MEDIUM | `schema-json-` | 1 |
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Schema Design - Primary Key (CRITICAL)
|
||||
|
||||
- `schema-pk-plan-before-creation` - Plan ORDER BY before table creation (immutable)
|
||||
- `schema-pk-cardinality-order` - Order columns low-to-high cardinality
|
||||
- `schema-pk-prioritize-filters` - Include frequently filtered columns
|
||||
- `schema-pk-filter-on-orderby` - Query filters must use ORDER BY prefix
|
||||
|
||||
### Schema Design - Data Types (CRITICAL)
|
||||
|
||||
- `schema-types-native-types` - Use native types, not String for everything
|
||||
- `schema-types-minimize-bitwidth` - Use smallest numeric type that fits
|
||||
- `schema-types-lowcardinality` - LowCardinality for <10K unique strings
|
||||
- `schema-types-enum` - Enum for finite value sets with validation
|
||||
- `schema-types-avoid-nullable` - Avoid Nullable; use DEFAULT instead
|
||||
|
||||
### Schema Design - Partitioning (HIGH)
|
||||
|
||||
- `schema-partition-low-cardinality` - Keep partition count 100-1,000
|
||||
- `schema-partition-lifecycle` - Use partitioning for data lifecycle, not queries
|
||||
- `schema-partition-query-tradeoffs` - Understand partition pruning trade-offs
|
||||
- `schema-partition-start-without` - Consider starting without partitioning
|
||||
|
||||
### Schema Design - JSON (MEDIUM)
|
||||
|
||||
- `schema-json-when-to-use` - JSON for dynamic schemas; typed columns for known
|
||||
|
||||
### Query Optimization - JOINs (CRITICAL)
|
||||
|
||||
- `query-join-choose-algorithm` - Select algorithm based on table sizes
|
||||
- `query-join-use-any` - ANY JOIN when only one match needed
|
||||
- `query-join-filter-before` - Filter tables before joining
|
||||
- `query-join-consider-alternatives` - Dictionaries/denormalization vs JOIN
|
||||
- `query-join-null-handling` - join_use_nulls=0 for default values
|
||||
|
||||
### Query Optimization - Indices (HIGH)
|
||||
|
||||
- `query-index-skipping-indices` - Skipping indices for non-ORDER BY filters
|
||||
|
||||
### Query Optimization - Materialized Views (HIGH)
|
||||
|
||||
- `query-mv-incremental` - Incremental MVs for real-time aggregations
|
||||
- `query-mv-refreshable` - Refreshable MVs for complex joins
|
||||
|
||||
### Insert Strategy - Batching (CRITICAL)
|
||||
|
||||
- `insert-batch-size` - Batch 10K-100K rows per INSERT
|
||||
|
||||
### Insert Strategy - Async (HIGH)
|
||||
|
||||
- `insert-async-small-batches` - Async inserts for high-frequency small batches
|
||||
- `insert-format-native` - Native format for best performance
|
||||
|
||||
### Insert Strategy - Mutations (CRITICAL)
|
||||
|
||||
- `insert-mutation-avoid-update` - ReplacingMergeTree instead of ALTER UPDATE
|
||||
- `insert-mutation-avoid-delete` - Lightweight DELETE or DROP PARTITION
|
||||
|
||||
### Insert Strategy - Optimization (HIGH)
|
||||
|
||||
- `insert-optimize-avoid-final` - Let background merges work
|
||||
|
||||
---
|
||||
|
||||
## When to Apply
|
||||
|
||||
This skill activates when you encounter:
|
||||
|
||||
- `CREATE TABLE` statements
|
||||
- `ALTER TABLE` modifications
|
||||
- `ORDER BY` or `PRIMARY KEY` discussions
|
||||
- Data type selection questions
|
||||
- Slow query troubleshooting
|
||||
- JOIN optimization requests
|
||||
- Data ingestion pipeline design
|
||||
- Update/delete strategy questions
|
||||
- ReplacingMergeTree or other specialized engine usage
|
||||
- Partitioning strategy decisions
|
||||
|
||||
---
|
||||
|
||||
## Rule File Structure
|
||||
|
||||
Each rule file in `rules/` contains:
|
||||
|
||||
- **YAML frontmatter**: title, impact level, tags
|
||||
- **Brief explanation**: Why this rule matters
|
||||
- **Incorrect example**: Anti-pattern with explanation
|
||||
- **Correct example**: Best practice with explanation
|
||||
- **Additional context**: Trade-offs, when to apply, references
|
||||
|
||||
---
|
||||
|
||||
## Full Compiled Document
|
||||
|
||||
For the complete guide with all rules expanded inline: `AGENTS.md`
|
||||
|
||||
Use `AGENTS.md` when you need to check multiple rules quickly without reading individual files.
|
||||
@@ -1,24 +0,0 @@
|
||||
# Sections
|
||||
|
||||
This file defines all sections, their ordering, impact levels, and descriptions.
|
||||
The section ID (in parentheses) is the filename prefix used to group rules.
|
||||
|
||||
---
|
||||
|
||||
## 1. Schema Design (schema)
|
||||
|
||||
**Impact:** CRITICAL
|
||||
|
||||
**Description:** Proper schema design is foundational to ClickHouse performance. ORDER BY is immutable after table creation; wrong choices require full data migration. Includes primary key selection, data types, partitioning strategy, and JSON usage. Column types and ordering can impact query speed by orders of magnitude.
|
||||
|
||||
## 2. Query Optimization (query)
|
||||
|
||||
**Impact:** CRITICAL
|
||||
|
||||
**Description:** Query patterns dramatically affect performance. JOIN algorithms, filtering strategies, skipping indices, and materialized views can reduce query time from minutes to milliseconds. Pre-computed aggregations read thousands of rows instead of billions.
|
||||
|
||||
## 3. Insert Strategy (insert)
|
||||
|
||||
**Impact:** CRITICAL
|
||||
|
||||
**Description:** Each INSERT creates a data part. Single-row inserts overwhelm the merge process. Proper batching (10K-100K rows), async inserts for high-frequency writes, mutation avoidance, and letting background merges work are essential for stable cluster performance.
|
||||
@@ -1,28 +0,0 @@
|
||||
---
|
||||
title: Rule Title Here
|
||||
impact: CRITICAL | HIGH | MEDIUM | LOW
|
||||
impactDescription: "Quantified improvement (e.g., 10x faster queries)"
|
||||
tags: [tag1, tag2]
|
||||
---
|
||||
|
||||
## Rule Title Here
|
||||
|
||||
**Impact: CRITICAL** (optional description)
|
||||
|
||||
Brief explanation of the rule and why it matters. This should be clear and concise, explaining the performance implications.
|
||||
|
||||
**Incorrect (description of what's wrong):**
|
||||
|
||||
```sql
|
||||
-- Bad: description
|
||||
SELECT * FROM table;
|
||||
```
|
||||
|
||||
**Correct (description of what's right):**
|
||||
|
||||
```sql
|
||||
-- Good: description
|
||||
SELECT * FROM table;
|
||||
```
|
||||
|
||||
Reference: [Official Docs](https://clickhouse.com/docs/best-practices/...)
|
||||
@@ -1,55 +0,0 @@
|
||||
---
|
||||
title: Use Async Inserts for High-Frequency Small Batches
|
||||
impact: HIGH
|
||||
impactDescription: "Server-side buffering when client batching isn't practical"
|
||||
tags: [insert, async, buffering, small-batches]
|
||||
---
|
||||
|
||||
## Use Async Inserts for High-Frequency Small Batches
|
||||
|
||||
**Impact: HIGH**
|
||||
|
||||
When client-side batching isn't practical, async inserts buffer server-side and create larger parts automatically.
|
||||
|
||||
**Incorrect (small batches without async):**
|
||||
|
||||
```python
|
||||
# Small batches without async_insert - creates too many parts
|
||||
for batch in chunks(events, 100):
|
||||
client.execute("INSERT INTO events VALUES", batch)
|
||||
```
|
||||
|
||||
**Correct (enable async inserts):**
|
||||
|
||||
```python
|
||||
# Enable async_insert with safe defaults
|
||||
client.execute("SET async_insert = 1")
|
||||
client.execute("SET wait_for_async_insert = 1") # Confirms durability
|
||||
|
||||
for batch in chunks(events, 100):
|
||||
client.execute("INSERT INTO events VALUES", batch)
|
||||
# Server buffers and creates larger parts automatically
|
||||
```
|
||||
|
||||
```sql
|
||||
-- Configure server-side for specific users
|
||||
ALTER USER my_app_user SETTINGS
|
||||
async_insert = 1,
|
||||
wait_for_async_insert = 1,
|
||||
async_insert_max_data_size = 10000000, -- Flush at 10MB
|
||||
async_insert_busy_timeout_ms = 1000; -- Flush after 1s
|
||||
```
|
||||
|
||||
**Flush conditions (whichever occurs first):**
|
||||
- Buffer reaches `async_insert_max_data_size`
|
||||
- Time threshold `async_insert_busy_timeout_ms` elapses
|
||||
- Maximum insert queries accumulate
|
||||
|
||||
**Return modes:**
|
||||
|
||||
| Setting | Behavior | Use Case |
|
||||
|---------|----------|----------|
|
||||
| `wait_for_async_insert=1` | Waits for flush, confirms durability | **Recommended** |
|
||||
| `wait_for_async_insert=0` | Fire-and-forget, unaware of errors | **Risky** - only if you accept data loss |
|
||||
|
||||
Reference: [Selecting an Insert Strategy](https://clickhouse.com/docs/best-practices/selecting-an-insert-strategy)
|
||||
@@ -1,54 +0,0 @@
|
||||
---
|
||||
title: Batch Inserts Appropriately (10K-100K rows)
|
||||
impact: CRITICAL
|
||||
impactDescription: "Each INSERT creates a part; single-row inserts overwhelm merge process"
|
||||
tags: [insert, batching, parts, performance]
|
||||
---
|
||||
|
||||
## Batch Inserts Appropriately (10K-100K rows)
|
||||
|
||||
**Impact: CRITICAL**
|
||||
|
||||
Each INSERT creates a new data part. Single-row or small-batch inserts create thousands of tiny parts, overwhelming the merge process and causing cluster instability.
|
||||
|
||||
**Incorrect (single-row or tiny batches):**
|
||||
|
||||
```python
|
||||
# Single-row inserts - creates 10,000 parts!
|
||||
for event in events:
|
||||
client.execute("INSERT INTO events VALUES", [event])
|
||||
|
||||
# Tiny batches - still too many parts
|
||||
for batch in chunks(events, 100): # 100 rows per INSERT
|
||||
client.execute("INSERT INTO events VALUES", batch)
|
||||
```
|
||||
|
||||
**Correct (proper batch size):**
|
||||
|
||||
```python
|
||||
# Ideal batch size: 10,000-100,000 rows
|
||||
BATCH_SIZE = 10_000
|
||||
for batch in chunks(events, BATCH_SIZE):
|
||||
client.execute("INSERT INTO events VALUES", batch)
|
||||
```
|
||||
|
||||
**Recommended batch sizes:**
|
||||
|
||||
| Threshold | Value |
|
||||
|-----------|-------|
|
||||
| Minimum | 1,000 rows |
|
||||
| Ideal range | 10,000-100,000 rows |
|
||||
| Insert rate (sync) | ~1 insert per second |
|
||||
|
||||
**Validation:**
|
||||
|
||||
```sql
|
||||
-- Monitor part count (>3000 per partition blocks inserts)
|
||||
SELECT table, count() as parts, sum(rows) as total_rows
|
||||
FROM system.parts
|
||||
WHERE active AND database = 'default'
|
||||
GROUP BY table
|
||||
ORDER BY parts DESC;
|
||||
```
|
||||
|
||||
Reference: [Selecting an Insert Strategy](https://clickhouse.com/docs/best-practices/selecting-an-insert-strategy)
|
||||
@@ -1,29 +0,0 @@
|
||||
---
|
||||
title: Use Native Format for Best Insert Performance
|
||||
impact: MEDIUM
|
||||
impactDescription: "Native format is most efficient; JSONEachRow is expensive to parse"
|
||||
tags: [insert, format, Native, performance]
|
||||
---
|
||||
|
||||
## Use Native Format for Best Insert Performance
|
||||
|
||||
**Impact: MEDIUM**
|
||||
|
||||
Data format affects insert performance. Native format is column-oriented with minimal parsing overhead.
|
||||
|
||||
**Performance Ranking (fastest to slowest):**
|
||||
|
||||
| Format | Notes |
|
||||
|--------|-------|
|
||||
| **Native** | Most efficient. Column-oriented, minimal parsing. Recommended. |
|
||||
| **RowBinary** | Efficient row-based alternative |
|
||||
| **JSONEachRow** | Easier to use but expensive to parse |
|
||||
|
||||
**Example:**
|
||||
|
||||
```python
|
||||
# Use Native format for best performance
|
||||
client.execute("INSERT INTO events VALUES", data, settings={'input_format': 'Native'})
|
||||
```
|
||||
|
||||
Reference: [Selecting an Insert Strategy](https://clickhouse.com/docs/best-practices/selecting-an-insert-strategy)
|
||||
@@ -1,74 +0,0 @@
|
||||
---
|
||||
title: Avoid ALTER TABLE DELETE
|
||||
impact: CRITICAL
|
||||
impactDescription: "Use lightweight DELETE, CollapsingMergeTree, or DROP PARTITION instead"
|
||||
tags: [insert, mutation, DELETE, CollapsingMergeTree]
|
||||
---
|
||||
|
||||
## Avoid ALTER TABLE DELETE
|
||||
|
||||
**Impact: CRITICAL**
|
||||
|
||||
`ALTER TABLE DELETE` is a mutation that rewrites entire data parts. Use alternatives like lightweight DELETE, CollapsingMergeTree, or DROP PARTITION.
|
||||
|
||||
**Incorrect (mutation delete):**
|
||||
|
||||
```sql
|
||||
-- Mutation delete for cleanup
|
||||
ALTER TABLE orders DELETE WHERE status = 'cancelled';
|
||||
|
||||
-- Time-based cleanup via mutation (very expensive)
|
||||
ALTER TABLE sessions DELETE WHERE created_at < now() - INTERVAL 7 DAY;
|
||||
```
|
||||
|
||||
**Correct - CollapsingMergeTree:**
|
||||
|
||||
```sql
|
||||
CREATE TABLE orders (
|
||||
order_id UInt64,
|
||||
customer_id UInt64,
|
||||
total Decimal(10,2),
|
||||
sign Int8 -- 1 = active, -1 = deleted
|
||||
)
|
||||
ENGINE = CollapsingMergeTree(sign)
|
||||
ORDER BY order_id;
|
||||
|
||||
-- Insert order
|
||||
INSERT INTO orders VALUES (123, 456, 99.99, 1);
|
||||
|
||||
-- "Delete" by inserting with sign = -1
|
||||
INSERT INTO orders VALUES (123, 456, 99.99, -1);
|
||||
|
||||
-- Query collapses +1 and -1 pairs
|
||||
SELECT order_id, sum(total * sign) as total
|
||||
FROM orders GROUP BY order_id HAVING sum(sign) > 0;
|
||||
```
|
||||
|
||||
**Correct - Lightweight Deletes (23.3+):**
|
||||
|
||||
```sql
|
||||
-- Marks rows, doesn't rewrite immediately
|
||||
DELETE FROM orders WHERE status = 'cancelled';
|
||||
-- Physical deletion happens during normal merges
|
||||
```
|
||||
|
||||
**Correct - DROP PARTITION for Bulk Deletion:**
|
||||
|
||||
```sql
|
||||
-- Instant deletion of old data
|
||||
ALTER TABLE events DROP PARTITION '202301';
|
||||
|
||||
-- Much faster than:
|
||||
ALTER TABLE events DELETE WHERE toYYYYMM(timestamp) = 202301;
|
||||
```
|
||||
|
||||
**Delete strategy comparison:**
|
||||
|
||||
| Method | Speed | When to Use |
|
||||
|--------|-------|-------------|
|
||||
| ALTER DELETE | Slow | Rare corrections only |
|
||||
| CollapsingMergeTree | Fast | Frequent soft deletes |
|
||||
| Lightweight DELETE | Medium | Occasional deletes |
|
||||
| DROP PARTITION | Instant | Bulk deletion by partition |
|
||||
|
||||
Reference: [Avoid Mutations](https://clickhouse.com/docs/best-practices/avoid-mutations)
|
||||
@@ -1,58 +0,0 @@
|
||||
---
|
||||
title: Avoid ALTER TABLE UPDATE
|
||||
impact: CRITICAL
|
||||
impactDescription: "Mutations rewrite entire parts; use ReplacingMergeTree instead"
|
||||
tags: [insert, mutation, UPDATE, ReplacingMergeTree]
|
||||
---
|
||||
|
||||
## Avoid ALTER TABLE UPDATE
|
||||
|
||||
**Impact: CRITICAL**
|
||||
|
||||
`ALTER TABLE UPDATE` is a mutation - an asynchronous background process that rewrites entire data parts affected by the change. This is extremely expensive for frequent or large-scale operations.
|
||||
|
||||
**Why mutations are problematic:**
|
||||
- **Write amplification:** Rewrite complete parts even for minor changes
|
||||
- **Disk I/O spike:** Degrades overall cluster performance
|
||||
- **No rollback:** Cannot be rolled back after submission
|
||||
- **Inconsistent reads:** SELECT may read mix of mutated and unmutated parts
|
||||
|
||||
**Incorrect (mutation for updates):**
|
||||
|
||||
```sql
|
||||
-- Rewrites potentially huge amounts of data
|
||||
ALTER TABLE users UPDATE status = 'inactive'
|
||||
WHERE last_login < now() - INTERVAL 90 DAY;
|
||||
|
||||
-- Frequent row updates via mutation
|
||||
ALTER TABLE inventory UPDATE quantity = quantity - 1
|
||||
WHERE product_id = 123;
|
||||
-- If product exists across 100 parts, rewrites ALL 100 parts
|
||||
```
|
||||
|
||||
**Correct (ReplacingMergeTree):**
|
||||
|
||||
```sql
|
||||
-- Table design for updates
|
||||
CREATE TABLE users (
|
||||
user_id UInt64,
|
||||
name String,
|
||||
status LowCardinality(String),
|
||||
updated_at DateTime DEFAULT now()
|
||||
)
|
||||
ENGINE = ReplacingMergeTree(updated_at)
|
||||
ORDER BY user_id;
|
||||
|
||||
-- "Update" by inserting new version
|
||||
INSERT INTO users (user_id, name, status)
|
||||
VALUES (123, 'John', 'inactive');
|
||||
|
||||
-- Query with FINAL to get latest version
|
||||
SELECT * FROM users FINAL WHERE user_id = 123;
|
||||
|
||||
-- Or use aggregation
|
||||
SELECT user_id, argMax(status, updated_at) as status
|
||||
FROM users GROUP BY user_id;
|
||||
```
|
||||
|
||||
Reference: [Avoid Mutations](https://clickhouse.com/docs/best-practices/avoid-mutations)
|
||||
@@ -1,57 +0,0 @@
|
||||
---
|
||||
title: Avoid OPTIMIZE TABLE FINAL
|
||||
impact: HIGH
|
||||
impactDescription: "Forces expensive merge of all parts; let background merges work"
|
||||
tags: [insert, OPTIMIZE, merge, performance]
|
||||
---
|
||||
|
||||
## Avoid OPTIMIZE TABLE FINAL
|
||||
|
||||
**Impact: HIGH**
|
||||
|
||||
`OPTIMIZE TABLE ... FINAL` forces immediate merge of all parts into one part per partition. This is resource-intensive and rarely necessary. ClickHouse already performs smart background merges.
|
||||
|
||||
**Note:** `OPTIMIZE FINAL` is not the same as `FINAL`. The `FINAL` modifier in SELECT queries may be necessary for deduplicated results in ReplacingMergeTree and is generally fine to use.
|
||||
|
||||
**Incorrect (OPTIMIZE FINAL after inserts):**
|
||||
|
||||
```sql
|
||||
-- Running OPTIMIZE FINAL after every batch insert
|
||||
INSERT INTO events SELECT * FROM staging_events;
|
||||
OPTIMIZE TABLE events FINAL; -- Expensive and unnecessary!
|
||||
|
||||
-- Scheduled OPTIMIZE FINAL jobs
|
||||
-- Cron: 0 * * * * clickhouse-client -q "OPTIMIZE TABLE events FINAL"
|
||||
```
|
||||
|
||||
**Correct (let background merges work):**
|
||||
|
||||
```sql
|
||||
-- Let background merges handle optimization
|
||||
INSERT INTO events SELECT * FROM staging_events;
|
||||
-- Done! ClickHouse merges automatically
|
||||
|
||||
-- For ReplacingMergeTree deduplication, use FINAL in queries
|
||||
SELECT * FROM events FINAL WHERE user_id = 123;
|
||||
-- Instead of running OPTIMIZE FINAL to deduplicate
|
||||
```
|
||||
|
||||
**Problems with OPTIMIZE FINAL:**
|
||||
- Rewrites entire partition regardless of need
|
||||
- Ignores the ~150 GB part size safeguard
|
||||
- Can cause memory pressure or OOM errors
|
||||
- Lengthy execution time for large datasets
|
||||
|
||||
**When OPTIMIZE FINAL may be acceptable:**
|
||||
- Finalizing data before table freezing
|
||||
- Preparing data for export operations
|
||||
- One-time operations, not regular workflows
|
||||
|
||||
**Better alternatives:**
|
||||
|
||||
| Need | Alternative |
|
||||
|------|-------------|
|
||||
| Deduplicate ReplacingMergeTree | Use `FINAL` modifier in SELECT |
|
||||
| Reduce part count | Rely on background merges |
|
||||
|
||||
Reference: [Avoid OPTIMIZE FINAL](https://clickhouse.com/docs/best-practices/avoid-optimize-final)
|
||||
@@ -1,77 +0,0 @@
|
||||
---
|
||||
title: Use Data Skipping Indices for Non-ORDER BY Filters
|
||||
impact: HIGH
|
||||
impactDescription: "Up to 60x faster queries by skipping irrelevant granules"
|
||||
tags: [query, index, skipping, bloom_filter]
|
||||
---
|
||||
|
||||
## Use Data Skipping Indices for Non-ORDER BY Filters
|
||||
|
||||
**Impact: HIGH**
|
||||
|
||||
Queries filtering on columns not in ORDER BY cannot use the primary index and result in full scans. Data skipping indices store metadata about blocks and skip granules that definitely don't match.
|
||||
|
||||
**Important:** Skip indices should be considered **after** optimizing data types, primary key selection, and materialized views.
|
||||
|
||||
**When to use:**
|
||||
- High overall cardinality but low cardinality within blocks
|
||||
- Rare values critical for search (error codes, specific IDs)
|
||||
- Column correlates with primary key
|
||||
|
||||
**When NOT to use:**
|
||||
- As a first optimization step
|
||||
- Matching values scattered across many blocks
|
||||
- Without testing on real data
|
||||
|
||||
**Incorrect (filtering on non-ORDER BY column):**
|
||||
|
||||
```sql
|
||||
CREATE TABLE events (
|
||||
event_type LowCardinality(String),
|
||||
timestamp DateTime,
|
||||
user_id UInt64 -- Not in ORDER BY
|
||||
)
|
||||
ENGINE = MergeTree()
|
||||
ORDER BY (event_type, toDate(timestamp));
|
||||
|
||||
-- Query filters on user_id - scans all matching event_type
|
||||
SELECT * FROM events
|
||||
WHERE event_type = 'click' AND user_id = 12345;
|
||||
```
|
||||
|
||||
**Correct (add skipping index):**
|
||||
|
||||
```sql
|
||||
CREATE TABLE events (
|
||||
event_type LowCardinality(String),
|
||||
timestamp DateTime,
|
||||
user_id UInt64,
|
||||
INDEX idx_user_id user_id TYPE bloom_filter GRANULARITY 4
|
||||
)
|
||||
ENGINE = MergeTree()
|
||||
ORDER BY (event_type, toDate(timestamp));
|
||||
|
||||
-- Or add to existing table
|
||||
ALTER TABLE events ADD INDEX idx_user_id user_id TYPE bloom_filter GRANULARITY 4;
|
||||
ALTER TABLE events MATERIALIZE INDEX idx_user_id;
|
||||
```
|
||||
|
||||
**Index types:**
|
||||
|
||||
| Type | Best For | Example Filter |
|
||||
|------|----------|----------------|
|
||||
| `bloom_filter` | Equality on high-cardinality | `WHERE user_id = 123` |
|
||||
| `set(N)` | Low cardinality (N unique values) | `WHERE status IN ('a','b')` |
|
||||
| `minmax` | Range queries | `WHERE amount > 1000` |
|
||||
| `ngrambf_v1` | Text search | `WHERE text LIKE '%term%'` |
|
||||
| `tokenbf_v1` | Token search | `WHERE hasToken(text, 'word')` |
|
||||
|
||||
**Validation:**
|
||||
|
||||
```sql
|
||||
EXPLAIN indexes = 1
|
||||
SELECT * FROM events WHERE user_id = 12345;
|
||||
-- Look for "Skip" in output showing granules skipped
|
||||
```
|
||||
|
||||
Reference: [Use Data Skipping Indices Where Appropriate](https://clickhouse.com/docs/best-practices/use-data-skipping-indices-where-appropriate)
|
||||
@@ -1,43 +0,0 @@
|
||||
---
|
||||
title: Choose the Right JOIN Algorithm
|
||||
impact: CRITICAL
|
||||
impactDescription: "Wrong algorithm causes OOM; right algorithm handles large tables efficiently"
|
||||
tags: [query, JOIN, algorithm, memory]
|
||||
---
|
||||
|
||||
## Choose the Right JOIN Algorithm
|
||||
|
||||
**Impact: CRITICAL**
|
||||
|
||||
ClickHouse's default hash join loads the RIGHT table entirely into memory. Choose the right algorithm based on table sizes and constraints.
|
||||
|
||||
**Algorithm selection:**
|
||||
|
||||
| Algorithm | Best For | Trade-off |
|
||||
|-----------|----------|-----------|
|
||||
| `parallel_hash` | Small-to-medium in-memory tables | Default since 24.11; fast, concurrent |
|
||||
| `hash` | General purpose, all join types | Single-threaded hash table build |
|
||||
| `direct` | Dictionary lookups (INNER/LEFT only) | Fastest; no hash table construction |
|
||||
| `full_sorting_merge` | Tables already sorted on join key | Skips sort if pre-ordered; low memory |
|
||||
| `partial_merge` | Large tables, memory-constrained | Minimized memory; slower execution |
|
||||
| `grace_hash` | Large datasets, tunable memory | Flexible; disk-spilling capability |
|
||||
| `auto` | Adaptive algorithm selection | Tries hash first, falls back on memory pressure |
|
||||
|
||||
**Example usage:**
|
||||
|
||||
```sql
|
||||
-- Let ClickHouse choose automatically
|
||||
SET join_algorithm = 'auto';
|
||||
|
||||
-- For large-to-large joins where memory is constrained
|
||||
SET join_algorithm = 'partial_merge';
|
||||
SELECT * FROM large_a JOIN large_b ON large_b.id = large_a.id;
|
||||
|
||||
-- When joining by primary key columns, sort-merge skips sorting step
|
||||
SET join_algorithm = 'full_sorting_merge';
|
||||
SELECT * FROM table_a a JOIN table_b b ON b.pk_col = a.pk_col;
|
||||
```
|
||||
|
||||
**Note:** ClickHouse 24.12+ automatically positions smaller tables on the right side. For earlier versions, manually ensure the smaller table is on the RIGHT.
|
||||
|
||||
Reference: [Minimize and Optimize JOINs](https://clickhouse.com/docs/best-practices/minimize-optimize-joins)
|
||||
@@ -1,72 +0,0 @@
|
||||
---
|
||||
title: Consider Alternatives to JOINs
|
||||
impact: CRITICAL
|
||||
impactDescription: "Dictionaries and denormalization shift work from query time to insert time"
|
||||
tags: [query, JOIN, dictionary, denormalization]
|
||||
---
|
||||
|
||||
## Consider Alternatives to JOINs
|
||||
|
||||
**Impact: CRITICAL**
|
||||
|
||||
Repeated JOINs to dimension tables add overhead. Dictionaries or denormalization shift computational work from query time to insert/pre-processing time.
|
||||
|
||||
**Incorrect (JOIN on every query):**
|
||||
|
||||
```sql
|
||||
-- JOIN on every query
|
||||
SELECT o.order_id, c.name, c.email
|
||||
FROM orders o
|
||||
JOIN customers c ON c.id = o.customer_id
|
||||
WHERE o.created_at > '2024-01-01';
|
||||
```
|
||||
|
||||
**Correct - Dictionary Lookup:**
|
||||
|
||||
```sql
|
||||
-- Create dictionary
|
||||
CREATE DICTIONARY customer_dict (
|
||||
id UInt64,
|
||||
name String,
|
||||
email String
|
||||
)
|
||||
PRIMARY KEY id
|
||||
SOURCE(CLICKHOUSE(TABLE 'customers'))
|
||||
LAYOUT(HASHED())
|
||||
LIFETIME(MIN 300 MAX 360);
|
||||
|
||||
-- Use dictGet instead of JOIN (uses direct join algorithm - fastest)
|
||||
SELECT
|
||||
order_id,
|
||||
dictGet('customer_dict', 'name', customer_id) as customer_name,
|
||||
dictGet('customer_dict', 'email', customer_id) as customer_email
|
||||
FROM orders
|
||||
WHERE created_at > '2024-01-01';
|
||||
```
|
||||
|
||||
**Correct - Denormalization:**
|
||||
|
||||
```sql
|
||||
-- Denormalized table with materialized view
|
||||
CREATE MATERIALIZED VIEW orders_enriched_mv TO orders_enriched AS
|
||||
SELECT
|
||||
o.order_id, o.customer_id,
|
||||
c.name as customer_name,
|
||||
c.email as customer_email,
|
||||
o.total, o.created_at
|
||||
FROM orders o
|
||||
JOIN customers c ON c.id = o.customer_id;
|
||||
```
|
||||
|
||||
**Approach comparison:**
|
||||
|
||||
| Approach | Use Case | Performance |
|
||||
|----------|----------|-------------|
|
||||
| Dictionary | Frequent lookups to small dimension | Fastest (in-memory) |
|
||||
| Denormalization | Analytics always need enriched data | Fast (no join at query) |
|
||||
| IN subquery | Existence filtering | Often faster than JOIN |
|
||||
| JOIN | Infrequent or complex joins | Acceptable |
|
||||
|
||||
**Critical dictionary caveat:** Dictionaries silently deduplicate duplicate keys, retaining only the final value. Only use when source has unique keys.
|
||||
|
||||
Reference: [Minimize and Optimize JOINs](https://clickhouse.com/docs/best-practices/minimize-optimize-joins)
|
||||
@@ -1,54 +0,0 @@
|
||||
---
|
||||
title: Filter Tables Before Joining
|
||||
impact: CRITICAL
|
||||
impactDescription: "Joining full tables then filtering wastes resources"
|
||||
tags: [query, JOIN, filtering, subquery]
|
||||
---
|
||||
|
||||
## Filter Tables Before Joining
|
||||
|
||||
**Impact: CRITICAL**
|
||||
|
||||
Joining full tables then filtering wastes resources. Add filtering in `WHERE` or `JOIN ON` clauses. If automatic pushdown fails, restructure as a subquery.
|
||||
|
||||
**Incorrect (join then filter):**
|
||||
|
||||
```sql
|
||||
-- Joins entire tables, then filters
|
||||
SELECT o.order_id, c.name, o.total
|
||||
FROM orders o
|
||||
JOIN customers c ON c.id = o.customer_id
|
||||
WHERE o.created_at > '2024-01-01' AND c.country = 'US';
|
||||
```
|
||||
|
||||
**Correct (filter in subqueries before joining):**
|
||||
|
||||
```sql
|
||||
-- Filter in subqueries before joining
|
||||
SELECT o.order_id, c.name, o.total
|
||||
FROM (
|
||||
SELECT order_id, customer_id, total
|
||||
FROM orders
|
||||
WHERE created_at > '2024-01-01'
|
||||
) o
|
||||
JOIN (
|
||||
SELECT id, name
|
||||
FROM customers
|
||||
WHERE country = 'US'
|
||||
) c ON c.id = o.customer_id;
|
||||
```
|
||||
|
||||
**Even better - aggregate before joining:**
|
||||
|
||||
```sql
|
||||
SELECT c.country, o.total_revenue
|
||||
FROM (
|
||||
SELECT customer_id, sum(total) as total_revenue
|
||||
FROM orders
|
||||
WHERE created_at > '2024-01-01'
|
||||
GROUP BY customer_id
|
||||
) o
|
||||
JOIN customers c ON c.id = o.customer_id;
|
||||
```
|
||||
|
||||
Reference: [Minimize and Optimize JOINs](https://clickhouse.com/docs/best-practices/minimize-optimize-joins)
|
||||
@@ -1,33 +0,0 @@
|
||||
---
|
||||
title: Optimize NULL Handling in Outer JOINs
|
||||
impact: MEDIUM
|
||||
impactDescription: "Default values instead of NULL reduces memory overhead"
|
||||
tags: [query, JOIN, NULL, memory]
|
||||
---
|
||||
|
||||
## Optimize NULL Handling in Outer JOINs
|
||||
|
||||
**Impact: MEDIUM**
|
||||
|
||||
Set `join_use_nulls = 0` to use default column values instead of NULL markers, reducing memory overhead compared to Nullable wrappers.
|
||||
|
||||
**Example:**
|
||||
|
||||
```sql
|
||||
-- Use default values instead of NULLs for non-matching rows
|
||||
SET join_use_nulls = 0;
|
||||
|
||||
SELECT o.order_id, c.name
|
||||
FROM orders o
|
||||
LEFT JOIN customers c ON c.id = o.customer_id;
|
||||
-- Non-matching rows get '' for name instead of NULL
|
||||
```
|
||||
|
||||
**When to use:**
|
||||
|
||||
| Setting | Behavior | Use Case |
|
||||
|---------|----------|----------|
|
||||
| `join_use_nulls = 0` | Default values (empty string, 0) for non-matches | When you can handle default values |
|
||||
| `join_use_nulls = 1` (default) | NULL for non-matches | When you need to distinguish "no match" from "matched with default" |
|
||||
|
||||
Reference: [Minimize and Optimize JOINs](https://clickhouse.com/docs/best-practices/minimize-optimize-joins)
|
||||
@@ -1,40 +0,0 @@
|
||||
---
|
||||
title: Use ANY JOIN When Only One Match Needed
|
||||
impact: HIGH
|
||||
impactDescription: "Returns first match only; less memory and faster execution"
|
||||
tags: [query, JOIN, ANY, performance]
|
||||
---
|
||||
|
||||
## Use ANY JOIN When Only One Match Needed
|
||||
|
||||
**Impact: HIGH**
|
||||
|
||||
Use `ANY` JOINs when you only need a single match rather than all matches. They consume less memory and execute faster.
|
||||
|
||||
**Incorrect (returns all matches):**
|
||||
|
||||
```sql
|
||||
-- Returns all matching rows, uses more memory
|
||||
SELECT o.order_id, c.name
|
||||
FROM orders o
|
||||
LEFT JOIN customers c ON c.id = o.customer_id;
|
||||
```
|
||||
|
||||
**Correct (returns first match only):**
|
||||
|
||||
```sql
|
||||
-- Returns only first match per row, faster and less memory
|
||||
SELECT o.order_id, c.name
|
||||
FROM orders o
|
||||
LEFT ANY JOIN customers c ON c.id = o.customer_id;
|
||||
```
|
||||
|
||||
**ANY JOIN types:**
|
||||
|
||||
| Type | Behavior |
|
||||
|------|----------|
|
||||
| `LEFT ANY JOIN` | At most one match from right table |
|
||||
| `INNER ANY JOIN` | At most one match, only matching rows |
|
||||
| `RIGHT ANY JOIN` | At most one match from left table |
|
||||
|
||||
Reference: [Minimize and Optimize JOINs](https://clickhouse.com/docs/best-practices/minimize-optimize-joins)
|
||||
@@ -1,68 +0,0 @@
|
||||
---
|
||||
title: Use Incremental MVs for Real-Time Aggregations
|
||||
impact: HIGH
|
||||
impactDescription: "Read thousands of rows instead of billions; minimal cluster overhead"
|
||||
tags: [query, materialized-view, aggregation, real-time]
|
||||
---
|
||||
|
||||
## Use Incremental MVs for Real-Time Aggregations
|
||||
|
||||
**Impact: HIGH**
|
||||
|
||||
Incremental MVs automatically apply the view's query to new data blocks at insert time. Results are written to a target table and partial results merge over time.
|
||||
|
||||
**Incorrect (full aggregation on every query):**
|
||||
|
||||
```sql
|
||||
-- Full aggregation on every dashboard load
|
||||
SELECT
|
||||
event_type,
|
||||
toStartOfHour(timestamp) as hour,
|
||||
count() as events,
|
||||
uniq(user_id) as unique_users
|
||||
FROM events
|
||||
WHERE timestamp >= now() - INTERVAL 7 DAY
|
||||
GROUP BY event_type, hour;
|
||||
-- Scans 7 days of data every time (billions of rows)
|
||||
```
|
||||
|
||||
**Correct (incremental MV with pre-aggregation):**
|
||||
|
||||
```sql
|
||||
-- Create target table for aggregated data
|
||||
CREATE TABLE events_hourly (
|
||||
event_type LowCardinality(String),
|
||||
hour DateTime,
|
||||
events AggregateFunction(count),
|
||||
unique_users AggregateFunction(uniq, UInt64)
|
||||
)
|
||||
ENGINE = AggregatingMergeTree()
|
||||
ORDER BY (event_type, hour);
|
||||
|
||||
-- Create materialized view to populate incrementally
|
||||
CREATE MATERIALIZED VIEW events_hourly_mv TO events_hourly AS
|
||||
SELECT
|
||||
event_type,
|
||||
toStartOfHour(timestamp) as hour,
|
||||
countState() as events,
|
||||
uniqState(user_id) as unique_users
|
||||
FROM events
|
||||
GROUP BY event_type, hour;
|
||||
|
||||
-- Query the pre-aggregated data
|
||||
SELECT
|
||||
event_type, hour,
|
||||
countMerge(events) as events,
|
||||
uniqMerge(unique_users) as unique_users
|
||||
FROM events_hourly
|
||||
WHERE hour >= now() - INTERVAL 7 DAY
|
||||
GROUP BY event_type, hour;
|
||||
-- Reads thousands of rows instead of billions
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
- Use `-State` functions in MV, `-Merge` functions in query
|
||||
- Incremental - existing data not automatically included (backfill separately)
|
||||
- Minimal cluster overhead at insert time
|
||||
|
||||
Reference: [Use Materialized Views](https://clickhouse.com/docs/best-practices/use-materialized-views)
|
||||
@@ -1,64 +0,0 @@
|
||||
---
|
||||
title: Use Refreshable MVs for Complex Joins and Batch Workflows
|
||||
impact: HIGH
|
||||
impactDescription: "Sub-millisecond queries with periodic refresh; ideal for complex joins"
|
||||
tags: [query, materialized-view, refresh, batch]
|
||||
---
|
||||
|
||||
## Use Refreshable MVs for Complex Joins and Batch Workflows
|
||||
|
||||
**Impact: HIGH**
|
||||
|
||||
Refreshable MVs execute queries periodically on a schedule. The full query re-executes and overwrites (or appends to) the target table.
|
||||
|
||||
**Best for:**
|
||||
- Sub-millisecond latency where minor staleness is acceptable
|
||||
- Caching "top N" results or lookup tables
|
||||
- Complex multi-table joins requiring denormalization
|
||||
- Batch workflows and DAG dependencies
|
||||
|
||||
**Incorrect (expensive join on every request):**
|
||||
|
||||
```sql
|
||||
-- Complex join executed on every request
|
||||
SELECT
|
||||
o.order_id, o.total,
|
||||
c.name as customer_name,
|
||||
p.name as product_name
|
||||
FROM orders o
|
||||
JOIN customers c ON o.customer_id = c.id
|
||||
JOIN products p ON o.product_id = p.id
|
||||
WHERE o.created_at >= now() - INTERVAL 1 DAY;
|
||||
```
|
||||
|
||||
**Correct (refreshable MV):**
|
||||
|
||||
```sql
|
||||
-- Create refreshable MV that runs every 5 minutes
|
||||
CREATE MATERIALIZED VIEW orders_denormalized
|
||||
REFRESH EVERY 5 MINUTE
|
||||
ENGINE = MergeTree()
|
||||
ORDER BY (created_at, order_id)
|
||||
AS SELECT
|
||||
o.order_id, o.created_at, o.total,
|
||||
c.name as customer_name, c.segment,
|
||||
p.name as product_name
|
||||
FROM orders o
|
||||
JOIN customers c ON o.customer_id = c.id
|
||||
JOIN products p ON o.product_id = p.id
|
||||
WHERE o.created_at >= now() - INTERVAL 1 DAY;
|
||||
|
||||
-- Query the pre-joined data (sub-millisecond)
|
||||
SELECT * FROM orders_denormalized WHERE segment = 'enterprise';
|
||||
```
|
||||
|
||||
**APPEND vs REPLACE modes:**
|
||||
|
||||
| Mode | Behavior | Use Case |
|
||||
|------|----------|----------|
|
||||
| `REPLACE` (default) | Overwrites previous contents | Current state, lookup tables |
|
||||
| `APPEND` | Adds new rows to existing data | Periodic snapshots, historical accumulation |
|
||||
|
||||
**Critical warning:** Query should run quickly compared to refresh interval. Don't schedule every 10 seconds if the query takes 10+ seconds.
|
||||
|
||||
Reference: [Use Materialized Views](https://clickhouse.com/docs/best-practices/use-materialized-views)
|
||||
@@ -1,76 +0,0 @@
|
||||
---
|
||||
title: Use JSON Type for Dynamic Schemas
|
||||
impact: MEDIUM
|
||||
impactDescription: "Field-level querying for semi-structured data; use typed columns for known schemas"
|
||||
tags: [schema, JSON, semi-structured, flexibility]
|
||||
---
|
||||
|
||||
## Use JSON Type for Dynamic Schemas
|
||||
|
||||
**Impact: MEDIUM**
|
||||
|
||||
ClickHouse's JSON type splits JSON objects into separate sub-columns, enabling field-level query optimization. Use it for truly dynamic data, not everything.
|
||||
|
||||
**Incorrect (schema bloat or opaque String):**
|
||||
|
||||
```sql
|
||||
-- BAD: Hundreds of nullable columns for event properties
|
||||
CREATE TABLE events (
|
||||
event_id UUID,
|
||||
prop_page_url Nullable(String),
|
||||
prop_button_id Nullable(String),
|
||||
-- ... 100 more nullable columns
|
||||
)
|
||||
|
||||
-- BAD: JSON as String when you need field queries
|
||||
CREATE TABLE events (
|
||||
event_id UUID,
|
||||
properties String -- No field-level optimization
|
||||
)
|
||||
```
|
||||
|
||||
**Correct (JSON for dynamic, typed for known):**
|
||||
|
||||
```sql
|
||||
-- Use JSON type for dynamic properties
|
||||
CREATE TABLE events (
|
||||
event_id UUID DEFAULT generateUUIDv4(),
|
||||
event_type LowCardinality(String),
|
||||
timestamp DateTime DEFAULT now(),
|
||||
properties JSON -- Flexible schema with type inference
|
||||
)
|
||||
ENGINE = MergeTree()
|
||||
ORDER BY (event_type, timestamp);
|
||||
|
||||
-- Query JSON paths directly
|
||||
SELECT
|
||||
event_type,
|
||||
properties.url as page_url,
|
||||
properties.amount as purchase_amount
|
||||
FROM events
|
||||
WHERE event_type = 'page_view' AND properties.url = '/home';
|
||||
```
|
||||
|
||||
**When to use JSON:**
|
||||
|
||||
| Scenario | Use JSON? |
|
||||
|----------|-----------|
|
||||
| Data structure varies unpredictably | Yes |
|
||||
| Field types/schemas change over time | Yes |
|
||||
| Need field-level querying | Yes |
|
||||
| Fixed, known schema | No (use typed columns) |
|
||||
| JSON as opaque blob (no field queries) | No (use String) |
|
||||
|
||||
**Optimization: specify types for known paths:**
|
||||
|
||||
```sql
|
||||
CREATE TABLE events (
|
||||
properties JSON(
|
||||
url String,
|
||||
amount Float64,
|
||||
product_id UInt64
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
Reference: [Use JSON Where Appropriate](https://clickhouse.com/docs/best-practices/use-json-where-appropriate)
|
||||
@@ -1,50 +0,0 @@
|
||||
---
|
||||
title: Use Partitioning for Data Lifecycle Management
|
||||
impact: HIGH
|
||||
impactDescription: "DROP PARTITION is instant; DELETE is expensive row-by-row scan"
|
||||
tags: [schema, partitioning, TTL, data-management]
|
||||
---
|
||||
|
||||
## Use Partitioning for Data Lifecycle Management
|
||||
|
||||
**Impact: HIGH**
|
||||
|
||||
Partitioning is **primarily a data management technique, not a query optimization tool**. It excels at:
|
||||
- **Dropping data**: Remove entire partitions as single metadata operations
|
||||
- **TTL retention**: Implement time-based retention policies efficiently
|
||||
- **Tiered storage**: Move old partitions to cold storage
|
||||
- **Archiving**: Move partitions between tables
|
||||
|
||||
**Incorrect (no time alignment for lifecycle):**
|
||||
|
||||
```sql
|
||||
-- Cannot efficiently drop old data by time
|
||||
CREATE TABLE events (...)
|
||||
ENGINE = MergeTree()
|
||||
PARTITION BY event_type -- No time alignment
|
||||
ORDER BY (timestamp);
|
||||
|
||||
-- Slow: must scan and delete row by row
|
||||
DELETE FROM events WHERE timestamp < '2023-01-01';
|
||||
```
|
||||
|
||||
**Correct (time-based for lifecycle):**
|
||||
|
||||
```sql
|
||||
CREATE TABLE events (
|
||||
timestamp DateTime,
|
||||
event_type LowCardinality(String)
|
||||
)
|
||||
ENGINE = MergeTree()
|
||||
PARTITION BY toStartOfMonth(timestamp)
|
||||
ORDER BY (event_type, timestamp)
|
||||
TTL timestamp + INTERVAL 1 YEAR DELETE; -- Drops whole partitions
|
||||
|
||||
-- Fast: metadata-only operation
|
||||
ALTER TABLE events DROP PARTITION '202301';
|
||||
|
||||
-- Archive to cold storage
|
||||
ALTER TABLE events_archive ATTACH PARTITION '202301' FROM events;
|
||||
```
|
||||
|
||||
Reference: [Choosing a Partitioning Key](https://clickhouse.com/docs/best-practices/choosing-a-partitioning-key)
|
||||
@@ -1,61 +0,0 @@
|
||||
---
|
||||
title: Keep Partition Cardinality Low (100-1,000 Values)
|
||||
impact: HIGH
|
||||
impactDescription: "Too many partitions cause part explosion and 'too many parts' errors"
|
||||
tags: [schema, partitioning, parts]
|
||||
---
|
||||
|
||||
## Keep Partition Cardinality Low (100-1,000 Values)
|
||||
|
||||
**Impact: HIGH**
|
||||
|
||||
Too many distinct partition values create excessive data parts, eventually triggering "too many parts" errors. ClickHouse enforces limits via `max_parts_in_total` and `parts_to_throw_insert` settings.
|
||||
|
||||
**Incorrect (high cardinality partitioning):**
|
||||
|
||||
```sql
|
||||
-- High cardinality = too many partitions
|
||||
CREATE TABLE events (...)
|
||||
ENGINE = MergeTree()
|
||||
PARTITION BY user_id -- Millions of partitions!
|
||||
ORDER BY (timestamp);
|
||||
|
||||
-- Daily partitions can grow unbounded over years
|
||||
CREATE TABLE logs (...)
|
||||
ENGINE = MergeTree()
|
||||
PARTITION BY toDate(timestamp) -- 3650 partitions over 10 years
|
||||
ORDER BY (service, timestamp);
|
||||
```
|
||||
|
||||
**Correct (bounded cardinality):**
|
||||
|
||||
```sql
|
||||
-- Monthly partitions = 12 per year, bounded cardinality
|
||||
CREATE TABLE events (
|
||||
timestamp DateTime,
|
||||
event_type LowCardinality(String),
|
||||
user_id UInt64
|
||||
)
|
||||
ENGINE = MergeTree()
|
||||
PARTITION BY toStartOfMonth(timestamp)
|
||||
ORDER BY (event_type, timestamp);
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
|
||||
```sql
|
||||
-- Check partition count and health
|
||||
SELECT
|
||||
partition,
|
||||
count() as parts,
|
||||
sum(rows) as rows,
|
||||
formatReadableSize(sum(bytes_on_disk)) as size
|
||||
FROM system.parts
|
||||
WHERE table = 'events' AND active
|
||||
GROUP BY partition
|
||||
ORDER BY partition;
|
||||
|
||||
-- Warning signs: hundreds or thousands of partitions
|
||||
```
|
||||
|
||||
Reference: [Choosing a Partitioning Key](https://clickhouse.com/docs/best-practices/choosing-a-partitioning-key)
|
||||
@@ -1,35 +0,0 @@
|
||||
---
|
||||
title: Understand Partition Query Performance Trade-offs
|
||||
impact: MEDIUM
|
||||
impactDescription: "Partition pruning helps some queries; spanning many partitions hurts others"
|
||||
tags: [schema, partitioning, query, performance]
|
||||
---
|
||||
|
||||
## Understand Partition Query Performance Trade-offs
|
||||
|
||||
**Impact: MEDIUM**
|
||||
|
||||
Partitioning can help or hurt query performance:
|
||||
- **Potential improvement**: Queries filtering by partition key may benefit from partition pruning
|
||||
- **Potential degradation**: Queries spanning many partitions increase total parts scanned
|
||||
|
||||
ClickHouse automatically builds **MinMax indexes** on partition columns. Data merges occur **within partitions only**, not across them.
|
||||
|
||||
**Incorrect (query scans all partitions):**
|
||||
|
||||
```sql
|
||||
-- Query must scan all partitions
|
||||
SELECT count(*) FROM events
|
||||
WHERE event_type = 'click'; -- No partition pruning
|
||||
```
|
||||
|
||||
**Correct (query prunes to single partition):**
|
||||
|
||||
```sql
|
||||
-- Query prunes to single partition
|
||||
SELECT count(*) FROM events
|
||||
WHERE timestamp >= '2024-01-01' AND timestamp < '2024-02-01'
|
||||
AND event_type = 'click';
|
||||
```
|
||||
|
||||
Reference: [Choosing a Partitioning Key](https://clickhouse.com/docs/best-practices/choosing-a-partitioning-key)
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
title: Consider Starting Without Partitioning
|
||||
impact: MEDIUM
|
||||
impactDescription: "Add partitioning later when you have clear lifecycle requirements"
|
||||
tags: [schema, partitioning, simplicity]
|
||||
---
|
||||
|
||||
## Consider Starting Without Partitioning
|
||||
|
||||
**Impact: MEDIUM**
|
||||
|
||||
Start without partitioning and add it later only if:
|
||||
- You have clear data lifecycle requirements (retention, archiving)
|
||||
- Your access patterns clearly benefit from partition pruning
|
||||
- You understand the cardinality implications
|
||||
|
||||
**Example (start simple):**
|
||||
|
||||
```sql
|
||||
-- Start simple, no partitioning
|
||||
CREATE TABLE events (
|
||||
timestamp DateTime,
|
||||
event_type LowCardinality(String),
|
||||
user_id UInt64
|
||||
)
|
||||
ENGINE = MergeTree()
|
||||
ORDER BY (event_type, timestamp);
|
||||
|
||||
-- Add partitioning later if needed for lifecycle management
|
||||
-- (requires table recreation or materialized view migration)
|
||||
```
|
||||
|
||||
**When to add partitioning:**
|
||||
|
||||
| Need | Add Partitioning? |
|
||||
|------|-------------------|
|
||||
| Time-based data retention | Yes |
|
||||
| Archive old data to cold storage | Yes |
|
||||
| Query performance on time ranges | Maybe (test first) |
|
||||
| No specific lifecycle needs | No |
|
||||
|
||||
Reference: [Choosing a Partitioning Key](https://clickhouse.com/docs/best-practices/choosing-a-partitioning-key)
|
||||
@@ -1,45 +0,0 @@
|
||||
---
|
||||
title: Order Columns by Cardinality (Low to High)
|
||||
impact: CRITICAL
|
||||
impactDescription: "Enables granule skipping; high-cardinality first prevents index pruning"
|
||||
tags: [schema, primary-key, cardinality, ORDER BY]
|
||||
---
|
||||
|
||||
## Order Columns by Cardinality (Low to High)
|
||||
|
||||
**Impact: CRITICAL**
|
||||
|
||||
Since the sparse primary index operates on data blocks (granules) rather than individual rows, low-cardinality leading columns create more useful index entries that can skip entire blocks. Place lower-cardinality columns before higher-cardinality ones in the ordering key.
|
||||
|
||||
**Incorrect (high cardinality first):**
|
||||
|
||||
```sql
|
||||
-- UUID first means no pruning benefit
|
||||
CREATE TABLE events (...)
|
||||
ENGINE = MergeTree()
|
||||
ORDER BY (event_id, event_type, timestamp);
|
||||
-- Every granule has different event_id values, index can't skip anything
|
||||
```
|
||||
|
||||
**Correct (low cardinality first):**
|
||||
|
||||
```sql
|
||||
-- Low cardinality first enables pruning
|
||||
CREATE TABLE events (...)
|
||||
ENGINE = MergeTree()
|
||||
ORDER BY (event_type, event_date, event_id);
|
||||
-- Index can skip entire event_type groups
|
||||
```
|
||||
|
||||
**Column Order Guidelines:**
|
||||
|
||||
| Position | Cardinality | Examples |
|
||||
|----------|-------------|----------|
|
||||
| 1st | Low (few distinct values) | event_type, status, country |
|
||||
| 2nd | Date (coarse granularity) | toDate(timestamp) |
|
||||
| 3rd+ | Medium-High | user_id, session_id |
|
||||
| Last | High (if needed) | event_id, uuid |
|
||||
|
||||
**Tip:** Use `toDate(timestamp)` instead of raw `DateTime` columns when day-level filtering suffices - this reduces index size from 32-bit to 16-bit representations.
|
||||
|
||||
Reference: [Choosing a Primary Key](https://clickhouse.com/docs/best-practices/choosing-a-primary-key)
|
||||
@@ -1,52 +0,0 @@
|
||||
---
|
||||
title: Filter on ORDER BY Columns in Queries
|
||||
impact: CRITICAL
|
||||
impactDescription: "Skipping prefix columns prevents index usage"
|
||||
tags: [schema, primary-key, WHERE, query]
|
||||
---
|
||||
|
||||
## Filter on ORDER BY Columns in Queries
|
||||
|
||||
**Impact: CRITICAL**
|
||||
|
||||
Even with good schema design, queries must use ORDER BY columns to benefit. Skipping prefix columns or filtering on non-ORDER BY columns prevents index usage.
|
||||
|
||||
**Incorrect (skips prefix or uses non-ORDER BY columns):**
|
||||
|
||||
```sql
|
||||
-- Given: ORDER BY (tenant_id, event_type, timestamp)
|
||||
|
||||
-- Skips prefix columns - can't use index effectively
|
||||
SELECT * FROM events WHERE event_type = 'click';
|
||||
|
||||
-- Filter on column not in ORDER BY - full table scan
|
||||
SELECT * FROM events WHERE user_agent LIKE '%Chrome%';
|
||||
```
|
||||
|
||||
**Correct (uses ORDER BY prefix):**
|
||||
|
||||
```sql
|
||||
-- Given: ORDER BY (tenant_id, event_type, timestamp)
|
||||
|
||||
-- Full prefix match - best performance
|
||||
SELECT * FROM events
|
||||
WHERE tenant_id = 123 AND event_type = 'click';
|
||||
|
||||
-- Partial prefix - still uses index
|
||||
SELECT * FROM events WHERE tenant_id = 123;
|
||||
|
||||
-- Range on later column after equality on earlier
|
||||
SELECT * FROM events
|
||||
WHERE tenant_id = 123 AND event_type = 'click' AND timestamp >= '2024-01-01';
|
||||
```
|
||||
|
||||
**Index usage reference:**
|
||||
|
||||
| Filter | Index Used? |
|
||||
|--------|-------------|
|
||||
| `WHERE tenant_id = 123` | Full |
|
||||
| `WHERE tenant_id = 123 AND event_type = 'click'` | Full |
|
||||
| `WHERE event_type = 'click'` | None (skipped prefix) |
|
||||
| `WHERE timestamp > '2024-01-01'` | None (skipped both) |
|
||||
|
||||
Reference: [Choosing a Primary Key](https://clickhouse.com/docs/best-practices/choosing-a-primary-key)
|
||||
@@ -1,64 +0,0 @@
|
||||
---
|
||||
title: Plan PRIMARY KEY Before Table Creation
|
||||
impact: CRITICAL
|
||||
impactDescription: "ORDER BY is immutable; wrong choice requires full data migration"
|
||||
tags: [schema, primary-key, ORDER BY]
|
||||
---
|
||||
|
||||
## Plan PRIMARY KEY Before Table Creation
|
||||
|
||||
**Impact: CRITICAL** (immutable after creation)
|
||||
|
||||
ClickHouse's ORDER BY clause defines physical data ordering and the sparse index. Unlike other databases, **ORDER BY cannot be modified after table creation**. A wrong choice requires creating a new table and migrating all data.
|
||||
|
||||
**Incorrect (arbitrary ORDER BY without query analysis):**
|
||||
|
||||
```sql
|
||||
-- Creating table without analyzing query patterns
|
||||
CREATE TABLE events (
|
||||
event_id UUID,
|
||||
user_id UInt64,
|
||||
timestamp DateTime
|
||||
)
|
||||
ENGINE = MergeTree()
|
||||
ORDER BY (event_id); -- Chosen arbitrarily
|
||||
|
||||
-- Later: "Most queries filter by user_id!"
|
||||
-- Cannot fix with: ALTER TABLE events MODIFY ORDER BY (user_id, timestamp)
|
||||
-- ERROR: Cannot modify ORDER BY
|
||||
```
|
||||
|
||||
**Correct (query-driven ORDER BY selection):**
|
||||
|
||||
```sql
|
||||
-- Step 1: Document query patterns BEFORE creating table
|
||||
/*
|
||||
Query Analysis:
|
||||
- 60% of queries: WHERE user_id = ? AND timestamp BETWEEN ? AND ?
|
||||
- 25% of queries: WHERE event_type = ? AND timestamp > ?
|
||||
- 15% of queries: WHERE event_id = ?
|
||||
|
||||
Conclusion: user_id and event_type are primary filters
|
||||
*/
|
||||
|
||||
-- Step 2: Create table with correct ORDER BY
|
||||
CREATE TABLE events (
|
||||
event_id UUID DEFAULT generateUUIDv4(),
|
||||
user_id UInt64,
|
||||
event_type LowCardinality(String),
|
||||
timestamp DateTime,
|
||||
event_date Date DEFAULT toDate(timestamp)
|
||||
)
|
||||
ENGINE = MergeTree()
|
||||
PARTITION BY toYYYYMM(event_date)
|
||||
ORDER BY (user_id, event_date, event_id);
|
||||
```
|
||||
|
||||
**Pre-creation checklist:**
|
||||
- [ ] Listed top 5-10 query patterns
|
||||
- [ ] Identified columns in WHERE clauses with frequency
|
||||
- [ ] Prioritized columns that exclude large numbers of rows
|
||||
- [ ] Ordered columns by cardinality (low first, high last)
|
||||
- [ ] Limited to 4-5 key columns (typically sufficient)
|
||||
|
||||
Reference: [Choosing a Primary Key](https://clickhouse.com/docs/best-practices/choosing-a-primary-key)
|
||||
@@ -1,44 +0,0 @@
|
||||
---
|
||||
title: Prioritize Filter Columns in ORDER BY
|
||||
impact: CRITICAL
|
||||
impactDescription: "Columns not in ORDER BY cause full table scans"
|
||||
tags: [schema, primary-key, WHERE, filtering]
|
||||
---
|
||||
|
||||
## Prioritize Filter Columns in ORDER BY
|
||||
|
||||
**Impact: CRITICAL**
|
||||
|
||||
Prioritize columns frequently used in query filters (WHERE clause), especially those that exclude large numbers of rows. Queries filtering on columns not in ORDER BY result in full table scans.
|
||||
|
||||
**Incorrect (ORDER BY doesn't match query patterns):**
|
||||
|
||||
```sql
|
||||
-- If most queries filter by tenant_id:
|
||||
CREATE TABLE events (...)
|
||||
ENGINE = MergeTree()
|
||||
ORDER BY (event_id); -- Queries by tenant_id will full-scan!
|
||||
```
|
||||
|
||||
**Correct (ORDER BY matches filter patterns):**
|
||||
|
||||
```sql
|
||||
-- ORDER BY matches query filter patterns
|
||||
CREATE TABLE events (...)
|
||||
ENGINE = MergeTree()
|
||||
ORDER BY (tenant_id, event_date, event_id);
|
||||
|
||||
-- Query now uses primary index:
|
||||
SELECT * FROM events WHERE tenant_id = 123 AND event_date >= '2024-01-01';
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
|
||||
```sql
|
||||
-- Verify index usage
|
||||
EXPLAIN indexes = 1
|
||||
SELECT * FROM events WHERE tenant_id = 123;
|
||||
-- Look for "PrimaryKey" with Key Condition
|
||||
```
|
||||
|
||||
Reference: [Choosing a Primary Key](https://clickhouse.com/docs/best-practices/choosing-a-primary-key)
|
||||
@@ -1,55 +0,0 @@
|
||||
---
|
||||
title: Avoid Nullable Unless Semantically Required
|
||||
impact: HIGH
|
||||
impactDescription: "Nullable adds storage overhead; use DEFAULT values instead"
|
||||
tags: [schema, data-types, Nullable, DEFAULT]
|
||||
---
|
||||
|
||||
## Avoid Nullable Unless Semantically Required
|
||||
|
||||
**Impact: HIGH**
|
||||
|
||||
Nullable columns maintain a separate UInt8 column for tracking null values, increasing storage and degrading performance. Use DEFAULT values instead when feasible.
|
||||
|
||||
**Incorrect (Nullable everywhere):**
|
||||
|
||||
```sql
|
||||
CREATE TABLE users (
|
||||
id Nullable(UInt64), -- IDs should never be null
|
||||
name Nullable(String), -- Empty string is fine
|
||||
age Nullable(UInt8), -- 0 is a valid default
|
||||
login_count Nullable(UInt32) -- 0 is a valid default
|
||||
)
|
||||
```
|
||||
|
||||
**Correct (DEFAULT values, Nullable only when semantic):**
|
||||
|
||||
```sql
|
||||
CREATE TABLE users (
|
||||
id UInt64, -- Never null
|
||||
name String DEFAULT '', -- Empty = unknown
|
||||
age UInt8 DEFAULT 0, -- 0 = unknown
|
||||
login_count UInt32 DEFAULT 0, -- 0 = never logged in
|
||||
deleted_at Nullable(DateTime), -- NULL = not deleted (semantic!)
|
||||
parent_id Nullable(UInt64) -- NULL = no parent (semantic!)
|
||||
)
|
||||
```
|
||||
|
||||
**When Nullable IS appropriate:**
|
||||
|
||||
| Use Case | Why |
|
||||
|----------|-----|
|
||||
| `deleted_at` | NULL = "not deleted", timestamp = "deleted at X" |
|
||||
| `parent_id` | NULL = "no parent", value = "has parent" |
|
||||
| `discount_percent` | NULL = "no discount", 0 = "0% discount" |
|
||||
|
||||
**Defaults instead of Nullable:**
|
||||
|
||||
| Type | Default |
|
||||
|------|---------|
|
||||
| String | `''` (empty string) |
|
||||
| UInt*/Int* | `0` |
|
||||
| DateTime | `now()` or `toDateTime(0)` |
|
||||
| UUID | `generateUUIDv4()` |
|
||||
|
||||
Reference: [Select Data Types](https://clickhouse.com/docs/best-practices/select-data-types)
|
||||
@@ -1,58 +0,0 @@
|
||||
---
|
||||
title: Use Enum for Finite Value Sets
|
||||
impact: MEDIUM
|
||||
impactDescription: "Insert-time validation and natural ordering; 1-2 bytes storage"
|
||||
tags: [schema, data-types, Enum, validation]
|
||||
---
|
||||
|
||||
## Use Enum for Finite Value Sets
|
||||
|
||||
**Impact: MEDIUM**
|
||||
|
||||
Enum types provide validation at insert time and enable queries that exploit natural ordering. Use Enum8 (up to 256 values) or Enum16 (up to 65,536 values).
|
||||
|
||||
**Incorrect (String without validation):**
|
||||
|
||||
```sql
|
||||
CREATE TABLE orders (
|
||||
status String -- No validation, typos like "shiped" allowed
|
||||
)
|
||||
|
||||
-- Ordering requires CASE statements
|
||||
SELECT * FROM orders ORDER BY
|
||||
CASE status
|
||||
WHEN 'pending' THEN 1
|
||||
WHEN 'processing' THEN 2
|
||||
WHEN 'shipped' THEN 3
|
||||
END;
|
||||
```
|
||||
|
||||
**Correct (Enum with validation and ordering):**
|
||||
|
||||
```sql
|
||||
CREATE TABLE orders (
|
||||
status Enum8('pending' = 1, 'processing' = 2, 'shipped' = 3, 'delivered' = 4)
|
||||
)
|
||||
|
||||
-- Insert validation: invalid values rejected
|
||||
INSERT INTO orders VALUES ('shiped'); -- ERROR: Unknown element 'shiped'
|
||||
|
||||
-- Natural ordering works automatically
|
||||
SELECT * FROM orders ORDER BY status; -- Orders by enum value (1, 2, 3, 4)
|
||||
|
||||
-- Comparisons use natural order
|
||||
SELECT * FROM orders WHERE status > 'processing'; -- shipped and delivered
|
||||
```
|
||||
|
||||
**Enum Guidelines:**
|
||||
|
||||
| Scenario | Use |
|
||||
|----------|-----|
|
||||
| Fixed set of values known at schema time | Enum8/Enum16 |
|
||||
| Values may change frequently | LowCardinality(String) |
|
||||
| Need insert-time validation | Enum |
|
||||
| Need natural ordering in queries | Enum |
|
||||
| < 256 distinct values | Enum8 (1 byte) |
|
||||
| 256-65,536 distinct values | Enum16 (2 bytes) |
|
||||
|
||||
Reference: [Select Data Types](https://clickhouse.com/docs/best-practices/select-data-types)
|
||||
@@ -1,58 +0,0 @@
|
||||
---
|
||||
title: Use LowCardinality for Repeated Strings
|
||||
impact: HIGH
|
||||
impactDescription: "Dictionary encoding for <10K unique values; significant storage reduction"
|
||||
tags: [schema, data-types, LowCardinality, storage]
|
||||
---
|
||||
|
||||
## Use LowCardinality for Repeated Strings
|
||||
|
||||
**Impact: HIGH**
|
||||
|
||||
String columns with repeated values store each value repeatedly. LowCardinality uses dictionary encoding for significant storage reduction.
|
||||
|
||||
**Incorrect (plain String for repeated values):**
|
||||
|
||||
```sql
|
||||
CREATE TABLE events (
|
||||
country String, -- "United States" stored 500M times
|
||||
browser String, -- "Chrome" stored 300M times
|
||||
event_type String -- "page_view" stored 800M times
|
||||
)
|
||||
```
|
||||
|
||||
**Correct (LowCardinality for low unique counts):**
|
||||
|
||||
```sql
|
||||
CREATE TABLE events (
|
||||
country LowCardinality(String), -- ~200 unique values
|
||||
browser LowCardinality(String), -- ~50 unique values
|
||||
event_type LowCardinality(String) -- ~100 unique values
|
||||
)
|
||||
```
|
||||
|
||||
**When to use LowCardinality:**
|
||||
|
||||
| Unique Values | Recommendation |
|
||||
|---------------|----------------|
|
||||
| < 10,000 | Use LowCardinality |
|
||||
| > 10,000 | Use regular String |
|
||||
|
||||
```sql
|
||||
-- Check cardinality before deciding
|
||||
SELECT uniq(column_name) FROM table_name;
|
||||
```
|
||||
|
||||
**LowCardinality vs FixedString:**
|
||||
|
||||
Reserve `FixedString` for strictly fixed-length data (e.g., 2-char country codes). For most low-cardinality text, `LowCardinality(String)` outperforms `FixedString`.
|
||||
|
||||
```sql
|
||||
-- FixedString: Only for truly fixed-length data
|
||||
country_code FixedString(2), -- "US", "DE", "JP" - always 2 chars
|
||||
|
||||
-- LowCardinality: For variable-length low-cardinality strings
|
||||
country_name LowCardinality(String), -- "United States", "Germany"
|
||||
```
|
||||
|
||||
Reference: [Select Data Types](https://clickhouse.com/docs/best-practices/select-data-types)
|
||||
@@ -1,49 +0,0 @@
|
||||
---
|
||||
title: Minimize Bit-Width for Numeric Types
|
||||
impact: HIGH
|
||||
impactDescription: "Smaller types reduce storage and improve cache efficiency"
|
||||
tags: [schema, data-types, numeric, storage]
|
||||
---
|
||||
|
||||
## Minimize Bit-Width for Numeric Types
|
||||
|
||||
**Impact: HIGH**
|
||||
|
||||
Select the smallest numeric type that accommodates your data range. Prefer unsigned types when negative values aren't needed.
|
||||
|
||||
**Incorrect (oversized types):**
|
||||
|
||||
```sql
|
||||
CREATE TABLE metrics (
|
||||
status_code Int64, -- HTTP codes are 100-599
|
||||
age Int64, -- Human age fits in UInt8
|
||||
year Int64, -- Years fit in UInt16
|
||||
item_count Int64 -- Often small numbers
|
||||
)
|
||||
```
|
||||
|
||||
**Correct (right-sized types):**
|
||||
|
||||
```sql
|
||||
CREATE TABLE metrics (
|
||||
status_code UInt16, -- 0-65,535 (HTTP codes fit easily)
|
||||
age UInt8, -- 0-255 (sufficient for age)
|
||||
year UInt16, -- 0-65,535 (sufficient for years)
|
||||
item_count UInt32 -- 0-4 billion (adjust based on actual max)
|
||||
)
|
||||
```
|
||||
|
||||
**Numeric Type Reference:**
|
||||
|
||||
| Type | Range | Bytes |
|
||||
|------|-------|-------|
|
||||
| UInt8 | 0 to 255 | 1 |
|
||||
| UInt16 | 0 to 65,535 | 2 |
|
||||
| UInt32 | 0 to 4.3 billion | 4 |
|
||||
| UInt64 | 0 to 18 quintillion | 8 |
|
||||
| Int8 | -128 to 127 | 1 |
|
||||
| Int16 | -32,768 to 32,767 | 2 |
|
||||
| Int32 | -2.1 billion to 2.1 billion | 4 |
|
||||
| Int64 | -9 quintillion to 9 quintillion | 8 |
|
||||
|
||||
Reference: [Select Data Types](https://clickhouse.com/docs/best-practices/select-data-types)
|
||||
@@ -1,51 +0,0 @@
|
||||
---
|
||||
title: Use Native Types Instead of String
|
||||
impact: CRITICAL
|
||||
impactDescription: "2-10x storage reduction; enables compression and correct semantics"
|
||||
tags: [schema, data-types, storage]
|
||||
---
|
||||
|
||||
## Use Native Types Instead of String
|
||||
|
||||
**Impact: CRITICAL**
|
||||
|
||||
Using String for all data wastes storage, prevents compression optimization, and makes comparisons slower. ClickHouse's column-oriented architecture benefits directly from optimal type selection.
|
||||
|
||||
**Incorrect (String for everything):**
|
||||
|
||||
```sql
|
||||
CREATE TABLE events (
|
||||
event_id String, -- "550e8400-e29b-41d4-a716-446655440000" = 36 bytes
|
||||
user_id String, -- "12345" = 5 bytes (no numeric operations)
|
||||
created_at String, -- "2024-01-15 10:30:00" = 19 bytes
|
||||
count String, -- "42" - can't do math!
|
||||
is_active String -- "true" = 4 bytes
|
||||
)
|
||||
```
|
||||
|
||||
**Correct (native types):**
|
||||
|
||||
```sql
|
||||
CREATE TABLE events (
|
||||
event_id UUID DEFAULT generateUUIDv4(), -- 16 bytes (vs 36)
|
||||
user_id UInt64, -- 8 bytes, numeric ops
|
||||
created_at DateTime DEFAULT now(), -- 4 bytes (vs 19)
|
||||
count UInt32 DEFAULT 0, -- 4 bytes, math works
|
||||
is_active Bool DEFAULT true -- 1 byte (vs 4)
|
||||
)
|
||||
```
|
||||
|
||||
**Type Selection Quick Reference:**
|
||||
|
||||
| Data | Use | Avoid |
|
||||
|------|-----|-------|
|
||||
| Sequential IDs | UInt32/UInt64 | String |
|
||||
| UUIDs | UUID | String |
|
||||
| Status/Category | Enum8 or LowCardinality(String) | String |
|
||||
| Timestamps | DateTime | DateTime64, String |
|
||||
| Dates only | Date or Date32 | DateTime, String |
|
||||
| Counts | UInt8/16/32 (smallest that fits) | Int64, String |
|
||||
| Money | Decimal(P,S) or Int64 (cents) | Float64, String |
|
||||
| Booleans | Bool or UInt8 | String |
|
||||
|
||||
Reference: [Select Data Types](https://clickhouse.com/docs/best-practices/select-data-types)
|
||||
@@ -1,54 +0,0 @@
|
||||
---
|
||||
name: code-review
|
||||
description: |
|
||||
Shared code review workflow for Langfuse. Use when reviewing a PR, branch, diff,
|
||||
or local changes for correctness, regressions, risk, and missing tests.
|
||||
Start with references/review-checklist.md for repo-specific review rules and
|
||||
use package AGENTS.md files plus any matching shared skills when the change
|
||||
touches those areas.
|
||||
---
|
||||
|
||||
# Code Review
|
||||
|
||||
Use this skill when the task is to review code changes rather than implement a
|
||||
feature.
|
||||
|
||||
## Start Here
|
||||
|
||||
- Read [`references/review-checklist.md`](references/review-checklist.md) for
|
||||
the repo's canonical review rules.
|
||||
- Read root [`AGENTS.md`](../../../AGENTS.md) and the nearest package
|
||||
`AGENTS.md` for the files under review.
|
||||
- If the review touches ClickHouse, also use the shared
|
||||
`clickhouse-best-practices` skill.
|
||||
- If the review touches backend code, also use the shared
|
||||
`backend-dev-guidelines` skill where relevant.
|
||||
|
||||
## Review Priorities
|
||||
|
||||
Focus on:
|
||||
|
||||
- correctness bugs
|
||||
- behavioral regressions
|
||||
- security and tenant-isolation risks
|
||||
- performance issues with real impact
|
||||
- missing or weak tests for risky changes
|
||||
|
||||
## Output Expectations
|
||||
|
||||
- Findings first, ordered by severity
|
||||
- File and line references for each finding
|
||||
- Short summary only after findings
|
||||
- If no findings, say so explicitly and mention any residual risk or coverage gaps
|
||||
|
||||
## Scope Guidance
|
||||
|
||||
Use `references/review-checklist.md` for Langfuse-specific checks such as:
|
||||
|
||||
- ClickHouse and Postgres migration expectations
|
||||
- project-scoped tenant isolation checks
|
||||
- API/Fern consistency
|
||||
- banner-offset UI positioning
|
||||
- environment variable access patterns
|
||||
|
||||
Do not duplicate those rules in ad hoc prompts or tool-specific command files.
|
||||
@@ -1,57 +0,0 @@
|
||||
# Langfuse Review Checklist
|
||||
|
||||
This is the canonical shared review checklist for Langfuse.
|
||||
|
||||
## Database Migrations
|
||||
|
||||
### ClickHouse
|
||||
|
||||
- ClickHouse migrations in the `packages/shared/clickhouse/migrations/clustered` directory should include `ON CLUSTER default` and should use `Replicated` merge tree table types.
|
||||
- E.g. `ReplacingMergeTree` is likely an error while `ReplicatedReplacingMergeTree` would be correct in most cases.
|
||||
- ClickHouse migrations in the `packages/shared/clickhouse/migrations/unclustered` directory must not include `ON CLUSTER` statements and must not use `Replicated` merge tree table types.
|
||||
- Migrations in `packages/shared/clickhouse/migrations/clustered` should match their counterparts in `packages/shared/clickhouse/migrations/unclustered` aside from the restrictions listed above.
|
||||
- When adding new indexes on ClickHouse, ensure that there is a corresponding `MATERIALIZE INDEX` statement in the same migration. The materialization can use `SETTINGS mutations_sync = 2` if they operate on smaller tables, but may timeout otherwise.
|
||||
- All ClickHouse queries on project-scoped tables (traces, observations, scores, events, sessions, etc.) must include `WHERE project_id = {projectId: String}` filter to ensure proper tenant isolation and that queries only access data from the intended project.
|
||||
- For operations on the `events` table, you must never use the `FINAL` keyword as it kills performance. `events` is built so that `FINAL` is never required.
|
||||
|
||||
### Postgres
|
||||
|
||||
- Most `schema.prisma` changes should produce a change in `packages/shared/prisma/migrations`.
|
||||
- All Prisma queries on project-scoped tables must include `projectId` in the WHERE clause (e.g., `where: { id: traceId, projectId }`) to ensure proper tenant isolation and that queries only access data from the intended project.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
- Environment variables should be imported from the `env.mjs/ts` file of the respective package and not from `process.env.*` to ensure validation and typing.
|
||||
|
||||
## Redis Invocations
|
||||
|
||||
- Highlight usage of `redis.call` invocations. Those may have suboptimal redis cluster routing and will raise errors. Instead, use the native call patterns.
|
||||
Example: `await redis?.call("SET", key, "1", "NX", "EX", TTLSeconds);` should use `await redis?.set(key, "1", "EX", TTLSeconds, "NX");` instead.
|
||||
|
||||
## Langfuse Cloud
|
||||
|
||||
- When attempting to confirm if the current environment is Langfuse Cloud in the frontend, use the `useLangfuseCloudRegion` hook and never environment variables directly.
|
||||
|
||||
## Banner Height System
|
||||
|
||||
- Use `top-banner-offset` instead of `top-0` for any elements that are positioned `sticky`, `fixed`, or `absolute` with a global reference point (e.g., `top-0`). This ensures proper spacing when system banners (payment, maintenance, etc.) are displayed.
|
||||
- The banner height is managed through CSS variables (`--banner-height` and `--banner-offset`) defined in `web/src/styles/globals.css`.
|
||||
- Banner components (like PaymentBanner) dynamically update `--banner-height` using ResizeObserver to track their actual height, ensuring accurate positioning even when banners resize (e.g., on mobile wrapping).
|
||||
- Available Tailwind utilities:
|
||||
- `top-banner-offset` / `pt-banner-offset` - For sticky/fixed/absolute positioning and padding
|
||||
- `h-screen-with-banner` / `min-h-screen-with-banner` - For full-height containers accounting for banners
|
||||
|
||||
## JavaScript / TypeScript Style
|
||||
|
||||
- use concat instead of spread to avoid stack overflow with large arrays
|
||||
|
||||
## Seeder
|
||||
|
||||
- make sure that for new features with data model changes, the database seeder is adjusted.
|
||||
|
||||
## API Documentation
|
||||
|
||||
- Whenever a file in `web/src/features/public-api/types` changes, the `fern/apis` definition probably needs to be adjusted, too.
|
||||
- `nullish` types should map to `optional<nullable<T>>` in fern.
|
||||
- `nullable` types should map to `nullable<T>` in fern.
|
||||
- `optional` types should map to `optional<T>` in fern.
|
||||
@@ -1,60 +0,0 @@
|
||||
---
|
||||
name: frontend-browser-review
|
||||
description: |
|
||||
Shared workflow for browser-based review of user-visible frontend changes in Langfuse.
|
||||
Use when a change affects UI behavior, layout, styling, navigation, or browser-visible
|
||||
regressions and should be checked with the Playwright MCP server before signoff.
|
||||
---
|
||||
|
||||
# Frontend Browser Review
|
||||
|
||||
Use this skill when a change affects what users see or do in the browser.
|
||||
|
||||
## Start Here
|
||||
|
||||
- Read [`../../../web/AGENTS.md`](../../../web/AGENTS.md) for web-specific
|
||||
entry points and test commands.
|
||||
- Use the workspace `playwright` MCP server configured from the repo-owned
|
||||
shared agent setup.
|
||||
|
||||
## When To Use It
|
||||
|
||||
- UI changes in `web/**`
|
||||
- Layout, styling, or responsive behavior changes
|
||||
- Changes to navigation or page flows
|
||||
- Bug fixes where the failure mode is visible in the browser
|
||||
- Final signoff for user-visible frontend work
|
||||
|
||||
## Review Loop
|
||||
|
||||
1. Start the app with `pnpm run dev:web` unless an existing local server is
|
||||
already running.
|
||||
2. Install Chromium with `pnpm run playwright:install` if Playwright has not
|
||||
been set up on the machine yet.
|
||||
3. Open the primary changed flow with the Playwright MCP server.
|
||||
4. Exercise the main happy path affected by the change.
|
||||
5. Check for obvious visual regressions:
|
||||
- broken layout or spacing
|
||||
- banner overlap or viewport anchoring issues
|
||||
- missing loading, empty, or error states
|
||||
- broken responsive behavior on narrow widths
|
||||
6. If the page changed materially, inspect the resulting UI state and compare
|
||||
it against the intended behavior from the task or existing patterns.
|
||||
7. If the browser session fails, inspect traces and artifacts under
|
||||
`.playwright-mcp/`.
|
||||
|
||||
## Output Expectations
|
||||
|
||||
Report:
|
||||
|
||||
1. What flow you reviewed
|
||||
2. Whether the primary flow worked
|
||||
3. Any visible regressions or follow-up risks
|
||||
4. If review was blocked, exactly what prevented browser verification
|
||||
|
||||
## Scope Notes
|
||||
|
||||
- This skill complements, not replaces, targeted tests and linting.
|
||||
- For implementation details, stay in `web/AGENTS.md` and package-local skills.
|
||||
- Use this as the browser-signoff workflow, not as a generic frontend coding
|
||||
guide.
|
||||
@@ -1,72 +0,0 @@
|
||||
# PNPM Upgrade Package
|
||||
|
||||
Use this workflow when a user wants to upgrade a dependency in the Langfuse
|
||||
pnpm workspace.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Collect missing inputs.
|
||||
- Ask for the package if missing.
|
||||
- Ask for the target version if missing.
|
||||
- If the user says `latest`, resolve the real registry latest first.
|
||||
|
||||
2. Run the main helper once.
|
||||
- Run
|
||||
`node .agents/skills/pnpm-upgrade-package/scripts/check-release-age-window.mjs <package> <targetVersion>`.
|
||||
- Treat this as the single source of truth for:
|
||||
- direct workspace references
|
||||
- root `pnpm.overrides` / `pnpm.patchedDependencies`
|
||||
- latest registry version
|
||||
- latest version installable under the current release-age rules
|
||||
- existing matching `minimumReleaseAgeExclude` entries
|
||||
- exact dependency companions from `dependencies` and `optionalDependencies`
|
||||
- exact peer dependencies that are actually installed in the workspace
|
||||
|
||||
3. Handle the transitive-only case before editing anything.
|
||||
- If the helper shows no direct workspace references, run `pnpm why -r <package>`.
|
||||
- Identify the current top-level parent that pulls the package in.
|
||||
- Check whether that parent's current dependency range already permits the
|
||||
requested transitive version.
|
||||
- If the current parent range already covers the requested version, prefer a
|
||||
lock refresh / reinstall path before changing `package.json`.
|
||||
- If the current parent range does not cover the requested version, upgrade
|
||||
the direct parent dependency that pulls the package in.
|
||||
- If a compatible transitive package still stays pinned after the normal
|
||||
refresh path, you may suggest `pnpm dedupe` to the user as an optional
|
||||
manual follow-up, but do not run it automatically and do not require it.
|
||||
- Do not add the transitive package directly unless the user explicitly asks.
|
||||
|
||||
4. Ask before changing `minimumReleaseAgeExclude`.
|
||||
- Prefer `package@version` entries.
|
||||
- Only use bare `package` entries after explicit approval.
|
||||
- Ask about exact companion packages only when the helper says they still
|
||||
need a new exclusion.
|
||||
- Treat range-based dependency or peer entries as manual review.
|
||||
|
||||
5. Bump at the narrowest useful scope.
|
||||
- `pnpm -w up <package>@<version>` for root-only changes.
|
||||
- `pnpm --filter <workspace> up <package>@<version>` for one workspace.
|
||||
- `pnpm -r up <package>@<version>` only when every current reference should move.
|
||||
- Do not hand-edit `pnpm-lock.yaml`.
|
||||
|
||||
6. Validate.
|
||||
- Use the nearest package `AGENTS.md` plus the root verification matrix.
|
||||
- Finish with `pnpm why -r <package>`.
|
||||
- If companions moved too, run `pnpm why -r <companion-package>` for them as well.
|
||||
|
||||
## Quick Commands
|
||||
|
||||
- Run the single analysis pass:
|
||||
`node .agents/skills/pnpm-upgrade-package/scripts/check-release-age-window.mjs <package> <targetVersion>`
|
||||
- Transitive provenance check:
|
||||
`pnpm why -r <package>`
|
||||
- Inspect the current parent manifest on the registry:
|
||||
`npm view <parent>@<installedVersion> dependencies peerDependencies optionalDependencies --json`
|
||||
- Final graph verification:
|
||||
`pnpm why -r <package>`
|
||||
- Bump in the root workspace:
|
||||
`pnpm -w up <package>@<version>`
|
||||
- Bump in one workspace:
|
||||
`pnpm --filter web up <package>@<version>`
|
||||
- Bump everywhere that should move together:
|
||||
`pnpm -r up <package>@<version>`
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
name: pnpm-upgrade-package
|
||||
description: Use when upgrading a dependency in this pnpm workspace, including requests to bump a package to a specific version, compare the registry latest version with the latest version installable under the current minimum-release-age window, or decide whether minimumReleaseAgeExclude in pnpm-workspace.yaml must change. Ask the user for the package name or target version when either is missing.
|
||||
---
|
||||
|
||||
# PNPM Upgrade Package
|
||||
|
||||
Use this skill for interactive dependency bumps in Langfuse.
|
||||
|
||||
## Read Order
|
||||
|
||||
- Start with [AGENTS.md](AGENTS.md) for the end-to-end workflow.
|
||||
- Run the main helper once at the start of the upgrade:
|
||||
`node .agents/skills/pnpm-upgrade-package/scripts/check-release-age-window.mjs <package> [targetVersion]`
|
||||
|
||||
## Apply This Skill
|
||||
|
||||
- Ask for the package name if the user did not provide one.
|
||||
- Ask for the target version if the user did not provide one.
|
||||
- Run the main helper once as the first analysis step and use that single
|
||||
output for scope, exclusion decisions, and the final bump.
|
||||
- If the target package is not directly declared anywhere, run
|
||||
`pnpm why -r <package>` to find which direct dependency brings it in, then
|
||||
inspect whether the current top-level parent already allows the requested
|
||||
transitive version via its dependency range.
|
||||
- If the current parent range already covers the requested transitive version,
|
||||
prefer a lockfile refresh / reinstall path over bumping the parent manifest.
|
||||
- If the current parent range does not cover the requested transitive version,
|
||||
upgrade that parent dependency instead of adding the target package directly
|
||||
unless the user explicitly wants that.
|
||||
- If a compatible transitive package still stays pinned after the normal
|
||||
refresh path, you may suggest `pnpm dedupe` to the user as an optional manual
|
||||
follow-up, but do not run it automatically and do not require it.
|
||||
- Resolve the registry latest version, but do not silently upgrade to latest
|
||||
unless the user asked for latest.
|
||||
- Compare the target version with the latest version installable under the
|
||||
current `minimumReleaseAge` window.
|
||||
- Ask before adding `minimumReleaseAgeExclude` entries for the target package,
|
||||
exact dependency companions from `dependencies` or `optionalDependencies`, or
|
||||
locally installed exact peer dependencies.
|
||||
- Finish with `pnpm why -r <package>` to confirm that only the intended version
|
||||
remains in the workspace.
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "PNPM Upgrade Package"
|
||||
short_description: "Interactive pnpm package bump workflow"
|
||||
default_prompt: "Use $pnpm-upgrade-package to upgrade a package in this pnpm workspace, asking me for the package or version if I did not provide them."
|
||||
@@ -1,418 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
entryCoversVersion,
|
||||
findLocalPackageReferences,
|
||||
formatWorkspaceReference,
|
||||
getRootPnpmControls,
|
||||
readWorkspaceConfig,
|
||||
} from "./lib/workspace-utils.mjs";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const asJson = args.includes("--json");
|
||||
const positional = args.filter((arg) => !arg.startsWith("--"));
|
||||
const packageName = positional[0];
|
||||
const requestedTargetVersion = positional[1] ?? null;
|
||||
|
||||
if (!packageName) {
|
||||
console.error(
|
||||
"Usage: node .agents/skills/pnpm-upgrade-package/scripts/check-release-age-window.mjs <package> [targetVersion] [--json]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const workspaceConfig = readWorkspaceConfig(join(repoRoot, "pnpm-workspace.yaml"));
|
||||
const minimumReleaseAgeMinutes = workspaceConfig.minimumReleaseAge ?? 0;
|
||||
const thresholdMs = Date.now() - minimumReleaseAgeMinutes * 60 * 1000;
|
||||
const REGISTRY_FETCH_TIMEOUT_MS = 30_000;
|
||||
const registryCache = new Map();
|
||||
const workspaceReferenceCache = new Map();
|
||||
|
||||
const getWorkspaceReferences = (name) => {
|
||||
if (!workspaceReferenceCache.has(name)) {
|
||||
workspaceReferenceCache.set(name, findLocalPackageReferences(repoRoot, name));
|
||||
}
|
||||
|
||||
return workspaceReferenceCache.get(name);
|
||||
};
|
||||
|
||||
function printSectionHeader(title) {
|
||||
console.log("");
|
||||
console.log(title);
|
||||
}
|
||||
|
||||
function isPrerelease(version) {
|
||||
return version.includes("-");
|
||||
}
|
||||
|
||||
function isExactVersion(spec) {
|
||||
return /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(spec.trim());
|
||||
}
|
||||
|
||||
function getMatchingExcludeEntries(name, version) {
|
||||
return workspaceConfig.minimumReleaseAgeExclude.filter((entry) =>
|
||||
entryCoversVersion(entry, name, version),
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchRegistryPackage(name) {
|
||||
if (registryCache.has(name)) return registryCache.get(name);
|
||||
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = setTimeout(
|
||||
() => abortController.abort(),
|
||||
REGISTRY_FETCH_TIMEOUT_MS,
|
||||
);
|
||||
timeoutId.unref?.();
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://registry.npmjs.org/${encodeURIComponent(name)}`,
|
||||
{
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"user-agent": "langfuse-pnpm-upgrade-package-skill",
|
||||
},
|
||||
signal: abortController.signal,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch ${name} from npm registry: ${response.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const metadata = await response.json();
|
||||
registryCache.set(name, metadata);
|
||||
return metadata;
|
||||
} catch (error) {
|
||||
if (error?.name === "AbortError") {
|
||||
throw new Error(
|
||||
`Timed out fetching ${name} from npm registry after ${REGISTRY_FETCH_TIMEOUT_MS}ms`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
function getInstallability(metadata, name, version) {
|
||||
const publishedAt = metadata.time?.[version] ?? null;
|
||||
const publishedAtMs = publishedAt ? Date.parse(publishedAt) : null;
|
||||
const matchingExcludeEntries = getMatchingExcludeEntries(name, version);
|
||||
const isYoungerThanMinimumReleaseAge =
|
||||
publishedAtMs != null ? publishedAtMs > thresholdMs : null;
|
||||
const isInstallableWithoutNewExclude =
|
||||
isYoungerThanMinimumReleaseAge == null
|
||||
? null
|
||||
: !isYoungerThanMinimumReleaseAge || matchingExcludeEntries.length > 0;
|
||||
|
||||
return {
|
||||
name,
|
||||
version,
|
||||
publishedAt,
|
||||
isYoungerThanMinimumReleaseAge,
|
||||
isInstallableWithoutNewExclude,
|
||||
matchingExcludeEntries,
|
||||
suggestedExclude:
|
||||
isInstallableWithoutNewExclude === false ? `${name}@${version}` : null,
|
||||
};
|
||||
}
|
||||
|
||||
function selectLatestInstallableVersion(metadata, name) {
|
||||
const times = metadata.time ?? {};
|
||||
|
||||
return (
|
||||
Object.keys(metadata.versions ?? {})
|
||||
.filter((version) => times[version] && !isPrerelease(version))
|
||||
.sort((left, right) => Date.parse(times[right]) - Date.parse(times[left]))
|
||||
.map((version) => getInstallability(metadata, name, version))
|
||||
.find((candidate) => candidate.isInstallableWithoutNewExclude) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function collectManifestEntries(manifest, fields) {
|
||||
const merged = new Map();
|
||||
|
||||
for (const field of fields) {
|
||||
for (const [name, spec] of Object.entries(manifest[field] ?? {})) {
|
||||
const key = `${name}:${spec}`;
|
||||
const entry = merged.get(key);
|
||||
|
||||
if (entry) {
|
||||
entry.fields.push(field);
|
||||
continue;
|
||||
}
|
||||
|
||||
merged.set(key, { name, spec, fields: [field] });
|
||||
}
|
||||
}
|
||||
|
||||
return [...merged.values()].sort((left, right) =>
|
||||
left.name.localeCompare(right.name),
|
||||
);
|
||||
}
|
||||
|
||||
async function analyzeManifestEntries(entries, { includeWorkspace = false } = {}) {
|
||||
const exact = [];
|
||||
const range = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const workspaceReferences = includeWorkspace
|
||||
? getWorkspaceReferences(entry.name)
|
||||
: null;
|
||||
|
||||
if (!isExactVersion(entry.spec)) {
|
||||
range.push({
|
||||
...entry,
|
||||
...(includeWorkspace ? { workspaceReferences } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const metadata = await fetchRegistryPackage(entry.name);
|
||||
const installability = getInstallability(metadata, entry.name, entry.spec);
|
||||
|
||||
exact.push({
|
||||
...entry,
|
||||
...installability,
|
||||
...(includeWorkspace
|
||||
? {
|
||||
workspaceReferences,
|
||||
isInstalledInWorkspace: workspaceReferences.length > 0,
|
||||
}
|
||||
: {}),
|
||||
suggestedExclude:
|
||||
includeWorkspace && workspaceReferences.length === 0
|
||||
? null
|
||||
: installability.suggestedExclude,
|
||||
});
|
||||
}
|
||||
|
||||
return { exact, range };
|
||||
}
|
||||
|
||||
function printWorkspaceReferences(title, references) {
|
||||
printSectionHeader(title);
|
||||
if (references.length === 0) {
|
||||
console.log("- none");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const reference of references) {
|
||||
console.log(`- ${formatWorkspaceReference(reference)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function printRootPnpmControls(rootPnpm) {
|
||||
printSectionHeader("Root pnpm controls:");
|
||||
if (
|
||||
rootPnpm.overrideMatches.length === 0 &&
|
||||
rootPnpm.patchedDependencyMatches.length === 0
|
||||
) {
|
||||
console.log("- none");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const match of rootPnpm.overrideMatches) {
|
||||
console.log(`- override ${match.selector}: ${match.value}`);
|
||||
}
|
||||
for (const match of rootPnpm.patchedDependencyMatches) {
|
||||
console.log(`- patched dependency ${match.selector}: ${match.value}`);
|
||||
}
|
||||
}
|
||||
|
||||
function printVersionEntries(title, entries, { includeWorkspace = false } = {}) {
|
||||
printSectionHeader(title);
|
||||
if (entries.length === 0) {
|
||||
console.log("- none");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const status =
|
||||
entry.isInstallableWithoutNewExclude == null
|
||||
? "unknown"
|
||||
: entry.isInstallableWithoutNewExclude
|
||||
? "installable now"
|
||||
: "needs exclude";
|
||||
|
||||
console.log(
|
||||
`- ${entry.name}@${entry.version} (${status}; via ${entry.fields.join(", ")})`,
|
||||
);
|
||||
if (entry.publishedAt) {
|
||||
console.log(` published at: ${entry.publishedAt}`);
|
||||
}
|
||||
if (includeWorkspace) {
|
||||
console.log(
|
||||
` installed in workspace: ${entry.isInstalledInWorkspace ? "yes" : "no"}`,
|
||||
);
|
||||
for (const reference of entry.workspaceReferences) {
|
||||
console.log(` workspace reference: ${formatWorkspaceReference(reference)}`);
|
||||
}
|
||||
}
|
||||
if (entry.matchingExcludeEntries.length > 0) {
|
||||
console.log(
|
||||
` matching exclude entries: ${entry.matchingExcludeEntries.join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (entry.suggestedExclude) {
|
||||
console.log(` suggested exclude: ${entry.suggestedExclude}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function printRangeEntries(title, entries) {
|
||||
printSectionHeader(title);
|
||||
if (entries.length === 0) {
|
||||
console.log("- none");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
console.log(
|
||||
`- ${entry.name}: ${entry.spec} (manual review; via ${entry.fields.join(", ")})`,
|
||||
);
|
||||
for (const reference of entry.workspaceReferences ?? []) {
|
||||
console.log(` workspace reference: ${formatWorkspaceReference(reference)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const packageMetadata = await fetchRegistryPackage(packageName);
|
||||
const latestVersion = packageMetadata["dist-tags"]?.latest ?? null;
|
||||
const targetVersion = requestedTargetVersion ?? latestVersion;
|
||||
|
||||
if (!targetVersion) {
|
||||
console.error(`Could not resolve a target version for ${packageName}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!packageMetadata.versions?.[targetVersion]) {
|
||||
console.error(`Version ${targetVersion} was not found for ${packageName}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const packageWorkspaceReferences = getWorkspaceReferences(packageName);
|
||||
const rootPnpm = getRootPnpmControls(repoRoot, packageName);
|
||||
const latestInstallableWithoutNewExclude = selectLatestInstallableVersion(
|
||||
packageMetadata,
|
||||
packageName,
|
||||
);
|
||||
const targetInstallability = getInstallability(
|
||||
packageMetadata,
|
||||
packageName,
|
||||
targetVersion,
|
||||
);
|
||||
const targetManifest = packageMetadata.versions[targetVersion];
|
||||
|
||||
const dependencyCompanions = await analyzeManifestEntries(
|
||||
collectManifestEntries(targetManifest, [
|
||||
"dependencies",
|
||||
"optionalDependencies",
|
||||
]),
|
||||
);
|
||||
const peerDependencies = await analyzeManifestEntries(
|
||||
collectManifestEntries(targetManifest, ["peerDependencies"]),
|
||||
{ includeWorkspace: true },
|
||||
);
|
||||
|
||||
const result = {
|
||||
packageName,
|
||||
targetVersion,
|
||||
targetWasExplicitlyProvided: requestedTargetVersion != null,
|
||||
packageWorkspaceReferences,
|
||||
rootPnpm,
|
||||
minimumReleaseAgeMinutes,
|
||||
thresholdIso: new Date(thresholdMs).toISOString(),
|
||||
latestRegistryVersion: latestVersion,
|
||||
latestRegistryPublishedAt:
|
||||
latestVersion != null ? packageMetadata.time?.[latestVersion] ?? null : null,
|
||||
latestInstallableWithoutNewExclude,
|
||||
targetPublishedAt: targetInstallability.publishedAt,
|
||||
targetIsYoungerThanMinimumReleaseAge:
|
||||
targetInstallability.isYoungerThanMinimumReleaseAge,
|
||||
targetIsInstallableWithoutNewExclude:
|
||||
targetInstallability.isInstallableWithoutNewExclude,
|
||||
matchingPackageExcludeEntries: targetInstallability.matchingExcludeEntries,
|
||||
suggestedPackageExclude: targetInstallability.suggestedExclude,
|
||||
exactDependencyCompanions: dependencyCompanions.exact,
|
||||
rangeDependencyCompanions: dependencyCompanions.range,
|
||||
exactPeerDependencies: peerDependencies.exact,
|
||||
rangePeerDependencies: peerDependencies.range,
|
||||
};
|
||||
|
||||
if (asJson) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`Package: ${packageName}`);
|
||||
console.log(
|
||||
`Target version: ${targetVersion}${
|
||||
requestedTargetVersion
|
||||
? ""
|
||||
: " (resolved latest; still ask before bumping if version was omitted)"
|
||||
}`,
|
||||
);
|
||||
|
||||
printWorkspaceReferences(
|
||||
"Target package workspace references:",
|
||||
packageWorkspaceReferences,
|
||||
);
|
||||
printRootPnpmControls(rootPnpm);
|
||||
|
||||
printSectionHeader("Release-age window:");
|
||||
console.log(`minimumReleaseAge: ${minimumReleaseAgeMinutes} minutes`);
|
||||
console.log(`Threshold: ${result.thresholdIso}`);
|
||||
console.log(`Latest registry version: ${result.latestRegistryVersion ?? "unknown"}`);
|
||||
if (result.latestRegistryPublishedAt) {
|
||||
console.log(`Latest registry published at: ${result.latestRegistryPublishedAt}`);
|
||||
}
|
||||
if (latestInstallableWithoutNewExclude) {
|
||||
console.log(
|
||||
`Latest installable without new exclude: ${latestInstallableWithoutNewExclude.version} (${latestInstallableWithoutNewExclude.publishedAt})`,
|
||||
);
|
||||
} else {
|
||||
console.log("Latest installable without new exclude: none found");
|
||||
}
|
||||
console.log(`Target published at: ${result.targetPublishedAt ?? "unknown"}`);
|
||||
console.log(
|
||||
`Target installable without new exclude: ${
|
||||
result.targetIsInstallableWithoutNewExclude == null
|
||||
? "unknown"
|
||||
: result.targetIsInstallableWithoutNewExclude
|
||||
? "yes"
|
||||
: "no"
|
||||
}`,
|
||||
);
|
||||
if (result.matchingPackageExcludeEntries.length > 0) {
|
||||
console.log("Matching package exclude entries:");
|
||||
for (const entry of result.matchingPackageExcludeEntries) {
|
||||
console.log(`- ${entry}`);
|
||||
}
|
||||
} else {
|
||||
console.log("Matching package exclude entries: none");
|
||||
}
|
||||
if (result.suggestedPackageExclude) {
|
||||
console.log(`Suggested package exclude: ${result.suggestedPackageExclude}`);
|
||||
}
|
||||
|
||||
printVersionEntries(
|
||||
"Exact dependency companions (dependencies + optionalDependencies):",
|
||||
result.exactDependencyCompanions,
|
||||
);
|
||||
printRangeEntries(
|
||||
"Range dependency companions (dependencies + optionalDependencies):",
|
||||
result.rangeDependencyCompanions,
|
||||
);
|
||||
printVersionEntries("Exact peer dependencies:", result.exactPeerDependencies, {
|
||||
includeWorkspace: true,
|
||||
});
|
||||
printRangeEntries("Range peer dependencies:", result.rangePeerDependencies);
|
||||
@@ -1,208 +0,0 @@
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
|
||||
const packageFields = [
|
||||
"dependencies",
|
||||
"devDependencies",
|
||||
"peerDependencies",
|
||||
"optionalDependencies",
|
||||
];
|
||||
|
||||
export function formatWorkspaceReference(reference) {
|
||||
const label = reference.workspaceName
|
||||
? `${reference.path} (${reference.workspaceName})`
|
||||
: reference.path;
|
||||
const specs = reference.matches
|
||||
.map((match) => `${match.field}: ${match.spec}`)
|
||||
.join(", ");
|
||||
|
||||
return `${label} -> ${specs}`;
|
||||
}
|
||||
|
||||
export function readJson(path) {
|
||||
return JSON.parse(readFileSync(path, "utf8"));
|
||||
}
|
||||
|
||||
function stripInlineComment(line) {
|
||||
let quote = null;
|
||||
let escaped = false;
|
||||
|
||||
for (let index = 0; index < line.length; index += 1) {
|
||||
const char = line[index];
|
||||
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote) {
|
||||
if (char === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === quote) {
|
||||
quote = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "'" || char === '"') {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "#") {
|
||||
return line.slice(0, index).trimEnd();
|
||||
}
|
||||
}
|
||||
|
||||
return line;
|
||||
}
|
||||
|
||||
export function readWorkspaceConfig(path) {
|
||||
const raw = readFileSync(path, "utf8");
|
||||
const lines = raw.split(/\r?\n/);
|
||||
let minimumReleaseAge = 0;
|
||||
const minimumReleaseAgeExclude = [];
|
||||
let inExcludeBlock = false;
|
||||
|
||||
for (const line of lines) {
|
||||
const uncommented = stripInlineComment(line);
|
||||
const trimmed = uncommented.trim();
|
||||
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
|
||||
const ageMatch = trimmed.match(/^minimumReleaseAge:\s*(\d+)\s*$/);
|
||||
if (ageMatch) {
|
||||
minimumReleaseAge = Number(ageMatch[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^minimumReleaseAgeExclude:\s*$/.test(trimmed)) {
|
||||
inExcludeBlock = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inExcludeBlock) {
|
||||
const excludeMatch = uncommented.match(/^\s*-\s+(.+?)\s*$/);
|
||||
if (excludeMatch) {
|
||||
minimumReleaseAgeExclude.push(
|
||||
excludeMatch[1].replace(/^['"]|['"]$/g, ""),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\S/.test(uncommented)) {
|
||||
inExcludeBlock = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { minimumReleaseAge, minimumReleaseAgeExclude };
|
||||
}
|
||||
|
||||
export function collectPackageJsonPaths(repoRoot) {
|
||||
const paths = [
|
||||
"package.json",
|
||||
"web/package.json",
|
||||
"worker/package.json",
|
||||
"ee/package.json",
|
||||
];
|
||||
const packagesRoot = join(repoRoot, "packages");
|
||||
|
||||
if (!existsSync(packagesRoot)) return paths;
|
||||
|
||||
const stack = [packagesRoot];
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
||||
if (
|
||||
entry.name === "node_modules" ||
|
||||
entry.name === "dist" ||
|
||||
entry.name === ".git"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextPath = join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(nextPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile() && entry.name === "package.json") {
|
||||
paths.push(relative(repoRoot, nextPath));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(paths)];
|
||||
}
|
||||
|
||||
export function matchesPackageSelector(selector, wantedPackage) {
|
||||
if (selector === wantedPackage) return true;
|
||||
if (selector.startsWith(`${wantedPackage}@`)) return true;
|
||||
if (selector.endsWith(`>${wantedPackage}`)) return true;
|
||||
if (selector.includes(`>${wantedPackage}@`)) return true;
|
||||
if (selector.endsWith("/*")) {
|
||||
const prefix = selector.slice(0, -1);
|
||||
return wantedPackage.startsWith(prefix);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function entryCoversVersion(entry, wantedPackage, wantedVersion) {
|
||||
if (entry === wantedPackage) return true;
|
||||
if (entry.endsWith("/*")) {
|
||||
const prefix = entry.slice(0, -1);
|
||||
return wantedPackage.startsWith(prefix);
|
||||
}
|
||||
if (!entry.startsWith(`${wantedPackage}@`)) return false;
|
||||
|
||||
return entry
|
||||
.slice(wantedPackage.length + 1)
|
||||
.split("||")
|
||||
.map((part) => part.trim())
|
||||
.includes(wantedVersion);
|
||||
}
|
||||
|
||||
export function findLocalPackageReferences(repoRoot, wantedPackage) {
|
||||
const results = [];
|
||||
|
||||
for (const packageJsonPath of collectPackageJsonPaths(repoRoot)) {
|
||||
const json = readJson(join(repoRoot, packageJsonPath));
|
||||
const matches = [];
|
||||
|
||||
for (const field of packageFields) {
|
||||
if (json[field]?.[wantedPackage]) {
|
||||
matches.push({ field, spec: json[field][wantedPackage] });
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length > 0) {
|
||||
results.push({
|
||||
path: packageJsonPath,
|
||||
workspaceName: json.name ?? null,
|
||||
matches,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export function getRootPnpmControls(repoRoot, packageName) {
|
||||
const rootPackageJson = readJson(join(repoRoot, "package.json"));
|
||||
|
||||
return {
|
||||
overrideMatches: Object.entries(rootPackageJson.pnpm?.overrides ?? {})
|
||||
.filter(([selector]) => matchesPackageSelector(selector, packageName))
|
||||
.map(([selector, value]) => ({ selector, value })),
|
||||
patchedDependencyMatches: Object.entries(
|
||||
rootPackageJson.pnpm?.patchedDependencies ?? {},
|
||||
)
|
||||
.filter(([selector]) => matchesPackageSelector(selector, packageName))
|
||||
.map(([selector, value]) => ({ selector, value })),
|
||||
};
|
||||
}
|
||||
@@ -1,951 +0,0 @@
|
||||
---
|
||||
name: turborepo
|
||||
description: |
|
||||
Turborepo monorepo build system guidance. Triggers on: turbo.json, task pipelines,
|
||||
dependsOn, caching, remote cache, the "turbo" CLI, --filter, --affected, CI optimization, environment
|
||||
variables, internal packages, monorepo structure/best practices, and boundaries.
|
||||
|
||||
Use when user: configures tasks/workflows/pipelines, creates packages, sets up
|
||||
monorepo, shares code between apps, runs changed/affected packages, debugs cache,
|
||||
or has apps/packages directories.
|
||||
metadata:
|
||||
version: 2.8.21-canary.9
|
||||
---
|
||||
|
||||
# Turborepo Skill
|
||||
|
||||
Build system for JavaScript/TypeScript monorepos. Turborepo caches task outputs and runs tasks in parallel based on dependency graph.
|
||||
|
||||
## IMPORTANT: Package Tasks, Not Root Tasks
|
||||
|
||||
**DO NOT create Root Tasks. ALWAYS create package tasks.**
|
||||
|
||||
When creating tasks/scripts/pipelines, you MUST:
|
||||
|
||||
1. Add the script to each relevant package's `package.json`
|
||||
2. Register the task in root `turbo.json`
|
||||
3. Root `package.json` only delegates via `turbo run <task>`
|
||||
|
||||
**DO NOT** put task logic in root `package.json`. This defeats Turborepo's parallelization.
|
||||
|
||||
```json
|
||||
// DO THIS: Scripts in each package
|
||||
// apps/web/package.json
|
||||
{ "scripts": { "build": "next build", "lint": "eslint .", "test": "vitest" } }
|
||||
|
||||
// apps/api/package.json
|
||||
{ "scripts": { "build": "tsc", "lint": "eslint .", "test": "vitest" } }
|
||||
|
||||
// packages/ui/package.json
|
||||
{ "scripts": { "build": "tsc", "lint": "eslint .", "test": "vitest" } }
|
||||
```
|
||||
|
||||
```json
|
||||
// turbo.json - register tasks
|
||||
{
|
||||
"tasks": {
|
||||
"build": { "dependsOn": ["^build"], "outputs": ["dist/**"] },
|
||||
"lint": {},
|
||||
"test": { "dependsOn": ["build"] }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
// Root package.json - ONLY delegates, no task logic
|
||||
{
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"lint": "turbo run lint",
|
||||
"test": "turbo run test"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
// DO NOT DO THIS - defeats parallelization
|
||||
// Root package.json
|
||||
{
|
||||
"scripts": {
|
||||
"build": "cd apps/web && next build && cd ../api && tsc",
|
||||
"lint": "eslint apps/ packages/",
|
||||
"test": "vitest"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Root Tasks (`//#taskname`) are ONLY for tasks that truly cannot exist in packages (rare).
|
||||
|
||||
## Secondary Rule: `turbo run` vs `turbo`
|
||||
|
||||
**Always use `turbo run` when the command is written into code:**
|
||||
|
||||
```json
|
||||
// package.json - ALWAYS "turbo run"
|
||||
{
|
||||
"scripts": {
|
||||
"build": "turbo run build"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```yaml
|
||||
# CI workflows - ALWAYS "turbo run"
|
||||
- run: turbo run build --affected
|
||||
```
|
||||
|
||||
**The shorthand `turbo <tasks>` is ONLY for one-off terminal commands** typed directly by humans or agents. Never write `turbo build` into package.json, CI, or scripts.
|
||||
|
||||
## Quick Decision Trees
|
||||
|
||||
### "I need to configure a task"
|
||||
|
||||
```
|
||||
Configure a task?
|
||||
├─ Define task dependencies → references/configuration/tasks.md
|
||||
├─ Lint/check-types (parallel + caching) → Use Transit Nodes pattern (see below)
|
||||
├─ Specify build outputs → references/configuration/tasks.md#outputs
|
||||
├─ Handle environment variables → references/environment/RULE.md
|
||||
├─ Set up dev/watch tasks → references/configuration/tasks.md#persistent
|
||||
├─ Package-specific config → references/configuration/RULE.md#package-configurations
|
||||
└─ Global settings (cacheDir, daemon) → references/configuration/global-options.md
|
||||
```
|
||||
|
||||
### "My cache isn't working"
|
||||
|
||||
```
|
||||
Cache problems?
|
||||
├─ Tasks run but outputs not restored → Missing `outputs` key
|
||||
├─ Cache misses unexpectedly → references/caching/gotchas.md
|
||||
├─ Need to debug hash inputs → Use --summarize or --dry
|
||||
├─ Want to skip cache entirely → Use --force or cache: false
|
||||
├─ Remote cache not working → references/caching/remote-cache.md
|
||||
└─ Environment causing misses → references/environment/gotchas.md
|
||||
```
|
||||
|
||||
### "I want to run only changed packages"
|
||||
|
||||
```
|
||||
Run only what changed?
|
||||
├─ Changed packages + dependents (RECOMMENDED) → turbo run build --affected
|
||||
├─ Custom base branch → --affected --affected-base=origin/develop
|
||||
├─ Manual git comparison → --filter=...[origin/main]
|
||||
└─ See all filter options → references/filtering/RULE.md
|
||||
```
|
||||
|
||||
**`--affected` is the primary way to run only changed packages.** It automatically compares against the default branch and includes dependents.
|
||||
|
||||
### "I want to filter packages"
|
||||
|
||||
```
|
||||
Filter packages?
|
||||
├─ Only changed packages → --affected (see above)
|
||||
├─ By package name → --filter=web
|
||||
├─ By directory → --filter=./apps/*
|
||||
├─ Package + dependencies → --filter=web...
|
||||
├─ Package + dependents → --filter=...web
|
||||
└─ Complex combinations → references/filtering/patterns.md
|
||||
```
|
||||
|
||||
### "Environment variables aren't working"
|
||||
|
||||
```
|
||||
Environment issues?
|
||||
├─ Vars not available at runtime → Strict mode filtering (default)
|
||||
├─ Cache hits with wrong env → Var not in `env` key
|
||||
├─ .env changes not causing rebuilds → .env not in `inputs`
|
||||
├─ CI variables missing → references/environment/gotchas.md
|
||||
└─ Framework vars (NEXT_PUBLIC_*) → Auto-included via inference
|
||||
```
|
||||
|
||||
### "I need to set up CI"
|
||||
|
||||
```
|
||||
CI setup?
|
||||
├─ GitHub Actions → references/ci/github-actions.md
|
||||
├─ Vercel deployment → references/ci/vercel.md
|
||||
├─ Remote cache in CI → references/caching/remote-cache.md
|
||||
├─ Only build changed packages → --affected flag
|
||||
├─ Skip unnecessary builds → turbo-ignore (references/cli/commands.md)
|
||||
└─ Skip container setup when no changes → turbo-ignore
|
||||
```
|
||||
|
||||
### "I want to watch for changes during development"
|
||||
|
||||
```
|
||||
Watch mode?
|
||||
├─ Re-run tasks on change → turbo watch (references/watch/RULE.md)
|
||||
├─ Dev servers with dependencies → Use `with` key (references/configuration/tasks.md#with)
|
||||
├─ Restart dev server on dep change → Use `interruptible: true`
|
||||
└─ Persistent dev tasks → Use `persistent: true`
|
||||
```
|
||||
|
||||
### "I need to create/structure a package"
|
||||
|
||||
```
|
||||
Package creation/structure?
|
||||
├─ Create an internal package → references/best-practices/packages.md
|
||||
├─ Repository structure → references/best-practices/structure.md
|
||||
├─ Dependency management → references/best-practices/dependencies.md
|
||||
├─ Best practices overview → references/best-practices/RULE.md
|
||||
├─ JIT vs Compiled packages → references/best-practices/packages.md#compilation-strategies
|
||||
└─ Sharing code between apps → references/best-practices/RULE.md#package-types
|
||||
```
|
||||
|
||||
### "How should I structure my monorepo?"
|
||||
|
||||
```
|
||||
Monorepo structure?
|
||||
├─ Standard layout (apps/, packages/) → references/best-practices/RULE.md
|
||||
├─ Package types (apps vs libraries) → references/best-practices/RULE.md#package-types
|
||||
├─ Creating internal packages → references/best-practices/packages.md
|
||||
├─ TypeScript configuration → references/best-practices/structure.md#typescript-configuration
|
||||
├─ ESLint configuration → references/best-practices/structure.md#eslint-configuration
|
||||
├─ Dependency management → references/best-practices/dependencies.md
|
||||
└─ Enforce package boundaries → references/boundaries/RULE.md
|
||||
```
|
||||
|
||||
### "I want to enforce architectural boundaries"
|
||||
|
||||
```
|
||||
Enforce boundaries?
|
||||
├─ Check for violations → turbo boundaries
|
||||
├─ Tag packages → references/boundaries/RULE.md#tags
|
||||
├─ Restrict which packages can import others → references/boundaries/RULE.md#rule-types
|
||||
└─ Prevent cross-package file imports → references/boundaries/RULE.md
|
||||
```
|
||||
|
||||
## Critical Anti-Patterns
|
||||
|
||||
### Using `turbo` Shorthand in Code
|
||||
|
||||
**`turbo run` is recommended in package.json scripts and CI pipelines.** The shorthand `turbo <task>` is intended for interactive terminal use.
|
||||
|
||||
```json
|
||||
// WRONG - using shorthand in package.json
|
||||
{
|
||||
"scripts": {
|
||||
"build": "turbo build",
|
||||
"dev": "turbo dev"
|
||||
}
|
||||
}
|
||||
|
||||
// CORRECT
|
||||
{
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```yaml
|
||||
# WRONG - using shorthand in CI
|
||||
- run: turbo build --affected
|
||||
|
||||
# CORRECT
|
||||
- run: turbo run build --affected
|
||||
```
|
||||
|
||||
### Root Scripts Bypassing Turbo
|
||||
|
||||
Root `package.json` scripts MUST delegate to `turbo run`, not run tasks directly.
|
||||
|
||||
```json
|
||||
// WRONG - bypasses turbo entirely
|
||||
{
|
||||
"scripts": {
|
||||
"build": "bun build",
|
||||
"dev": "bun dev"
|
||||
}
|
||||
}
|
||||
|
||||
// CORRECT - delegates to turbo
|
||||
{
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Using `&&` to Chain Turbo Tasks
|
||||
|
||||
Don't chain turbo tasks with `&&`. Let turbo orchestrate.
|
||||
|
||||
```json
|
||||
// WRONG - turbo task not using turbo run
|
||||
{
|
||||
"scripts": {
|
||||
"changeset:publish": "bun build && changeset publish"
|
||||
}
|
||||
}
|
||||
|
||||
// CORRECT
|
||||
{
|
||||
"scripts": {
|
||||
"changeset:publish": "turbo run build && changeset publish"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `prebuild` Scripts That Manually Build Dependencies
|
||||
|
||||
Scripts like `prebuild` that manually build other packages bypass Turborepo's dependency graph.
|
||||
|
||||
```json
|
||||
// WRONG - manually building dependencies
|
||||
{
|
||||
"scripts": {
|
||||
"prebuild": "cd ../../packages/types && bun run build && cd ../utils && bun run build",
|
||||
"build": "next build"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**However, the fix depends on whether workspace dependencies are declared:**
|
||||
|
||||
1. **If dependencies ARE declared** (e.g., `"@repo/types": "workspace:*"` in package.json), remove the `prebuild` script. Turbo's `dependsOn: ["^build"]` handles this automatically.
|
||||
|
||||
2. **If dependencies are NOT declared**, the `prebuild` exists because `^build` won't trigger without a dependency relationship. The fix is to:
|
||||
- Add the dependency to package.json: `"@repo/types": "workspace:*"`
|
||||
- Then remove the `prebuild` script
|
||||
|
||||
```json
|
||||
// CORRECT - declare dependency, let turbo handle build order
|
||||
// package.json
|
||||
{
|
||||
"dependencies": {
|
||||
"@repo/types": "workspace:*",
|
||||
"@repo/utils": "workspace:*"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "next build"
|
||||
}
|
||||
}
|
||||
|
||||
// turbo.json
|
||||
{
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key insight:** `^build` only runs build in packages listed as dependencies. No dependency declaration = no automatic build ordering.
|
||||
|
||||
### Overly Broad `globalDependencies`
|
||||
|
||||
`globalDependencies` affects ALL tasks in ALL packages via the **global hash** — tasks cannot opt out of specific files, even with negation globs in `inputs`. Be specific.
|
||||
|
||||
```json
|
||||
// WRONG - heavy hammer, affects all hashes
|
||||
{
|
||||
"globalDependencies": ["**/.env.*local"]
|
||||
}
|
||||
|
||||
// BETTER - move to task-level inputs
|
||||
{
|
||||
"globalDependencies": [".env"],
|
||||
"tasks": {
|
||||
"build": {
|
||||
"inputs": ["$TURBO_DEFAULT$", ".env*"],
|
||||
"outputs": ["dist/**"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
With `futureFlags.globalConfiguration`, this problem is reduced because `global.inputs` files are folded into each task's inputs (not the global hash). Tasks can exclude specific files:
|
||||
|
||||
```json
|
||||
// BEST - global.inputs with per-task exclusion
|
||||
{
|
||||
"futureFlags": { "globalConfiguration": true },
|
||||
"global": {
|
||||
"inputs": [".env"]
|
||||
},
|
||||
"tasks": {
|
||||
"build": { "outputs": ["dist/**"] },
|
||||
"lint": {
|
||||
"inputs": ["$TURBO_DEFAULT$", "!$TURBO_ROOT$/.env"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Repetitive Task Configuration
|
||||
|
||||
Look for repeated configuration across tasks that can be collapsed. Turborepo supports shared configuration patterns.
|
||||
|
||||
```json
|
||||
// WRONG - repetitive env and inputs across tasks
|
||||
{
|
||||
"tasks": {
|
||||
"build": {
|
||||
"env": ["API_URL", "DATABASE_URL"],
|
||||
"inputs": ["$TURBO_DEFAULT$", ".env*"]
|
||||
},
|
||||
"test": {
|
||||
"env": ["API_URL", "DATABASE_URL"],
|
||||
"inputs": ["$TURBO_DEFAULT$", ".env*"]
|
||||
},
|
||||
"dev": {
|
||||
"env": ["API_URL", "DATABASE_URL"],
|
||||
"inputs": ["$TURBO_DEFAULT$", ".env*"],
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BETTER - use globalEnv and globalDependencies for shared config
|
||||
{
|
||||
"globalEnv": ["API_URL", "DATABASE_URL"],
|
||||
"globalDependencies": [".env*"],
|
||||
"tasks": {
|
||||
"build": {},
|
||||
"test": {},
|
||||
"dev": {
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**When to use global vs task-level:**
|
||||
|
||||
- `globalEnv` / `globalDependencies` - affects ALL tasks, use for truly shared config
|
||||
- Task-level `env` / `inputs` - use when only specific tasks need it
|
||||
|
||||
### NOT an Anti-Pattern: Large `env` Arrays
|
||||
|
||||
A large `env` array (even 50+ variables) is **not** a problem. It usually means the user was thorough about declaring their build's environment dependencies. Do not flag this as an issue.
|
||||
|
||||
### Using `--parallel` Flag
|
||||
|
||||
The `--parallel` flag bypasses Turborepo's dependency graph. If tasks need parallel execution, configure `dependsOn` correctly instead.
|
||||
|
||||
```bash
|
||||
# WRONG - bypasses dependency graph
|
||||
turbo run lint --parallel
|
||||
|
||||
# CORRECT - configure tasks to allow parallel execution
|
||||
# In turbo.json, set dependsOn appropriately (or use transit nodes)
|
||||
turbo run lint
|
||||
```
|
||||
|
||||
### Package-Specific Task Overrides in Root turbo.json
|
||||
|
||||
When multiple packages need different task configurations, use **Package Configurations** (`turbo.json` in each package) instead of cluttering root `turbo.json` with `package#task` overrides.
|
||||
|
||||
```json
|
||||
// WRONG - root turbo.json with many package-specific overrides
|
||||
{
|
||||
"tasks": {
|
||||
"test": { "dependsOn": ["build"] },
|
||||
"@repo/web#test": { "outputs": ["coverage/**"] },
|
||||
"@repo/api#test": { "outputs": ["coverage/**"] },
|
||||
"@repo/utils#test": { "outputs": [] },
|
||||
"@repo/cli#test": { "outputs": [] },
|
||||
"@repo/core#test": { "outputs": [] }
|
||||
}
|
||||
}
|
||||
|
||||
// CORRECT - use Package Configurations
|
||||
// Root turbo.json - base config only
|
||||
{
|
||||
"tasks": {
|
||||
"test": { "dependsOn": ["build"] }
|
||||
}
|
||||
}
|
||||
|
||||
// packages/web/turbo.json - package-specific override
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tasks": {
|
||||
"test": { "outputs": ["coverage/**"] }
|
||||
}
|
||||
}
|
||||
|
||||
// packages/api/turbo.json
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tasks": {
|
||||
"test": { "outputs": ["coverage/**"] }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits of Package Configurations:**
|
||||
|
||||
- Keeps configuration close to the code it affects
|
||||
- Root turbo.json stays clean and focused on base patterns
|
||||
- Easier to understand what's special about each package
|
||||
- Works with `$TURBO_EXTENDS$` to inherit + extend arrays
|
||||
|
||||
**When to use `package#task` in root:**
|
||||
|
||||
- Single package needs a unique dependency (e.g., `"deploy": { "dependsOn": ["web#build"] }`)
|
||||
- Temporary override while migrating
|
||||
|
||||
See `references/configuration/RULE.md#package-configurations` for full details.
|
||||
|
||||
### Using `../` to Traverse Out of Package in `inputs`
|
||||
|
||||
Don't use relative paths like `../` to reference files outside the package. Use `$TURBO_ROOT$` instead.
|
||||
|
||||
```json
|
||||
// WRONG - traversing out of package
|
||||
{
|
||||
"tasks": {
|
||||
"build": {
|
||||
"inputs": ["$TURBO_DEFAULT$", "../shared-config.json"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CORRECT - use $TURBO_ROOT$ for repo root
|
||||
{
|
||||
"tasks": {
|
||||
"build": {
|
||||
"inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/shared-config.json"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Missing `outputs` for File-Producing Tasks
|
||||
|
||||
**Before flagging missing `outputs`, check what the task actually produces:**
|
||||
|
||||
1. Read the package's script (e.g., `"build": "tsc"`, `"test": "vitest"`)
|
||||
2. Determine if it writes files to disk or only outputs to stdout
|
||||
3. Only flag if the task produces files that should be cached
|
||||
|
||||
```json
|
||||
// WRONG: build produces files but they're not cached
|
||||
{
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CORRECT: build outputs are cached
|
||||
{
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Common outputs by framework:
|
||||
|
||||
- Next.js: `[".next/**", "!.next/cache/**"]`
|
||||
- Vite/Rollup: `["dist/**"]`
|
||||
- tsc: `["dist/**"]` or custom `outDir`
|
||||
|
||||
**TypeScript `--noEmit` can still produce cache files:**
|
||||
|
||||
When `incremental: true` in tsconfig.json, `tsc --noEmit` writes `.tsbuildinfo` files even without emitting JS. Check the tsconfig before assuming no outputs:
|
||||
|
||||
```json
|
||||
// If tsconfig has incremental: true, tsc --noEmit produces cache files
|
||||
{
|
||||
"tasks": {
|
||||
"typecheck": {
|
||||
"outputs": ["node_modules/.cache/tsbuildinfo.json"] // or wherever tsBuildInfoFile points
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To determine correct outputs for TypeScript tasks:
|
||||
|
||||
1. Check if `incremental` or `composite` is enabled in tsconfig
|
||||
2. Check `tsBuildInfoFile` for custom cache location (default: alongside `outDir` or in project root)
|
||||
3. If no incremental mode, `tsc --noEmit` produces no files
|
||||
|
||||
### `^build` vs `build` Confusion
|
||||
|
||||
```json
|
||||
{
|
||||
"tasks": {
|
||||
// ^build = run build in DEPENDENCIES first (other packages this one imports)
|
||||
"build": {
|
||||
"dependsOn": ["^build"]
|
||||
},
|
||||
// build (no ^) = run build in SAME PACKAGE first
|
||||
"test": {
|
||||
"dependsOn": ["build"]
|
||||
},
|
||||
// pkg#task = specific package's task
|
||||
"deploy": {
|
||||
"dependsOn": ["web#build"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Environment Variables Not Hashed
|
||||
|
||||
```json
|
||||
// WRONG: API_URL changes won't cause rebuilds
|
||||
{
|
||||
"tasks": {
|
||||
"build": {
|
||||
"outputs": ["dist/**"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CORRECT: API_URL changes invalidate cache
|
||||
{
|
||||
"tasks": {
|
||||
"build": {
|
||||
"outputs": ["dist/**"],
|
||||
"env": ["API_URL", "API_KEY"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `.env` Files Not in Inputs
|
||||
|
||||
Turbo does NOT load `.env` files - your framework does. But Turbo needs to know about changes:
|
||||
|
||||
```json
|
||||
// WRONG: .env changes don't invalidate cache
|
||||
{
|
||||
"tasks": {
|
||||
"build": {
|
||||
"env": ["API_URL"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CORRECT: .env file changes invalidate cache
|
||||
{
|
||||
"tasks": {
|
||||
"build": {
|
||||
"env": ["API_URL"],
|
||||
"inputs": ["$TURBO_DEFAULT$", ".env", ".env.*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Root `.env` File in Monorepo
|
||||
|
||||
A `.env` file at the repo root is an anti-pattern — even for small monorepos or starter templates. It creates implicit coupling between packages and makes it unclear which packages depend on which variables.
|
||||
|
||||
```
|
||||
// WRONG - root .env affects all packages implicitly
|
||||
my-monorepo/
|
||||
├── .env # Which packages use this?
|
||||
├── apps/
|
||||
│ ├── web/
|
||||
│ └── api/
|
||||
└── packages/
|
||||
|
||||
// CORRECT - .env files in packages that need them
|
||||
my-monorepo/
|
||||
├── apps/
|
||||
│ ├── web/
|
||||
│ │ └── .env # Clear: web needs DATABASE_URL
|
||||
│ └── api/
|
||||
│ └── .env # Clear: api needs API_KEY
|
||||
└── packages/
|
||||
```
|
||||
|
||||
**Problems with root `.env`:**
|
||||
|
||||
- Unclear which packages consume which variables
|
||||
- All packages get all variables (even ones they don't need)
|
||||
- Cache invalidation is coarse-grained (root .env change invalidates everything)
|
||||
- Security risk: packages may accidentally access sensitive vars meant for others
|
||||
- Bad habits start small — starter templates should model correct patterns
|
||||
|
||||
**If you must share variables**, use `globalEnv` to be explicit about what's shared, and document why.
|
||||
|
||||
### Strict Mode Filtering CI Variables
|
||||
|
||||
By default, Turborepo filters environment variables to only those in `env`/`globalEnv`. CI variables may be missing:
|
||||
|
||||
```json
|
||||
// If CI scripts need GITHUB_TOKEN but it's not in env:
|
||||
{
|
||||
"globalPassThroughEnv": ["GITHUB_TOKEN", "CI"],
|
||||
"tasks": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
Or use `--env-mode=loose` (not recommended for production).
|
||||
|
||||
### Shared Code in Apps (Should Be a Package)
|
||||
|
||||
```
|
||||
// WRONG: Shared code inside an app
|
||||
apps/
|
||||
web/
|
||||
shared/ # This breaks monorepo principles!
|
||||
utils.ts
|
||||
|
||||
// CORRECT: Extract to a package
|
||||
packages/
|
||||
utils/
|
||||
src/utils.ts
|
||||
```
|
||||
|
||||
### Accessing Files Across Package Boundaries
|
||||
|
||||
```typescript
|
||||
// WRONG: Reaching into another package's internals
|
||||
import { Button } from "../../packages/ui/src/button";
|
||||
|
||||
// CORRECT: Install and import properly
|
||||
import { Button } from "@repo/ui/button";
|
||||
```
|
||||
|
||||
### Too Many Root Dependencies
|
||||
|
||||
```json
|
||||
// WRONG: App dependencies in root
|
||||
{
|
||||
"dependencies": {
|
||||
"react": "^18",
|
||||
"next": "^14"
|
||||
}
|
||||
}
|
||||
|
||||
// CORRECT: Only repo tools in root
|
||||
{
|
||||
"devDependencies": {
|
||||
"turbo": "latest"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Task Configurations
|
||||
|
||||
### Standard Build Pipeline
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://v2-8-21-canary-9.turborepo.dev/schema.json",
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
|
||||
},
|
||||
"dev": {
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add a `transit` task if you have tasks that need parallel execution with cache invalidation (see below).
|
||||
|
||||
### Dev Task with `^dev` Pattern (for `turbo watch`)
|
||||
|
||||
A `dev` task with `dependsOn: ["^dev"]` and `persistent: false` in root turbo.json may look unusual but is **correct for `turbo watch` workflows**:
|
||||
|
||||
```json
|
||||
// Root turbo.json
|
||||
{
|
||||
"tasks": {
|
||||
"dev": {
|
||||
"dependsOn": ["^dev"],
|
||||
"cache": false,
|
||||
"persistent": false // Packages have one-shot dev scripts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Package turbo.json (apps/web/turbo.json)
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tasks": {
|
||||
"dev": {
|
||||
"persistent": true // Apps run long-running dev servers
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why this works:**
|
||||
|
||||
- **Packages** (e.g., `@acme/db`, `@acme/validators`) have `"dev": "tsc"` — one-shot type generation that completes quickly
|
||||
- **Apps** override with `persistent: true` for actual dev servers (Next.js, etc.)
|
||||
- **`turbo watch`** re-runs the one-shot package `dev` scripts when source files change, keeping types in sync
|
||||
|
||||
**Intended usage:** Run `turbo watch dev` (not `turbo run dev`). Watch mode re-executes one-shot tasks on file changes while keeping persistent tasks running.
|
||||
|
||||
**Alternative pattern:** Use a separate task name like `prepare` or `generate` for one-shot dependency builds to make the intent clearer:
|
||||
|
||||
```json
|
||||
{
|
||||
"tasks": {
|
||||
"prepare": {
|
||||
"dependsOn": ["^prepare"],
|
||||
"outputs": ["dist/**"]
|
||||
},
|
||||
"dev": {
|
||||
"dependsOn": ["prepare"],
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Transit Nodes for Parallel Tasks with Cache Invalidation
|
||||
|
||||
Some tasks can run in parallel (don't need built output from dependencies) but must invalidate cache when dependency source code changes.
|
||||
|
||||
**The problem with `dependsOn: ["^taskname"]`:**
|
||||
|
||||
- Forces sequential execution (slow)
|
||||
|
||||
**The problem with `dependsOn: []` (no dependencies):**
|
||||
|
||||
- Allows parallel execution (fast)
|
||||
- But cache is INCORRECT - changing dependency source won't invalidate cache
|
||||
|
||||
**Transit Nodes solve both:**
|
||||
|
||||
```json
|
||||
{
|
||||
"tasks": {
|
||||
"transit": { "dependsOn": ["^transit"] },
|
||||
"my-task": { "dependsOn": ["transit"] }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `transit` task creates dependency relationships without matching any actual script, so tasks run in parallel with correct cache invalidation.
|
||||
|
||||
**How to identify tasks that need this pattern:** Look for tasks that read source files from dependencies but don't need their build outputs.
|
||||
|
||||
### With Environment Variables
|
||||
|
||||
```json
|
||||
{
|
||||
"globalEnv": ["NODE_ENV"],
|
||||
"globalDependencies": [".env"],
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**"],
|
||||
"env": ["API_URL", "DATABASE_URL"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
With `futureFlags.globalConfiguration`, the same config moves global settings under `global` — and `.env` becomes a per-task input instead of a global hash input:
|
||||
|
||||
```json
|
||||
{
|
||||
"futureFlags": { "globalConfiguration": true },
|
||||
"global": {
|
||||
"env": ["NODE_ENV"],
|
||||
"inputs": [".env"]
|
||||
},
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**"],
|
||||
"env": ["API_URL", "DATABASE_URL"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Reference Index
|
||||
|
||||
### Configuration
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
||||
| [configuration/RULE.md](./references/configuration/RULE.md) | turbo.json overview, Package Configurations |
|
||||
| [configuration/tasks.md](./references/configuration/tasks.md) | dependsOn, outputs, inputs, env, cache, persistent |
|
||||
| [configuration/global-options.md](./references/configuration/global-options.md) | globalEnv, globalDependencies, global key, futureFlags, cacheDir, envMode |
|
||||
| [configuration/gotchas.md](./references/configuration/gotchas.md) | Common configuration mistakes |
|
||||
|
||||
### Caching
|
||||
|
||||
| File | Purpose |
|
||||
| --------------------------------------------------------------- | -------------------------------------------- |
|
||||
| [caching/RULE.md](./references/caching/RULE.md) | How caching works, hash inputs |
|
||||
| [caching/remote-cache.md](./references/caching/remote-cache.md) | Vercel Remote Cache, self-hosted, login/link |
|
||||
| [caching/gotchas.md](./references/caching/gotchas.md) | Debugging cache misses, --summarize, --dry |
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------------------------------------------------- | ----------------------------------------- |
|
||||
| [environment/RULE.md](./references/environment/RULE.md) | env, globalEnv, passThroughEnv |
|
||||
| [environment/modes.md](./references/environment/modes.md) | Strict vs Loose mode, framework inference |
|
||||
| [environment/gotchas.md](./references/environment/gotchas.md) | .env files, CI issues |
|
||||
|
||||
### Filtering
|
||||
|
||||
| File | Purpose |
|
||||
| ----------------------------------------------------------- | ------------------------ |
|
||||
| [filtering/RULE.md](./references/filtering/RULE.md) | --filter syntax overview |
|
||||
| [filtering/patterns.md](./references/filtering/patterns.md) | Common filter patterns |
|
||||
|
||||
### CI/CD
|
||||
|
||||
| File | Purpose |
|
||||
| --------------------------------------------------------- | ------------------------------- |
|
||||
| [ci/RULE.md](./references/ci/RULE.md) | General CI principles |
|
||||
| [ci/github-actions.md](./references/ci/github-actions.md) | Complete GitHub Actions setup |
|
||||
| [ci/vercel.md](./references/ci/vercel.md) | Vercel deployment, turbo-ignore |
|
||||
| [ci/patterns.md](./references/ci/patterns.md) | --affected, caching strategies |
|
||||
|
||||
### CLI
|
||||
|
||||
| File | Purpose |
|
||||
| ----------------------------------------------- | --------------------------------------------- |
|
||||
| [cli/RULE.md](./references/cli/RULE.md) | turbo run basics |
|
||||
| [cli/commands.md](./references/cli/commands.md) | turbo run flags, turbo-ignore, other commands |
|
||||
|
||||
### Best Practices
|
||||
|
||||
| File | Purpose |
|
||||
| ----------------------------------------------------------------------------- | --------------------------------------------------------------- |
|
||||
| [best-practices/RULE.md](./references/best-practices/RULE.md) | Monorepo best practices overview |
|
||||
| [best-practices/structure.md](./references/best-practices/structure.md) | Repository structure, workspace config, TypeScript/ESLint setup |
|
||||
| [best-practices/packages.md](./references/best-practices/packages.md) | Creating internal packages, JIT vs Compiled, exports |
|
||||
| [best-practices/dependencies.md](./references/best-practices/dependencies.md) | Dependency management, installing, version sync |
|
||||
|
||||
### Watch Mode
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------------------------------- | ----------------------------------------------- |
|
||||
| [watch/RULE.md](./references/watch/RULE.md) | turbo watch, interruptible tasks, dev workflows |
|
||||
|
||||
### Boundaries (Experimental)
|
||||
|
||||
| File | Purpose |
|
||||
| ----------------------------------------------------- | ----------------------------------------------------- |
|
||||
| [boundaries/RULE.md](./references/boundaries/RULE.md) | Enforce package isolation, tag-based dependency rules |
|
||||
|
||||
## Source Documentation
|
||||
|
||||
This skill is based on the official Turborepo documentation at:
|
||||
|
||||
- Source: `apps/docs/content/docs/` in the Turborepo repository
|
||||
- Live: https://turborepo.dev/docs
|
||||
@@ -1,70 +0,0 @@
|
||||
---
|
||||
description: Load Turborepo skill for creating workflows, tasks, and pipelines in monorepos. Use when users ask to "create a workflow", "make a task", "generate a pipeline", or set up build orchestration.
|
||||
---
|
||||
|
||||
Load the Turborepo skill and help with monorepo task orchestration: creating workflows, configuring tasks, setting up pipelines, and optimizing builds.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Load turborepo skill
|
||||
|
||||
```
|
||||
skill({ name: 'turborepo' })
|
||||
```
|
||||
|
||||
### Step 2: Identify task type from user request
|
||||
|
||||
Analyze $ARGUMENTS to determine:
|
||||
|
||||
- **Topic**: configuration, caching, filtering, environment, CI, or CLI
|
||||
- **Task type**: new setup, debugging, optimization, or implementation
|
||||
|
||||
Use decision trees in SKILL.md to select the relevant reference files.
|
||||
|
||||
### Step 3: Read relevant reference files
|
||||
|
||||
Based on task type, read from `references/<topic>/`:
|
||||
|
||||
| Task | Files to Read |
|
||||
| -------------------- | ------------------------------------------------------- |
|
||||
| Configure turbo.json | `configuration/RULE.md` + `configuration/tasks.md` |
|
||||
| Debug cache issues | `caching/gotchas.md` |
|
||||
| Set up remote cache | `caching/remote-cache.md` |
|
||||
| Filter packages | `filtering/RULE.md` + `filtering/patterns.md` |
|
||||
| Environment problems | `environment/gotchas.md` + `environment/modes.md` |
|
||||
| Set up CI | `ci/RULE.md` + `ci/github-actions.md` or `ci/vercel.md` |
|
||||
| CLI usage | `cli/commands.md` |
|
||||
|
||||
### Step 4: Execute task
|
||||
|
||||
Apply Turborepo-specific patterns from references to complete the user's request.
|
||||
|
||||
**CRITICAL - When creating tasks/scripts/pipelines:**
|
||||
|
||||
1. **DO NOT create Root Tasks** - Always create package tasks
|
||||
2. Add scripts to each relevant package's `package.json` (e.g., `apps/web/package.json`, `packages/ui/package.json`)
|
||||
3. Register the task in root `turbo.json`
|
||||
4. Root `package.json` only contains `turbo run <task>` - never actual task logic
|
||||
|
||||
**Other things to verify:**
|
||||
|
||||
- `outputs` defined for cacheable tasks
|
||||
- `dependsOn` uses correct syntax (`^task` vs `task`)
|
||||
- Environment variables in `env` key
|
||||
- `.env` files in `inputs` if used
|
||||
- Use `turbo run` (not `turbo`) in package.json and CI
|
||||
|
||||
### Step 5: Summarize
|
||||
|
||||
```
|
||||
=== Turborepo Task Complete ===
|
||||
|
||||
Topic: <configuration|caching|filtering|environment|ci|cli>
|
||||
Files referenced: <reference files consulted>
|
||||
|
||||
<brief summary of what was done>
|
||||
```
|
||||
|
||||
<user-request>
|
||||
$ARGUMENTS
|
||||
</user-request>
|
||||
@@ -1,241 +0,0 @@
|
||||
# Monorepo Best Practices
|
||||
|
||||
Essential patterns for structuring and maintaining a healthy Turborepo monorepo.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
### Standard Layout
|
||||
|
||||
```
|
||||
my-monorepo/
|
||||
├── apps/ # Application packages (deployable)
|
||||
│ ├── web/
|
||||
│ ├── docs/
|
||||
│ └── api/
|
||||
├── packages/ # Library packages (shared code)
|
||||
│ ├── ui/
|
||||
│ ├── utils/
|
||||
│ └── config-*/ # Shared configs (eslint, typescript, etc.)
|
||||
├── package.json # Root package.json (minimal deps)
|
||||
├── turbo.json # Turborepo configuration
|
||||
├── pnpm-workspace.yaml # (pnpm) or workspaces in package.json
|
||||
└── pnpm-lock.yaml # Lockfile (required)
|
||||
```
|
||||
|
||||
### Key Principles
|
||||
|
||||
1. **`apps/` for deployables**: Next.js sites, APIs, CLIs - things that get deployed
|
||||
2. **`packages/` for libraries**: Shared code consumed by apps or other packages
|
||||
3. **One purpose per package**: Each package should do one thing well
|
||||
4. **No nested packages**: Don't put packages inside packages
|
||||
|
||||
## Package Types
|
||||
|
||||
### Application Packages (`apps/`)
|
||||
|
||||
- **Deployable**: These are the "endpoints" of your package graph
|
||||
- **Not installed by other packages**: Apps shouldn't be dependencies of other packages
|
||||
- **No shared code**: If code needs sharing, extract to `packages/`
|
||||
|
||||
```json
|
||||
// apps/web/package.json
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@repo/ui": "workspace:*",
|
||||
"next": "latest"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Library Packages (`packages/`)
|
||||
|
||||
- **Shared code**: Utilities, components, configs
|
||||
- **Namespaced names**: Use `@repo/` or `@yourorg/` prefix
|
||||
- **Clear exports**: Define what the package exposes
|
||||
|
||||
```json
|
||||
// packages/ui/package.json
|
||||
{
|
||||
"name": "@repo/ui",
|
||||
"exports": {
|
||||
"./button": "./src/button.tsx",
|
||||
"./card": "./src/card.tsx"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Package Compilation Strategies
|
||||
|
||||
### Just-in-Time (Simplest)
|
||||
|
||||
Export TypeScript directly; let the app's bundler compile it.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@repo/ui",
|
||||
"exports": {
|
||||
"./button": "./src/button.tsx"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Pros**: Zero build config, instant changes
|
||||
**Cons**: Can't cache builds, requires app bundler support
|
||||
|
||||
### Compiled (Recommended for Libraries)
|
||||
|
||||
Package compiles itself with `tsc` or bundler.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@repo/ui",
|
||||
"exports": {
|
||||
"./button": {
|
||||
"types": "./src/button.tsx",
|
||||
"default": "./dist/button.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Pros**: Cacheable by Turborepo, works everywhere
|
||||
**Cons**: More configuration
|
||||
|
||||
## Dependency Management
|
||||
|
||||
### Install Where Used
|
||||
|
||||
Install dependencies in the package that uses them, not the root.
|
||||
|
||||
```bash
|
||||
# Good: Install in the package that needs it
|
||||
pnpm add lodash --filter=@repo/utils
|
||||
|
||||
# Avoid: Installing everything at root
|
||||
pnpm add lodash -w # Only for repo-level tools
|
||||
```
|
||||
|
||||
### Root Dependencies
|
||||
|
||||
Only these belong in root `package.json`:
|
||||
|
||||
- `turbo` - The build system
|
||||
- `husky`, `lint-staged` - Git hooks
|
||||
- Repository-level tooling
|
||||
|
||||
### Internal Dependencies
|
||||
|
||||
Use workspace protocol for internal packages:
|
||||
|
||||
```json
|
||||
// pnpm/bun
|
||||
{ "@repo/ui": "workspace:*" }
|
||||
|
||||
// npm/yarn
|
||||
{ "@repo/ui": "*" }
|
||||
```
|
||||
|
||||
## Exports Best Practices
|
||||
|
||||
### Use `exports` Field (Not `main`)
|
||||
|
||||
```json
|
||||
{
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./button": "./src/button.tsx",
|
||||
"./utils": "./src/utils.ts"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Avoid Barrel Files
|
||||
|
||||
Don't create `index.ts` files that re-export everything:
|
||||
|
||||
```typescript
|
||||
// BAD: packages/ui/src/index.ts
|
||||
export * from './button';
|
||||
export * from './card';
|
||||
export * from './modal';
|
||||
// ... imports everything even if you need one thing
|
||||
|
||||
// GOOD: Direct exports in package.json
|
||||
{
|
||||
"exports": {
|
||||
"./button": "./src/button.tsx",
|
||||
"./card": "./src/card.tsx"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Namespace Your Packages
|
||||
|
||||
```json
|
||||
// Good
|
||||
{ "name": "@repo/ui" }
|
||||
{ "name": "@acme/utils" }
|
||||
|
||||
// Avoid (conflicts with npm registry)
|
||||
{ "name": "ui" }
|
||||
{ "name": "utils" }
|
||||
```
|
||||
|
||||
## Common Anti-Patterns
|
||||
|
||||
### Accessing Files Across Package Boundaries
|
||||
|
||||
```typescript
|
||||
// BAD: Reaching into another package
|
||||
import { Button } from "../../packages/ui/src/button";
|
||||
|
||||
// GOOD: Install and import properly
|
||||
import { Button } from "@repo/ui/button";
|
||||
```
|
||||
|
||||
### Shared Code in Apps
|
||||
|
||||
```
|
||||
// BAD
|
||||
apps/
|
||||
web/
|
||||
shared/ # This should be a package!
|
||||
utils.ts
|
||||
|
||||
// GOOD
|
||||
packages/
|
||||
utils/ # Proper shared package
|
||||
src/utils.ts
|
||||
```
|
||||
|
||||
### Too Many Root Dependencies
|
||||
|
||||
```json
|
||||
// BAD: Root has app dependencies
|
||||
{
|
||||
"dependencies": {
|
||||
"react": "^18",
|
||||
"next": "^14",
|
||||
"lodash": "^4"
|
||||
}
|
||||
}
|
||||
|
||||
// GOOD: Root only has repo tools
|
||||
{
|
||||
"devDependencies": {
|
||||
"turbo": "latest",
|
||||
"husky": "latest"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
- [structure.md](./structure.md) - Detailed repository structure patterns
|
||||
- [packages.md](./packages.md) - Creating and managing internal packages
|
||||
- [dependencies.md](./dependencies.md) - Dependency management strategies
|
||||
@@ -1,246 +0,0 @@
|
||||
# Dependency Management
|
||||
|
||||
Best practices for managing dependencies in a Turborepo monorepo.
|
||||
|
||||
## Core Principle: Install Where Used
|
||||
|
||||
Dependencies belong in the package that uses them, not the root.
|
||||
|
||||
```bash
|
||||
# Good: Install in specific package
|
||||
pnpm add react --filter=@repo/ui
|
||||
pnpm add next --filter=web
|
||||
|
||||
# Avoid: Installing in root
|
||||
pnpm add react -w # Only for repo-level tools!
|
||||
```
|
||||
|
||||
## Benefits of Local Installation
|
||||
|
||||
### 1. Clarity
|
||||
|
||||
Each package's `package.json` lists exactly what it needs:
|
||||
|
||||
```json
|
||||
// packages/ui/package.json
|
||||
{
|
||||
"dependencies": {
|
||||
"react": "^18.0.0",
|
||||
"class-variance-authority": "^0.7.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Flexibility
|
||||
|
||||
Different packages can use different versions when needed:
|
||||
|
||||
```json
|
||||
// packages/legacy-ui/package.json
|
||||
{ "dependencies": { "react": "^17.0.0" } }
|
||||
|
||||
// packages/ui/package.json
|
||||
{ "dependencies": { "react": "^18.0.0" } }
|
||||
```
|
||||
|
||||
### 3. Better Caching
|
||||
|
||||
Installing in root changes workspace lockfile, invalidating all caches.
|
||||
|
||||
### 4. Pruning Support
|
||||
|
||||
`turbo prune` can remove unused dependencies for Docker images.
|
||||
|
||||
## What Belongs in Root
|
||||
|
||||
Only repository-level tools:
|
||||
|
||||
```json
|
||||
// Root package.json
|
||||
{
|
||||
"devDependencies": {
|
||||
"turbo": "latest",
|
||||
"husky": "^8.0.0",
|
||||
"lint-staged": "^15.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**NOT** application dependencies:
|
||||
|
||||
- react, next, express
|
||||
- lodash, axios, zod
|
||||
- Testing libraries (unless truly repo-wide)
|
||||
|
||||
## Installing Dependencies
|
||||
|
||||
### Single Package
|
||||
|
||||
```bash
|
||||
# pnpm
|
||||
pnpm add lodash --filter=@repo/utils
|
||||
|
||||
# npm
|
||||
npm install lodash --workspace=@repo/utils
|
||||
|
||||
# yarn
|
||||
yarn workspace @repo/utils add lodash
|
||||
|
||||
# bun
|
||||
cd packages/utils && bun add lodash
|
||||
```
|
||||
|
||||
### Multiple Packages
|
||||
|
||||
```bash
|
||||
# pnpm
|
||||
pnpm add jest --save-dev --filter=web --filter=@repo/ui
|
||||
|
||||
# npm
|
||||
npm install jest --save-dev --workspace=web --workspace=@repo/ui
|
||||
|
||||
# yarn (v2+)
|
||||
yarn workspaces foreach -R --from '{web,@repo/ui}' add jest --dev
|
||||
```
|
||||
|
||||
### Internal Packages
|
||||
|
||||
```bash
|
||||
# pnpm
|
||||
pnpm add @repo/ui --filter=web
|
||||
|
||||
# This updates package.json:
|
||||
{
|
||||
"dependencies": {
|
||||
"@repo/ui": "workspace:*"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Keeping Versions in Sync
|
||||
|
||||
### Option 1: Tooling
|
||||
|
||||
```bash
|
||||
# syncpack - Check and fix version mismatches
|
||||
npx syncpack list-mismatches
|
||||
npx syncpack fix-mismatches
|
||||
|
||||
# manypkg - Similar functionality
|
||||
npx @manypkg/cli check
|
||||
npx @manypkg/cli fix
|
||||
|
||||
# sherif - Rust-based, very fast
|
||||
npx sherif
|
||||
```
|
||||
|
||||
### Option 2: Package Manager Commands
|
||||
|
||||
```bash
|
||||
# pnpm - Update everywhere
|
||||
pnpm up --recursive typescript@latest
|
||||
|
||||
# npm - Update in all workspaces
|
||||
npm install typescript@latest --workspaces
|
||||
```
|
||||
|
||||
### Option 3: pnpm Catalogs (pnpm 9.5+)
|
||||
|
||||
```yaml
|
||||
# pnpm-workspace.yaml
|
||||
packages:
|
||||
- "apps/*"
|
||||
- "packages/*"
|
||||
|
||||
catalog:
|
||||
react: ^18.2.0
|
||||
typescript: ^5.3.0
|
||||
```
|
||||
|
||||
```json
|
||||
// Any package.json
|
||||
{
|
||||
"dependencies": {
|
||||
"react": "catalog:" // Uses version from catalog
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Internal vs External Dependencies
|
||||
|
||||
### Internal (Workspace)
|
||||
|
||||
```json
|
||||
// pnpm/bun
|
||||
{ "@repo/ui": "workspace:*" }
|
||||
|
||||
// npm/yarn
|
||||
{ "@repo/ui": "*" }
|
||||
```
|
||||
|
||||
Turborepo understands these relationships and orders builds accordingly.
|
||||
|
||||
### External (npm Registry)
|
||||
|
||||
```json
|
||||
{ "lodash": "^4.17.21" }
|
||||
```
|
||||
|
||||
Standard semver versioning from npm.
|
||||
|
||||
## Peer Dependencies
|
||||
|
||||
For library packages that expect the consumer to provide dependencies:
|
||||
|
||||
```json
|
||||
// packages/ui/package.json
|
||||
{
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"react": "^18.0.0", // For development/testing
|
||||
"react-dom": "^18.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Issues
|
||||
|
||||
### "Module not found"
|
||||
|
||||
1. Check the dependency is installed in the right package
|
||||
2. Run `pnpm install` / `npm install` to update lockfile
|
||||
3. Check exports are defined in the package
|
||||
|
||||
### Version Conflicts
|
||||
|
||||
Packages can use different versions - this is a feature, not a bug. But if you need consistency:
|
||||
|
||||
1. Use tooling (syncpack, manypkg)
|
||||
2. Use pnpm catalogs
|
||||
3. Create a lint rule
|
||||
|
||||
### Hoisting Issues
|
||||
|
||||
Some tools expect dependencies in specific locations. Use package manager config:
|
||||
|
||||
```yaml
|
||||
# .npmrc (pnpm)
|
||||
public-hoist-pattern[]=*eslint*
|
||||
public-hoist-pattern[]=*prettier*
|
||||
```
|
||||
|
||||
## Lockfile
|
||||
|
||||
**Required** for:
|
||||
|
||||
- Reproducible builds
|
||||
- Turborepo dependency analysis
|
||||
- Cache correctness
|
||||
|
||||
```bash
|
||||
# Commit your lockfile!
|
||||
git add pnpm-lock.yaml # or package-lock.json, yarn.lock
|
||||
```
|
||||
@@ -1,335 +0,0 @@
|
||||
# Creating Internal Packages
|
||||
|
||||
How to create and structure internal packages in your monorepo.
|
||||
|
||||
## Package Creation Checklist
|
||||
|
||||
1. Create directory in `packages/`
|
||||
2. Add `package.json` with name and exports
|
||||
3. Add source code in `src/`
|
||||
4. Add `tsconfig.json` if using TypeScript
|
||||
5. Install as dependency in consuming packages
|
||||
6. Run package manager install to update lockfile
|
||||
|
||||
## Package Compilation Strategies
|
||||
|
||||
### Just-in-Time (JIT)
|
||||
|
||||
Export TypeScript directly. The consuming app's bundler compiles it.
|
||||
|
||||
```json
|
||||
// packages/ui/package.json
|
||||
{
|
||||
"name": "@repo/ui",
|
||||
"exports": {
|
||||
"./button": "./src/button.tsx",
|
||||
"./card": "./src/card.tsx"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint .",
|
||||
"check-types": "tsc --noEmit"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**When to use:**
|
||||
|
||||
- Apps use modern bundlers (Turbopack, webpack, Vite)
|
||||
- You want minimal configuration
|
||||
- Build times are acceptable without caching
|
||||
|
||||
**Limitations:**
|
||||
|
||||
- No Turborepo cache for the package itself
|
||||
- Consumer must support TypeScript compilation
|
||||
- Can't use TypeScript `paths` (use Node.js subpath imports instead)
|
||||
|
||||
### Compiled
|
||||
|
||||
Package handles its own compilation.
|
||||
|
||||
```json
|
||||
// packages/ui/package.json
|
||||
{
|
||||
"name": "@repo/ui",
|
||||
"exports": {
|
||||
"./button": {
|
||||
"types": "./src/button.tsx",
|
||||
"default": "./dist/button.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
// packages/ui/tsconfig.json
|
||||
{
|
||||
"extends": "@repo/typescript-config/library.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
```
|
||||
|
||||
**When to use:**
|
||||
|
||||
- You want Turborepo to cache builds
|
||||
- Package will be used by non-bundler tools
|
||||
- You need maximum compatibility
|
||||
|
||||
**Remember:** Add `dist/**` to turbo.json outputs!
|
||||
|
||||
## Defining Exports
|
||||
|
||||
### Multiple Entrypoints
|
||||
|
||||
```json
|
||||
{
|
||||
"exports": {
|
||||
".": "./src/index.ts", // @repo/ui
|
||||
"./button": "./src/button.tsx", // @repo/ui/button
|
||||
"./card": "./src/card.tsx", // @repo/ui/card
|
||||
"./hooks": "./src/hooks/index.ts" // @repo/ui/hooks
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Conditional Exports (Compiled)
|
||||
|
||||
```json
|
||||
{
|
||||
"exports": {
|
||||
"./button": {
|
||||
"types": "./src/button.tsx",
|
||||
"import": "./dist/button.mjs",
|
||||
"require": "./dist/button.cjs",
|
||||
"default": "./dist/button.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Installing Internal Packages
|
||||
|
||||
### Add to Consuming Package
|
||||
|
||||
```json
|
||||
// apps/web/package.json
|
||||
{
|
||||
"dependencies": {
|
||||
"@repo/ui": "workspace:*" // pnpm/bun
|
||||
// "@repo/ui": "*" // npm/yarn
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Run Install
|
||||
|
||||
```bash
|
||||
pnpm install # Updates lockfile with new dependency
|
||||
```
|
||||
|
||||
### Import and Use
|
||||
|
||||
```typescript
|
||||
// apps/web/src/page.tsx
|
||||
import { Button } from '@repo/ui/button';
|
||||
|
||||
export default function Page() {
|
||||
return <Button>Click me</Button>;
|
||||
}
|
||||
```
|
||||
|
||||
## One Purpose Per Package
|
||||
|
||||
### Good Examples
|
||||
|
||||
```
|
||||
packages/
|
||||
├── ui/ # Shared UI components
|
||||
├── utils/ # General utilities
|
||||
├── auth/ # Authentication logic
|
||||
├── database/ # Database client/schemas
|
||||
├── eslint-config/ # ESLint configuration
|
||||
├── typescript-config/ # TypeScript configuration
|
||||
└── api-client/ # Generated API client
|
||||
```
|
||||
|
||||
### Avoid Mega-Packages
|
||||
|
||||
```
|
||||
// BAD: One package for everything
|
||||
packages/
|
||||
└── shared/
|
||||
├── components/
|
||||
├── utils/
|
||||
├── hooks/
|
||||
├── types/
|
||||
└── api/
|
||||
|
||||
// GOOD: Separate by purpose
|
||||
packages/
|
||||
├── ui/ # Components
|
||||
├── utils/ # Utilities
|
||||
├── hooks/ # React hooks
|
||||
├── types/ # Shared TypeScript types
|
||||
└── api-client/ # API utilities
|
||||
```
|
||||
|
||||
## Config Packages
|
||||
|
||||
### TypeScript Config
|
||||
|
||||
```json
|
||||
// packages/typescript-config/package.json
|
||||
{
|
||||
"name": "@repo/typescript-config",
|
||||
"exports": {
|
||||
"./base.json": "./base.json",
|
||||
"./nextjs.json": "./nextjs.json",
|
||||
"./library.json": "./library.json"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ESLint Config
|
||||
|
||||
```json
|
||||
// packages/eslint-config/package.json
|
||||
{
|
||||
"name": "@repo/eslint-config",
|
||||
"exports": {
|
||||
"./base": "./base.js",
|
||||
"./next": "./next.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"eslint": "^8.0.0",
|
||||
"eslint-config-next": "latest"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### Forgetting to Export
|
||||
|
||||
```json
|
||||
// BAD: No exports defined
|
||||
{
|
||||
"name": "@repo/ui"
|
||||
}
|
||||
|
||||
// GOOD: Clear exports
|
||||
{
|
||||
"name": "@repo/ui",
|
||||
"exports": {
|
||||
"./button": "./src/button.tsx"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Wrong Workspace Syntax
|
||||
|
||||
```json
|
||||
// pnpm/bun
|
||||
{ "@repo/ui": "workspace:*" } // Correct
|
||||
|
||||
// npm/yarn
|
||||
{ "@repo/ui": "*" } // Correct
|
||||
{ "@repo/ui": "workspace:*" } // Wrong for npm/yarn!
|
||||
```
|
||||
|
||||
### Missing from turbo.json Outputs
|
||||
|
||||
```json
|
||||
// Package builds to dist/, but turbo.json doesn't know
|
||||
{
|
||||
"tasks": {
|
||||
"build": {
|
||||
"outputs": [".next/**"] // Missing dist/**!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Correct
|
||||
{
|
||||
"tasks": {
|
||||
"build": {
|
||||
"outputs": [".next/**", "dist/**"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## TypeScript Best Practices
|
||||
|
||||
### Use Node.js Subpath Imports (Not `paths`)
|
||||
|
||||
TypeScript `compilerOptions.paths` breaks with JIT packages. Use Node.js subpath imports instead (TypeScript 5.4+).
|
||||
|
||||
**JIT Package:**
|
||||
|
||||
```json
|
||||
// packages/ui/package.json
|
||||
{
|
||||
"imports": {
|
||||
"#*": "./src/*"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// packages/ui/button.tsx
|
||||
import { MY_STRING } from "#utils.ts"; // Uses .ts extension
|
||||
```
|
||||
|
||||
**Compiled Package:**
|
||||
|
||||
```json
|
||||
// packages/ui/package.json
|
||||
{
|
||||
"imports": {
|
||||
"#*": "./dist/*"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// packages/ui/button.tsx
|
||||
import { MY_STRING } from "#utils.js"; // Uses .js extension
|
||||
```
|
||||
|
||||
### Use `tsc` for Internal Packages
|
||||
|
||||
For internal packages, prefer `tsc` over bundlers. Bundlers can mangle code before it reaches your app's bundler, causing hard-to-debug issues.
|
||||
|
||||
### Enable Go-to-Definition
|
||||
|
||||
For Compiled Packages, enable declaration maps:
|
||||
|
||||
```json
|
||||
// tsconfig.json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"declarationMap": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This creates `.d.ts` and `.d.ts.map` files for IDE navigation.
|
||||
|
||||
### No Root tsconfig.json Needed
|
||||
|
||||
Each package should have its own `tsconfig.json`. A root one causes all tasks to miss cache when changed. Only use root `tsconfig.json` for non-package scripts.
|
||||
|
||||
### Avoid TypeScript Project References
|
||||
|
||||
They add complexity and another caching layer. Turborepo handles dependencies better.
|
||||
@@ -1,297 +0,0 @@
|
||||
# Repository Structure
|
||||
|
||||
Detailed guidance on structuring a Turborepo monorepo.
|
||||
|
||||
## Workspace Configuration
|
||||
|
||||
### pnpm (Recommended)
|
||||
|
||||
```yaml
|
||||
# pnpm-workspace.yaml
|
||||
packages:
|
||||
- "apps/*"
|
||||
- "packages/*"
|
||||
```
|
||||
|
||||
### npm/yarn/bun
|
||||
|
||||
```json
|
||||
// package.json
|
||||
{
|
||||
"workspaces": ["apps/*", "packages/*"]
|
||||
}
|
||||
```
|
||||
|
||||
## Root package.json
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-monorepo",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.0.0",
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev",
|
||||
"lint": "turbo run lint",
|
||||
"test": "turbo run test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"turbo": "latest"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Key points:
|
||||
|
||||
- `private: true` - Prevents accidental publishing
|
||||
- `packageManager` - Enforces consistent package manager version
|
||||
- **Scripts only delegate to `turbo run`** - No actual build logic here!
|
||||
- Minimal devDependencies (just turbo and repo tools)
|
||||
|
||||
## Always Prefer Package Tasks
|
||||
|
||||
**Always use package tasks. Only use Root Tasks if you cannot succeed with package tasks.**
|
||||
|
||||
```json
|
||||
// packages/web/package.json
|
||||
{
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
}
|
||||
}
|
||||
|
||||
// packages/api/package.json
|
||||
{
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Package tasks enable Turborepo to:
|
||||
|
||||
1. **Parallelize** - Run `web#lint` and `api#lint` simultaneously
|
||||
2. **Cache individually** - Each package's task output is cached separately
|
||||
3. **Filter precisely** - Run `turbo run test --filter=web` for just one package
|
||||
|
||||
**Root Tasks are a fallback** for tasks that truly cannot run per-package:
|
||||
|
||||
```json
|
||||
// AVOID unless necessary - sequential, not parallelized, can't filter
|
||||
{
|
||||
"scripts": {
|
||||
"lint": "eslint apps/web && eslint apps/api && eslint packages/ui"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Root turbo.json
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://v2-8-21-canary-9.turborepo.dev/schema.json",
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
|
||||
},
|
||||
"lint": {},
|
||||
"test": {
|
||||
"dependsOn": ["build"]
|
||||
},
|
||||
"dev": {
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
With `futureFlags.globalConfiguration`, global settings move under a `global` key:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://v2-8-21-canary-9.turborepo.dev/schema.json",
|
||||
"futureFlags": { "globalConfiguration": true },
|
||||
"global": {
|
||||
"inputs": ["tsconfig.json"],
|
||||
"env": ["CI"]
|
||||
},
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
|
||||
},
|
||||
"lint": {},
|
||||
"test": {
|
||||
"dependsOn": ["build"]
|
||||
},
|
||||
"dev": {
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Directory Organization
|
||||
|
||||
### Grouping Packages
|
||||
|
||||
You can group packages by adding more workspace paths:
|
||||
|
||||
```yaml
|
||||
# pnpm-workspace.yaml
|
||||
packages:
|
||||
- "apps/*"
|
||||
- "packages/*"
|
||||
- "packages/config/*" # Grouped configs
|
||||
- "packages/features/*" # Feature packages
|
||||
```
|
||||
|
||||
This allows:
|
||||
|
||||
```
|
||||
packages/
|
||||
├── ui/
|
||||
├── utils/
|
||||
├── config/
|
||||
│ ├── eslint/
|
||||
│ ├── typescript/
|
||||
│ └── tailwind/
|
||||
└── features/
|
||||
├── auth/
|
||||
└── payments/
|
||||
```
|
||||
|
||||
### What NOT to Do
|
||||
|
||||
```yaml
|
||||
# BAD: Nested wildcards cause ambiguous behavior
|
||||
packages:
|
||||
- "packages/**" # Don't do this!
|
||||
```
|
||||
|
||||
## Package Anatomy
|
||||
|
||||
### Minimum Required Files
|
||||
|
||||
```
|
||||
packages/ui/
|
||||
├── package.json # Required: Makes it a package
|
||||
├── src/ # Source code
|
||||
│ └── button.tsx
|
||||
└── tsconfig.json # TypeScript config (if using TS)
|
||||
```
|
||||
|
||||
### package.json Requirements
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@repo/ui", // Unique, namespaced name
|
||||
"version": "0.0.0", // Version (can be 0.0.0 for internal)
|
||||
"private": true, // Prevents accidental publishing
|
||||
"exports": {
|
||||
// Entry points
|
||||
"./button": "./src/button.tsx"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## TypeScript Configuration
|
||||
|
||||
### Shared Base Config
|
||||
|
||||
Create a shared TypeScript config package:
|
||||
|
||||
```
|
||||
packages/
|
||||
└── typescript-config/
|
||||
├── package.json
|
||||
├── base.json
|
||||
├── nextjs.json
|
||||
└── library.json
|
||||
```
|
||||
|
||||
```json
|
||||
// packages/typescript-config/base.json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"module": "ESNext",
|
||||
"target": "ES2022"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Extending in Packages
|
||||
|
||||
```json
|
||||
// packages/ui/tsconfig.json
|
||||
{
|
||||
"extends": "@repo/typescript-config/library.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
```
|
||||
|
||||
### No Root tsconfig.json
|
||||
|
||||
You likely don't need a `tsconfig.json` in the workspace root. Each package should have its own config extending from the shared config package.
|
||||
|
||||
## ESLint Configuration
|
||||
|
||||
### Shared Config Package
|
||||
|
||||
```
|
||||
packages/
|
||||
└── eslint-config/
|
||||
├── package.json
|
||||
├── base.js
|
||||
├── next.js
|
||||
└── library.js
|
||||
```
|
||||
|
||||
```json
|
||||
// packages/eslint-config/package.json
|
||||
{
|
||||
"name": "@repo/eslint-config",
|
||||
"exports": {
|
||||
"./base": "./base.js",
|
||||
"./next": "./next.js",
|
||||
"./library": "./library.js"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Using in Packages
|
||||
|
||||
```js
|
||||
// apps/web/.eslintrc.js
|
||||
module.exports = {
|
||||
extends: ["@repo/eslint-config/next"]
|
||||
};
|
||||
```
|
||||
|
||||
## Lockfile
|
||||
|
||||
A lockfile is **required** for:
|
||||
|
||||
- Reproducible builds
|
||||
- Turborepo to understand package dependencies
|
||||
- Cache correctness
|
||||
|
||||
Without a lockfile, you'll see unpredictable behavior.
|
||||
@@ -1,126 +0,0 @@
|
||||
# Boundaries
|
||||
|
||||
**Experimental feature** - See [RFC](https://github.com/vercel/turborepo/discussions/9435)
|
||||
|
||||
Full docs: https://turborepo.dev/docs/reference/boundaries
|
||||
|
||||
Boundaries enforce package isolation by detecting:
|
||||
|
||||
1. Imports of files outside the package's directory
|
||||
2. Imports of packages not declared in `package.json` dependencies
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
turbo boundaries
|
||||
```
|
||||
|
||||
Run this to check for workspace violations across your monorepo.
|
||||
|
||||
## Tags
|
||||
|
||||
Tags allow you to create rules for which packages can depend on each other.
|
||||
|
||||
### Adding Tags to a Package
|
||||
|
||||
```json
|
||||
// packages/ui/turbo.json
|
||||
{
|
||||
"tags": ["internal"]
|
||||
}
|
||||
```
|
||||
|
||||
### Configuring Tag Rules
|
||||
|
||||
Rules go in root `turbo.json`:
|
||||
|
||||
```json
|
||||
// turbo.json
|
||||
{
|
||||
"boundaries": {
|
||||
"tags": {
|
||||
"public": {
|
||||
"dependencies": {
|
||||
"deny": ["internal"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This prevents `public`-tagged packages from importing `internal`-tagged packages.
|
||||
|
||||
### Rule Types
|
||||
|
||||
**Allow-list approach** (only allow specific tags):
|
||||
|
||||
```json
|
||||
{
|
||||
"boundaries": {
|
||||
"tags": {
|
||||
"public": {
|
||||
"dependencies": {
|
||||
"allow": ["public"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Deny-list approach** (block specific tags):
|
||||
|
||||
```json
|
||||
{
|
||||
"boundaries": {
|
||||
"tags": {
|
||||
"public": {
|
||||
"dependencies": {
|
||||
"deny": ["internal"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Restrict dependents** (who can import this package):
|
||||
|
||||
```json
|
||||
{
|
||||
"boundaries": {
|
||||
"tags": {
|
||||
"private": {
|
||||
"dependents": {
|
||||
"deny": ["public"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Using Package Names
|
||||
|
||||
Package names work in place of tags:
|
||||
|
||||
```json
|
||||
{
|
||||
"boundaries": {
|
||||
"tags": {
|
||||
"private": {
|
||||
"dependents": {
|
||||
"deny": ["@repo/my-pkg"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Key Points
|
||||
|
||||
- Rules apply transitively (dependencies of dependencies)
|
||||
- Helps enforce architectural boundaries at scale
|
||||
- Catches violations before runtime/build errors
|
||||
@@ -1,153 +0,0 @@
|
||||
# How Turborepo Caching Works
|
||||
|
||||
Turborepo's core principle: **never do the same work twice**.
|
||||
|
||||
## The Cache Equation
|
||||
|
||||
```
|
||||
fingerprint(inputs) → stored outputs
|
||||
```
|
||||
|
||||
If inputs haven't changed, restore outputs from cache instead of re-running the task.
|
||||
|
||||
## What Determines the Cache Key
|
||||
|
||||
### Global Hash Inputs
|
||||
|
||||
These affect ALL tasks in the repo:
|
||||
|
||||
- `package-lock.json` / `yarn.lock` / `pnpm-lock.yaml`
|
||||
- Files listed in `globalDependencies` (or `global.env` when using `globalConfiguration`)
|
||||
- Environment variables in `globalEnv` (or `global.env`)
|
||||
- `turbo.json` configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"globalDependencies": [".env", "tsconfig.base.json"],
|
||||
"globalEnv": ["CI", "NODE_ENV"]
|
||||
}
|
||||
```
|
||||
|
||||
### Task Hash Inputs
|
||||
|
||||
These affect specific tasks:
|
||||
|
||||
- All files in the package (unless filtered by `inputs`)
|
||||
- `package.json` contents
|
||||
- Environment variables in task's `env` key
|
||||
- Task configuration (command, outputs, dependencies)
|
||||
- Hashes of dependent tasks (`dependsOn`)
|
||||
- Files from `global.inputs` (when using `futureFlags.globalConfiguration` — see below)
|
||||
|
||||
```json
|
||||
{
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"inputs": ["src/**", "package.json", "tsconfig.json"],
|
||||
"env": ["API_URL"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### How `global.inputs` Changes the Hash Equation
|
||||
|
||||
When `futureFlags.globalConfiguration` is enabled, `global.inputs` files are **not** part of the global hash. Instead, they are prepended to every task's `inputs` and folded into the **task hash**. This is a fundamental change from `globalDependencies`.
|
||||
|
||||
**With `globalDependencies` (default):**
|
||||
|
||||
```
|
||||
task cache key = hash(global hash, task hash)
|
||||
↑ includes globalDependencies file hashes
|
||||
```
|
||||
|
||||
Changing a `globalDependencies` file invalidates **every** task, regardless of task-level `inputs`. There is no way for a task to opt out.
|
||||
|
||||
**With `global.inputs` (`futureFlags.globalConfiguration`):**
|
||||
|
||||
```
|
||||
task cache key = hash(global hash, task hash)
|
||||
↑ includes global.inputs file hashes (merged with task inputs)
|
||||
```
|
||||
|
||||
`global.inputs` files are merged into each task's input globs. This means:
|
||||
|
||||
- Tasks can **exclude** specific global files with negation globs: `"inputs": ["$TURBO_DEFAULT$", "!$TURBO_ROOT$/tsconfig.json"]`
|
||||
- The global hash is smaller (it still includes lockfile, engines, `global.env`, etc. — but not file hashes from `global.inputs`)
|
||||
- The task hash correctly includes the global input file hashes alongside the task's own inputs
|
||||
|
||||
```json
|
||||
{
|
||||
"futureFlags": { "globalConfiguration": true },
|
||||
"global": {
|
||||
"inputs": ["tsconfig.json", ".env"]
|
||||
},
|
||||
"tasks": {
|
||||
"build": {
|
||||
"outputs": ["dist/**"]
|
||||
},
|
||||
"lint": {
|
||||
"inputs": ["$TURBO_DEFAULT$", "!$TURBO_ROOT$/tsconfig.json"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In this example, changing `tsconfig.json` invalidates `build` (it's in the task's inputs) but **not** `lint` (which explicitly excludes it). With `globalDependencies`, both would have been invalidated.
|
||||
|
||||
## What Gets Cached
|
||||
|
||||
1. **File outputs** - files/directories specified in `outputs`
|
||||
2. **Task logs** - stdout/stderr for replay on cache hit
|
||||
|
||||
```json
|
||||
{
|
||||
"tasks": {
|
||||
"build": {
|
||||
"outputs": ["dist/**", ".next/**"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Local Cache Location
|
||||
|
||||
```
|
||||
.turbo/cache/
|
||||
├── <hash1>.tar.zst # compressed outputs
|
||||
├── <hash2>.tar.zst
|
||||
└── ...
|
||||
```
|
||||
|
||||
Add `.turbo` to `.gitignore`.
|
||||
|
||||
## Cache Restoration
|
||||
|
||||
On cache hit, Turborepo:
|
||||
|
||||
1. Extracts archived outputs to their original locations
|
||||
2. Replays the logged stdout/stderr
|
||||
3. Reports the task as cached (shows `FULL TURBO` in output)
|
||||
|
||||
## Example Flow
|
||||
|
||||
```bash
|
||||
# First run - executes build, caches result
|
||||
turbo build
|
||||
# → packages/ui: cache miss, executing...
|
||||
# → packages/web: cache miss, executing...
|
||||
|
||||
# Second run - same inputs, restores from cache
|
||||
turbo build
|
||||
# → packages/ui: cache hit, replaying output
|
||||
# → packages/web: cache hit, replaying output
|
||||
# → FULL TURBO
|
||||
```
|
||||
|
||||
## Key Points
|
||||
|
||||
- Cache is content-addressed (based on input hash, not timestamps)
|
||||
- Empty `outputs` array means task runs but nothing is cached
|
||||
- Tasks without `outputs` key cache nothing (use `"outputs": []` to be explicit)
|
||||
- Cache is invalidated when ANY input changes
|
||||
@@ -1,190 +0,0 @@
|
||||
# Debugging Cache Issues
|
||||
|
||||
## Diagnostic Tools
|
||||
|
||||
### `--summarize`
|
||||
|
||||
Generates a JSON file with all hash inputs. Compare two runs to find differences.
|
||||
|
||||
```bash
|
||||
turbo build --summarize
|
||||
# Creates .turbo/runs/<run-id>.json
|
||||
```
|
||||
|
||||
The summary includes:
|
||||
|
||||
- Global hash and its inputs
|
||||
- Per-task hashes and their inputs
|
||||
- Environment variables that affected the hash
|
||||
|
||||
**Comparing runs:**
|
||||
|
||||
```bash
|
||||
# Run twice, compare the summaries
|
||||
diff .turbo/runs/<first-run>.json .turbo/runs/<second-run>.json
|
||||
```
|
||||
|
||||
### `--dry` / `--dry=json`
|
||||
|
||||
See what would run without executing anything:
|
||||
|
||||
```bash
|
||||
turbo build --dry
|
||||
turbo build --dry=json # machine-readable output
|
||||
```
|
||||
|
||||
Shows cache status for each task without running them.
|
||||
|
||||
### `--force`
|
||||
|
||||
Skip reading cache, re-execute all tasks:
|
||||
|
||||
```bash
|
||||
turbo build --force
|
||||
```
|
||||
|
||||
Useful to verify tasks actually work (not just cached results).
|
||||
|
||||
## Unexpected Cache Misses
|
||||
|
||||
**Symptom:** Task runs when you expected a cache hit.
|
||||
|
||||
### Environment Variable Changed
|
||||
|
||||
Check if an env var in the `env` key changed:
|
||||
|
||||
```json
|
||||
{
|
||||
"tasks": {
|
||||
"build": {
|
||||
"env": ["API_URL", "NODE_ENV"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Different `API_URL` between runs = cache miss.
|
||||
|
||||
### .env File Changed
|
||||
|
||||
`.env` files aren't tracked by default. Add to `inputs`:
|
||||
|
||||
```json
|
||||
{
|
||||
"tasks": {
|
||||
"build": {
|
||||
"inputs": ["$TURBO_DEFAULT$", ".env", ".env.local"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or use `globalDependencies` for repo-wide env files:
|
||||
|
||||
```json
|
||||
{
|
||||
"globalDependencies": [".env"]
|
||||
}
|
||||
```
|
||||
|
||||
With `futureFlags.globalConfiguration`, use `global.inputs` instead. The key difference: `global.inputs` files are folded into each task's hash individually (not the global hash), so tasks can exclude specific files with negation globs.
|
||||
|
||||
```json
|
||||
{
|
||||
"futureFlags": { "globalConfiguration": true },
|
||||
"global": {
|
||||
"inputs": [".env"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Lockfile Changed
|
||||
|
||||
Installing/updating packages changes the global hash.
|
||||
|
||||
### Source Files Changed
|
||||
|
||||
Any file in the package (or in `inputs`) triggers a miss.
|
||||
|
||||
### turbo.json Changed
|
||||
|
||||
Config changes invalidate the global hash.
|
||||
|
||||
## Incorrect Cache Hits
|
||||
|
||||
**Symptom:** Cached output is stale/wrong.
|
||||
|
||||
### Missing Environment Variable
|
||||
|
||||
Task uses an env var not listed in `env`:
|
||||
|
||||
```javascript
|
||||
// build.js
|
||||
const apiUrl = process.env.API_URL; // not tracked!
|
||||
```
|
||||
|
||||
Fix: add to task config:
|
||||
|
||||
```json
|
||||
{
|
||||
"tasks": {
|
||||
"build": {
|
||||
"env": ["API_URL"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Missing File in Inputs
|
||||
|
||||
Task reads a file outside default inputs:
|
||||
|
||||
```json
|
||||
{
|
||||
"tasks": {
|
||||
"build": {
|
||||
"inputs": [
|
||||
"$TURBO_DEFAULT$",
|
||||
"../../shared-config.json" // file outside package
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Useful Flags
|
||||
|
||||
```bash
|
||||
# Only show output for cache misses
|
||||
turbo build --output-logs=new-only
|
||||
|
||||
# Show output for everything (debugging)
|
||||
turbo build --output-logs=full
|
||||
|
||||
# See why tasks are running
|
||||
turbo build --verbosity=2
|
||||
```
|
||||
|
||||
## Debugging with `globalConfiguration` Enabled
|
||||
|
||||
When `futureFlags.globalConfiguration` is on, `global.inputs` files appear in per-task hash inputs (not the global hash). If you're getting unexpected cache misses:
|
||||
|
||||
1. Check `--summarize` output — global input files will show up in the **task inputs** section, not the global hash section
|
||||
2. Verify tasks aren't accidentally excluding global inputs via negation globs in `inputs`
|
||||
3. Remember that toggling the `globalConfiguration` flag itself invalidates all caches (the flag value is part of the global hash)
|
||||
|
||||
If you're getting unexpected cache **hits** after changing a global input file, the task may be excluding that file with a negation glob. Check the task's `inputs` for `!$TURBO_ROOT$/...` patterns.
|
||||
|
||||
## Quick Checklist
|
||||
|
||||
Cache miss when expected hit:
|
||||
|
||||
1. Run with `--summarize`, compare with previous run
|
||||
2. Check env vars with `--dry=json`
|
||||
3. Look for lockfile/config changes in git
|
||||
|
||||
Cache hit when expected miss:
|
||||
|
||||
1. Verify env var is in `env` array
|
||||
2. Verify file is in `inputs` array
|
||||
3. Check if file is outside package directory
|
||||
@@ -1,127 +0,0 @@
|
||||
# Remote Caching
|
||||
|
||||
Share cache artifacts across your team and CI pipelines.
|
||||
|
||||
## Benefits
|
||||
|
||||
- Team members get cache hits from each other's work
|
||||
- CI gets cache hits from local development (and vice versa)
|
||||
- Dramatically faster CI runs after first build
|
||||
- No more "works on my machine" rebuilds
|
||||
|
||||
## Vercel Remote Cache
|
||||
|
||||
Free, zero-config when deploying on Vercel. For local dev and other CI:
|
||||
|
||||
### Local Development Setup
|
||||
|
||||
```bash
|
||||
# Authenticate with Vercel
|
||||
npx turbo login
|
||||
|
||||
# Link repo to your Vercel team
|
||||
npx turbo link
|
||||
```
|
||||
|
||||
This creates `.turbo/config.json` with your team info (gitignored by default).
|
||||
|
||||
### CI Setup
|
||||
|
||||
Set these environment variables:
|
||||
|
||||
```bash
|
||||
TURBO_TOKEN=<your-token>
|
||||
TURBO_TEAM=<your-team-slug>
|
||||
```
|
||||
|
||||
Get your token from Vercel dashboard → Settings → Tokens.
|
||||
|
||||
**GitHub Actions example:**
|
||||
|
||||
```yaml
|
||||
- name: Build
|
||||
run: npx turbo build
|
||||
env:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||
```
|
||||
|
||||
## Configuration in turbo.json
|
||||
|
||||
```json
|
||||
{
|
||||
"remoteCache": {
|
||||
"enabled": true,
|
||||
"signature": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
- `enabled`: toggle remote cache (default: true when authenticated)
|
||||
- `signature`: require artifact signing (default: false)
|
||||
|
||||
## Artifact Signing
|
||||
|
||||
Verify cache artifacts haven't been tampered with:
|
||||
|
||||
```bash
|
||||
# Set a secret key (use same key across all environments)
|
||||
export TURBO_REMOTE_CACHE_SIGNATURE_KEY="your-secret-key"
|
||||
```
|
||||
|
||||
Enable in config:
|
||||
|
||||
```json
|
||||
{
|
||||
"remoteCache": {
|
||||
"signature": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Signed artifacts can only be restored if the signature matches.
|
||||
|
||||
## Self-Hosted Options
|
||||
|
||||
Community implementations for running your own cache server:
|
||||
|
||||
- **turbo-remote-cache** (Node.js) - supports S3, GCS, Azure
|
||||
- **turborepo-remote-cache** (Go) - lightweight, S3-compatible
|
||||
- **ducktape** (Rust) - high-performance option
|
||||
|
||||
Configure with environment variables:
|
||||
|
||||
```bash
|
||||
TURBO_API=https://your-cache-server.com
|
||||
TURBO_TOKEN=your-auth-token
|
||||
TURBO_TEAM=your-team
|
||||
```
|
||||
|
||||
## Cache Behavior Control
|
||||
|
||||
```bash
|
||||
# Disable remote cache for a run
|
||||
turbo build --remote-cache-read-only # read but don't write
|
||||
turbo build --no-cache # skip cache entirely
|
||||
|
||||
# Environment variable alternative
|
||||
TURBO_REMOTE_ONLY=true # only use remote, skip local
|
||||
```
|
||||
|
||||
## Debugging Remote Cache
|
||||
|
||||
```bash
|
||||
# Verbose output shows cache operations
|
||||
turbo build --verbosity=2
|
||||
|
||||
# Check if remote cache is configured
|
||||
turbo config
|
||||
```
|
||||
|
||||
Look for:
|
||||
|
||||
- "Remote caching enabled" in output
|
||||
- Upload/download messages during runs
|
||||
- "cache hit, replaying output" with remote cache indicator
|
||||
@@ -1,79 +0,0 @@
|
||||
# CI/CD with Turborepo
|
||||
|
||||
General principles for running Turborepo in continuous integration environments.
|
||||
|
||||
## Core Principles
|
||||
|
||||
### Always Use `turbo run` in CI
|
||||
|
||||
**Never use the `turbo <tasks>` shorthand in CI or scripts.** Always use `turbo run`:
|
||||
|
||||
```bash
|
||||
# CORRECT - Always use in CI, package.json, scripts
|
||||
turbo run build test lint
|
||||
|
||||
# WRONG - Shorthand is only for one-off terminal commands
|
||||
turbo build test lint
|
||||
```
|
||||
|
||||
The shorthand `turbo <tasks>` is only for one-off invocations typed directly in terminal by humans or agents. Anywhere the command is written into code (CI, package.json, scripts), use `turbo run`.
|
||||
|
||||
### Enable Remote Caching
|
||||
|
||||
Remote caching dramatically speeds up CI by sharing cached artifacts across runs.
|
||||
|
||||
Required environment variables:
|
||||
|
||||
```bash
|
||||
TURBO_TOKEN=your_vercel_token
|
||||
TURBO_TEAM=your_team_slug
|
||||
```
|
||||
|
||||
### Use --affected for PR Builds
|
||||
|
||||
The `--affected` flag only runs tasks for packages changed since the base branch:
|
||||
|
||||
```bash
|
||||
turbo run build test --affected
|
||||
```
|
||||
|
||||
This requires Git history to compute what changed.
|
||||
|
||||
## Git History Requirements
|
||||
|
||||
### Fetch Depth
|
||||
|
||||
`--affected` needs access to the merge base. Shallow clones break this.
|
||||
|
||||
```yaml
|
||||
# GitHub Actions
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2 # Minimum for --affected
|
||||
# Use 0 for full history if merge base is far
|
||||
```
|
||||
|
||||
### Why Shallow Clones Break --affected
|
||||
|
||||
Turborepo compares the current HEAD to the merge base with `main`. If that commit isn't fetched, `--affected` falls back to running everything.
|
||||
|
||||
For PRs with many commits, consider:
|
||||
|
||||
```yaml
|
||||
fetch-depth: 0 # Full history
|
||||
```
|
||||
|
||||
## Environment Variables Reference
|
||||
|
||||
| Variable | Purpose |
|
||||
| ------------------- | ------------------------------------ |
|
||||
| `TURBO_TOKEN` | Vercel access token for remote cache |
|
||||
| `TURBO_TEAM` | Your Vercel team slug |
|
||||
| `TURBO_REMOTE_ONLY` | Skip local cache, use remote only |
|
||||
| `TURBO_LOG_ORDER` | Set to `grouped` for cleaner CI logs |
|
||||
|
||||
## See Also
|
||||
|
||||
- [github-actions.md](./github-actions.md) - GitHub Actions setup
|
||||
- [vercel.md](./vercel.md) - Vercel deployment
|
||||
- [patterns.md](./patterns.md) - CI optimization patterns
|
||||
@@ -1,162 +0,0 @@
|
||||
# GitHub Actions
|
||||
|
||||
Complete setup guide for Turborepo with GitHub Actions.
|
||||
|
||||
## Basic Workflow Structure
|
||||
|
||||
```yaml
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build and Test
|
||||
run: turbo run build test lint
|
||||
```
|
||||
|
||||
## Package Manager Setup
|
||||
|
||||
### pnpm
|
||||
|
||||
```yaml
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: "pnpm"
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
```
|
||||
|
||||
### Yarn
|
||||
|
||||
```yaml
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: "yarn"
|
||||
|
||||
- run: yarn install --frozen-lockfile
|
||||
```
|
||||
|
||||
### Bun
|
||||
|
||||
```yaml
|
||||
- uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- run: bun install --frozen-lockfile
|
||||
```
|
||||
|
||||
## Remote Cache Setup
|
||||
|
||||
### 1. Create Vercel Access Token
|
||||
|
||||
1. Go to [Vercel Dashboard](https://vercel.com/account/tokens)
|
||||
2. Create a new token with appropriate scope
|
||||
3. Copy the token value
|
||||
|
||||
### 2. Add Secrets and Variables
|
||||
|
||||
In your GitHub repository settings:
|
||||
|
||||
**Secrets** (Settings > Secrets and variables > Actions > Secrets):
|
||||
|
||||
- `TURBO_TOKEN`: Your Vercel access token
|
||||
|
||||
**Variables** (Settings > Secrets and variables > Actions > Variables):
|
||||
|
||||
- `TURBO_TEAM`: Your Vercel team slug
|
||||
|
||||
### 3. Add to Workflow
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||
```
|
||||
|
||||
## Alternative: actions/cache
|
||||
|
||||
If you can't use remote cache, cache Turborepo's local cache directory:
|
||||
|
||||
```yaml
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: .turbo
|
||||
key: turbo-${{ runner.os }}-${{ hashFiles('**/turbo.json', '**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
turbo-${{ runner.os }}-
|
||||
```
|
||||
|
||||
Note: This is less effective than remote cache since it's per-branch.
|
||||
|
||||
## Complete Example
|
||||
|
||||
```yaml
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: turbo run build --affected
|
||||
|
||||
- name: Test
|
||||
run: turbo run test --affected
|
||||
|
||||
- name: Lint
|
||||
run: turbo run lint --affected
|
||||
```
|
||||
@@ -1,145 +0,0 @@
|
||||
# CI Optimization Patterns
|
||||
|
||||
Strategies for efficient CI/CD with Turborepo.
|
||||
|
||||
## PR vs Main Branch Builds
|
||||
|
||||
### PR Builds: Only Affected
|
||||
|
||||
Test only what changed in the PR:
|
||||
|
||||
```yaml
|
||||
- name: Test (PR)
|
||||
if: github.event_name == 'pull_request'
|
||||
run: turbo run build test --affected
|
||||
```
|
||||
|
||||
### Main Branch: Full Build
|
||||
|
||||
Ensure complete validation on merge:
|
||||
|
||||
```yaml
|
||||
- name: Test (Main)
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: turbo run build test
|
||||
```
|
||||
|
||||
## Custom Git Ranges with --filter
|
||||
|
||||
For advanced scenarios, use `--filter` with git refs:
|
||||
|
||||
```bash
|
||||
# Changes since specific commit
|
||||
turbo run test --filter="...[abc123]"
|
||||
|
||||
# Changes between refs
|
||||
turbo run test --filter="...[main...HEAD]"
|
||||
|
||||
# Changes in last 3 commits
|
||||
turbo run test --filter="...[HEAD~3]"
|
||||
```
|
||||
|
||||
## Caching Strategies
|
||||
|
||||
### Remote Cache (Recommended)
|
||||
|
||||
Best performance - shared across all CI runs and developers:
|
||||
|
||||
```yaml
|
||||
env:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||
```
|
||||
|
||||
### actions/cache Fallback
|
||||
|
||||
When remote cache isn't available:
|
||||
|
||||
```yaml
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: .turbo
|
||||
key: turbo-${{ runner.os }}-${{ github.sha }}
|
||||
restore-keys: |
|
||||
turbo-${{ runner.os }}-${{ github.ref }}-
|
||||
turbo-${{ runner.os }}-
|
||||
```
|
||||
|
||||
Limitations:
|
||||
|
||||
- Cache is branch-scoped
|
||||
- PRs restore from base branch cache
|
||||
- Less efficient than remote cache
|
||||
|
||||
## Matrix Builds
|
||||
|
||||
Test across Node versions:
|
||||
|
||||
```yaml
|
||||
strategy:
|
||||
matrix:
|
||||
node: [18, 20, 22]
|
||||
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- run: turbo run test
|
||||
```
|
||||
|
||||
## Parallelizing Across Jobs
|
||||
|
||||
Split tasks into separate jobs:
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: turbo run lint --affected
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: turbo run test --affected
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, test]
|
||||
steps:
|
||||
- run: turbo run build
|
||||
```
|
||||
|
||||
### Cache Considerations
|
||||
|
||||
When parallelizing:
|
||||
|
||||
- Each job has separate cache writes
|
||||
- Remote cache handles this automatically
|
||||
- With actions/cache, use unique keys per job to avoid conflicts
|
||||
|
||||
```yaml
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: .turbo
|
||||
key: turbo-${{ runner.os }}-${{ github.job }}-${{ github.sha }}
|
||||
```
|
||||
|
||||
## Conditional Tasks
|
||||
|
||||
Skip expensive tasks on draft PRs:
|
||||
|
||||
```yaml
|
||||
- name: E2E Tests
|
||||
if: github.event.pull_request.draft == false
|
||||
run: turbo run test:e2e --affected
|
||||
```
|
||||
|
||||
Or require label for full test:
|
||||
|
||||
```yaml
|
||||
- name: Full Test Suite
|
||||
if: contains(github.event.pull_request.labels.*.name, 'full-test')
|
||||
run: turbo run test
|
||||
```
|
||||
@@ -1,103 +0,0 @@
|
||||
# Vercel Deployment
|
||||
|
||||
Turborepo integrates seamlessly with Vercel for monorepo deployments.
|
||||
|
||||
## Remote Cache
|
||||
|
||||
Remote caching is **automatically enabled** when deploying to Vercel. No configuration needed - Vercel detects Turborepo and enables caching.
|
||||
|
||||
This means:
|
||||
|
||||
- No `TURBO_TOKEN` or `TURBO_TEAM` setup required on Vercel
|
||||
- Cache is shared across all deployments
|
||||
- Preview and production builds benefit from cache
|
||||
|
||||
## turbo-ignore
|
||||
|
||||
Skip unnecessary builds when a package hasn't changed using `turbo-ignore`.
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
npx turbo-ignore
|
||||
```
|
||||
|
||||
Or install globally in your project:
|
||||
|
||||
```bash
|
||||
pnpm add -D turbo-ignore
|
||||
```
|
||||
|
||||
### Setup in Vercel
|
||||
|
||||
1. Go to your project in Vercel Dashboard
|
||||
2. Navigate to Settings > Git > Ignored Build Step
|
||||
3. Select "Custom" and enter:
|
||||
|
||||
```bash
|
||||
npx turbo-ignore
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
`turbo-ignore` checks if the current package (or its dependencies) changed since the last successful deployment:
|
||||
|
||||
1. Compares current commit to last deployed commit
|
||||
2. Uses Turborepo's dependency graph
|
||||
3. Returns exit code 0 (skip) if no changes
|
||||
4. Returns exit code 1 (build) if changes detected
|
||||
|
||||
### Options
|
||||
|
||||
```bash
|
||||
# Check specific package
|
||||
npx turbo-ignore web
|
||||
|
||||
# Use specific comparison ref
|
||||
npx turbo-ignore --fallback=HEAD~1
|
||||
|
||||
# Verbose output
|
||||
npx turbo-ignore --verbose
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Set environment variables in Vercel Dashboard:
|
||||
|
||||
1. Go to Project Settings > Environment Variables
|
||||
2. Add variables for each environment (Production, Preview, Development)
|
||||
|
||||
Common variables:
|
||||
|
||||
- `DATABASE_URL`
|
||||
- `API_KEY`
|
||||
- Package-specific config
|
||||
|
||||
## Monorepo Root Directory
|
||||
|
||||
For monorepos, set the root directory in Vercel:
|
||||
|
||||
1. Project Settings > General > Root Directory
|
||||
2. Set to the package path (e.g., `apps/web`)
|
||||
|
||||
Vercel automatically:
|
||||
|
||||
- Installs dependencies from monorepo root
|
||||
- Runs build from the package directory
|
||||
- Detects framework settings
|
||||
|
||||
## Build Command
|
||||
|
||||
Vercel auto-detects `turbo run build` when `turbo.json` exists at root.
|
||||
|
||||
Override if needed:
|
||||
|
||||
```bash
|
||||
turbo run build --filter=web
|
||||
```
|
||||
|
||||
Or for production-only optimizations:
|
||||
|
||||
```bash
|
||||
turbo run build --filter=web --env-mode=strict
|
||||
```
|
||||
@@ -1,100 +0,0 @@
|
||||
# turbo run
|
||||
|
||||
The primary command for executing tasks across your monorepo.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```bash
|
||||
# Full form (use in CI, package.json, scripts)
|
||||
turbo run <tasks>
|
||||
|
||||
# Shorthand (only for one-off terminal invocations)
|
||||
turbo <tasks>
|
||||
```
|
||||
|
||||
## When to Use `turbo run` vs `turbo`
|
||||
|
||||
**Always use `turbo run` when the command is written into code:**
|
||||
|
||||
- `package.json` scripts
|
||||
- CI/CD workflows (GitHub Actions, etc.)
|
||||
- Shell scripts
|
||||
- Documentation
|
||||
- Any static/committed configuration
|
||||
|
||||
**Only use `turbo` (shorthand) for:**
|
||||
|
||||
- One-off commands typed directly in terminal
|
||||
- Ad-hoc invocations by humans or agents
|
||||
|
||||
```json
|
||||
// package.json - ALWAYS use "turbo run"
|
||||
{
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev",
|
||||
"lint": "turbo run lint",
|
||||
"test": "turbo run test"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```yaml
|
||||
# CI workflow - ALWAYS use "turbo run"
|
||||
- run: turbo run build --affected
|
||||
- run: turbo run test --affected
|
||||
```
|
||||
|
||||
```bash
|
||||
# Terminal one-off - shorthand OK
|
||||
turbo build --filter=web
|
||||
```
|
||||
|
||||
## Running Tasks
|
||||
|
||||
Tasks must be defined in `turbo.json` before running.
|
||||
|
||||
```bash
|
||||
# Single task
|
||||
turbo build
|
||||
|
||||
# Multiple tasks
|
||||
turbo run build lint test
|
||||
|
||||
# See available tasks (run without arguments)
|
||||
turbo run
|
||||
```
|
||||
|
||||
## Passing Arguments to Scripts
|
||||
|
||||
Use `--` to pass arguments through to the underlying package scripts:
|
||||
|
||||
```bash
|
||||
turbo run build -- --sourcemap
|
||||
turbo test -- --watch
|
||||
turbo lint -- --fix
|
||||
```
|
||||
|
||||
Everything after `--` goes directly to the task's script.
|
||||
|
||||
## Package Selection
|
||||
|
||||
By default, turbo runs tasks in all packages. Use `--filter` to narrow scope:
|
||||
|
||||
```bash
|
||||
turbo build --filter=web
|
||||
turbo test --filter=./apps/*
|
||||
```
|
||||
|
||||
See `filtering/` for complete filter syntax.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Goal | Command |
|
||||
| ------------------- | -------------------------- |
|
||||
| Build everything | `turbo build` |
|
||||
| Build one package | `turbo build --filter=web` |
|
||||
| Multiple tasks | `turbo build lint test` |
|
||||
| Pass args to script | `turbo build -- --arg` |
|
||||
| Preview run | `turbo build --dry` |
|
||||
| Force rebuild | `turbo build --force` |
|
||||
@@ -1,297 +0,0 @@
|
||||
# turbo run Flags Reference
|
||||
|
||||
Full docs: https://turborepo.dev/docs/reference/run
|
||||
|
||||
## Package Selection
|
||||
|
||||
### `--filter` / `-F`
|
||||
|
||||
Select specific packages to run tasks in.
|
||||
|
||||
```bash
|
||||
turbo build --filter=web
|
||||
turbo build -F=@repo/ui -F=@repo/utils
|
||||
turbo test --filter=./apps/*
|
||||
```
|
||||
|
||||
See `filtering/` for complete syntax (globs, dependencies, git ranges).
|
||||
|
||||
### Task Identifier Syntax (v2.2.4+)
|
||||
|
||||
Run specific package tasks directly:
|
||||
|
||||
```bash
|
||||
turbo run web#build # Build web package
|
||||
turbo run web#build docs#lint # Multiple specific tasks
|
||||
```
|
||||
|
||||
### `--affected`
|
||||
|
||||
Run only in packages changed since the base branch.
|
||||
|
||||
```bash
|
||||
turbo build --affected
|
||||
turbo test --affected --filter=./apps/* # combine with filter
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
|
||||
- Default: compares `main...HEAD`
|
||||
- In GitHub Actions: auto-detects `GITHUB_BASE_REF`
|
||||
- Override base: `TURBO_SCM_BASE=development turbo build --affected`
|
||||
- Override head: `TURBO_SCM_HEAD=your-branch turbo build --affected`
|
||||
|
||||
**Requires git history** - shallow clones may fall back to running all tasks.
|
||||
|
||||
## Execution Control
|
||||
|
||||
### `--dry` / `--dry=json`
|
||||
|
||||
Preview what would run without executing.
|
||||
|
||||
```bash
|
||||
turbo build --dry # human-readable
|
||||
turbo build --dry=json # machine-readable
|
||||
```
|
||||
|
||||
### `--force`
|
||||
|
||||
Ignore all cached artifacts, re-run everything.
|
||||
|
||||
```bash
|
||||
turbo build --force
|
||||
```
|
||||
|
||||
### `--concurrency`
|
||||
|
||||
Limit parallel task execution.
|
||||
|
||||
```bash
|
||||
turbo build --concurrency=4 # max 4 tasks
|
||||
turbo build --concurrency=50% # 50% of CPU cores
|
||||
```
|
||||
|
||||
### `--continue`
|
||||
|
||||
Keep running other tasks when one fails.
|
||||
|
||||
```bash
|
||||
turbo build test --continue
|
||||
```
|
||||
|
||||
### `--only`
|
||||
|
||||
Run only the specified task, skip its dependencies.
|
||||
|
||||
```bash
|
||||
turbo build --only # skip running dependsOn tasks
|
||||
```
|
||||
|
||||
### `--parallel` (Discouraged)
|
||||
|
||||
Ignores task graph dependencies, runs all tasks simultaneously. **Avoid using this flag**—if tasks need to run in parallel, configure `dependsOn` correctly instead. Using `--parallel` bypasses Turborepo's dependency graph, which can cause race conditions and incorrect builds.
|
||||
|
||||
## Cache Control
|
||||
|
||||
### `--cache`
|
||||
|
||||
Fine-grained cache behavior control.
|
||||
|
||||
```bash
|
||||
# Default: read/write both local and remote
|
||||
turbo build --cache=local:rw,remote:rw
|
||||
|
||||
# Read-only local, no remote
|
||||
turbo build --cache=local:r,remote:
|
||||
|
||||
# Disable local, read-only remote
|
||||
turbo build --cache=local:,remote:r
|
||||
|
||||
# Disable all caching
|
||||
turbo build --cache=local:,remote:
|
||||
```
|
||||
|
||||
## Output & Debugging
|
||||
|
||||
### `--graph`
|
||||
|
||||
Generate task graph visualization.
|
||||
|
||||
```bash
|
||||
turbo build --graph # opens in browser
|
||||
turbo build --graph=graph.svg # SVG file
|
||||
turbo build --graph=graph.png # PNG file
|
||||
turbo build --graph=graph.json # JSON data
|
||||
turbo build --graph=graph.mermaid # Mermaid diagram
|
||||
```
|
||||
|
||||
### `--summarize`
|
||||
|
||||
Generate JSON run summary for debugging.
|
||||
|
||||
```bash
|
||||
turbo build --summarize
|
||||
# creates .turbo/runs/<run-id>.json
|
||||
```
|
||||
|
||||
### `--output-logs`
|
||||
|
||||
Control log output verbosity.
|
||||
|
||||
```bash
|
||||
turbo build --output-logs=full # all logs (default)
|
||||
turbo build --output-logs=new-only # only cache misses
|
||||
turbo build --output-logs=errors-only # only failures
|
||||
turbo build --output-logs=none # silent
|
||||
```
|
||||
|
||||
### `--profile`
|
||||
|
||||
Generate Chrome tracing profile for performance analysis.
|
||||
|
||||
```bash
|
||||
turbo build --profile=profile.json
|
||||
# open chrome://tracing and load the file
|
||||
```
|
||||
|
||||
### `--verbosity` / `-v`
|
||||
|
||||
Control turbo's own log level.
|
||||
|
||||
```bash
|
||||
turbo build -v # verbose
|
||||
turbo build -vv # more verbose
|
||||
turbo build -vvv # maximum verbosity
|
||||
```
|
||||
|
||||
## Environment
|
||||
|
||||
### `--env-mode`
|
||||
|
||||
Control environment variable handling.
|
||||
|
||||
```bash
|
||||
turbo build --env-mode=strict # only declared env vars (default)
|
||||
turbo build --env-mode=loose # include all env vars in hash
|
||||
```
|
||||
|
||||
## UI
|
||||
|
||||
### `--ui`
|
||||
|
||||
Select output interface.
|
||||
|
||||
```bash
|
||||
turbo build --ui=tui # interactive terminal UI (default in TTY)
|
||||
turbo build --ui=stream # streaming logs (default in CI)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# turbo-ignore
|
||||
|
||||
Full docs: https://turborepo.dev/docs/reference/turbo-ignore
|
||||
|
||||
Skip CI work when nothing relevant changed. Useful for skipping container setup.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```bash
|
||||
# Check if build is needed for current package (uses Automatic Package Scoping)
|
||||
npx turbo-ignore
|
||||
|
||||
# Check specific package
|
||||
npx turbo-ignore web
|
||||
|
||||
# Check specific task
|
||||
npx turbo-ignore --task=test
|
||||
```
|
||||
|
||||
## Exit Codes
|
||||
|
||||
- `0`: No changes detected - skip CI work
|
||||
- `1`: Changes detected - proceed with CI
|
||||
|
||||
## CI Integration Example
|
||||
|
||||
```yaml
|
||||
# GitHub Actions
|
||||
- name: Check for changes
|
||||
id: turbo-ignore
|
||||
run: npx turbo-ignore web
|
||||
continue-on-error: true
|
||||
|
||||
- name: Build
|
||||
if: steps.turbo-ignore.outcome == 'failure' # changes detected
|
||||
run: pnpm build
|
||||
```
|
||||
|
||||
## Comparison Depth
|
||||
|
||||
Default: compares to parent commit (`HEAD^1`).
|
||||
|
||||
```bash
|
||||
# Compare to specific commit
|
||||
npx turbo-ignore --fallback=abc123
|
||||
|
||||
# Compare to branch
|
||||
npx turbo-ignore --fallback=main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Other Commands
|
||||
|
||||
## turbo boundaries
|
||||
|
||||
Check workspace violations (experimental).
|
||||
|
||||
```bash
|
||||
turbo boundaries
|
||||
```
|
||||
|
||||
See `references/boundaries/` for configuration.
|
||||
|
||||
## turbo watch
|
||||
|
||||
Re-run tasks on file changes.
|
||||
|
||||
```bash
|
||||
turbo watch build test
|
||||
```
|
||||
|
||||
See `references/watch/` for details.
|
||||
|
||||
## turbo prune
|
||||
|
||||
Create sparse checkout for Docker.
|
||||
|
||||
```bash
|
||||
turbo prune web --docker
|
||||
```
|
||||
|
||||
## turbo link / unlink
|
||||
|
||||
Connect/disconnect Remote Cache.
|
||||
|
||||
```bash
|
||||
turbo link # connect to Vercel Remote Cache
|
||||
turbo unlink # disconnect
|
||||
```
|
||||
|
||||
## turbo login / logout
|
||||
|
||||
Authenticate with Remote Cache provider.
|
||||
|
||||
```bash
|
||||
turbo login # authenticate
|
||||
turbo logout # log out
|
||||
```
|
||||
|
||||
## turbo generate
|
||||
|
||||
Scaffold new packages.
|
||||
|
||||
```bash
|
||||
turbo generate
|
||||
```
|
||||
@@ -1,235 +0,0 @@
|
||||
# turbo.json Configuration Overview
|
||||
|
||||
Configuration reference for Turborepo. Full docs: https://turborepo.dev/docs/reference/configuration
|
||||
|
||||
## File Location
|
||||
|
||||
Root `turbo.json` lives at repo root, sibling to root `package.json`:
|
||||
|
||||
```
|
||||
my-monorepo/
|
||||
├── turbo.json # Root configuration
|
||||
├── package.json
|
||||
└── packages/
|
||||
└── web/
|
||||
├── turbo.json # Package Configuration (optional)
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Always Prefer Package Tasks Over Root Tasks
|
||||
|
||||
**Always use package tasks. Only use Root Tasks if you cannot succeed with package tasks.**
|
||||
|
||||
Package tasks enable parallelization, individual caching, and filtering. Define scripts in each package's `package.json`:
|
||||
|
||||
```json
|
||||
// packages/web/package.json
|
||||
{
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
}
|
||||
}
|
||||
|
||||
// packages/api/package.json
|
||||
{
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
// Root package.json - delegates to turbo
|
||||
{
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"lint": "turbo run lint",
|
||||
"test": "turbo run test",
|
||||
"typecheck": "turbo run typecheck"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When you run `turbo run lint`, Turborepo finds all packages with a `lint` script and runs them **in parallel**.
|
||||
|
||||
**Root Tasks are a fallback**, not the default. Only use them for tasks that truly cannot run per-package (e.g., repo-level CI scripts, workspace-wide config generation).
|
||||
|
||||
```json
|
||||
// AVOID: Task logic in root defeats parallelization
|
||||
{
|
||||
"scripts": {
|
||||
"lint": "eslint apps/web && eslint apps/api && eslint packages/ui"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Basic Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://v2-8-21-canary-9.turborepo.dev/schema.json",
|
||||
"globalEnv": ["CI"],
|
||||
"globalDependencies": ["tsconfig.json"],
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**"]
|
||||
},
|
||||
"dev": {
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `$schema` key enables IDE autocompletion and validation.
|
||||
|
||||
### With `futureFlags.globalConfiguration`
|
||||
|
||||
When the `globalConfiguration` future flag is enabled, global options move under a `global` key with cleaner names:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://v2-8-21-canary-9.turborepo.dev/schema.json",
|
||||
"futureFlags": { "globalConfiguration": true },
|
||||
"global": {
|
||||
"inputs": ["tsconfig.json"],
|
||||
"env": ["CI"],
|
||||
"ui": "tui"
|
||||
},
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See the [global options reference](./global-options.md) for the full rename mapping and behavior changes.
|
||||
|
||||
## Configuration Sections
|
||||
|
||||
**Global options** - Settings affecting all tasks:
|
||||
|
||||
- Without flag: `globalEnv`, `globalDependencies`, `globalPassThroughEnv`, `cacheDir`, `daemon`, `envMode`, `ui`, `remoteCache`
|
||||
- With `globalConfiguration` flag: all of the above move under the `global` key (see [global options](./global-options.md))
|
||||
|
||||
**Task definitions** - Per-task settings in `tasks` object:
|
||||
|
||||
- `dependsOn`, `outputs`, `inputs`, `env`
|
||||
- `cache`, `persistent`, `interactive`, `outputLogs`
|
||||
|
||||
## Package Configurations
|
||||
|
||||
Use `turbo.json` in individual packages to override root settings:
|
||||
|
||||
```json
|
||||
// packages/web/turbo.json
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tasks": {
|
||||
"build": {
|
||||
"outputs": [".next/**", "!.next/cache/**"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `"extends": ["//"]` is required - it references the root configuration.
|
||||
|
||||
**When to use Package Configurations:**
|
||||
|
||||
- Framework-specific outputs (Next.js, Vite, etc.)
|
||||
- Package-specific env vars
|
||||
- Different caching rules for specific packages
|
||||
- Keeping framework config close to the framework code
|
||||
|
||||
### Extending from Other Packages
|
||||
|
||||
You can extend from config packages instead of just root:
|
||||
|
||||
```json
|
||||
// packages/web/turbo.json
|
||||
{
|
||||
"extends": ["//", "@repo/turbo-config"]
|
||||
}
|
||||
```
|
||||
|
||||
### Adding to Inherited Arrays with `$TURBO_EXTENDS$`
|
||||
|
||||
By default, array fields in Package Configurations **replace** root values. Use `$TURBO_EXTENDS$` to **append** instead:
|
||||
|
||||
```json
|
||||
// Root turbo.json
|
||||
{
|
||||
"tasks": {
|
||||
"build": {
|
||||
"outputs": ["dist/**"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
// packages/web/turbo.json
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tasks": {
|
||||
"build": {
|
||||
// Inherits "dist/**" from root, adds ".next/**"
|
||||
"outputs": ["$TURBO_EXTENDS$", ".next/**", "!.next/cache/**"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Without `$TURBO_EXTENDS$`, outputs would only be `[".next/**", "!.next/cache/**"]`.
|
||||
|
||||
**Works with:**
|
||||
|
||||
- `dependsOn`
|
||||
- `env`
|
||||
- `inputs`
|
||||
- `outputs`
|
||||
- `passThroughEnv`
|
||||
- `with`
|
||||
|
||||
### Excluding Tasks from Packages
|
||||
|
||||
Use `extends: false` to exclude a task from a package:
|
||||
|
||||
```json
|
||||
// packages/ui/turbo.json
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tasks": {
|
||||
"e2e": {
|
||||
"extends": false // UI package doesn't have e2e tests
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## `turbo.jsonc` for Comments
|
||||
|
||||
Use `turbo.jsonc` extension to add comments with IDE support:
|
||||
|
||||
```jsonc
|
||||
// turbo.jsonc
|
||||
{
|
||||
"tasks": {
|
||||
"build": {
|
||||
// Next.js outputs
|
||||
"outputs": [".next/**", "!.next/cache/**"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1,239 +0,0 @@
|
||||
# Global Options Reference
|
||||
|
||||
Options that affect all tasks. Full docs: https://turborepo.dev/docs/reference/configuration
|
||||
|
||||
## globalEnv
|
||||
|
||||
Environment variables affecting all task hashes.
|
||||
|
||||
```json
|
||||
{
|
||||
"globalEnv": ["CI", "NODE_ENV", "VERCEL_*"]
|
||||
}
|
||||
```
|
||||
|
||||
Use for variables that should invalidate all caches when changed.
|
||||
|
||||
## globalDependencies
|
||||
|
||||
Files that affect all task hashes.
|
||||
|
||||
```json
|
||||
{
|
||||
"globalDependencies": ["tsconfig.json", ".env", "pnpm-lock.yaml"]
|
||||
}
|
||||
```
|
||||
|
||||
Lockfile is included by default. Add shared configs here.
|
||||
|
||||
## globalPassThroughEnv
|
||||
|
||||
Variables available to tasks but not included in hash.
|
||||
|
||||
```json
|
||||
{
|
||||
"globalPassThroughEnv": ["AWS_SECRET_KEY", "GITHUB_TOKEN"]
|
||||
}
|
||||
```
|
||||
|
||||
Use for credentials that shouldn't affect cache keys.
|
||||
|
||||
## cacheDir
|
||||
|
||||
Custom cache location. Default: `node_modules/.cache/turbo`.
|
||||
|
||||
```json
|
||||
{
|
||||
"cacheDir": ".turbo/cache"
|
||||
}
|
||||
```
|
||||
|
||||
## daemon
|
||||
|
||||
**Deprecated**: The daemon is no longer used for `turbo run` and this option will be removed in version 3.0. The daemon is still used by `turbo watch` and the Turborepo LSP.
|
||||
|
||||
## envMode
|
||||
|
||||
How unspecified env vars are handled. Default: `"strict"`.
|
||||
|
||||
```json
|
||||
{
|
||||
"envMode": "strict" // Only specified vars available
|
||||
// or
|
||||
"envMode": "loose" // All vars pass through
|
||||
}
|
||||
```
|
||||
|
||||
Strict mode catches missing env declarations.
|
||||
|
||||
## ui
|
||||
|
||||
Terminal UI mode. Default: `"stream"`.
|
||||
|
||||
```json
|
||||
{
|
||||
"ui": "tui" // Interactive terminal UI
|
||||
// or
|
||||
"ui": "stream" // Traditional streaming logs
|
||||
}
|
||||
```
|
||||
|
||||
TUI provides better UX for parallel tasks.
|
||||
|
||||
## remoteCache
|
||||
|
||||
Configure remote caching.
|
||||
|
||||
```json
|
||||
{
|
||||
"remoteCache": {
|
||||
"enabled": true,
|
||||
"signature": true,
|
||||
"timeout": 30,
|
||||
"uploadTimeout": 60
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
| --------------- | ---------------------- | ------------------------------------------------------ |
|
||||
| `enabled` | `true` | Enable/disable remote caching |
|
||||
| `signature` | `false` | Sign artifacts with `TURBO_REMOTE_CACHE_SIGNATURE_KEY` |
|
||||
| `preflight` | `false` | Send OPTIONS request before cache requests |
|
||||
| `timeout` | `30` | Timeout in seconds for cache operations |
|
||||
| `uploadTimeout` | `60` | Timeout in seconds for uploads |
|
||||
| `apiUrl` | `"https://vercel.com"` | Remote cache API endpoint |
|
||||
| `loginUrl` | `"https://vercel.com"` | Login endpoint |
|
||||
| `teamId` | - | Team ID (must start with `team_`) |
|
||||
| `teamSlug` | - | Team slug for querystring |
|
||||
|
||||
See https://turborepo.dev/docs/core-concepts/remote-caching for setup.
|
||||
|
||||
## concurrency
|
||||
|
||||
Default: `"10"`
|
||||
|
||||
Limit parallel task execution.
|
||||
|
||||
```json
|
||||
{
|
||||
"concurrency": "4" // Max 4 tasks at once
|
||||
// or
|
||||
"concurrency": "50%" // 50% of available CPUs
|
||||
}
|
||||
```
|
||||
|
||||
## futureFlags
|
||||
|
||||
Enable experimental features that will become default in future versions.
|
||||
|
||||
```json
|
||||
{
|
||||
"futureFlags": {
|
||||
"errorsOnlyShowHash": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `errorsOnlyShowHash`
|
||||
|
||||
When using `outputLogs: "errors-only"`, show task hashes on start/completion:
|
||||
|
||||
- Cache miss: `cache miss, executing <hash> (only logging errors)`
|
||||
- Cache hit: `cache hit, replaying logs (no errors) <hash>`
|
||||
|
||||
### `longerSignatureKey`
|
||||
|
||||
Enforce a minimum key length of 32 bytes for `TURBO_REMOTE_CACHE_SIGNATURE_KEY` when `remoteCache.signature` is enabled. Short keys weaken HMAC-SHA256 signatures. Fails the run immediately if the key is too short.
|
||||
|
||||
### `globalConfiguration`
|
||||
|
||||
Moves global configuration keys under a top-level `global` key for clarity and changes how `global.inputs` (formerly `globalDependencies`) affects task hashing.
|
||||
|
||||
When enabled:
|
||||
|
||||
- Global config keys move under `global` with cleaner names
|
||||
- `global.inputs` files are **prepended to every task's inputs** instead of being folded into the global hash — tasks can opt out of specific global inputs using negation globs
|
||||
|
||||
```json
|
||||
{
|
||||
"futureFlags": { "globalConfiguration": true },
|
||||
"global": {
|
||||
"inputs": ["tsconfig.json", ".env"],
|
||||
"env": ["CI", "NODE_ENV"],
|
||||
"passThroughEnv": ["AWS_SECRET_KEY"],
|
||||
"ui": "tui",
|
||||
"envMode": "strict",
|
||||
"cacheDir": ".turbo/cache",
|
||||
"remoteCache": { "enabled": true },
|
||||
"concurrency": "50%"
|
||||
},
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key rename mapping:**
|
||||
|
||||
| Old (top-level) | New (`global.`) |
|
||||
| -------------------------------------------------------------------------------------------------------------------------------- | ------------------------- |
|
||||
| `globalDependencies` | `inputs` |
|
||||
| `globalEnv` | `env` |
|
||||
| `globalPassThroughEnv` | `passThroughEnv` |
|
||||
| `ui`, `envMode`, `cacheDir`, `daemon`, `concurrency`, `noUpdateNotifier`, `dangerouslyDisablePackageManagerCheck`, `remoteCache` | Same names under `global` |
|
||||
|
||||
**Behavior change for `global.inputs`:**
|
||||
|
||||
With `globalDependencies` (old): files are hashed into the **global hash**, which is embedded in every task's cache key. Changing any of these files invalidates all tasks — there is no opt-out.
|
||||
|
||||
With `global.inputs` (new): files are treated as **implicit task inputs** prepended to each task's `inputs` globs. This means:
|
||||
|
||||
- Tasks can exclude specific global files: `"inputs": ["$TURBO_DEFAULT$", "!$TURBO_ROOT$/tsconfig.json"]`
|
||||
- The global hash no longer includes these file hashes (it still includes lockfile, engines, global env, etc.)
|
||||
- Tasks with no explicit `inputs` still hash all package files plus the global inputs
|
||||
|
||||
See the [gotchas doc](./gotchas.md) for guidance on using `$TURBO_DEFAULT$` with `global.inputs`.
|
||||
|
||||
## noUpdateNotifier
|
||||
|
||||
Disable update notifications when new turbo versions are available.
|
||||
|
||||
```json
|
||||
{
|
||||
"noUpdateNotifier": true
|
||||
}
|
||||
```
|
||||
|
||||
## dangerouslyDisablePackageManagerCheck
|
||||
|
||||
Bypass the `packageManager` field requirement. Use for incremental migration.
|
||||
|
||||
```json
|
||||
{
|
||||
"dangerouslyDisablePackageManagerCheck": true
|
||||
}
|
||||
```
|
||||
|
||||
**Warning**: Unstable lockfiles can cause unpredictable behavior.
|
||||
|
||||
## Git Worktree Cache Sharing
|
||||
|
||||
When working in Git worktrees, Turborepo automatically shares local cache between the main worktree and linked worktrees.
|
||||
|
||||
**How it works:**
|
||||
|
||||
- Detects worktree configuration
|
||||
- Redirects cache to main worktree's `.turbo/cache`
|
||||
- Works alongside Remote Cache
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Cache hits across branches
|
||||
- Reduced disk usage
|
||||
- Faster branch switching
|
||||
|
||||
**Disabled by**: Setting explicit `cacheDir` in turbo.json.
|
||||
@@ -1,368 +0,0 @@
|
||||
# Configuration Gotchas
|
||||
|
||||
Common mistakes and how to fix them.
|
||||
|
||||
## #1 Root Scripts Not Using `turbo run`
|
||||
|
||||
Root `package.json` scripts for turbo tasks MUST use `turbo run`, not direct commands.
|
||||
|
||||
```json
|
||||
// WRONG - bypasses turbo, no parallelization or caching
|
||||
{
|
||||
"scripts": {
|
||||
"build": "bun build",
|
||||
"dev": "bun dev"
|
||||
}
|
||||
}
|
||||
|
||||
// CORRECT - delegates to turbo
|
||||
{
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why this matters:** Running `bun build` or `npm run build` at root bypasses Turborepo entirely - no parallelization, no caching, no dependency graph awareness.
|
||||
|
||||
## #2 Using `&&` to Chain Turbo Tasks
|
||||
|
||||
Don't use `&&` to chain tasks that turbo should orchestrate.
|
||||
|
||||
```json
|
||||
// WRONG - changeset:publish chains turbo task with non-turbo command
|
||||
{
|
||||
"scripts": {
|
||||
"changeset:publish": "bun build && changeset publish"
|
||||
}
|
||||
}
|
||||
|
||||
// CORRECT - use turbo run, let turbo handle dependencies
|
||||
{
|
||||
"scripts": {
|
||||
"changeset:publish": "turbo run build && changeset publish"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If the second command (`changeset publish`) depends on build outputs, the turbo task should run through turbo to get caching and parallelization benefits.
|
||||
|
||||
## #3 Overly Broad globalDependencies
|
||||
|
||||
`globalDependencies` affects hash for ALL tasks in ALL packages. Be specific.
|
||||
|
||||
```json
|
||||
// WRONG - affects all hashes
|
||||
{
|
||||
"globalDependencies": ["**/.env.*local"]
|
||||
}
|
||||
|
||||
// CORRECT - move to specific tasks that need it
|
||||
{
|
||||
"globalDependencies": [".env"],
|
||||
"tasks": {
|
||||
"build": {
|
||||
"inputs": ["$TURBO_DEFAULT$", ".env*"],
|
||||
"outputs": ["dist/**"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why this matters:** `**/.env.*local` matches .env files in ALL packages, causing unnecessary cache invalidation. Instead:
|
||||
|
||||
- Use `globalDependencies` only for truly global files (root `.env`)
|
||||
- Use task-level `inputs` for package-specific .env files with `$TURBO_DEFAULT$` to preserve default behavior
|
||||
|
||||
With `futureFlags.globalConfiguration`, this is less of a concern because `global.inputs` acts as implicit task inputs — tasks can opt out of specific files with negation globs. But keeping the list focused is still good practice.
|
||||
|
||||
## #4 Repetitive Task Configuration
|
||||
|
||||
Look for repeated configuration across tasks that can be collapsed.
|
||||
|
||||
```json
|
||||
// WRONG - repetitive env and inputs across tasks
|
||||
{
|
||||
"tasks": {
|
||||
"build": {
|
||||
"env": ["API_URL", "DATABASE_URL"],
|
||||
"inputs": ["$TURBO_DEFAULT$", ".env*"]
|
||||
},
|
||||
"test": {
|
||||
"env": ["API_URL", "DATABASE_URL"],
|
||||
"inputs": ["$TURBO_DEFAULT$", ".env*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BETTER - use globalEnv and globalDependencies
|
||||
{
|
||||
"globalEnv": ["API_URL", "DATABASE_URL"],
|
||||
"globalDependencies": [".env*"],
|
||||
"tasks": {
|
||||
"build": {},
|
||||
"test": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**When to use global vs task-level:**
|
||||
|
||||
- `globalEnv` / `globalDependencies` - affects ALL tasks, use for truly shared config
|
||||
- Task-level `env` / `inputs` - use when only specific tasks need it
|
||||
|
||||
## #5 Using `../` to Traverse Out of Package in `inputs`
|
||||
|
||||
Don't use relative paths like `../` to reference files outside the package. Use `$TURBO_ROOT$` instead.
|
||||
|
||||
```json
|
||||
// WRONG - traversing out of package
|
||||
{
|
||||
"tasks": {
|
||||
"build": {
|
||||
"inputs": ["$TURBO_DEFAULT$", "../shared-config.json"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CORRECT - use $TURBO_ROOT$ for repo root
|
||||
{
|
||||
"tasks": {
|
||||
"build": {
|
||||
"inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/shared-config.json"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## #6 MOST COMMON MISTAKE: Creating Root Tasks
|
||||
|
||||
**DO NOT create Root Tasks. ALWAYS create package tasks.**
|
||||
|
||||
When you need to create a task (build, lint, test, typecheck, etc.):
|
||||
|
||||
1. Add the script to **each relevant package's** `package.json`
|
||||
2. Register the task in root `turbo.json`
|
||||
3. Root `package.json` only contains `turbo run <task>`
|
||||
|
||||
```json
|
||||
// WRONG - DO NOT DO THIS
|
||||
// Root package.json with task logic
|
||||
{
|
||||
"scripts": {
|
||||
"build": "cd apps/web && next build && cd ../api && tsc",
|
||||
"lint": "eslint apps/ packages/",
|
||||
"test": "vitest"
|
||||
}
|
||||
}
|
||||
|
||||
// CORRECT - DO THIS
|
||||
// apps/web/package.json
|
||||
{ "scripts": { "build": "next build", "lint": "eslint .", "test": "vitest" } }
|
||||
|
||||
// apps/api/package.json
|
||||
{ "scripts": { "build": "tsc", "lint": "eslint .", "test": "vitest" } }
|
||||
|
||||
// packages/ui/package.json
|
||||
{ "scripts": { "build": "tsc", "lint": "eslint .", "test": "vitest" } }
|
||||
|
||||
// Root package.json - ONLY delegates
|
||||
{ "scripts": { "build": "turbo run build", "lint": "turbo run lint", "test": "turbo run test" } }
|
||||
|
||||
// turbo.json - register tasks
|
||||
{
|
||||
"tasks": {
|
||||
"build": { "dependsOn": ["^build"], "outputs": ["dist/**"] },
|
||||
"lint": {},
|
||||
"test": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why this matters:**
|
||||
|
||||
- Package tasks run in **parallel** across all packages
|
||||
- Each package's output is cached **individually**
|
||||
- You can **filter** to specific packages: `turbo run test --filter=web`
|
||||
|
||||
Root Tasks (`//#taskname`) defeat all these benefits. Only use them for tasks that truly cannot exist in any package (extremely rare).
|
||||
|
||||
## #7 Tasks That Need Parallel Execution + Cache Invalidation
|
||||
|
||||
Some tasks can run in parallel (don't need built output from dependencies) but must still invalidate cache when dependency source code changes. Using `dependsOn: ["^taskname"]` forces sequential execution. Using no dependencies breaks cache invalidation.
|
||||
|
||||
**Use Transit Nodes for these tasks:**
|
||||
|
||||
```json
|
||||
// WRONG - forces sequential execution (SLOW)
|
||||
"my-task": {
|
||||
"dependsOn": ["^my-task"]
|
||||
}
|
||||
|
||||
// ALSO WRONG - no dependency awareness (INCORRECT CACHING)
|
||||
"my-task": {}
|
||||
|
||||
// CORRECT - use Transit Nodes for parallel + correct caching
|
||||
{
|
||||
"tasks": {
|
||||
"transit": { "dependsOn": ["^transit"] },
|
||||
"my-task": { "dependsOn": ["transit"] }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why Transit Nodes work:**
|
||||
|
||||
- `transit` creates dependency relationships without matching any actual script
|
||||
- Tasks that depend on `transit` gain dependency awareness
|
||||
- Since `transit` completes instantly (no script), tasks run in parallel
|
||||
- Cache correctly invalidates when dependency source code changes
|
||||
|
||||
**How to identify tasks that need this pattern:** Look for tasks that read source files from dependencies but don't need their build outputs.
|
||||
|
||||
## Missing outputs for File-Producing Tasks
|
||||
|
||||
**Before flagging missing `outputs`, check what the task actually produces:**
|
||||
|
||||
1. Read the package's script (e.g., `"build": "tsc"`, `"test": "vitest"`)
|
||||
2. Determine if it writes files to disk or only outputs to stdout
|
||||
3. Only flag if the task produces files that should be cached
|
||||
|
||||
```json
|
||||
// WRONG - build produces files but they're not cached
|
||||
"build": {
|
||||
"dependsOn": ["^build"]
|
||||
}
|
||||
|
||||
// CORRECT - outputs are cached
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**"]
|
||||
}
|
||||
```
|
||||
|
||||
No `outputs` key is fine for stdout-only tasks. For file-producing tasks, missing `outputs` means Turbo has nothing to cache.
|
||||
|
||||
## Forgetting ^ in dependsOn
|
||||
|
||||
```json
|
||||
// WRONG - looks for "build" in SAME package (infinite loop or missing)
|
||||
"build": {
|
||||
"dependsOn": ["build"]
|
||||
}
|
||||
|
||||
// CORRECT - runs dependencies' build first
|
||||
"build": {
|
||||
"dependsOn": ["^build"]
|
||||
}
|
||||
```
|
||||
|
||||
The `^` means "in dependency packages", not "in this package".
|
||||
|
||||
## Missing persistent on Dev Tasks
|
||||
|
||||
```json
|
||||
// WRONG - dependent tasks hang waiting for dev to "finish"
|
||||
"dev": {
|
||||
"cache": false
|
||||
}
|
||||
|
||||
// CORRECT
|
||||
"dev": {
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
}
|
||||
```
|
||||
|
||||
## Package Config Missing extends
|
||||
|
||||
```json
|
||||
// WRONG - packages/web/turbo.json
|
||||
{
|
||||
"tasks": {
|
||||
"build": { "outputs": [".next/**"] }
|
||||
}
|
||||
}
|
||||
|
||||
// CORRECT
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tasks": {
|
||||
"build": { "outputs": [".next/**"] }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Without `"extends": ["//"]`, Package Configurations are invalid.
|
||||
|
||||
## Root Tasks Need Special Syntax
|
||||
|
||||
To run a task defined only in root `package.json`:
|
||||
|
||||
```bash
|
||||
# WRONG
|
||||
turbo run format
|
||||
|
||||
# CORRECT
|
||||
turbo run //#format
|
||||
```
|
||||
|
||||
And in dependsOn:
|
||||
|
||||
```json
|
||||
"build": {
|
||||
"dependsOn": ["//#codegen"] // Root package's codegen
|
||||
}
|
||||
```
|
||||
|
||||
## Overwriting Default Inputs
|
||||
|
||||
```json
|
||||
// WRONG - only watches test files, ignores source changes
|
||||
"test": {
|
||||
"inputs": ["tests/**"]
|
||||
}
|
||||
|
||||
// CORRECT - extends defaults, adds test files
|
||||
"test": {
|
||||
"inputs": ["$TURBO_DEFAULT$", "tests/**"]
|
||||
}
|
||||
```
|
||||
|
||||
Without `$TURBO_DEFAULT$`, you replace all default file watching.
|
||||
|
||||
## Excluding `global.inputs` Without `$TURBO_DEFAULT$`
|
||||
|
||||
When using `futureFlags.globalConfiguration`, `global.inputs` values are prepended to every task's inputs. If you want to exclude a global input from a specific task, you **must** include `$TURBO_DEFAULT$` to preserve default file hashing.
|
||||
|
||||
```json
|
||||
// WRONG - task hashes NO files at all (global input cancelled, no defaults)
|
||||
"build": {
|
||||
"inputs": ["!$TURBO_ROOT$/config.txt"]
|
||||
}
|
||||
|
||||
// CORRECT - task hashes all package files, minus config.txt
|
||||
"build": {
|
||||
"inputs": ["$TURBO_DEFAULT$", "!$TURBO_ROOT$/config.txt"]
|
||||
}
|
||||
```
|
||||
|
||||
Without `$TURBO_DEFAULT$`, the only inclusion glob comes from `global.inputs`, which the negation cancels out. The task ends up with no inclusions and no default file hashing, so it hashes nothing. Changes to source files won't cause cache misses.
|
||||
|
||||
## Caching Tasks with Side Effects
|
||||
|
||||
```json
|
||||
// WRONG - deploy might be skipped on cache hit
|
||||
"deploy": {
|
||||
"dependsOn": ["build"]
|
||||
}
|
||||
|
||||
// CORRECT
|
||||
"deploy": {
|
||||
"dependsOn": ["build"],
|
||||
"cache": false
|
||||
}
|
||||
```
|
||||
|
||||
Always disable cache for deploy, publish, or mutation tasks.
|
||||
@@ -1,325 +0,0 @@
|
||||
# Task Configuration Reference
|
||||
|
||||
Full docs: https://turborepo.dev/docs/reference/configuration#tasks
|
||||
|
||||
## dependsOn
|
||||
|
||||
Controls task execution order.
|
||||
|
||||
```json
|
||||
{
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": [
|
||||
"^build", // Dependencies' build tasks first
|
||||
"codegen", // Same package's codegen task first
|
||||
"shared#build" // Specific package's build task
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Syntax | Meaning |
|
||||
| ---------- | ------------------------------------ |
|
||||
| `^task` | Run `task` in all dependencies first |
|
||||
| `task` | Run `task` in same package first |
|
||||
| `pkg#task` | Run specific package's task first |
|
||||
|
||||
The `^` prefix is crucial - without it, you're referencing the same package.
|
||||
|
||||
### Transit Nodes for Parallel Tasks
|
||||
|
||||
For tasks like `lint` and `check-types` that can run in parallel but need dependency-aware caching:
|
||||
|
||||
```json
|
||||
{
|
||||
"tasks": {
|
||||
"transit": { "dependsOn": ["^transit"] },
|
||||
"lint": { "dependsOn": ["transit"] },
|
||||
"check-types": { "dependsOn": ["transit"] }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**DO NOT use `dependsOn: ["^lint"]`** - this forces sequential execution.
|
||||
**DO NOT use `dependsOn: []`** - this breaks cache invalidation.
|
||||
|
||||
The `transit` task creates dependency relationships without running anything (no matching script), so tasks run in parallel with correct caching.
|
||||
|
||||
## outputs
|
||||
|
||||
Glob patterns for files to cache. **If omitted, nothing is cached.**
|
||||
|
||||
```json
|
||||
{
|
||||
"tasks": {
|
||||
"build": {
|
||||
"outputs": ["dist/**", "build/**"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Framework examples:**
|
||||
|
||||
```json
|
||||
// Next.js
|
||||
"outputs": [".next/**", "!.next/cache/**"]
|
||||
|
||||
// Vite
|
||||
"outputs": ["dist/**"]
|
||||
|
||||
// TypeScript (tsc)
|
||||
"outputs": ["dist/**", "*.tsbuildinfo"]
|
||||
|
||||
// No file outputs (lint, typecheck)
|
||||
"outputs": []
|
||||
```
|
||||
|
||||
Use `!` prefix to exclude patterns from caching.
|
||||
|
||||
## inputs
|
||||
|
||||
Files considered when calculating task hash. Defaults to all tracked files in package.
|
||||
|
||||
```json
|
||||
{
|
||||
"tasks": {
|
||||
"test": {
|
||||
"inputs": ["src/**", "tests/**", "vitest.config.ts"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Special values:**
|
||||
|
||||
| Value | Meaning |
|
||||
| --------------------- | --------------------------------------- |
|
||||
| `$TURBO_DEFAULT$` | Include default inputs, then add/remove |
|
||||
| `$TURBO_ROOT$/<path>` | Reference files from repo root |
|
||||
|
||||
```json
|
||||
{
|
||||
"tasks": {
|
||||
"build": {
|
||||
"inputs": [
|
||||
"$TURBO_DEFAULT$",
|
||||
"!README.md",
|
||||
"$TURBO_ROOT$/tsconfig.base.json"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Interaction with `global.inputs`
|
||||
|
||||
When `futureFlags.globalConfiguration` is enabled, files listed in `global.inputs` are prepended to every task's `inputs`. The combined list is then used to compute the task hash.
|
||||
|
||||
This is different from `globalDependencies`, where files were hashed into the **global** hash and could not be influenced by task-level `inputs`.
|
||||
|
||||
**With `globalDependencies` (old behavior):**
|
||||
|
||||
- `globalDependencies` files contribute to the global hash
|
||||
- Task `inputs` only control which **package** files are hashed
|
||||
- There is no way for a task to "opt out" of a `globalDependencies` file
|
||||
|
||||
**With `global.inputs` (new behavior):**
|
||||
|
||||
- `global.inputs` files are merged into each task's `inputs` globs
|
||||
- Task `inputs` and `global.inputs` are combined, then the full list is hashed into the **task** hash
|
||||
- Tasks can exclude specific global files with negation globs
|
||||
|
||||
```json
|
||||
{
|
||||
"futureFlags": { "globalConfiguration": true },
|
||||
"global": {
|
||||
"inputs": ["tsconfig.json", ".env"]
|
||||
},
|
||||
"tasks": {
|
||||
"build": {},
|
||||
"lint": {
|
||||
"inputs": ["$TURBO_DEFAULT$", "!$TURBO_ROOT$/.env"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In this example:
|
||||
|
||||
- `build` hashes all package files + `tsconfig.json` + `.env` (from `global.inputs`)
|
||||
- `lint` hashes all package files + `tsconfig.json`, but **excludes** `.env` because of the negation glob
|
||||
|
||||
Tasks with no explicit `inputs` key still hash all package files (the default behavior) plus the `global.inputs` files.
|
||||
|
||||
## env
|
||||
|
||||
Environment variables to include in task hash.
|
||||
|
||||
```json
|
||||
{
|
||||
"tasks": {
|
||||
"build": {
|
||||
"env": [
|
||||
"API_URL",
|
||||
"NEXT_PUBLIC_*", // Wildcard matching
|
||||
"!DEBUG" // Exclude from hash
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Variables listed here affect cache hits - changing the value invalidates cache.
|
||||
|
||||
## cache
|
||||
|
||||
Enable/disable caching for a task. Default: `true`.
|
||||
|
||||
```json
|
||||
{
|
||||
"tasks": {
|
||||
"dev": { "cache": false },
|
||||
"deploy": { "cache": false }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Disable for: dev servers, deploy commands, tasks with side effects.
|
||||
|
||||
## persistent
|
||||
|
||||
Mark long-running tasks that don't exit. Default: `false`.
|
||||
|
||||
```json
|
||||
{
|
||||
"tasks": {
|
||||
"dev": {
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Required for dev servers - without it, dependent tasks wait forever.
|
||||
|
||||
## interactive
|
||||
|
||||
Allow task to receive stdin input. Default: `false`.
|
||||
|
||||
```json
|
||||
{
|
||||
"tasks": {
|
||||
"login": {
|
||||
"cache": false,
|
||||
"interactive": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## outputLogs
|
||||
|
||||
Control when logs are shown. Options: `full`, `hash-only`, `new-only`, `errors-only`, `none`.
|
||||
|
||||
```json
|
||||
{
|
||||
"tasks": {
|
||||
"build": {
|
||||
"outputLogs": "new-only" // Only show logs on cache miss
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## with
|
||||
|
||||
Run tasks alongside this task. For long-running tasks that need runtime dependencies.
|
||||
|
||||
```json
|
||||
{
|
||||
"tasks": {
|
||||
"dev": {
|
||||
"with": ["api#dev"],
|
||||
"persistent": true,
|
||||
"cache": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Unlike `dependsOn`, `with` runs tasks concurrently (not sequentially). Use for dev servers that need other services running.
|
||||
|
||||
## interruptible
|
||||
|
||||
Allow `turbo watch` to restart the task on changes. Default: `false`.
|
||||
|
||||
```json
|
||||
{
|
||||
"tasks": {
|
||||
"dev": {
|
||||
"persistent": true,
|
||||
"interruptible": true,
|
||||
"cache": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use for dev servers that don't automatically detect dependency changes.
|
||||
|
||||
## description
|
||||
|
||||
Human-readable description of the task.
|
||||
|
||||
```json
|
||||
{
|
||||
"tasks": {
|
||||
"build": {
|
||||
"description": "Compiles the application for production deployment"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For documentation only - doesn't affect execution or caching.
|
||||
|
||||
## passThroughEnv
|
||||
|
||||
Environment variables available at runtime but NOT included in cache hash.
|
||||
|
||||
```json
|
||||
{
|
||||
"tasks": {
|
||||
"build": {
|
||||
"passThroughEnv": ["AWS_SECRET_KEY", "GITHUB_TOKEN"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Warning**: Changes to these vars won't cause cache misses. Use `env` if changes should invalidate cache.
|
||||
|
||||
## extends (Package Configuration only)
|
||||
|
||||
Control task inheritance in Package Configurations.
|
||||
|
||||
```json
|
||||
// packages/ui/turbo.json
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tasks": {
|
||||
"lint": {
|
||||
"extends": false // Exclude from this package
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Value | Behavior |
|
||||
| ---------------- | -------------------------------------------------------------- |
|
||||
| `true` (default) | Inherit from root turbo.json |
|
||||
| `false` | Exclude task from package, or define fresh without inheritance |
|
||||
@@ -1,123 +0,0 @@
|
||||
# Environment Variables in Turborepo
|
||||
|
||||
Turborepo provides fine-grained control over which environment variables affect task hashing and runtime availability.
|
||||
|
||||
## Configuration Keys
|
||||
|
||||
### `env` - Task-Specific Variables
|
||||
|
||||
Variables that affect a specific task's hash. When these change, only that task rebuilds.
|
||||
|
||||
```json
|
||||
{
|
||||
"tasks": {
|
||||
"build": {
|
||||
"env": ["DATABASE_URL", "API_KEY"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `globalEnv` - Variables Affecting All Tasks
|
||||
|
||||
Variables that affect EVERY task's hash. When these change, all tasks rebuild.
|
||||
|
||||
```json
|
||||
{
|
||||
"globalEnv": ["CI", "NODE_ENV"]
|
||||
}
|
||||
```
|
||||
|
||||
### `passThroughEnv` - Runtime-Only Variables (Not Hashed)
|
||||
|
||||
Variables available at runtime but NOT included in hash. **Use with caution** - changes won't trigger rebuilds.
|
||||
|
||||
```json
|
||||
{
|
||||
"tasks": {
|
||||
"deploy": {
|
||||
"passThroughEnv": ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `globalPassThroughEnv` - Global Runtime Variables
|
||||
|
||||
Same as `passThroughEnv` but for all tasks.
|
||||
|
||||
```json
|
||||
{
|
||||
"globalPassThroughEnv": ["GITHUB_TOKEN"]
|
||||
}
|
||||
```
|
||||
|
||||
## Wildcards and Negation
|
||||
|
||||
### Wildcards
|
||||
|
||||
Match multiple variables with `*`:
|
||||
|
||||
```json
|
||||
{
|
||||
"env": ["MY_API_*", "FEATURE_FLAG_*"]
|
||||
}
|
||||
```
|
||||
|
||||
This matches `MY_API_URL`, `MY_API_KEY`, `FEATURE_FLAG_DARK_MODE`, etc.
|
||||
|
||||
### Negation
|
||||
|
||||
Exclude variables (useful with framework inference):
|
||||
|
||||
```json
|
||||
{
|
||||
"env": ["!NEXT_PUBLIC_ANALYTICS_ID"]
|
||||
}
|
||||
```
|
||||
|
||||
## With `futureFlags.globalConfiguration`
|
||||
|
||||
When the `globalConfiguration` future flag is enabled, global environment keys move under the `global` key with cleaner names:
|
||||
|
||||
| Old (top-level) | New (`global.`) |
|
||||
| ---------------------- | ---------------- |
|
||||
| `globalEnv` | `env` |
|
||||
| `globalPassThroughEnv` | `passThroughEnv` |
|
||||
|
||||
`global.env` and `global.passThroughEnv` behave identically to their top-level counterparts — they affect the global hash and all tasks, respectively. The rename is purely organizational.
|
||||
|
||||
```json
|
||||
{
|
||||
"futureFlags": { "globalConfiguration": true },
|
||||
"global": {
|
||||
"env": ["CI", "NODE_ENV"],
|
||||
"passThroughEnv": ["GITHUB_TOKEN", "NPM_TOKEN"]
|
||||
},
|
||||
"tasks": {
|
||||
"build": {
|
||||
"env": ["DATABASE_URL", "API_*"],
|
||||
"passThroughEnv": ["SENTRY_AUTH_TOKEN"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://v2-8-21-canary-9.turborepo.dev/schema.json",
|
||||
"globalEnv": ["CI", "NODE_ENV"],
|
||||
"globalPassThroughEnv": ["GITHUB_TOKEN", "NPM_TOKEN"],
|
||||
"tasks": {
|
||||
"build": {
|
||||
"env": ["DATABASE_URL", "API_*"],
|
||||
"passThroughEnv": ["SENTRY_AUTH_TOKEN"]
|
||||
},
|
||||
"test": {
|
||||
"env": ["TEST_DATABASE_URL"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1,175 +0,0 @@
|
||||
# Environment Variable Gotchas
|
||||
|
||||
Common mistakes and how to fix them.
|
||||
|
||||
## .env Files Must Be in `inputs`
|
||||
|
||||
Turbo does NOT read `.env` files. Your framework (Next.js, Vite, etc.) or `dotenv` loads them. But Turbo needs to know when they change.
|
||||
|
||||
**Wrong:**
|
||||
|
||||
```json
|
||||
{
|
||||
"tasks": {
|
||||
"build": {
|
||||
"env": ["DATABASE_URL"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Right:**
|
||||
|
||||
```json
|
||||
{
|
||||
"tasks": {
|
||||
"build": {
|
||||
"env": ["DATABASE_URL"],
|
||||
"inputs": ["$TURBO_DEFAULT$", ".env", ".env.local", ".env.production"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Strict Mode Filters CI Variables
|
||||
|
||||
In strict mode, CI provider variables (GITHUB_TOKEN, GITLAB_CI, etc.) are filtered unless explicitly listed.
|
||||
|
||||
**Symptom:** Task fails with "authentication required" or "permission denied" in CI.
|
||||
|
||||
**Solution:**
|
||||
|
||||
```json
|
||||
{
|
||||
"globalPassThroughEnv": ["GITHUB_TOKEN", "GITLAB_CI", "CI"]
|
||||
}
|
||||
```
|
||||
|
||||
## passThroughEnv Doesn't Affect Hash
|
||||
|
||||
Variables in `passThroughEnv` are available at runtime but changes WON'T trigger rebuilds.
|
||||
|
||||
**Dangerous example:**
|
||||
|
||||
```json
|
||||
{
|
||||
"tasks": {
|
||||
"build": {
|
||||
"passThroughEnv": ["API_URL"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If `API_URL` changes from staging to production, Turbo may serve a cached build pointing to the wrong API.
|
||||
|
||||
**Use passThroughEnv only for:**
|
||||
|
||||
- Auth tokens that don't affect output (SENTRY_AUTH_TOKEN)
|
||||
- CI metadata (GITHUB_RUN_ID)
|
||||
- Variables consumed after build (deploy credentials)
|
||||
|
||||
## Runtime-Created Variables Are Invisible
|
||||
|
||||
Turbo captures env vars at startup. Variables created during execution aren't seen.
|
||||
|
||||
**Won't work:**
|
||||
|
||||
```bash
|
||||
# In package.json scripts
|
||||
"build": "export API_URL=$COMPUTED_VALUE && next build"
|
||||
```
|
||||
|
||||
**Solution:** Set vars before invoking turbo:
|
||||
|
||||
```bash
|
||||
API_URL=$COMPUTED_VALUE turbo run build
|
||||
```
|
||||
|
||||
## Different .env Files for Different Environments
|
||||
|
||||
If you use `.env.development` and `.env.production`, both should be in inputs.
|
||||
|
||||
```json
|
||||
{
|
||||
"tasks": {
|
||||
"build": {
|
||||
"inputs": [
|
||||
"$TURBO_DEFAULT$",
|
||||
".env",
|
||||
".env.local",
|
||||
".env.development",
|
||||
".env.development.local",
|
||||
".env.production",
|
||||
".env.production.local"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Complete Next.js Example
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://v2-8-21-canary-9.turborepo.dev/schema.json",
|
||||
"globalEnv": ["CI", "NODE_ENV", "VERCEL"],
|
||||
"globalPassThroughEnv": ["GITHUB_TOKEN", "VERCEL_URL"],
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"env": ["DATABASE_URL", "NEXT_PUBLIC_*", "!NEXT_PUBLIC_ANALYTICS_ID"],
|
||||
"passThroughEnv": ["SENTRY_AUTH_TOKEN"],
|
||||
"inputs": [
|
||||
"$TURBO_DEFAULT$",
|
||||
".env",
|
||||
".env.local",
|
||||
".env.production",
|
||||
".env.production.local"
|
||||
],
|
||||
"outputs": [".next/**", "!.next/cache/**"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This config:
|
||||
|
||||
- Hashes DATABASE*URL and NEXT_PUBLIC*\* vars (except analytics)
|
||||
- Passes through SENTRY_AUTH_TOKEN without hashing
|
||||
- Includes all .env file variants in the hash
|
||||
- Makes CI tokens available globally
|
||||
|
||||
### With `futureFlags.globalConfiguration`
|
||||
|
||||
The same config using the `global` key. The `.env` files move to `global.inputs`, which means they get folded into each task's hash individually rather than the global hash. This lets tasks exclude specific `.env` files if needed.
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://v2-8-21-canary-9.turborepo.dev/schema.json",
|
||||
"futureFlags": { "globalConfiguration": true },
|
||||
"global": {
|
||||
"env": ["CI", "NODE_ENV", "VERCEL"],
|
||||
"passThroughEnv": ["GITHUB_TOKEN", "VERCEL_URL"],
|
||||
"inputs": [".env", ".env.local", ".env.production", ".env.production.local"]
|
||||
},
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"env": ["DATABASE_URL", "NEXT_PUBLIC_*", "!NEXT_PUBLIC_ANALYTICS_ID"],
|
||||
"passThroughEnv": ["SENTRY_AUTH_TOKEN"],
|
||||
"outputs": [".next/**", "!.next/cache/**"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
With this approach, a task that doesn't care about `.env.production` can exclude it:
|
||||
|
||||
```json
|
||||
"lint": {
|
||||
"inputs": ["$TURBO_DEFAULT$", "!$TURBO_ROOT$/.env.production"]
|
||||
}
|
||||
```
|
||||
|
||||
This wouldn't have been possible with `globalDependencies`, where `.env.production` would be baked into the global hash and affect every task unconditionally.
|
||||
@@ -1,101 +0,0 @@
|
||||
# Environment Modes
|
||||
|
||||
Turborepo supports different modes for handling environment variables during task execution.
|
||||
|
||||
## Strict Mode (Default)
|
||||
|
||||
Only explicitly configured variables are available to tasks.
|
||||
|
||||
**Behavior:**
|
||||
|
||||
- Tasks only see vars listed in `env`, `globalEnv`, `passThroughEnv`, or `globalPassThroughEnv`
|
||||
- Unlisted vars are filtered out
|
||||
- Tasks fail if they require unlisted variables
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Guarantees cache correctness
|
||||
- Prevents accidental dependencies on system vars
|
||||
- Reproducible builds across machines
|
||||
|
||||
```bash
|
||||
# Explicit (though it's the default)
|
||||
turbo run build --env-mode=strict
|
||||
```
|
||||
|
||||
## Loose Mode
|
||||
|
||||
All system environment variables are available to tasks.
|
||||
|
||||
```bash
|
||||
turbo run build --env-mode=loose
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
|
||||
- Every system env var is passed through
|
||||
- Only vars in `env`/`globalEnv` affect the hash
|
||||
- Other vars are available but NOT hashed
|
||||
|
||||
**Risks:**
|
||||
|
||||
- Cache may restore incorrect results if unhashed vars changed
|
||||
- "Works on my machine" bugs
|
||||
- CI vs local environment mismatches
|
||||
|
||||
**Use case:** Migrating legacy projects or debugging strict mode issues.
|
||||
|
||||
## Framework Inference (Automatic)
|
||||
|
||||
Turborepo automatically detects frameworks and includes their conventional env vars.
|
||||
|
||||
### Inferred Variables by Framework
|
||||
|
||||
| Framework | Pattern |
|
||||
| ---------------- | ------------------- |
|
||||
| Next.js | `NEXT_PUBLIC_*` |
|
||||
| Vite | `VITE_*` |
|
||||
| Create React App | `REACT_APP_*` |
|
||||
| Gatsby | `GATSBY_*` |
|
||||
| Nuxt | `NUXT_*`, `NITRO_*` |
|
||||
| Expo | `EXPO_PUBLIC_*` |
|
||||
| Astro | `PUBLIC_*` |
|
||||
| SvelteKit | `PUBLIC_*` |
|
||||
| Remix | `REMIX_*` |
|
||||
| Redwood | `REDWOOD_ENV_*` |
|
||||
| Sanity | `SANITY_STUDIO_*` |
|
||||
| Solid | `VITE_*` |
|
||||
|
||||
### Disabling Framework Inference
|
||||
|
||||
Globally via CLI:
|
||||
|
||||
```bash
|
||||
turbo run build --framework-inference=false
|
||||
```
|
||||
|
||||
Or exclude specific patterns in config:
|
||||
|
||||
```json
|
||||
{
|
||||
"tasks": {
|
||||
"build": {
|
||||
"env": ["!NEXT_PUBLIC_*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Why Disable?
|
||||
|
||||
- You want explicit control over all env vars
|
||||
- Framework vars shouldn't bust the cache (e.g., analytics IDs)
|
||||
- Debugging unexpected cache misses
|
||||
|
||||
## Checking Environment Mode
|
||||
|
||||
Use `--dry` to see which vars affect each task:
|
||||
|
||||
```bash
|
||||
turbo run build --dry=json | jq '.tasks[].environmentVariables'
|
||||
```
|
||||
@@ -1,148 +0,0 @@
|
||||
# Turborepo Filter Syntax Reference
|
||||
|
||||
## Running Only Changed Packages: `--affected`
|
||||
|
||||
**The primary way to run only changed packages is `--affected`:**
|
||||
|
||||
```bash
|
||||
# Run build/test/lint only in changed packages and their dependents
|
||||
turbo run build test lint --affected
|
||||
```
|
||||
|
||||
This compares your current branch to the default branch (usually `main` or `master`) and runs tasks in:
|
||||
|
||||
1. Packages with file changes
|
||||
2. Packages that depend on changed packages (dependents)
|
||||
|
||||
### Why Include Dependents?
|
||||
|
||||
If you change `@repo/ui`, packages that import `@repo/ui` (like `apps/web`) need to re-run their tasks to verify they still work with the changes.
|
||||
|
||||
### Customizing --affected
|
||||
|
||||
```bash
|
||||
# Use a different base branch
|
||||
turbo run build --affected --affected-base=origin/develop
|
||||
|
||||
# Use a different head (current state)
|
||||
turbo run build --affected --affected-head=HEAD~5
|
||||
```
|
||||
|
||||
### Common CI Pattern
|
||||
|
||||
```yaml
|
||||
# .github/workflows/ci.yml
|
||||
- run: turbo run build test lint --affected
|
||||
```
|
||||
|
||||
This is the most efficient CI setup - only run tasks for what actually changed.
|
||||
|
||||
---
|
||||
|
||||
## Manual Git Comparison with --filter
|
||||
|
||||
For more control, use `--filter` with git comparison syntax:
|
||||
|
||||
```bash
|
||||
# Changed packages + dependents (same as --affected)
|
||||
turbo run build --filter=...[origin/main]
|
||||
|
||||
# Only changed packages (no dependents)
|
||||
turbo run build --filter=[origin/main]
|
||||
|
||||
# Changed packages + dependencies (packages they import)
|
||||
turbo run build --filter=[origin/main]...
|
||||
|
||||
# Changed since last commit
|
||||
turbo run build --filter=...[HEAD^1]
|
||||
|
||||
# Changed between two commits
|
||||
turbo run build --filter=[a1b2c3d...e4f5g6h]
|
||||
```
|
||||
|
||||
### Comparison Syntax
|
||||
|
||||
| Syntax | Meaning |
|
||||
| ------------- | ------------------------------------- |
|
||||
| `[ref]` | Packages changed since `ref` |
|
||||
| `...[ref]` | Changed packages + their dependents |
|
||||
| `[ref]...` | Changed packages + their dependencies |
|
||||
| `...[ref]...` | Dependencies, changed, AND dependents |
|
||||
|
||||
---
|
||||
|
||||
## Other Filter Types
|
||||
|
||||
Filters select which packages to include in a `turbo run` invocation.
|
||||
|
||||
### Basic Syntax
|
||||
|
||||
```bash
|
||||
turbo run build --filter=<package-name>
|
||||
turbo run build -F <package-name>
|
||||
```
|
||||
|
||||
Multiple filters combine as a union (packages matching ANY filter run).
|
||||
|
||||
### By Package Name
|
||||
|
||||
```bash
|
||||
--filter=web # exact match
|
||||
--filter=@acme/* # scope glob
|
||||
--filter=*-app # name glob
|
||||
```
|
||||
|
||||
### By Directory
|
||||
|
||||
```bash
|
||||
--filter=./apps/* # all packages in apps/
|
||||
--filter=./packages/ui # specific directory
|
||||
```
|
||||
|
||||
### By Dependencies/Dependents
|
||||
|
||||
| Syntax | Meaning |
|
||||
| ----------- | -------------------------------------- |
|
||||
| `pkg...` | Package AND all its dependencies |
|
||||
| `...pkg` | Package AND all its dependents |
|
||||
| `...pkg...` | Dependencies, package, AND dependents |
|
||||
| `^pkg...` | Only dependencies (exclude pkg itself) |
|
||||
| `...^pkg` | Only dependents (exclude pkg itself) |
|
||||
|
||||
### Negation
|
||||
|
||||
Exclude packages with `!`:
|
||||
|
||||
```bash
|
||||
--filter=!web # exclude web
|
||||
--filter=./apps/* --filter=!admin # apps except admin
|
||||
```
|
||||
|
||||
### Task Identifiers
|
||||
|
||||
Run a specific task in a specific package:
|
||||
|
||||
```bash
|
||||
turbo run web#build # only web's build task
|
||||
turbo run web#build api#test # web build + api test
|
||||
```
|
||||
|
||||
### Combining Filters
|
||||
|
||||
Multiple `--filter` flags create a union:
|
||||
|
||||
```bash
|
||||
turbo run build --filter=web --filter=api # runs in both
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference: Changed Packages
|
||||
|
||||
| Goal | Command |
|
||||
| ---------------------------------- | ----------------------------------------------------------- |
|
||||
| Changed + dependents (recommended) | `turbo run build --affected` |
|
||||
| Custom base branch | `turbo run build --affected --affected-base=origin/develop` |
|
||||
| Only changed (no dependents) | `turbo run build --filter=[origin/main]` |
|
||||
| Changed + dependencies | `turbo run build --filter=[origin/main]...` |
|
||||
| Since last commit | `turbo run build --filter=...[HEAD^1]` |
|
||||
@@ -1,152 +0,0 @@
|
||||
# Common Filter Patterns
|
||||
|
||||
Practical examples for typical monorepo scenarios.
|
||||
|
||||
## Single Package
|
||||
|
||||
Run task in one package:
|
||||
|
||||
```bash
|
||||
turbo run build --filter=web
|
||||
turbo run test --filter=@acme/api
|
||||
```
|
||||
|
||||
## Package with Dependencies
|
||||
|
||||
Build a package and everything it depends on:
|
||||
|
||||
```bash
|
||||
turbo run build --filter=web...
|
||||
```
|
||||
|
||||
Useful for: ensuring all dependencies are built before the target.
|
||||
|
||||
## Package Dependents
|
||||
|
||||
Run in all packages that depend on a library:
|
||||
|
||||
```bash
|
||||
turbo run test --filter=...ui
|
||||
```
|
||||
|
||||
Useful for: testing consumers after changing a shared package.
|
||||
|
||||
## Dependents Only (Exclude Target)
|
||||
|
||||
Test packages that depend on ui, but not ui itself:
|
||||
|
||||
```bash
|
||||
turbo run test --filter=...^ui
|
||||
```
|
||||
|
||||
## Changed Packages
|
||||
|
||||
Run only in packages with file changes since last commit:
|
||||
|
||||
```bash
|
||||
turbo run lint --filter=[HEAD^1]
|
||||
```
|
||||
|
||||
Since a specific branch point:
|
||||
|
||||
```bash
|
||||
turbo run lint --filter=[main...HEAD]
|
||||
```
|
||||
|
||||
## Changed + Dependents (PR Builds)
|
||||
|
||||
Run in changed packages AND packages that depend on them:
|
||||
|
||||
```bash
|
||||
turbo run build test --filter=...[HEAD^1]
|
||||
```
|
||||
|
||||
Or use the shortcut:
|
||||
|
||||
```bash
|
||||
turbo run build test --affected
|
||||
```
|
||||
|
||||
## Directory-Based
|
||||
|
||||
Run in all apps:
|
||||
|
||||
```bash
|
||||
turbo run build --filter=./apps/*
|
||||
```
|
||||
|
||||
Run in specific directories:
|
||||
|
||||
```bash
|
||||
turbo run build --filter=./apps/web --filter=./apps/api
|
||||
```
|
||||
|
||||
## Scope-Based
|
||||
|
||||
Run in all packages under a scope:
|
||||
|
||||
```bash
|
||||
turbo run build --filter=@acme/*
|
||||
```
|
||||
|
||||
## Exclusions
|
||||
|
||||
Run in all apps except admin:
|
||||
|
||||
```bash
|
||||
turbo run build --filter=./apps/* --filter=!admin
|
||||
```
|
||||
|
||||
Run everywhere except specific packages:
|
||||
|
||||
```bash
|
||||
turbo run lint --filter=!legacy-app --filter=!deprecated-pkg
|
||||
```
|
||||
|
||||
## Complex Combinations
|
||||
|
||||
Apps that changed, plus their dependents:
|
||||
|
||||
```bash
|
||||
turbo run build --filter=...[HEAD^1] --filter=./apps/*
|
||||
```
|
||||
|
||||
All packages except docs, but only if changed:
|
||||
|
||||
```bash
|
||||
turbo run build --filter=[main...HEAD] --filter=!docs
|
||||
```
|
||||
|
||||
## Debugging Filters
|
||||
|
||||
Use `--dry` to see what would run without executing:
|
||||
|
||||
```bash
|
||||
turbo run build --filter=web... --dry
|
||||
```
|
||||
|
||||
Use `--dry=json` for machine-readable output:
|
||||
|
||||
```bash
|
||||
turbo run build --filter=...[HEAD^1] --dry=json
|
||||
```
|
||||
|
||||
## CI/CD Patterns
|
||||
|
||||
PR validation (most common):
|
||||
|
||||
```bash
|
||||
turbo run build test lint --affected
|
||||
```
|
||||
|
||||
Deploy only changed apps:
|
||||
|
||||
```bash
|
||||
turbo run deploy --filter=./apps/* --filter=[main...HEAD]
|
||||
```
|
||||
|
||||
Full rebuild of specific app and deps:
|
||||
|
||||
```bash
|
||||
turbo run build --filter=production-app...
|
||||
```
|
||||
@@ -1,99 +0,0 @@
|
||||
# turbo watch
|
||||
|
||||
Full docs: https://turborepo.dev/docs/reference/watch
|
||||
|
||||
Re-run tasks automatically when code changes. Dependency-aware.
|
||||
|
||||
```bash
|
||||
turbo watch [tasks]
|
||||
```
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```bash
|
||||
# Watch and re-run build task when code changes
|
||||
turbo watch build
|
||||
|
||||
# Watch multiple tasks
|
||||
turbo watch build test lint
|
||||
```
|
||||
|
||||
Tasks re-run in order configured in `turbo.json` when source files change.
|
||||
|
||||
## With Persistent Tasks
|
||||
|
||||
Persistent tasks (`"persistent": true`) won't exit, so they can't be depended on. They work the same in `turbo watch` as `turbo run`.
|
||||
|
||||
### Dependency-Aware Persistent Tasks
|
||||
|
||||
If your tool has built-in watching (like `next dev`), use its watcher:
|
||||
|
||||
```json
|
||||
{
|
||||
"tasks": {
|
||||
"dev": {
|
||||
"persistent": true,
|
||||
"cache": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Non-Dependency-Aware Tools
|
||||
|
||||
For tools that don't detect dependency changes, use `interruptible`:
|
||||
|
||||
```json
|
||||
{
|
||||
"tasks": {
|
||||
"dev": {
|
||||
"persistent": true,
|
||||
"interruptible": true,
|
||||
"cache": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`turbo watch` will restart interruptible tasks when dependencies change.
|
||||
|
||||
## Limitations
|
||||
|
||||
### Caching
|
||||
|
||||
Caching is experimental with watch mode:
|
||||
|
||||
```bash
|
||||
turbo watch your-tasks --experimental-write-cache
|
||||
```
|
||||
|
||||
### Task Outputs in Source Control
|
||||
|
||||
If tasks write files tracked by git, watch mode may loop infinitely. Watch mode uses file hashes to prevent this but it's not foolproof.
|
||||
|
||||
**Recommendation**: Remove task outputs from git.
|
||||
|
||||
## vs turbo run
|
||||
|
||||
| Feature | `turbo run` | `turbo watch` |
|
||||
| ----------------- | ----------- | ------------- |
|
||||
| Runs once | Yes | No |
|
||||
| Re-runs on change | No | Yes |
|
||||
| Caching | Full | Experimental |
|
||||
| Use case | CI, one-off | Development |
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Development Workflow
|
||||
|
||||
```bash
|
||||
# Run dev servers and watch for build changes
|
||||
turbo watch dev build
|
||||
```
|
||||
|
||||
### Type Checking During Development
|
||||
|
||||
```bash
|
||||
# Watch and re-run type checks
|
||||
turbo watch check-types
|
||||
```
|
||||
@@ -1,4 +0,0 @@
|
||||
[codespell]
|
||||
skip = .git,*.pdf,*.svg,package-lock.json,*.prisma,pnpm-lock.yaml,./worker/src/__tests__/chatml/framework-traces
|
||||
ignore-words-list = afterall,vertx,notIn,alue,allTime
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
# Dev container Dockerfile
|
||||
FROM --platform=${BUILDPLATFORM} golang:1.24 AS migrate-builder
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
ENV CGO_ENABLED=0 \
|
||||
GOBIN=/out \
|
||||
GOOS=${TARGETOS} \
|
||||
GOARCH=${TARGETARCH}
|
||||
# Build only the ClickHouse migrate CLI used in this repo.
|
||||
RUN /usr/local/go/bin/go install -trimpath -tags 'clickhouse' -ldflags='-s -w' \
|
||||
github.com/golang-migrate/migrate/v4/cmd/migrate@v4.19.1
|
||||
|
||||
FROM mcr.microsoft.com/devcontainers/universal:2
|
||||
|
||||
# Install golang-migrate for database migrations
|
||||
COPY --from=migrate-builder /out/migrate /usr/local/bin/migrate
|
||||
|
||||
# Activate the repo's pinned pnpm via Corepack
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
|
||||
|
||||
# Install Clickhouse
|
||||
RUN curl https://clickhouse.com/ | sh && \
|
||||
sudo ./clickhouse install
|
||||
|
||||
# Install agent CLIs used in this repo
|
||||
RUN npm install -g @anthropic-ai/claude-code @openai/codex
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"name": "langfuse-development",
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile"
|
||||
},
|
||||
"forwardPorts": [3000, 5432, 6379, 8123, 9000],
|
||||
"postCreateCommand": "cp .env.dev.example .env && pnpm i"
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
node_modules
|
||||
npm-debug.log
|
||||
README.md
|
||||
.pnpm-store
|
||||
**/.pnpm-store
|
||||
.turbo
|
||||
**/.turbo
|
||||
**/.next
|
||||
**/.next-check
|
||||
**/dist
|
||||
**/*.tsbuildinfo
|
||||
.git
|
||||
**/node_modules
|
||||
@@ -1,85 +0,0 @@
|
||||
# When adding additional environment variables, the schema in "/src/env.mjs"
|
||||
# should be updated accordingly.
|
||||
|
||||
# Prisma
|
||||
# https://www.prisma.io/docs/reference/database-reference/connection-urls#env
|
||||
DIRECT_URL="postgresql://postgres:postgres@localhost:5432/postgres"
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
|
||||
|
||||
# Clickhouse
|
||||
CLICKHOUSE_MIGRATION_URL="clickhouse://localhost:9000"
|
||||
CLICKHOUSE_URL="http://localhost:8123"
|
||||
CLICKHOUSE_USER="clickhouse"
|
||||
CLICKHOUSE_PASSWORD="clickhouse"
|
||||
CLICKHOUSE_CLUSTER_ENABLED="false"
|
||||
|
||||
# Next Auth
|
||||
# You can generate a new secret on the command line with:
|
||||
# openssl rand -base64 32
|
||||
# https://next-auth.js.org/configuration/options#secret
|
||||
# NEXTAUTH_SECRET=""
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
NEXTAUTH_SECRET="secret"
|
||||
|
||||
# Langfuse Cloud Environment
|
||||
NEXT_PUBLIC_LANGFUSE_CLOUD_REGION="DEV"
|
||||
|
||||
# Langfuse experimental features
|
||||
LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES="false"
|
||||
|
||||
# Salt for API key hashing
|
||||
SALT="salt"
|
||||
|
||||
# Email
|
||||
EMAIL_FROM_ADDRESS="" # Defines the email address to use as the from address.
|
||||
SMTP_CONNECTION_URL="" # Defines the connection url for smtp server.
|
||||
|
||||
# DON'T PANIC: The Azurite Secrets are well-known and meant to be hard-coded
|
||||
# S3 Batch Exports
|
||||
LANGFUSE_S3_BATCH_EXPORT_ENABLED=true
|
||||
LANGFUSE_S3_BATCH_EXPORT_BUCKET=langfuse
|
||||
LANGFUSE_S3_BATCH_EXPORT_ACCESS_KEY_ID=devstoreaccount1
|
||||
LANGFUSE_S3_BATCH_EXPORT_SECRET_ACCESS_KEY=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==
|
||||
LANGFUSE_S3_BATCH_EXPORT_REGION=auto
|
||||
LANGFUSE_S3_BATCH_EXPORT_ENDPOINT=http://localhost:10000/devstoreaccount1
|
||||
## Necessary for minio compatibility
|
||||
LANGFUSE_S3_BATCH_EXPORT_FORCE_PATH_STYLE=true
|
||||
LANGFUSE_S3_BATCH_EXPORT_PREFIX=exports/
|
||||
|
||||
# S3 Media Upload LOCAL
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_BUCKET=langfuse
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID=devstoreaccount1
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_REGION=auto
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT=http://localhost:10000/devstoreaccount1
|
||||
## Necessary for minio compatibility
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE=true
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_PREFIX=media/
|
||||
|
||||
# S3 Event Bucket Upload
|
||||
## Set to true to test uploading all events to S3
|
||||
LANGFUSE_S3_EVENT_UPLOAD_BUCKET=langfuse
|
||||
LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID=devstoreaccount1
|
||||
LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==
|
||||
LANGFUSE_S3_EVENT_UPLOAD_REGION=auto
|
||||
LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT=http://localhost:10000/devstoreaccount1
|
||||
## Necessary for minio compatibility
|
||||
LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE=true
|
||||
LANGFUSE_S3_EVENT_UPLOAD_PREFIX=events/
|
||||
|
||||
LANGFUSE_USE_AZURE_BLOB=true
|
||||
LANGFUSE_AZURE_SKIP_CONTAINER_CHECK=false
|
||||
|
||||
# Set during docker build of application
|
||||
# Used to disable environment verification at build time
|
||||
# DOCKER_BUILD=1
|
||||
|
||||
REDIS_HOST="127.0.0.1"
|
||||
REDIS_PORT=6379
|
||||
REDIS_AUTH="myredissecret"
|
||||
|
||||
# openssl rand -hex 32 used only here
|
||||
ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
|
||||
|
||||
# speeds up local development by not executing init scripts on server startup
|
||||
NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT="false"
|
||||
@@ -1,93 +0,0 @@
|
||||
# When adding additional environment variables, the schema in "/src/env.mjs"
|
||||
# should be updated accordingly.
|
||||
|
||||
# Prisma
|
||||
# https://www.prisma.io/docs/reference/database-reference/connection-urls#env
|
||||
DIRECT_URL="postgresql://postgres:postgres@localhost:5432/postgres"
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
|
||||
|
||||
# Clickhouse
|
||||
CLICKHOUSE_MIGRATION_URL="clickhouse://localhost:9000"
|
||||
CLICKHOUSE_URL="http://localhost:8123"
|
||||
CLICKHOUSE_USER="clickhouse"
|
||||
CLICKHOUSE_PASSWORD="clickhouse"
|
||||
CLICKHOUSE_CLUSTER_ENABLED="false"
|
||||
|
||||
# Next Auth
|
||||
# You can generate a new secret on the command line with:
|
||||
# openssl rand -base64 32
|
||||
# https://next-auth.js.org/configuration/options#secret
|
||||
# NEXTAUTH_SECRET=""
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
NEXTAUTH_SECRET="secret"
|
||||
|
||||
# Langfuse Cloud Environment
|
||||
NEXT_PUBLIC_LANGFUSE_CLOUD_REGION="DEV"
|
||||
|
||||
# Langfuse experimental features
|
||||
LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES="false"
|
||||
|
||||
# Salt for API key hashing
|
||||
SALT="salt"
|
||||
|
||||
# Email
|
||||
EMAIL_FROM_ADDRESS="" # Defines the email address to use as the from address.
|
||||
SMTP_CONNECTION_URL="" # Defines the connection url for smtp server.
|
||||
|
||||
# S3 Batch Exports
|
||||
LANGFUSE_S3_BATCH_EXPORT_ENABLED=true
|
||||
LANGFUSE_S3_BATCH_EXPORT_BUCKET=langfuse
|
||||
LANGFUSE_S3_BATCH_EXPORT_ACCESS_KEY_ID=minio
|
||||
LANGFUSE_S3_BATCH_EXPORT_SECRET_ACCESS_KEY=miniosecret
|
||||
LANGFUSE_S3_BATCH_EXPORT_REGION=us-east-1
|
||||
LANGFUSE_S3_BATCH_EXPORT_ENDPOINT=http://localhost:9090
|
||||
## Necessary for minio compatibility
|
||||
LANGFUSE_S3_BATCH_EXPORT_FORCE_PATH_STYLE=true
|
||||
LANGFUSE_S3_BATCH_EXPORT_PREFIX=exports/
|
||||
|
||||
# S3 Media Upload LOCAL
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_BUCKET=langfuse
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID=minio
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY=miniosecret
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_REGION=us-east-1
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT=http://localhost:9090
|
||||
## Necessary for minio compatibility
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE=true
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_PREFIX=media/
|
||||
|
||||
# S3 Event Bucket Upload
|
||||
## Set to true to test uploading all events to S3
|
||||
LANGFUSE_S3_EVENT_UPLOAD_BUCKET=langfuse
|
||||
LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID=minio
|
||||
LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY=miniosecret
|
||||
LANGFUSE_S3_EVENT_UPLOAD_REGION=us-east-1
|
||||
LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT=http://localhost:9090
|
||||
## Necessary for minio compatibility
|
||||
LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE=true
|
||||
LANGFUSE_S3_EVENT_UPLOAD_PREFIX=events/
|
||||
|
||||
# Set during docker build of application
|
||||
# Used to disable environment verification at build time
|
||||
# DOCKER_BUILD=1
|
||||
|
||||
REDIS_HOST="127.0.0.1"
|
||||
REDIS_PORT=6379
|
||||
REDIS_AUTH="bitnami"
|
||||
# REDIS_KEY_PREFIX="" # Optional: Prefix for Redis keys (useful for multi-tenant Redis instances)
|
||||
# BullMQ queues will use this via BullMQ's native prefix option
|
||||
# Cache operations will use this via ioredis keyPrefix
|
||||
REDIS_CLUSTER_ENABLED="true"
|
||||
REDIS_CLUSTER_NODES="127.0.0.1:6370,127.0.0.1:6371,127.0.0.1:6372,127.0.0.1:6373,127.0.0.1:6374,127.0.0.1:6375"
|
||||
LANGFUSE_INGESTION_QUEUE_SHARD_COUNT=8
|
||||
LANGFUSE_INGESTION_SECONDARY_QUEUE_SHARD_COUNT=8
|
||||
LANGFUSE_OTEL_INGESTION_QUEUE_SHARD_COUNT=4
|
||||
LANGFUSE_EVAL_EXECUTION_QUEUE_SHARD_COUNT=4
|
||||
LANGFUSE_EVAL_EXECUTION_SECONDARY_QUEUE_SHARD_COUNT=4
|
||||
LANGFUSE_LLM_AS_JUDGE_EXECUTION_QUEUE_SHARD_COUNT=4
|
||||
LANGFUSE_TRACE_UPSERT_QUEUE_SHARD_COUNT=4
|
||||
|
||||
# openssl rand -hex 32 used only here
|
||||
ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
|
||||
|
||||
# speeds up local development by not executing init scripts on server startup
|
||||
NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT="false"
|
||||
@@ -1,165 +0,0 @@
|
||||
# When adding additional environment variables, the schema in "/src/env.mjs"
|
||||
# should be updated accordingly.
|
||||
|
||||
# ============================================================================
|
||||
# DOCKER CONFIGURATION
|
||||
# ============================================================================
|
||||
# These variables configure docker-compose port mappings and container names
|
||||
# To run multiple instances, copy this file to .env and customize these values
|
||||
|
||||
# Host Ports
|
||||
# POSTGRES_HOST_PORT=5432
|
||||
# REDIS_HOST_PORT=6379
|
||||
# CLICKHOUSE_HTTP_PORT=8123
|
||||
# CLICKHOUSE_NATIVE_PORT=9000
|
||||
# MINIO_API_PORT=9090
|
||||
# MINIO_CONSOLE_PORT=9091
|
||||
# WEB_HOST_PORT=3000
|
||||
# WORKER_HOST_PORT=3030
|
||||
|
||||
# Container Names
|
||||
# POSTGRES_CONTAINER_NAME=langfuse-postgres
|
||||
# CLICKHOUSE_CONTAINER_NAME=langfuse-clickhouse
|
||||
# REDIS_CONTAINER_NAME=langfuse-redis
|
||||
# MINIO_CONTAINER_NAME=langfuse-minio
|
||||
# WEB_CONTAINER_NAME=langfuse-web
|
||||
# WORKER_CONTAINER_NAME=langfuse-worker
|
||||
|
||||
# Volumes
|
||||
# POSTGRES_VOLUME_NAME=langfuse_postgres_data
|
||||
# CLICKHOUSE_DATA_VOLUME_NAME=langfuse_clickhouse_data
|
||||
# CLICKHOUSE_LOGS_VOLUME_NAME=langfuse_clickhouse_logs
|
||||
# MINIO_VOLUME_NAME=langfuse_minio_data
|
||||
|
||||
# Network
|
||||
# DOCKER_NETWORK_NAME=langfuse-network
|
||||
|
||||
# ============================================================================
|
||||
# APPLICATION CONFIGURATION
|
||||
# ============================================================================
|
||||
|
||||
# Prisma
|
||||
# https://www.prisma.io/docs/reference/database-reference/connection-urls#env
|
||||
DIRECT_URL="postgresql://postgres:postgres@localhost:5432/postgres"
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
|
||||
|
||||
# Clickhouse
|
||||
CLICKHOUSE_MIGRATION_URL="clickhouse://localhost:9000"
|
||||
CLICKHOUSE_URL="http://localhost:8123"
|
||||
# CLICKHOUSE_READ_ONLY_URL="http://localhost:8123" # Optional: read replica for legacy tables
|
||||
# CLICKHOUSE_EVENTS_READ_ONLY_URL="http://localhost:8123" # Optional: read replica for events table queries
|
||||
CLICKHOUSE_USER="clickhouse"
|
||||
CLICKHOUSE_PASSWORD="clickhouse"
|
||||
CLICKHOUSE_CLUSTER_ENABLED="false"
|
||||
|
||||
# Next Auth
|
||||
# You can generate a new secret on the command line with:
|
||||
# openssl rand -base64 32
|
||||
# https://next-auth.js.org/configuration/options#secret
|
||||
# NEXTAUTH_SECRET=""
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
NEXTAUTH_SECRET="secret"
|
||||
|
||||
# Langfuse Cloud Environment
|
||||
NEXT_PUBLIC_LANGFUSE_CLOUD_REGION="DEV"
|
||||
|
||||
# Langfuse experimental features
|
||||
LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES="false"
|
||||
|
||||
# Salt for API key hashing
|
||||
SALT="salt"
|
||||
|
||||
# Email
|
||||
EMAIL_FROM_ADDRESS="" # Defines the email address to use as the from address.
|
||||
SMTP_CONNECTION_URL="" # Defines the connection url for smtp server.
|
||||
CLOUD_CRM_EMAIL="" # Optional BCC address for usage threshold emails (e.g., for CRM integration like HubSpot)
|
||||
|
||||
# S3 Batch Exports
|
||||
LANGFUSE_S3_BATCH_EXPORT_ENABLED=true
|
||||
LANGFUSE_S3_BATCH_EXPORT_BUCKET=langfuse
|
||||
LANGFUSE_S3_BATCH_EXPORT_ACCESS_KEY_ID=minio
|
||||
LANGFUSE_S3_BATCH_EXPORT_SECRET_ACCESS_KEY=miniosecret
|
||||
LANGFUSE_S3_BATCH_EXPORT_REGION=us-east-1
|
||||
LANGFUSE_S3_BATCH_EXPORT_ENDPOINT=http://localhost:9090
|
||||
## Necessary for minio compatibility
|
||||
LANGFUSE_S3_BATCH_EXPORT_FORCE_PATH_STYLE=true
|
||||
LANGFUSE_S3_BATCH_EXPORT_PREFIX=exports/
|
||||
|
||||
# S3 Media Upload LOCAL
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_BUCKET=langfuse
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID=minio
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY=miniosecret
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_REGION=us-east-1
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT=http://localhost:9090
|
||||
## Necessary for minio compatibility
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE=true
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_PREFIX=media/
|
||||
|
||||
# S3 Event Bucket Upload
|
||||
## Set to true to test uploading all events to S3
|
||||
LANGFUSE_S3_EVENT_UPLOAD_BUCKET=langfuse
|
||||
LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID=minio
|
||||
LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY=miniosecret
|
||||
LANGFUSE_S3_EVENT_UPLOAD_REGION=us-east-1
|
||||
LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT=http://localhost:9090
|
||||
## Necessary for minio compatibility
|
||||
LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE=true
|
||||
LANGFUSE_S3_EVENT_UPLOAD_PREFIX=events/
|
||||
|
||||
# Set during docker build of application
|
||||
# Used to disable environment verification at build time
|
||||
# DOCKER_BUILD=1
|
||||
|
||||
REDIS_HOST="127.0.0.1"
|
||||
REDIS_PORT=6379
|
||||
REDIS_AUTH="myredissecret"
|
||||
# REDIS_KEY_PREFIX="" # Optional: Prefix for Redis keys (useful for multi-tenant Redis instances)
|
||||
# BullMQ queues will use this via BullMQ's native prefix option
|
||||
# Cache operations will use this via ioredis keyPrefix
|
||||
# REDIS_SENTINEL_ENABLED="false"
|
||||
# REDIS_SENTINEL_NODES="sentinel1:26379,sentinel2:26379"
|
||||
# REDIS_SENTINEL_MASTER_NAME="mymaster"
|
||||
# REDIS_SENTINEL_USERNAME=""
|
||||
# REDIS_SENTINEL_PASSWORD=""
|
||||
|
||||
# openssl rand -hex 32 used only here
|
||||
ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
|
||||
|
||||
# speeds up local development by not executing init scripts on server startup
|
||||
NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT="false"
|
||||
|
||||
# For SDK integration tests to pass, decrease the ingestion queue delay by uncommenting the env vars:
|
||||
# LANGFUSE_INGESTION_QUEUE_DELAY_MS=10
|
||||
# LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS=10
|
||||
# LANGFUSE_EVAL_EXECUTION_WORKER_CONCURRENCY=5
|
||||
# LANGFUSE_LLM_AS_JUDGE_EXECUTION_WORKER_CONCURRENCY=5
|
||||
|
||||
# Slack credentials for development
|
||||
SLACK_CLIENT_ID=your_slack_client_id
|
||||
SLACK_CLIENT_SECRET=your_slack_client_secret
|
||||
SLACK_STATE_SECRET=your_slack_state_secret
|
||||
|
||||
# Langfuse AI instance for tracing, prompts
|
||||
LANGFUSE_AI_FEATURES_PUBLIC_KEY="pk-lf-1234567890"
|
||||
LANGFUSE_AI_FEATURES_SECRET_KEY="sk-lf-1234567890"
|
||||
LANGFUSE_AI_FEATURES_HOST="http://localhost:3000"
|
||||
LANGFUSE_AI_FEATURES_PROJECT_ID=7a88fb47-b4e2-43b8-a06c-a5ce950dc53a
|
||||
|
||||
# Self-hosted only: allow internal LLM proxy hosts/IPs for LLM connection base URLs.
|
||||
# LANGFUSE_LLM_CONNECTION_WHITELISTED_HOST=localhost
|
||||
# LANGFUSE_LLM_CONNECTION_WHITELISTED_IPS=127.0.0.1,::1
|
||||
# LANGFUSE_LLM_CONNECTION_WHITELISTED_IP_SEGMENTS=127.0.0.0/8
|
||||
|
||||
# Langfuse AI Bedrock credentials
|
||||
AWS_ACCESS_KEY_ID="A123456789"
|
||||
AWS_SECRET_ACCESS_KEY="SAK123456789"
|
||||
LANGFUSE_AWS_BEDROCK_REGION="eu-west-1"
|
||||
LANGFUSE_AWS_BEDROCK_MODEL="eu.anthropic.claude-3-haiku-20240307-v1:0"
|
||||
|
||||
# Events table migration
|
||||
LANGFUSE_ENABLE_EVENTS_TABLE_OBSERVATIONS=true
|
||||
LANGFUSE_ENABLE_EVENTS_TABLE_FLAGS=true
|
||||
LANGFUSE_ENABLE_EVENTS_TABLE_V2_APIS=true
|
||||
LANGFUSE_EXPERIMENT_INSERT_INTO_EVENTS_TABLE=true
|
||||
|
||||
CLICKHOUSE_USE_LIGHTWEIGHT_UPDATE="true"
|
||||
@@ -0,0 +1,28 @@
|
||||
# Hanzo Cloud Console — environment.
|
||||
# Copy to .env.local and adjust. All values are public (NEXT_PUBLIC_*) since the
|
||||
# console is a browser app that talks to the unified /v1 backend with cookies.
|
||||
|
||||
# Unified Hanzo Cloud backend (the casibase /v1 API). Default: production.
|
||||
# Local backend: http://localhost:14000
|
||||
NEXT_PUBLIC_CLOUD_URL=https://cloud.hanzo.ai
|
||||
|
||||
# Hanzo PaaS (platform.hanzo.ai) — DOKS cluster control plane for the Clusters module.
|
||||
NEXT_PUBLIC_PLATFORM_URL=https://platform.hanzo.ai
|
||||
|
||||
# hanzo.app builder — target of the Templates gallery "Open in builder" deep-link
|
||||
# (fork a starter → customize by prompt in the builder). Default: production.
|
||||
NEXT_PUBLIC_APP_URL=https://hanzo.app
|
||||
|
||||
# Hanzo IAM (OIDC authority). Canonical issuer is https://hanzo.id — tokens are
|
||||
# minted with iss=https://hanzo.id, which the cloud /v1 backend validates against.
|
||||
# iam.hanzo.ai is the legacy zone (iss=https://iam.hanzo.ai) and MUST NOT be used
|
||||
# by the browser, or sign-in drops on iam.hanzo.ai with an issuer mismatch.
|
||||
NEXT_PUBLIC_IAM_URL=https://hanzo.id
|
||||
|
||||
# IAM application console2 authenticates as. console2 is a front-end of the
|
||||
# shared Hanzo Cloud /v1 backend, which exchanges the OIDC code and validates the
|
||||
# token as app `hanzo-cloud` (aud=hanzo-cloud) — so the front-end presents the
|
||||
# same app/client_id. Must match the cloud-api binding, not a console-only app.
|
||||
NEXT_PUBLIC_IAM_APP_NAME=hanzo-cloud
|
||||
NEXT_PUBLIC_IAM_ORG_NAME=hanzo
|
||||
NEXT_PUBLIC_IAM_CLIENT_ID=hanzo-cloud
|
||||
@@ -1,335 +0,0 @@
|
||||
# More information: https://langfuse.com/docs/deployment/self-host
|
||||
|
||||
# When adding additional environment variables, the schema in "/src/env.mjs"
|
||||
# should be updated accordingly.
|
||||
|
||||
# Prisma
|
||||
# https://www.prisma.io/docs/reference/database-reference/connection-urls#env
|
||||
# DATABASE_URL supports pooled connections, but then you need to set DIRECT_URL
|
||||
DATABASE_URL="postgresql://postgres:postgres@db:5432/postgres"
|
||||
# DIRECT_URL="postgresql://postgres:postgres@db:5432/postgres"
|
||||
# SHADOW_DATABASE_URL=
|
||||
# optional, set to true to disable automated database migrations on Docker start
|
||||
# LANGFUSE_AUTO_POSTGRES_MIGRATION_DISABLED=
|
||||
|
||||
# Next Auth
|
||||
# NEXTAUTH_URL does not need to be set when deploying on Vercel
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
|
||||
# For each of these, you can generate a new secret on the command line with:
|
||||
# openssl rand -base64 32
|
||||
NEXTAUTH_SECRET="secret" # https://next-auth.js.org/configuration/options#secret
|
||||
SALT="salt" # salt used to hash api keys
|
||||
|
||||
# API level encryption for sensitive data
|
||||
# Must be 256 bits, 64 string characters in hex format, generate via: openssl rand -hex 32
|
||||
ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000"
|
||||
|
||||
# Use CSP headers to enforce HTTPS, optional
|
||||
# LANGFUSE_CSP_ENFORCE_HTTPS="true"
|
||||
|
||||
# Configure base path for self-hosting, optional
|
||||
# Note: You need to build the docker image with the base path set and cannot use the pre-built docker image if you set this.
|
||||
# NEXT_PUBLIC_BASE_PATH="/app"
|
||||
|
||||
# Docker only, optional
|
||||
# PORT=3000
|
||||
# HOSTNAME=localhost
|
||||
|
||||
# Opentelemetry, optional
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318"
|
||||
OTEL_SERVICE_NAME="langfuse"
|
||||
|
||||
# Default role for users who sign up, optional, can be org or org+project
|
||||
# Supports comma-separated IDs for multiple orgs (e.g., "org1,org2,org3")
|
||||
# LANGFUSE_DEFAULT_ORG_ID=
|
||||
# LANGFUSE_DEFAULT_ORG_ROLE=
|
||||
# Supports comma-separated IDs for multiple projects (e.g., "proj1,proj2,proj3")
|
||||
# LANGFUSE_DEFAULT_PROJECT_ID=
|
||||
# LANGFUSE_DEFAULT_PROJECT_ROLE=
|
||||
|
||||
# Logging, optional
|
||||
# LANGFUSE_LOG_LEVEL=info
|
||||
# LANGFUSE_LOG_FORMAT=text
|
||||
|
||||
# Enable experimental features, optional
|
||||
# LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES=false
|
||||
|
||||
# Auth, optional configuration
|
||||
# AUTH_DOMAINS_WITH_SSO_ENFORCEMENT=domain1.com,domain2.com
|
||||
# AUTH_IGNORE_ACCOUNT_FIELDS=foo,bar
|
||||
# AUTH_DISABLE_USERNAME_PASSWORD=true
|
||||
# AUTH_DISABLE_SIGNUP=true
|
||||
# AUTH_SESSION_MAX_AGE=43200 # 30 days in minutes (default)
|
||||
|
||||
# SSO, each group is optional
|
||||
# AUTH_GOOGLE_CLIENT_ID=
|
||||
# AUTH_GOOGLE_CLIENT_SECRET=
|
||||
# AUTH_GOOGLE_ALLOW_ACCOUNT_LINKING=false
|
||||
# AUTH_GOOGLE_ALLOWED_DOMAINS=langfuse.com,google.com # optional allowlist of workspace domains that can sign in via Google
|
||||
# AUTH_GOOGLE_CLIENT_AUTH_METHOD=
|
||||
# AUTH_GOOGLE_CHECKS=
|
||||
# AUTH_GITHUB_CLIENT_ID=
|
||||
# AUTH_GITHUB_CLIENT_SECRET=
|
||||
# AUTH_GITHUB_ALLOW_ACCOUNT_LINKING=false
|
||||
# AUTH_GITHUB_CLIENT_AUTH_METHOD=
|
||||
# AUTH_GITHUB_CHECKS=
|
||||
# AUTH_GITHUB_ENTERPRISE_CLIENT_ID=
|
||||
# AUTH_GITHUB_ENTERPRISE_CLIENT_SECRET=
|
||||
# AUTH_GITHUB_ENTERPRISE_BASE_URL=
|
||||
# AUTH_GITHUB_ENTERPRISE_ALLOW_ACCOUNT_LINKING=false
|
||||
# AUTH_GITHUB_ENTERPRISE_CLIENT_AUTH_METHOD=
|
||||
# AUTH_GITHUB_ENTERPRISE_CHECKS=
|
||||
# AUTH_GITLAB_CLIENT_ID=
|
||||
# AUTH_GITLAB_CLIENT_SECRET=
|
||||
# AUTH_GITLAB_ALLOW_ACCOUNT_LINKING=false
|
||||
# AUTH_GITLAB_ISSUER=
|
||||
# AUTH_GITLAB_CLIENT_AUTH_METHOD=
|
||||
# AUTH_GITLAB_CHECKS=
|
||||
# AUTH_GITLAB_URL=
|
||||
# AUTH_AZURE_AD_CLIENT_ID=
|
||||
# AUTH_AZURE_AD_CLIENT_SECRET=
|
||||
# AUTH_AZURE_AD_TENANT_ID=
|
||||
# AUTH_AZURE_AD_ALLOW_ACCOUNT_LINKING=false
|
||||
# AUTH_AZURE_AD_CLIENT_AUTH_METHOD=
|
||||
# AUTH_AZURE_AD_CHECKS=
|
||||
# AUTH_OKTA_CLIENT_ID=
|
||||
# AUTH_OKTA_CLIENT_SECRET=
|
||||
# AUTH_OKTA_ISSUER=
|
||||
# AUTH_OKTA_ALLOW_ACCOUNT_LINKING=false
|
||||
# AUTH_OKTA_CLIENT_AUTH_METHOD=
|
||||
# AUTH_OKTA_CHECKS=
|
||||
# AUTH_AUTH0_CLIENT_ID=
|
||||
# AUTH_AUTH0_CLIENT_SECRET=
|
||||
# AUTH_AUTH0_ISSUER=
|
||||
# AUTH_AUTH0_ALLOW_ACCOUNT_LINKING=false
|
||||
# AUTH_AUTH0_CLIENT_AUTH_METHOD=
|
||||
# AUTH_AUTH0_CHECKS=
|
||||
# AUTH_COGNITO_CLIENT_ID=
|
||||
# AUTH_COGNITO_CLIENT_SECRET=
|
||||
# AUTH_COGNITO_ISSUER=
|
||||
# AUTH_COGNITO_ALLOW_ACCOUNT_LINKING=false
|
||||
# AUTH_COGNITO_CLIENT_AUTH_METHOD=
|
||||
# AUTH_COGNITO_CHECKS=
|
||||
# AUTH_KEYCLOAK_CLIENT_ID=
|
||||
# AUTH_KEYCLOAK_CLIENT_SECRET=
|
||||
# AUTH_KEYCLOAK_ISSUER=
|
||||
# AUTH_KEYCLOAK_ALLOW_ACCOUNT_LINKING=false
|
||||
# AUTH_KEYCLOAK_CLIENT_AUTH_METHOD=
|
||||
# AUTH_KEYCLOAK_CHECKS=
|
||||
# AUTH_KEYCLOAK_NAME=
|
||||
# AUTH_WORKOS_CLIENT_ID=
|
||||
# AUTH_WORKOS_CLIENT_SECRET=
|
||||
# AUTH_WORKOS_ALLOW_ACCOUNT_LINKING=false
|
||||
# AUTH_WORKOS_ORGANIZATION_ID=
|
||||
# AUTH_WORKOS_CONNECTION_ID=
|
||||
# AUTH_CUSTOM_CLIENT_ID=
|
||||
# AUTH_CUSTOM_CLIENT_SECRET=
|
||||
# AUTH_CUSTOM_ISSUER=
|
||||
# AUTH_CUSTOM_NAME=
|
||||
# AUTH_CUSTOM_SCOPE="openid email profile" # optional
|
||||
# AUTH_CUSTOM_CLIENT_AUTH_METHOD="client_secret_basic" # optional
|
||||
# AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING=false
|
||||
# AUTH_CUSTOM_ID_TOKEN=false # optional, default is true
|
||||
# AUTH_CUSTOM_CLIENT_AUTH_METHOD=
|
||||
# AUTH_CUSTOM_CHECKS=
|
||||
# AUTH_JUMPCLOUD_CLIENT_ID=
|
||||
# AUTH_JUMPCLOUD_CLIENT_SECRET=
|
||||
# AUTH_JUMPCLOUD_ISSUER=
|
||||
# AUTH_JUMPCLOUD_ALLOW_ACCOUNT_LINKING=
|
||||
# AUTH_JUMPCLOUD_CLIENT_AUTH_METHOD=
|
||||
# AUTH_JUMPCLOUD_CHECKS=
|
||||
# AUTH_JUMPCLOUD_SCOPE=
|
||||
|
||||
# Transactional email, optional
|
||||
# Defines the email address to use as the from address.
|
||||
# EMAIL_FROM_ADDRESS=
|
||||
# Defines the connection url for smtp server.
|
||||
# SMTP_CONNECTION_URL=
|
||||
|
||||
# S3 Batch Exports
|
||||
# LANGFUSE_S3_BATCH_EXPORT_ENABLED=
|
||||
# LANGFUSE_S3_BATCH_EXPORT_BUCKET=
|
||||
# LANGFUSE_S3_BATCH_EXPORT_ACCESS_KEY_ID=
|
||||
# LANGFUSE_S3_BATCH_EXPORT_SECRET_ACCESS_KEY=
|
||||
# LANGFUSE_S3_BATCH_EXPORT_REGION=
|
||||
# LANGFUSE_S3_BATCH_EXPORT_ENDPOINT=
|
||||
# LANGFUSE_S3_BATCH_EXPORT_PREFIX=
|
||||
|
||||
# S3 storage for events, optional, used to persist all incoming events
|
||||
# LANGFUSE_S3_EVENT_UPLOAD_BUCKET=
|
||||
# Optional prefix to be used within the bucket. Must end with `/` if set
|
||||
# LANGFUSE_S3_EVENT_UPLOAD_PREFIX=events/
|
||||
# The following four options are optional and fallback to the normal SDK credential provider chain if omitted
|
||||
# See https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-credentials-node.html
|
||||
# LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT=
|
||||
# LANGFUSE_S3_EVENT_UPLOAD_REGION=
|
||||
# LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID=
|
||||
# LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY=
|
||||
# Whether to use blob_storage_file_log table to manage blob storage events
|
||||
# Can be set to `false` if `event` entities are managed using lifecycle policies in the blob storage bucket.
|
||||
LANGFUSE_ENABLE_BLOB_STORAGE_FILE_LOG=true
|
||||
|
||||
# Automated provisioning of default resources
|
||||
# LANGFUSE_INIT_ORG_ID=org-id
|
||||
# LANGFUSE_INIT_ORG_NAME=org-name
|
||||
# LANGFUSE_INIT_PROJECT_ID=project-id
|
||||
# LANGFUSE_INIT_PROJECT_NAME=project-name
|
||||
# LANGFUSE_INIT_PROJECT_PUBLIC_KEY=pk-1234567890
|
||||
# LANGFUSE_INIT_PROJECT_SECRET_KEY=sk-1234567890
|
||||
# LANGFUSE_INIT_USER_EMAIL=user@example.com
|
||||
# LANGFUSE_INIT_USER_NAME=User Name
|
||||
# LANGFUSE_INIT_USER_PASSWORD=password
|
||||
|
||||
# Redis configuration
|
||||
# REDIS_HOST=
|
||||
# REDIS_PORT=
|
||||
# REDIS_AUTH=
|
||||
# REDIS_USERNAME=default
|
||||
# REDIS_CONNECTION_STRING=
|
||||
# REDIS_ENABLE_AUTO_PIPELINING=
|
||||
# REDIS_KEY_PREFIX= # Optional: Prefix for Redis keys (useful for multi-tenant Redis instances)
|
||||
# BullMQ queues will use this via BullMQ's native prefix option
|
||||
# Cache operations will use this via ioredis keyPrefix
|
||||
|
||||
# Redis Cluster configuration (optional)
|
||||
# REDIS_CLUSTER_ENABLED=false
|
||||
# REDIS_CLUSTER_NODES=redis-node1:6379,redis-node2:6379,redis-node3:6379
|
||||
|
||||
# Redis Sentinel configuration (optional, cannot be enabled with cluster mode simultaneously)
|
||||
# REDIS_SENTINEL_ENABLED=false
|
||||
# REDIS_SENTINEL_NODES=sentinel1:26379,sentinel2:26379,sentinel3:26379
|
||||
# REDIS_SENTINEL_MASTER_NAME=mymaster
|
||||
# REDIS_SENTINEL_USERNAME=
|
||||
# REDIS_SENTINEL_PASSWORD=
|
||||
|
||||
# Cache configuration
|
||||
# LANGFUSE_CACHE_API_KEY_ENABLED=
|
||||
# LANGFUSE_CACHE_API_KEY_TTL_SECONDS=
|
||||
# LANGFUSE_CACHE_PROMPT_ENABLED=
|
||||
# LANGFUSE_CACHE_PROMPT_TTL_SECONDS=
|
||||
|
||||
# Clickhouse configuration
|
||||
# CLICKHOUSE_URL=
|
||||
# CLICKHOUSE_CLUSTER_NAME=default
|
||||
# CLICKHOUSE_DB=default
|
||||
# CLICKHOUSE_USER=
|
||||
# CLICKHOUSE_PASSWORD=
|
||||
# CLICKHOUSE_CLUSTER_ENABLED=true
|
||||
|
||||
# Ingestion configuration
|
||||
# LANGFUSE_INGESTION_QUEUE_DELAY_MS=
|
||||
# LANGFUSE_INGESTION_CLICKHOUSE_WRITE_BATCH_SIZE=
|
||||
# LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS=
|
||||
# LANGFUSE_INGESTION_CLICKHOUSE_MAX_ATTEMPTS=
|
||||
|
||||
# Evaluation worker concurrency
|
||||
# LANGFUSE_EVAL_EXECUTION_WORKER_CONCURRENCY=5
|
||||
# LANGFUSE_LLM_AS_JUDGE_EXECUTION_WORKER_CONCURRENCY=5
|
||||
|
||||
# API Traces endpoint controls (may induce breaking changes on API when changed!)
|
||||
# Reject GET /api/public/traces requests that do not include a fromTimestamp parameter (returns 400)
|
||||
# LANGFUSE_API_TRACES_REJECT_NO_DATE_RANGE=false
|
||||
# Apply a default date range (in days) to GET /api/public/traces when no fromTimestamp is provided
|
||||
# LANGFUSE_API_TRACES_DEFAULT_DATE_RANGE_DAYS=
|
||||
# Comma-separated default field groups for GET /api/public/traces when no fields param is provided
|
||||
# Valid values: core, io, scores, observations, metrics
|
||||
# LANGFUSE_API_TRACES_DEFAULT_FIELDS=
|
||||
# Comma-separated default field groups for GET /api/public/traces/{traceId} when no fields param is provided
|
||||
# Valid values: core, io, scores, observations, metrics
|
||||
# LANGFUSE_API_TRACEBYID_DEFAULT_FIELDS=
|
||||
|
||||
### START Enterprise Edition Configuration
|
||||
|
||||
# Allowlisted users that can create new organizations, by default all users can create organizations
|
||||
# LANGFUSE_ALLOWED_ORGANIZATION_CREATORS=user1@langfuse.com,user2@langfuse.com
|
||||
|
||||
# UI Customization Options
|
||||
# LANGFUSE_UI_API_HOST=https://api.example.com
|
||||
# LANGFUSE_UI_DOCUMENTATION_HREF=https://docs.example.com
|
||||
# LANGFUSE_UI_SUPPORT_HREF=https://support.example.com
|
||||
# LANGFUSE_UI_FEEDBACK_HREF=https://feedback.example.com
|
||||
# LANGFUSE_UI_LOGO_LIGHT_MODE_HREF=https://static.langfuse.com/langfuse-dev/example-logo-light-mode.png
|
||||
# LANGFUSE_UI_LOGO_DARK_MODE_HREF=https://static.langfuse.com/langfuse-dev/example-logo-dark-mode.png
|
||||
# LANGFUSE_UI_DEFAULT_MODEL_ADAPTER=Anthropic # OpenAI, Anthropic, Azure
|
||||
# LANGFUSE_UI_DEFAULT_BASE_URL_OPENAI=https://api.openai.com/v1
|
||||
# LANGFUSE_UI_DEFAULT_BASE_URL_ANTHROPIC=https://api.anthropic.com
|
||||
# LANGFUSE_UI_DEFAULT_BASE_URL_AZURE_OPENAI=https://{instanceName}.openai.azure.com/openai/deployments
|
||||
# LANGFUSE_UI_VISIBLE_PRODUCT_MODULES=
|
||||
# LANGFUSE_UI_HIDDEN_PRODUCT_MODULES=
|
||||
|
||||
### END Enterprise Edition Configuration
|
||||
|
||||
|
||||
### START Langfuse Cloud Config
|
||||
# Used for Langfuse Cloud deployments
|
||||
# Not recommended for self-hosted deployments as these are NOT COVERED BY SEMANTIC VERSIONING
|
||||
|
||||
# NEXT_PUBLIC_LANGFUSE_CLOUD_REGION="US"
|
||||
# NEXTAUTH_COOKIE_DOMAIN=".langfuse.com"
|
||||
|
||||
# LANGFUSE_TEAM_SLACK_WEBHOOK=
|
||||
# LANGFUSE_NEW_USER_SIGNUP_WEBHOOK=
|
||||
|
||||
# Posthog (optional for analytics of web ui)
|
||||
# NEXT_PUBLIC_POSTHOG_HOST=
|
||||
# NEXT_PUBLIC_POSTHOG_KEY=
|
||||
|
||||
# Sentry
|
||||
# NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE
|
||||
# NEXT_PUBLIC_SENTRY_DSN=
|
||||
# NEXT_SENTRY_ORG=
|
||||
# NEXT_SENTRY_PROJECT=
|
||||
# SENTRY_AUTH_TOKEN=
|
||||
# SENTRY_CSP_REPORT_URI=
|
||||
|
||||
|
||||
# Demo project that users can use to try the platform
|
||||
# NEXT_PUBLIC_DEMO_ORG_ID=
|
||||
# NEXT_PUBLIC_DEMO_PROJECT_ID=
|
||||
|
||||
# Plain Chat
|
||||
# NEXT_PUBLIC_PLAIN_APP_ID=
|
||||
# PLAIN_AUTHENTICATION_SECRET=
|
||||
# PLAIN_API_KEY=
|
||||
# PLAIN_CARDS_API_TOKEN=
|
||||
|
||||
# Pylon Support
|
||||
# PYLON_API_KEY=
|
||||
|
||||
# Admin API
|
||||
# ADMIN_API_KEY=
|
||||
# Self-hosted only: allow internal LLM proxy hosts/IPs for LLM connection base URLs.
|
||||
# LANGFUSE_LLM_CONNECTION_WHITELISTED_HOST=
|
||||
# LANGFUSE_LLM_CONNECTION_WHITELISTED_IPS=
|
||||
# LANGFUSE_LLM_CONNECTION_WHITELISTED_IP_SEGMENTS=
|
||||
# LANGFUSE_CACHE_MODEL_MATCH_ENABLED=
|
||||
# LANGFUSE_CACHE_MODEL_MATCH_TTL_SECONDS=
|
||||
|
||||
# Rate limiting
|
||||
# LANGFUSE_RATE_LIMITS_ENABLED=
|
||||
|
||||
# Free tier usage thresholds (Cloud deployments only)
|
||||
# Enable the queue consumer that monitors free tier usage (default: true, but requires cloud region)
|
||||
# QUEUE_CONSUMER_FREE_TIER_USAGE_THRESHOLD_QUEUE_IS_ENABLED=true
|
||||
# Enable enforcement: send emails and block orgs that exceed free tier limits (default: false)
|
||||
# LANGFUSE_FREE_TIER_USAGE_THRESHOLD_ENFORCEMENT_ENABLED=false
|
||||
# Optional BCC address for usage threshold emails (e.g., for CRM integration like HubSpot)
|
||||
# CLOUD_CRM_EMAIL=
|
||||
|
||||
# Stripe
|
||||
# STRIPE_SECRET_KEY=
|
||||
# STRIPE_WEBHOOK_SIGNING_SECRET=
|
||||
|
||||
# Betterstack Status Page
|
||||
# BETTERSTACK_UPTIME_API_KEY=
|
||||
# BETTERSTACK_UPTIME_STATUS_PAGE_ID=
|
||||
|
||||
### END Langfuse Cloud Config
|
||||
|
||||
|
||||
### START Langfuse CI Config
|
||||
|
||||
# LANGFUSE_INIT_ORG_CLOUD_PLAN=
|
||||
|
||||
### END Langfuse CI Config
|
||||
@@ -1,12 +0,0 @@
|
||||
# Test Environment Configuration
|
||||
# Copy this file to .env.test for test database isolation
|
||||
# Only overrides specific test variables - other values inherited from .env
|
||||
|
||||
# PostgreSQL - Test Database
|
||||
DIRECT_URL="postgresql://postgres:postgres@localhost:5432/langfuse_test"
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/langfuse_test"
|
||||
|
||||
# ClickHouse - Use Default Database for now, nothing set
|
||||
|
||||
# Redis - Test Database (database 1 for isolation)
|
||||
REDIS_CONNECTION_STRING="redis://:myredissecret@127.0.0.1:6379/1"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user