Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32b6502d13 | ||
|
|
1bc33c7b92 | ||
|
|
4967b20478 | ||
|
|
1130894cdc | ||
|
|
af276e9e78 | ||
|
|
d69dbd81a2 | ||
|
|
7e807c0de8 | ||
|
|
cfa2ac6da9 | ||
|
|
952a550847 | ||
|
|
6199903117 | ||
|
|
88cdb8999d | ||
|
|
9ad2ef1f91 | ||
|
|
7a9717cb5e | ||
|
|
ce31a806c7 | ||
|
|
e7e6cf1b99 | ||
|
|
9f76457f22 | ||
|
|
c79c84ea96 | ||
|
|
4634669fcd | ||
|
|
549e0eb629 | ||
|
|
80adf63fe6 | ||
|
|
6c1d7534bb | ||
|
|
23a4bbd2cb | ||
|
|
07d048d46f | ||
|
|
bd7226bbb8 | ||
|
|
fe70d92211 | ||
|
|
bb3621f31d | ||
|
|
63225a9fd6 | ||
|
|
947da9cc84 | ||
|
|
93a0ab6f3a | ||
|
|
68ad079d8c | ||
|
|
cf29c6b7e4 | ||
|
|
e4d2a5ec6c | ||
|
|
35f3092a1c | ||
|
|
7b222318e1 | ||
|
|
d9c077ea56 | ||
|
|
1ec7325b85 | ||
|
|
96b2127f36 | ||
|
|
d3c2b8cf27 | ||
|
|
beeef222e4 | ||
|
|
ee376f9cad | ||
|
|
c8936ec68d | ||
|
|
8d8170c62c | ||
|
|
72e1a43192 | ||
|
|
a842625bd1 | ||
|
|
890a542759 | ||
|
|
1e8af5b821 | ||
|
|
d3f3f354db | ||
|
|
18a7cb6684 | ||
|
|
2d70ca574e | ||
|
|
1c980c78dd | ||
|
|
5170718aad | ||
|
|
a01c27f4cc | ||
|
|
51354e6c6d | ||
|
|
10dd50cff0 | ||
|
|
d8783f8698 | ||
|
|
16a572f51d | ||
|
|
947641cbbf | ||
|
|
249e161473 | ||
|
|
90bab4386f | ||
|
|
33fddb1344 | ||
|
|
c24dcb068a | ||
|
|
2986723254 | ||
|
|
b45b2fd8f8 | ||
|
|
8936fb4ad9 | ||
|
|
7d7e25b39c | ||
|
|
138d2f1c09 | ||
|
|
51595eb15d | ||
|
|
6ee68cf508 | ||
|
|
308513e644 | ||
|
|
155543bc93 | ||
|
|
8f961925d5 | ||
|
|
7ddc844d7d | ||
|
|
5381c5f19b | ||
|
|
dcd6060665 | ||
|
|
0bfba7d690 | ||
|
|
25b363e1bc | ||
|
|
0de37b0437 | ||
|
|
ab5febb9c7 | ||
|
|
0e5cade9e7 | ||
|
|
1438e052df | ||
|
|
9917aff8cf | ||
|
|
c915c2b678 | ||
|
|
50379bd4b5 | ||
|
|
8fd93ecff9 | ||
|
|
046c6c6125 | ||
|
|
ba750a2d55 | ||
|
|
485120806e | ||
|
|
87acc4f7f3 | ||
|
|
21b1faa21b | ||
|
|
76cfaf1069 | ||
|
|
dbe808d520 | ||
|
|
6c977429c2 | ||
|
|
c42c6a0f91 | ||
|
|
f14b69f68d | ||
|
|
dd892459a4 | ||
|
|
3488b5c443 | ||
|
|
6e0f4483b4 | ||
|
|
b5d9fb3e63 | ||
|
|
e087da6b6e | ||
|
|
ea8734d709 | ||
|
|
9cf4e57da1 | ||
|
|
292501b6dd | ||
|
|
2a1424e4d0 |
@@ -55,6 +55,7 @@ OTEL_SERVICE_NAME="langfuse"
|
||||
|
||||
# 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)
|
||||
@@ -100,6 +101,7 @@ OTEL_SERVICE_NAME="langfuse"
|
||||
# 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
|
||||
|
||||
# Transactional email, optional
|
||||
@@ -243,6 +245,7 @@ OTEL_SERVICE_NAME="langfuse"
|
||||
# CLICKHOUSE_URL=
|
||||
# CLICKHOUSE_USER=
|
||||
# CLICKHOUSE_PASSWORD=
|
||||
# CLICKHOUSE_DB=
|
||||
|
||||
# Ingestion
|
||||
# LANGFUSE_INGESTION_QUEUE_DELAY_MS=
|
||||
|
||||
@@ -459,9 +459,9 @@ jobs:
|
||||
type=ref,event=pr
|
||||
type=sha
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v3') }}
|
||||
type=semver,pattern={{major}}.{{minor}},enable=${{ !contains(github.ref, '-rc') }}
|
||||
type=semver,pattern={{major}},enable=${{ !contains(github.ref, '-rc') }}
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v3') && !contains(github.ref, '-rc') }}
|
||||
- name: Build and push Docker image (web)
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
@@ -487,9 +487,9 @@ jobs:
|
||||
type=ref,event=pr
|
||||
type=sha
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v3') }}
|
||||
type=semver,pattern={{major}}.{{minor}},enable=${{ !contains(github.ref, '-rc') }}
|
||||
type=semver,pattern={{major}},enable=${{ !contains(github.ref, '-rc') }}
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v3') && !contains(github.ref, '-rc') }}
|
||||
- name: Build and push Docker image (worker)
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
|
||||
@@ -42,9 +42,7 @@
|
||||
|
||||
## Langfuse Overview
|
||||
|
||||
_Unmute video for voice-over_
|
||||
|
||||
https://github.com/langfuse/langfuse/assets/2834609/a94062e9-c782-4ee9-af59-dee6370149a8
|
||||
[](https://langfuse.com/watch-demo)
|
||||
|
||||
### Develop
|
||||
|
||||
@@ -194,37 +192,51 @@ You can opt-out by setting `TELEMETRY_ENABLED=false`.
|
||||
|
||||
### Open Source Projects Using Langfuse
|
||||
|
||||
Top open-source Python projects that use Langfuse, ranked by stars ([Source](https://github.com/langfuse/langfuse-docs/blob/main/components-mdx/dependents/python.md)):
|
||||
Top open-source Python projects that use Langfuse, ranked by stars ([Source](https://github.com/langfuse/langfuse-docs/blob/main/components-mdx/dependents)):
|
||||
|
||||
| Repository | Stars |
|
||||
| :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----: |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/127165244?s=40&v=4" width="20" height="20" alt=""> [langgenius](https://github.com/langgenius) / [dify](https://github.com/langgenius/dify) | 53464 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/158137808?s=40&v=4" width="20" height="20" alt=""> [open-webui](https://github.com/open-webui) / [open-webui](https://github.com/open-webui/open-webui) | 49387 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/130722866?s=40&v=4" width="20" height="20" alt=""> [run-llama](https://github.com/run-llama) / [llama_index](https://github.com/run-llama/llama_index) | 37087 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/85702467?s=40&v=4" width="20" height="20" alt=""> [langflow-ai](https://github.com/langflow-ai) / [langflow](https://github.com/langflow-ai/langflow) | 36690 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/31035808?s=40&v=4" width="20" height="20" alt=""> [mindsdb](https://github.com/mindsdb) / [mindsdb](https://github.com/mindsdb/mindsdb) | 26871 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/60330232?s=40&v=4" width="20" height="20" alt=""> [PostHog](https://github.com/PostHog) / [posthog](https://github.com/PostHog/posthog) | 22348 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/121462774?s=40&v=4" width="20" height="20" alt=""> [BerriAI](https://github.com/BerriAI) / [litellm](https://github.com/BerriAI/litellm) | 14508 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/78410652?s=40&v=4" width="20" height="20" alt=""> [GreyDGL](https://github.com/GreyDGL) / [PentestGPT](https://github.com/GreyDGL/PentestGPT) | 7319 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/152537519?s=40&v=4" width="20" height="20" alt=""> [superagent-ai](https://github.com/superagent-ai) / [superagent](https://github.com/superagent-ai/superagent) | 5352 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/7250217?s=40&v=4" width="20" height="20" alt=""> [Canner](https://github.com/Canner) / [WrenAI](https://github.com/Canner/WrenAI) | 2084 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/11855343?s=40&v=4" width="20" height="20" alt=""> [pingcap](https://github.com/pingcap) / [autoflow](https://github.com/pingcap/autoflow) | 1936 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/85268109?s=40&v=4" width="20" height="20" alt=""> [MLSysOps](https://github.com/MLSysOps) / [MLE-agent](https://github.com/MLSysOps/MLE-agent) | 1140 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/158137808?s=40&v=4" width="20" height="20" alt=""> [open-webui](https://github.com/open-webui) / [pipelines](https://github.com/open-webui/pipelines) | 1044 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/125468716?s=40&v=4" width="20" height="20" alt=""> [topoteretes](https://github.com/topoteretes) / [cognee](https://github.com/topoteretes/cognee) | 945 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/188657705?s=40&v=4" width="20" height="20" alt=""> [bRAGAI](https://github.com/bRAGAI) / [bRAG-langchain](https://github.com/bRAGAI/bRAG-langchain) | 770 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/169500408?s=40&v=4" width="20" height="20" alt=""> [opslane](https://github.com/opslane) / [opslane](https://github.com/opslane/opslane) | 675 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/151867818?s=40&v=4" width="20" height="20" alt=""> [dynamiq-ai](https://github.com/dynamiq-ai) / [dynamiq](https://github.com/dynamiq-ai/dynamiq) | 583 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/48585267?s=40&v=4" width="20" height="20" alt=""> [theopenconversationkit](https://github.com/theopenconversationkit) / [tock](https://github.com/theopenconversationkit/tock) | 505 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/20493493?s=40&v=4" width="20" height="20" alt=""> [andysingal](https://github.com/andysingal) / [llm-course](https://github.com/andysingal/llm-course) | 383 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/132396805?s=40&v=4" width="20" height="20" alt=""> [phospho-app](https://github.com/phospho-app) / [phospho](https://github.com/phospho-app/phospho) | 379 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/178644984?s=40&v=4" width="20" height="20" alt=""> [sentient-engineering](https://github.com/sentient-engineering) / [agent-q](https://github.com/sentient-engineering/agent-q) | 357 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/127165244?s=40&v=4" width="20" height="20" alt=""> [langgenius](https://github.com/langgenius) / [dify](https://github.com/langgenius/dify) | 54865 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/158137808?s=40&v=4" width="20" height="20" alt=""> [open-webui](https://github.com/open-webui) / [open-webui](https://github.com/open-webui/open-webui) | 51531 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/131470832?s=40&v=4" width="20" height="20" alt=""> [lobehub](https://github.com/lobehub) / [lobe-chat](https://github.com/lobehub/lobe-chat) | 49003 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/85702467?s=40&v=4" width="20" height="20" alt=""> [langflow-ai](https://github.com/langflow-ai) / [langflow](https://github.com/langflow-ai/langflow) | 39093 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/130722866?s=40&v=4" width="20" height="20" alt=""> [run-llama](https://github.com/run-llama) / [llama_index](https://github.com/run-llama/llama_index) | 37368 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/139558948?s=40&v=4" width="20" height="20" alt=""> [chatchat-space](https://github.com/chatchat-space) / [Langchain-Chatchat](https://github.com/chatchat-space/Langchain-Chatchat) | 32486 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/128289781?s=40&v=4" width="20" height="20" alt=""> [FlowiseAI](https://github.com/FlowiseAI) / [Flowise](https://github.com/FlowiseAI/Flowise) | 32448 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/31035808?s=40&v=4" width="20" height="20" alt=""> [mindsdb](https://github.com/mindsdb) / [mindsdb](https://github.com/mindsdb/mindsdb) | 26931 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/119600397?s=40&v=4" width="20" height="20" alt=""> [twentyhq](https://github.com/twentyhq) / [twenty](https://github.com/twentyhq/twenty) | 24195 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/60330232?s=40&v=4" width="20" height="20" alt=""> [PostHog](https://github.com/PostHog) / [posthog](https://github.com/PostHog/posthog) | 22618 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/121462774?s=40&v=4" width="20" height="20" alt=""> [BerriAI](https://github.com/BerriAI) / [litellm](https://github.com/BerriAI/litellm) | 15151 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/179202840?s=40&v=4" width="20" height="20" alt=""> [mediar-ai](https://github.com/mediar-ai) / [screenpipe](https://github.com/mediar-ai/screenpipe) | 11037 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/105877416?s=40&v=4" width="20" height="20" alt=""> [formbricks](https://github.com/formbricks) / [formbricks](https://github.com/formbricks/formbricks) | 9386 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/76263028?s=40&v=4" width="20" height="20" alt=""> [anthropics](https://github.com/anthropics) / [courses](https://github.com/anthropics/courses) | 8385 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/78410652?s=40&v=4" width="20" height="20" alt=""> [GreyDGL](https://github.com/GreyDGL) / [PentestGPT](https://github.com/GreyDGL/PentestGPT) | 7374 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/152537519?s=40&v=4" width="20" height="20" alt=""> [superagent-ai](https://github.com/superagent-ai) / [superagent](https://github.com/superagent-ai/superagent) | 5391 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/137907881?s=40&v=4" width="20" height="20" alt=""> [promptfoo](https://github.com/promptfoo) / [promptfoo](https://github.com/promptfoo/promptfoo) | 4976 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/157326433?s=40&v=4" width="20" height="20" alt=""> [onlook-dev](https://github.com/onlook-dev) / [onlook](https://github.com/onlook-dev/onlook) | 4141 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/7250217?s=40&v=4" width="20" height="20" alt=""> [Canner](https://github.com/Canner) / [WrenAI](https://github.com/Canner/WrenAI) | 2526 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/11855343?s=40&v=4" width="20" height="20" alt=""> [pingcap](https://github.com/pingcap) / [autoflow](https://github.com/pingcap/autoflow) | 2061 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/85268109?s=40&v=4" width="20" height="20" alt=""> [MLSysOps](https://github.com/MLSysOps) / [MLE-agent](https://github.com/MLSysOps/MLE-agent) | 1161 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/158137808?s=40&v=4" width="20" height="20" alt=""> [open-webui](https://github.com/open-webui) / [pipelines](https://github.com/open-webui/pipelines) | 1100 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/18422723?s=40&v=4" width="20" height="20" alt=""> [alishobeiri](https://github.com/alishobeiri) / [thread](https://github.com/alishobeiri/thread) | 1074 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/125468716?s=40&v=4" width="20" height="20" alt=""> [topoteretes](https://github.com/topoteretes) / [cognee](https://github.com/topoteretes/cognee) | 971 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/188657705?s=40&v=4" width="20" height="20" alt=""> [bRAGAI](https://github.com/bRAGAI) / [bRAG-langchain](https://github.com/bRAGAI/bRAG-langchain) | 823 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/169500408?s=40&v=4" width="20" height="20" alt=""> [opslane](https://github.com/opslane) / [opslane](https://github.com/opslane/opslane) | 677 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/151867818?s=40&v=4" width="20" height="20" alt=""> [dynamiq-ai](https://github.com/dynamiq-ai) / [dynamiq](https://github.com/dynamiq-ai/dynamiq) | 639 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/48585267?s=40&v=4" width="20" height="20" alt=""> [theopenconversationkit](https://github.com/theopenconversationkit) / [tock](https://github.com/theopenconversationkit/tock) | 514 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/20493493?s=40&v=4" width="20" height="20" alt=""> [andysingal](https://github.com/andysingal) / [llm-course](https://github.com/andysingal/llm-course) | 394 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/132396805?s=40&v=4" width="20" height="20" alt=""> [phospho-app](https://github.com/phospho-app) / [phospho](https://github.com/phospho-app/phospho) | 384 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/178644984?s=40&v=4" width="20" height="20" alt=""> [sentient-engineering](https://github.com/sentient-engineering) / [agent-q](https://github.com/sentient-engineering/agent-q) | 370 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/168552753?s=40&v=4" width="20" height="20" alt=""> [sql-agi](https://github.com/sql-agi) / [DB-GPT](https://github.com/sql-agi/DB-GPT) | 324 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/60330232?s=40&v=4" width="20" height="20" alt=""> [PostHog](https://github.com/PostHog) / [posthog-foss](https://github.com/PostHog/posthog-foss) | 300 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/609489?s=40&v=4" width="20" height="20" alt=""> [aorwall](https://github.com/aorwall) / [moatless-tools](https://github.com/aorwall/moatless-tools) | 278 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/185116535?s=40&v=4" width="20" height="20" alt=""> [block](https://github.com/block) / [goose](https://github.com/block/goose) | 237 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/66303003?s=40&v=4" width="20" height="20" alt=""> [RobotecAI](https://github.com/RobotecAI) / [rai](https://github.com/RobotecAI/rai) | 164 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/171735272?s=40&v=4" width="20" height="20" alt=""> [8090-inc](https://github.com/8090-inc) / [xrx-sample-apps](https://github.com/8090-inc/xrx-sample-apps) | 133 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/51827949?s=40&v=4" width="20" height="20" alt=""> [deepset-ai](https://github.com/deepset-ai) / [haystack-core-integrations](https://github.com/deepset-ai/haystack-core-integrations) | 122 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/134601687?s=40&v=4" width="20" height="20" alt=""> [langfuse](https://github.com/langfuse) / [langfuse-python](https://github.com/langfuse/langfuse-python) | 108 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/42655961?s=40&v=4" width="20" height="20" alt=""> [zozoheir](https://github.com/zozoheir) / [tinyllm](https://github.com/zozoheir/tinyllm) | 95 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/60330232?s=40&v=4" width="20" height="20" alt=""> [PostHog](https://github.com/PostHog) / [posthog-foss](https://github.com/PostHog/posthog-foss) | 305 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/154247157?s=40&v=4" width="20" height="20" alt=""> [vespperhq](https://github.com/vespperhq) / [vespper](https://github.com/vespperhq/vespper) | 304 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/185116535?s=40&v=4" width="20" height="20" alt=""> [block](https://github.com/block) / [goose](https://github.com/block/goose) | 295 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/609489?s=40&v=4" width="20" height="20" alt=""> [aorwall](https://github.com/aorwall) / [moatless-tools](https://github.com/aorwall/moatless-tools) | 291 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/2357342?s=40&v=4" width="20" height="20" alt=""> [dmayboroda](https://github.com/dmayboroda) / [minima](https://github.com/dmayboroda/minima) | 221 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/66303003?s=40&v=4" width="20" height="20" alt=""> [RobotecAI](https://github.com/RobotecAI) / [rai](https://github.com/RobotecAI/rai) | 172 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/148684274?s=40&v=4" width="20" height="20" alt=""> [i-am-alice](https://github.com/i-am-alice) / [3rd-devs](https://github.com/i-am-alice/3rd-devs) | 148 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/171735272?s=40&v=4" width="20" height="20" alt=""> [8090-inc](https://github.com/8090-inc) / [xrx-sample-apps](https://github.com/8090-inc/xrx-sample-apps) | 138 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/104478511?s=40&v=4" width="20" height="20" alt=""> [babelcloud](https://github.com/babelcloud) / [LLM-RGB](https://github.com/babelcloud/LLM-RGB) | 135 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/15125613?s=40&v=4" width="20" height="20" alt=""> [souzatharsis](https://github.com/souzatharsis) / [tamingLLMs](https://github.com/souzatharsis/tamingLLMs) | 129 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/169401942?s=40&v=4" width="20" height="20" alt=""> [LibreChat-AI](https://github.com/LibreChat-AI) / [librechat.ai](https://github.com/LibreChat-AI/librechat.ai) | 128 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/51827949?s=40&v=4" width="20" height="20" alt=""> [deepset-ai](https://github.com/deepset-ai) / [haystack-core-integrations](https://github.com/deepset-ai/haystack-core-integrations) | 126 |
|
||||
|
||||
@@ -155,4 +155,3 @@ volumes:
|
||||
driver: local
|
||||
langfuse_minio_data:
|
||||
driver: local
|
||||
|
||||
|
||||
@@ -38,6 +38,8 @@ services:
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT: ${LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT:-http://minio:9000}
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE: ${LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE:-true}
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_PREFIX: ${LANGFUSE_S3_MEDIA_UPLOAD_PREFIX:-media/}
|
||||
LANGFUSE_INGESTION_QUEUE_DELAY_MS: ${LANGFUSE_INGESTION_QUEUE_DELAY_MS:-}
|
||||
LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS: ${LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS:-}
|
||||
REDIS_HOST: ${REDIS_HOST:-redis}
|
||||
REDIS_PORT: ${REDIS_PORT:-6379}
|
||||
REDIS_AUTH: ${REDIS_AUTH:-myredissecret}
|
||||
|
||||
+3
-2
@@ -27,7 +27,7 @@
|
||||
"@langfuse/shared": "workspace:*",
|
||||
"@opentelemetry/api": ">=1.0.0 <1.10.0",
|
||||
"axios": "^1.7.7",
|
||||
"next": "^14.2.15",
|
||||
"next": "^14.2.21",
|
||||
"next-auth": "^4.24.11",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
@@ -47,7 +47,8 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"jsonpath-plus": "10.0.7"
|
||||
"jsonpath-plus": "10.0.7",
|
||||
"nanoid": "^3.3.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ service:
|
||||
Batched ingestion for Langfuse Tracing. If you want to use tracing via the API, such as to build your own Langfuse client implementation, this is the only API route you need to implement.
|
||||
|
||||
Notes:
|
||||
- Introduction to data model: https://langfuse.com/docs/tracing-data-model
|
||||
- Batch sizes are limited to 3.5 MB in total. You need to adjust the number of events per batch accordingly.
|
||||
- The API does not return a 4xx status code for input errors. Instead, it responds with a 207 status code, which includes a list of the encountered errors.
|
||||
method: POST
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "langfuse",
|
||||
"version": "3.2.0",
|
||||
"version": "3.7.0",
|
||||
"author": "engineering@langfuse.com",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
@@ -84,7 +84,8 @@
|
||||
"packageManager": "pnpm@9.5.0",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"jsonpath-plus": "10.0.7"
|
||||
"jsonpath-plus": "10.0.7",
|
||||
"nanoid": "^3.3.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,21 +18,31 @@ then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure CLICKHOUSE_DB is set
|
||||
if [ -z "${CLICKHOUSE_DB}" ]; then
|
||||
export CLICKHOUSE_DB="default"
|
||||
fi
|
||||
|
||||
# Ensure CLICKHOUSE_CLUSTER_NAME is set
|
||||
if [ -z "${CLICKHOUSE_CLUSTER_NAME}" ]; then
|
||||
export CLICKHOUSE_CLUSTER_NAME="default"
|
||||
fi
|
||||
|
||||
# Construct the database URL
|
||||
if [ "$CLICKHOUSE_CLUSTER_ENABLED" == "false" ] ; then
|
||||
if [ "$CLICKHOUSE_MIGRATION_SSL" = true ] ; then
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&secure=true&skip_verify=true&x-migrations-table-engine=MergeTree"
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=${CLICKHOUSE_DB}&x-multi-statement=true&secure=true&skip_verify=true&x-migrations-table-engine=MergeTree"
|
||||
else
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&x-migrations-table-engine=MergeTree"
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=${CLICKHOUSE_DB}&x-multi-statement=true&x-migrations-table-engine=MergeTree"
|
||||
fi
|
||||
|
||||
# Execute the up command
|
||||
migrate -source file://clickhouse/migrations/unclustered -database "$DATABASE_URL" down
|
||||
else
|
||||
if [ "$CLICKHOUSE_MIGRATION_SSL" = true ] ; then
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&secure=true&skip_verify=true&x-cluster-name=default&x-migrations-table-engine=ReplicatedMergeTree"
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=${CLICKHOUSE_DB}&x-multi-statement=true&secure=true&skip_verify=true&x-cluster-name=${CLICKHOUSE_CLUSTER_NAME}&x-migrations-table-engine=ReplicatedMergeTree"
|
||||
else
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&x-cluster-name=default&x-migrations-table-engine=ReplicatedMergeTree"
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=${CLICKHOUSE_DB}&x-multi-statement=true&x-cluster-name=${CLICKHOUSE_CLUSTER_NAME}&x-migrations-table-engine=ReplicatedMergeTree"
|
||||
fi
|
||||
|
||||
# Execute the up command
|
||||
|
||||
@@ -12,11 +12,16 @@ then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure CLICKHOUSE_DB is set
|
||||
if [ -z "${CLICKHOUSE_DB}" ]; then
|
||||
export CLICKHOUSE_DB="default"
|
||||
fi
|
||||
|
||||
# Construct the database URL
|
||||
if [ "$CLICKHOUSE_MIGRATION_SSL" = true ] ; then
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&secure=true&skip_verify=true&x-migrations-table-engine=MergeTree"
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=${CLICKHOUSE_DB}&x-multi-statement=true&secure=true&skip_verify=true&x-migrations-table-engine=MergeTree"
|
||||
else
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&x-migrations-table-engine=MergeTree"
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=${CLICKHOUSE_DB}&x-multi-statement=true&x-migrations-table-engine=MergeTree"
|
||||
fi
|
||||
# Execute the drop command
|
||||
migrate -source file://clickhouse/migrations -database "$DATABASE_URL" drop
|
||||
|
||||
@@ -18,21 +18,31 @@ then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure CLICKHOUSE_DB is set
|
||||
if [ -z "${CLICKHOUSE_DB}" ]; then
|
||||
export CLICKHOUSE_DB="default"
|
||||
fi
|
||||
|
||||
# Ensure CLICKHOUSE_CLUSTER_NAME is set
|
||||
if [ -z "${CLICKHOUSE_CLUSTER_NAME}" ]; then
|
||||
export CLICKHOUSE_CLUSTER_NAME="default"
|
||||
fi
|
||||
|
||||
# Construct the database URL
|
||||
if [ "$CLICKHOUSE_CLUSTER_ENABLED" == "false" ] ; then
|
||||
if [ "$CLICKHOUSE_MIGRATION_SSL" = true ] ; then
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&secure=true&skip_verify=true&x-migrations-table-engine=MergeTree"
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=${CLICKHOUSE_DB}&x-multi-statement=true&secure=true&skip_verify=true&x-migrations-table-engine=MergeTree"
|
||||
else
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&x-migrations-table-engine=MergeTree"
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=${CLICKHOUSE_DB}&x-multi-statement=true&x-migrations-table-engine=MergeTree"
|
||||
fi
|
||||
|
||||
# Execute the up command
|
||||
migrate -source file://clickhouse/migrations/unclustered -database "$DATABASE_URL" up
|
||||
else
|
||||
if [ "$CLICKHOUSE_MIGRATION_SSL" = true ] ; then
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&secure=true&skip_verify=true&x-cluster-name=default&x-migrations-table-engine=ReplicatedMergeTree"
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=${CLICKHOUSE_DB}&x-multi-statement=true&secure=true&skip_verify=true&x-cluster-name=${CLICKHOUSE_CLUSTER_NAME}&x-migrations-table-engine=ReplicatedMergeTree"
|
||||
else
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&x-cluster-name=default&x-migrations-table-engine=ReplicatedMergeTree"
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=${CLICKHOUSE_DB}&x-multi-statement=true&x-cluster-name=${CLICKHOUSE_CLUSTER_NAME}&x-migrations-table-engine=ReplicatedMergeTree"
|
||||
fi
|
||||
|
||||
# Execute the up command
|
||||
|
||||
@@ -121,7 +121,8 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"jsonpath-plus": "10.0.7"
|
||||
"jsonpath-plus": "10.0.7",
|
||||
"nanoid": "^3.3.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,6 +479,13 @@ export type Prompt = {
|
||||
tags: Generated<string[]>;
|
||||
labels: Generated<string[]>;
|
||||
};
|
||||
export type QueueBackUp = {
|
||||
id: string;
|
||||
project_id: string | null;
|
||||
queue_name: string;
|
||||
content: unknown;
|
||||
created_at: Generated<Timestamp>;
|
||||
};
|
||||
export type Score = {
|
||||
id: string;
|
||||
timestamp: Generated<Timestamp>;
|
||||
@@ -628,6 +635,7 @@ export type DB = {
|
||||
project_memberships: ProjectMembership;
|
||||
projects: Project;
|
||||
prompts: Prompt;
|
||||
queue_backups: QueueBackUp;
|
||||
score_configs: ScoreConfig;
|
||||
scores: Score;
|
||||
Session: Session;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "queue_backups" (
|
||||
"id" TEXT NOT NULL,
|
||||
"project_id" TEXT,
|
||||
"queue_name" TEXT NOT NULL,
|
||||
"content" JSONB NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "queue_backups_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "traces" ADD CONSTRAINT "traces_session_id_project_id_fkey" FOREIGN KEY ("session_id", "project_id") REFERENCES "trace_sessions"("id", "project_id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "traces" DROP CONSTRAINT "traces_session_id_project_id_fkey";
|
||||
@@ -272,7 +272,6 @@ model TraceSession {
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
bookmarked Boolean @default(false)
|
||||
public Boolean @default(false)
|
||||
traces Trace[]
|
||||
|
||||
@@id([id, projectId])
|
||||
@@index([projectId])
|
||||
@@ -284,25 +283,24 @@ model TraceSession {
|
||||
// Update TraceView below when making changes to this model!
|
||||
|
||||
model Trace {
|
||||
id String @id @default(cuid())
|
||||
externalId String? @map("external_id")
|
||||
timestamp DateTime @default(now())
|
||||
id String @id @default(cuid())
|
||||
externalId String? @map("external_id")
|
||||
timestamp DateTime @default(now())
|
||||
name String?
|
||||
userId String? @map("user_id")
|
||||
userId String? @map("user_id")
|
||||
metadata Json?
|
||||
release String?
|
||||
version String?
|
||||
projectId String @map("project_id")
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
public Boolean @default(false)
|
||||
bookmarked Boolean @default(false)
|
||||
tags String[] @default([])
|
||||
projectId String @map("project_id")
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
public Boolean @default(false)
|
||||
bookmarked Boolean @default(false)
|
||||
tags String[] @default([])
|
||||
input Json?
|
||||
output Json?
|
||||
sessionId String? @map("session_id")
|
||||
session TraceSession? @relation(fields: [sessionId, projectId], references: [id, projectId])
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
sessionId String? @map("session_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
|
||||
@@index([projectId, timestamp])
|
||||
@@index([sessionId])
|
||||
@@ -1017,3 +1015,14 @@ model ObservationMedia {
|
||||
@@index([projectId, observationId])
|
||||
@@map("observation_media")
|
||||
}
|
||||
|
||||
model QueueBackUp {
|
||||
id String @id @default(cuid())
|
||||
projectId String? @map("project_id")
|
||||
queueName String @map("queue_name")
|
||||
content Json
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@map("queue_backups")
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ const EnvSchema = z.object({
|
||||
LANGFUSE_CACHE_PROMPT_ENABLED: z.enum(["true", "false"]).default("false"),
|
||||
LANGFUSE_CACHE_PROMPT_TTL_SECONDS: z.coerce.number().default(60 * 60),
|
||||
CLICKHOUSE_URL: z.string().url(),
|
||||
CLICKHOUSE_CLUSTER_NAME: z.string().default("default"),
|
||||
CLICKHOUSE_DB: z.string().default("default"),
|
||||
CLICKHOUSE_USER: z.string(),
|
||||
CLICKHOUSE_PASSWORD: z.string(),
|
||||
LANGFUSE_SDK_CI_SYNC_PROCESSING_ENABLED: z
|
||||
@@ -66,6 +68,10 @@ const EnvSchema = z.object({
|
||||
.default("false"),
|
||||
LANGFUSE_USE_AZURE_BLOB: z.enum(["true", "false"]).default("false"),
|
||||
STRIPE_SECRET_KEY: z.string().optional(),
|
||||
|
||||
LANGFUSE_S3_CORE_DATA_EXPORT_IS_ENABLED: z
|
||||
.enum(["true", "false"])
|
||||
.default("false"),
|
||||
});
|
||||
|
||||
export const env: z.infer<typeof EnvSchema> =
|
||||
|
||||
@@ -112,6 +112,14 @@ export const observationsTableCols: ColumnDefinition[] = [
|
||||
options: [], // to be added at runtime
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Model ID",
|
||||
id: "modelId",
|
||||
type: "stringOptions",
|
||||
internal: 'o."internal_model_id"',
|
||||
options: [], // to be added at runtime
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Input Tokens",
|
||||
id: "inputTokens",
|
||||
@@ -186,6 +194,7 @@ export const observationsTableCols: ColumnDefinition[] = [
|
||||
// allows for undefined options, to offer filters while options are still loading
|
||||
export type ObservationOptions = {
|
||||
model: Array<OptionsDefinition>;
|
||||
modelId: Array<OptionsDefinition>;
|
||||
name: Array<OptionsDefinition>;
|
||||
traceName: Array<OptionsDefinition>;
|
||||
scores_avg: Array<string>;
|
||||
@@ -194,12 +203,15 @@ export type ObservationOptions = {
|
||||
};
|
||||
|
||||
export function observationsTableColsWithOptions(
|
||||
options?: ObservationOptions
|
||||
options?: ObservationOptions,
|
||||
): ColumnDefinition[] {
|
||||
return observationsTableCols.map((col) => {
|
||||
if (col.id === "model") {
|
||||
return { ...col, options: options?.model ?? [] };
|
||||
}
|
||||
if (col.id === "modelId") {
|
||||
return { ...col, options: options?.modelId ?? [] };
|
||||
}
|
||||
if (col.id === "name") {
|
||||
return { ...col, options: options?.name ?? [] };
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export const clickhouseClient = (opts?: NodeClickHouseClientConfigOptions) =>
|
||||
url: env.CLICKHOUSE_URL,
|
||||
username: env.CLICKHOUSE_USER,
|
||||
password: env.CLICKHOUSE_PASSWORD,
|
||||
database: "default",
|
||||
database: env.CLICKHOUSE_DB,
|
||||
clickhouse_settings: {
|
||||
async_insert: 1,
|
||||
wait_for_async_insert: 1, // if disabled, we won't get errors from clickhouse
|
||||
|
||||
@@ -32,6 +32,7 @@ export * from "./redis/legacyIngestion";
|
||||
export * from "./redis/ingestionQueue";
|
||||
export * from "./redis/postHogIntegrationQueue";
|
||||
export * from "./redis/postHogIntegrationProcessingQueue";
|
||||
export * from "./redis/coreDataS3ExportQueue";
|
||||
export * from "./redis/experimentCreateQueue";
|
||||
export * from "./auth/types";
|
||||
export * from "./ingestion/legacy/index";
|
||||
@@ -44,6 +45,7 @@ export * from "./logger";
|
||||
export * from "./queries";
|
||||
export * from "./repositories";
|
||||
export * from "./redis/evalExecutionQueue";
|
||||
export * from "./services/sessions-ui-table-service";
|
||||
|
||||
// test utils
|
||||
export * from "./test-utils";
|
||||
|
||||
@@ -394,7 +394,7 @@ export class ObservationProcessor implements EventProcessor {
|
||||
model?: Model,
|
||||
existingObservation?: Omit<Observation, "input" | "output">,
|
||||
) {
|
||||
let newPromptTokens = body.usage?.input || body.usageDetails?.input;
|
||||
let newPromptTokens = body.usage?.input ?? body.usageDetails?.input;
|
||||
if (newPromptTokens === undefined && model && model.tokenizerId) {
|
||||
if (body.input) {
|
||||
newPromptTokens = calculateTokenDelegate({
|
||||
@@ -419,7 +419,7 @@ export class ObservationProcessor implements EventProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
let newCompletionTokens = body.usage?.output || body.usageDetails?.output;
|
||||
let newCompletionTokens = body.usage?.output ?? body.usageDetails?.output;
|
||||
|
||||
if (newCompletionTokens === undefined && model && model.tokenizerId) {
|
||||
if (body.output) {
|
||||
|
||||
@@ -112,11 +112,13 @@ export enum QueueName {
|
||||
DatasetRunItemUpsert = "dataset-run-item-upsert-queue",
|
||||
BatchExport = "batch-export-queue",
|
||||
IngestionQueue = "ingestion-queue", // Process single events with S3-merge
|
||||
IngestionSecondaryQueue = "secondary-ingestion-queue", // Separates high priority + high throughput projects from other projects.
|
||||
LegacyIngestionQueue = "legacy-ingestion-queue", // Used for batch processing of Ingestion
|
||||
CloudUsageMeteringQueue = "cloud-usage-metering-queue",
|
||||
ExperimentCreate = "experiment-create-queue",
|
||||
PostHogIntegrationQueue = "posthog-integration-queue",
|
||||
PostHogIntegrationProcessingQueue = "posthog-integration-processing-queue",
|
||||
CoreDataS3ExportQueue = "core-data-s3-export-queue",
|
||||
}
|
||||
|
||||
export enum QueueJobs {
|
||||
@@ -129,9 +131,11 @@ export enum QueueJobs {
|
||||
LegacyIngestionJob = "legacy-ingestion-job",
|
||||
CloudUsageMeteringJob = "cloud-usage-metering-job",
|
||||
IngestionJob = "ingestion-job",
|
||||
IngestionSecondaryJob = "secondary-ingestion-job",
|
||||
ExperimentCreateJob = "experiment-create-job",
|
||||
PostHogIntegrationJob = "posthog-integration-job",
|
||||
PostHogIntegrationProcessingJob = "posthog-integration-processing-job",
|
||||
CoreDataS3ExportJob = "core-data-s3-export-job",
|
||||
}
|
||||
|
||||
export type TQueueJobTypes = {
|
||||
@@ -183,6 +187,12 @@ export type TQueueJobTypes = {
|
||||
payload: IngestionEventQueueType;
|
||||
name: QueueJobs.IngestionJob;
|
||||
};
|
||||
[QueueName.IngestionSecondaryQueue]: {
|
||||
timestamp: Date;
|
||||
id: string;
|
||||
payload: IngestionEventQueueType;
|
||||
name: QueueJobs.IngestionJob;
|
||||
};
|
||||
[QueueName.ExperimentCreate]: {
|
||||
timestamp: Date;
|
||||
id: string;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Queue } from "bullmq";
|
||||
import { QueueName, QueueJobs } from "../queues";
|
||||
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
|
||||
import { logger } from "../logger";
|
||||
import { env } from "../../env";
|
||||
|
||||
export class CoreDataS3ExportQueue {
|
||||
private static instance: Queue | null = null;
|
||||
|
||||
public static getInstance(): Queue | null {
|
||||
if (env.LANGFUSE_S3_CORE_DATA_EXPORT_IS_ENABLED !== "true") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (CoreDataS3ExportQueue.instance) {
|
||||
return CoreDataS3ExportQueue.instance;
|
||||
}
|
||||
|
||||
const newRedis = createNewRedisInstance({
|
||||
enableOfflineQueue: false,
|
||||
...redisQueueRetryOptions,
|
||||
});
|
||||
|
||||
CoreDataS3ExportQueue.instance = newRedis
|
||||
? new Queue(QueueName.CoreDataS3ExportQueue, {
|
||||
connection: newRedis,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 100,
|
||||
attempts: 5,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 5000,
|
||||
},
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
CoreDataS3ExportQueue.instance?.on("error", (err) => {
|
||||
logger.error("CoreDataS3ExportQueue error", err);
|
||||
});
|
||||
|
||||
if (CoreDataS3ExportQueue.instance) {
|
||||
logger.debug("Scheduling jobs for CoreDataS3ExportQueue");
|
||||
CoreDataS3ExportQueue.instance
|
||||
.add(
|
||||
QueueJobs.CoreDataS3ExportJob,
|
||||
{},
|
||||
{
|
||||
repeat: { pattern: "30 4 * * *" }, // every day at 4:30am
|
||||
},
|
||||
)
|
||||
.catch((err) => {
|
||||
logger.error("Error adding CoreDataS3ExportJob schedule", err);
|
||||
});
|
||||
}
|
||||
|
||||
return CoreDataS3ExportQueue.instance;
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,14 @@ import { CloudUsageMeteringQueue } from "./cloudUsageMeteringQueue";
|
||||
import { DatasetRunItemUpsertQueue } from "./datasetRunItemUpsert";
|
||||
import { EvalExecutionQueue } from "./evalExecutionQueue";
|
||||
import { ExperimentCreateQueue } from "./experimentCreateQueue";
|
||||
import { IngestionQueue } from "./ingestionQueue";
|
||||
import { IngestionQueue, SecondaryIngestionQueue } from "./ingestionQueue";
|
||||
import { LegacyIngestionQueue } from "./legacyIngestion";
|
||||
import { TraceUpsertQueue } from "./traceUpsert";
|
||||
import { TraceDeleteQueue } from "./traceDelete";
|
||||
import { ProjectDeleteQueue } from "./projectDelete";
|
||||
import { PostHogIntegrationQueue } from "./postHogIntegrationQueue";
|
||||
import { PostHogIntegrationProcessingQueue } from "./postHogIntegrationProcessingQueue";
|
||||
import { CoreDataS3ExportQueue } from "./coreDataS3ExportQueue";
|
||||
|
||||
export function getQueue(queueName: QueueName): Queue | null {
|
||||
switch (queueName) {
|
||||
@@ -39,6 +40,10 @@ export function getQueue(queueName: QueueName): Queue | null {
|
||||
return PostHogIntegrationQueue.getInstance();
|
||||
case QueueName.PostHogIntegrationProcessingQueue:
|
||||
return PostHogIntegrationProcessingQueue.getInstance();
|
||||
case QueueName.IngestionSecondaryQueue:
|
||||
return SecondaryIngestionQueue.getInstance();
|
||||
case QueueName.CoreDataS3ExportQueue:
|
||||
return CoreDataS3ExportQueue.getInstance();
|
||||
default:
|
||||
const exhaustiveCheckDefault: never = queueName;
|
||||
throw new Error(`Queue ${queueName} not found`);
|
||||
|
||||
@@ -43,3 +43,45 @@ export class IngestionQueue {
|
||||
return IngestionQueue.instance;
|
||||
}
|
||||
}
|
||||
|
||||
export class SecondaryIngestionQueue {
|
||||
private static instance: Queue<
|
||||
TQueueJobTypes[QueueName.IngestionSecondaryQueue]
|
||||
> | null = null;
|
||||
|
||||
public static getInstance(): Queue<
|
||||
TQueueJobTypes[QueueName.IngestionSecondaryQueue]
|
||||
> | null {
|
||||
if (SecondaryIngestionQueue.instance)
|
||||
return SecondaryIngestionQueue.instance;
|
||||
|
||||
const newRedis = createNewRedisInstance({
|
||||
enableOfflineQueue: false,
|
||||
...redisQueueRetryOptions,
|
||||
});
|
||||
|
||||
SecondaryIngestionQueue.instance = newRedis
|
||||
? new Queue<TQueueJobTypes[QueueName.IngestionSecondaryQueue]>(
|
||||
QueueName.IngestionSecondaryQueue,
|
||||
{
|
||||
connection: newRedis,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 100_000,
|
||||
attempts: 5,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 5000,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
: null;
|
||||
|
||||
SecondaryIngestionQueue.instance?.on("error", (err) => {
|
||||
logger.error("SecondaryIngestionQueue error", err);
|
||||
});
|
||||
|
||||
return SecondaryIngestionQueue.instance;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
convertDateToClickhouseDateTime,
|
||||
} from "../clickhouse/client";
|
||||
import { logger } from "../logger";
|
||||
import { instrumentAsync } from "../instrumentation";
|
||||
import { getTracer, instrumentAsync } from "../instrumentation";
|
||||
import {
|
||||
StorageService,
|
||||
StorageServiceFactory,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { randomUUID } from "crypto";
|
||||
import { getClickhouseEntityType } from "../clickhouse/schemaUtils";
|
||||
import { NodeClickHouseClientConfigOptions } from "@clickhouse/client/dist/config";
|
||||
import { context, trace } from "@opentelemetry/api";
|
||||
|
||||
let s3StorageServiceClient: StorageService;
|
||||
|
||||
@@ -101,6 +102,64 @@ export async function upsertClickhouse<
|
||||
});
|
||||
}
|
||||
|
||||
export async function* queryClickhouseStream<T>(opts: {
|
||||
query: string;
|
||||
params?: Record<string, unknown> | undefined;
|
||||
clickhouseConfigs?: NodeClickHouseClientConfigOptions;
|
||||
}): AsyncGenerator<T> {
|
||||
const tracer = getTracer("clickhouse-query-stream");
|
||||
const span = tracer.startSpan("clickhouse-query-stream");
|
||||
|
||||
try {
|
||||
const res = await context.with(
|
||||
trace.setSpan(context.active(), span),
|
||||
async () => {
|
||||
// https://opentelemetry.io/docs/specs/semconv/database/database-spans/
|
||||
span.setAttribute("ch.query.text", opts.query);
|
||||
|
||||
const res = await clickhouseClient(opts.clickhouseConfigs).query({
|
||||
query: opts.query,
|
||||
format: "JSONEachRow",
|
||||
query_params: opts.params,
|
||||
});
|
||||
// same logic as for prisma. we want to see queries in development
|
||||
if (env.NODE_ENV === "development") {
|
||||
logger.info(`clickhouse:query ${res.query_id} ${opts.query}`);
|
||||
}
|
||||
|
||||
span.setAttribute("ch.queryId", res.query_id);
|
||||
|
||||
// add summary headers to the span. Helps to tune performance
|
||||
const summaryHeader = res.response_headers["x-clickhouse-summary"];
|
||||
if (summaryHeader) {
|
||||
try {
|
||||
const summary = Array.isArray(summaryHeader)
|
||||
? JSON.parse(summaryHeader[0])
|
||||
: JSON.parse(summaryHeader);
|
||||
for (const key in summary) {
|
||||
span.setAttribute(`ch.${key}`, summary[key]);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`Failed to parse clickhouse summary header ${summaryHeader}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
return res;
|
||||
},
|
||||
);
|
||||
|
||||
for await (const rows of res.stream<T>()) {
|
||||
for (const row of rows) {
|
||||
yield row.json();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
}
|
||||
|
||||
export async function queryClickhouse<T>(opts: {
|
||||
query: string;
|
||||
params?: Record<string, unknown> | undefined;
|
||||
|
||||
@@ -418,7 +418,7 @@ export const getObservationLatencies = async (
|
||||
// Skipping FINAL here, as the quantiles are approximate to begin with.
|
||||
const query = `
|
||||
SELECT
|
||||
quantiles(0.5, 0.9, 0.95, 0.99)(date_diff('milliseconds', o.start_time, o.end_time)) as quantiles,
|
||||
quantiles(0.5, 0.9, 0.95, 0.99)(date_diff('millisecond', o.start_time, o.end_time)) as quantiles,
|
||||
name
|
||||
FROM observations o
|
||||
${chFilter.find((f) => f.clickhouseTable === "traces") ? "LEFT JOIN traces t ON o.trace_id = t.id AND o.project_id = t.project_id" : ""}
|
||||
@@ -465,7 +465,7 @@ export const getTracesLatencies = async (
|
||||
select o.trace_id,
|
||||
t.name,
|
||||
o.project_id,
|
||||
date_diff('milliseconds', min(o.start_time), coalesce(max(o.end_time), max(o.start_time))) as duration
|
||||
date_diff('millisecond', min(o.start_time), coalesce(max(o.end_time), max(o.start_time))) as duration
|
||||
FROM traces t
|
||||
JOIN observations o
|
||||
ON o.trace_id = t.id AND o.project_id = t.project_id
|
||||
@@ -521,7 +521,7 @@ export const getModelLatenciesOverTime = async (
|
||||
SELECT
|
||||
${selectTimeseriesColumn(groupBy, "o.start_time", "start_time_bucket")},
|
||||
provided_model_name,
|
||||
quantiles(0.5, 0.75, 0.9, 0.95, 0.99)(date_diff('milliseconds', o.start_time, o.end_time)) as quantiles
|
||||
quantiles(0.5, 0.75, 0.9, 0.95, 0.99)(date_diff('millisecond', o.start_time, o.end_time)) as quantiles
|
||||
FROM observations o
|
||||
${traceFilter ? "JOIN traces t ON o.trace_id = t.id AND o.project_id = t.project_id" : ""}
|
||||
WHERE project_id = {projectId: String}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
commandClickhouse,
|
||||
parseClickhouseUTCDateTimeFormat,
|
||||
queryClickhouse,
|
||||
queryClickhouseStream,
|
||||
upsertClickhouse,
|
||||
} from "./clickhouse";
|
||||
import { ObservationLevel } from "@prisma/client";
|
||||
@@ -536,9 +537,12 @@ const getObservationsTableInternal = async <T>(
|
||||
o.updated_at as "updated_at",
|
||||
o.provided_model_name as "provided_model_name",
|
||||
o.total_cost as "total_cost",
|
||||
o.prompt_id as "prompt_id",
|
||||
o.prompt_name as "prompt_name",
|
||||
o.prompt_version as "prompt_version",
|
||||
internal_model_id as "internal_model_id",
|
||||
if(isNull(end_time), NULL, date_diff('milliseconds', start_time, end_time)) as latency,
|
||||
if(isNull(completion_start_time), NULL, date_diff('milliseconds', start_time, completion_start_time)) as "time_to_first_token"`;
|
||||
if(isNull(end_time), NULL, date_diff('millisecond', start_time, end_time)) as latency,
|
||||
if(isNull(completion_start_time), NULL, date_diff('millisecond', start_time, completion_start_time)) as "time_to_first_token"`;
|
||||
|
||||
const { projectId, filter, selectIOAndMetadata, limit, offset, orderBy } =
|
||||
opts;
|
||||
@@ -747,6 +751,50 @@ export const getObservationsGroupedByModel = async (
|
||||
return res.map((r) => ({ model: r.name }));
|
||||
};
|
||||
|
||||
export const getObservationsGroupedByModelId = async (
|
||||
projectId: string,
|
||||
filter: FilterState,
|
||||
) => {
|
||||
const observationsFilter = new FilterList([
|
||||
new StringFilter({
|
||||
clickhouseTable: "observations",
|
||||
field: "project_id",
|
||||
operator: "=",
|
||||
value: projectId,
|
||||
tablePrefix: "o",
|
||||
}),
|
||||
]);
|
||||
|
||||
observationsFilter.push(
|
||||
...createFilterFromFilterState(
|
||||
filter,
|
||||
observationsTableUiColumnDefinitions,
|
||||
),
|
||||
);
|
||||
|
||||
const appliedObservationsFilter = observationsFilter.apply();
|
||||
|
||||
// We mainly use queries like this to retrieve filter options.
|
||||
// Therefore, we can skip final as some inaccuracy in count is acceptable.
|
||||
const query = `
|
||||
SELECT o.internal_model_id as modelId
|
||||
FROM observations o
|
||||
WHERE ${appliedObservationsFilter.query}
|
||||
AND o.type = 'GENERATION'
|
||||
GROUP BY o.internal_model_id
|
||||
ORDER BY count() DESC
|
||||
LIMIT 1000;
|
||||
`;
|
||||
|
||||
const res = await queryClickhouse<{ modelId: string }>({
|
||||
query,
|
||||
params: {
|
||||
...appliedObservationsFilter.params,
|
||||
},
|
||||
});
|
||||
return res.map((r) => ({ modelId: r.modelId }));
|
||||
};
|
||||
|
||||
export const getObservationsGroupedByName = async (
|
||||
projectId: string,
|
||||
filter: FilterState,
|
||||
@@ -963,7 +1011,7 @@ export const getObservationMetricsForPrompts = async (
|
||||
end_time,
|
||||
usage_details,
|
||||
cost_details,
|
||||
dateDiff('milliseconds', start_time, end_time) AS latency_ms
|
||||
dateDiff('millisecond', start_time, end_time) AS latency_ms
|
||||
FROM observations
|
||||
FINAL
|
||||
WHERE (type = 'GENERATION')
|
||||
@@ -1026,7 +1074,7 @@ export const getLatencyAndTotalCostForObservations = async (
|
||||
SELECT
|
||||
id,
|
||||
cost_details['total'] AS total_cost,
|
||||
dateDiff('milliseconds', start_time, end_time) AS latency_ms
|
||||
dateDiff('millisecond', start_time, end_time) AS latency_ms
|
||||
FROM observations FINAL
|
||||
WHERE project_id = {projectId: String}
|
||||
AND id IN ({observationIds: Array(String)})
|
||||
@@ -1058,7 +1106,7 @@ export const getLatencyAndTotalCostForObservationsByTraces = async (
|
||||
SELECT
|
||||
trace_id,
|
||||
sumMap(cost_details)['total'] AS total_cost,
|
||||
dateDiff('milliseconds', min(start_time), max(end_time)) AS latency_ms
|
||||
dateDiff('millisecond', min(start_time), max(end_time)) AS latency_ms
|
||||
FROM observations FINAL
|
||||
WHERE project_id = {projectId: String}
|
||||
AND trace_id IN ({traceIds: Array(String)})
|
||||
@@ -1167,23 +1215,23 @@ export const getTraceIdsForObservations = async (
|
||||
}));
|
||||
};
|
||||
|
||||
export const getGenerationsForPostHog = async (
|
||||
export const getGenerationsForPostHog = async function* (
|
||||
projectId: string,
|
||||
minTimestamp: Date,
|
||||
maxTimestamp: Date,
|
||||
) => {
|
||||
) {
|
||||
const query = `
|
||||
SELECT
|
||||
o.name as name,
|
||||
o.start_time as start_time,
|
||||
o.id as id,
|
||||
o.total_cost as total_cost,
|
||||
if(isNull(completion_start_time), NULL, date_diff('milliseconds', start_time, completion_start_time)) as time_to_first_token,
|
||||
if(isNull(completion_start_time), NULL, date_diff('millisecond', start_time, completion_start_time)) as time_to_first_token,
|
||||
o.usage_details['total'] as input_tokens,
|
||||
o.usage_details['output'] as output_tokens,
|
||||
o.cost_details['total'] as total_tokens,
|
||||
o.project_id as project_id,
|
||||
if(isNull(end_time), NULL, date_diff('milliseconds', start_time, end_time) / 1000) as latency,
|
||||
if(isNull(end_time), NULL, date_diff('millisecond', start_time, end_time) / 1000) as latency,
|
||||
o.provided_model_name as model,
|
||||
o.level as level,
|
||||
o.version as version,
|
||||
@@ -1205,7 +1253,7 @@ export const getGenerationsForPostHog = async (
|
||||
AND o.type = 'GENERATION'
|
||||
`;
|
||||
|
||||
const records = await queryClickhouse<Record<string, unknown>>({
|
||||
const records = queryClickhouseStream<Record<string, unknown>>({
|
||||
query,
|
||||
params: {
|
||||
projectId,
|
||||
@@ -1215,32 +1263,34 @@ export const getGenerationsForPostHog = async (
|
||||
});
|
||||
|
||||
const baseUrl = env.NEXTAUTH_URL?.replace("/api/auth", "");
|
||||
return records.map((record) => ({
|
||||
timestamp: record.start_time,
|
||||
langfuse_generation_name: record.name,
|
||||
langfuse_trace_name: record.trace_name,
|
||||
langfuse_url: `${baseUrl}/project/${projectId}/traces/${encodeURIComponent(record.trace_id as string)}?observation=${encodeURIComponent(record.id as string)}`,
|
||||
langfuse_id: record.id,
|
||||
langfuse_cost_usd: record.total_cost,
|
||||
langfuse_input_units: record.input_tokens,
|
||||
langfuse_output_units: record.output_tokens,
|
||||
langfuse_total_units: record.total_tokens,
|
||||
langfuse_session_id: record.trace_session_id,
|
||||
langfuse_project_id: projectId,
|
||||
langfuse_user_id: record.trace_user_id || "langfuse_unknown_user",
|
||||
langfuse_latency: record.latency,
|
||||
langfuse_time_to_first_token: record.time_to_first_token,
|
||||
langfuse_release: record.trace_release,
|
||||
langfuse_version: record.version,
|
||||
langfuse_model: record.model,
|
||||
langfuse_level: record.level,
|
||||
langfuse_tags: record.trace_tags,
|
||||
langfuse_event_version: "1.0.0",
|
||||
$session_id: record.posthog_session_id ?? null,
|
||||
$set: {
|
||||
langfuse_user_url: record.user_id
|
||||
? `${baseUrl}/project/${projectId}/users/${encodeURIComponent(record.user_id as string)}`
|
||||
: null,
|
||||
},
|
||||
}));
|
||||
for await (const record of records) {
|
||||
yield {
|
||||
timestamp: record.start_time,
|
||||
langfuse_generation_name: record.name,
|
||||
langfuse_trace_name: record.trace_name,
|
||||
langfuse_url: `${baseUrl}/project/${projectId}/traces/${encodeURIComponent(record.trace_id as string)}?observation=${encodeURIComponent(record.id as string)}`,
|
||||
langfuse_id: record.id,
|
||||
langfuse_cost_usd: record.total_cost,
|
||||
langfuse_input_units: record.input_tokens,
|
||||
langfuse_output_units: record.output_tokens,
|
||||
langfuse_total_units: record.total_tokens,
|
||||
langfuse_session_id: record.trace_session_id,
|
||||
langfuse_project_id: projectId,
|
||||
langfuse_user_id: record.trace_user_id || "langfuse_unknown_user",
|
||||
langfuse_latency: record.latency,
|
||||
langfuse_time_to_first_token: record.time_to_first_token,
|
||||
langfuse_release: record.trace_release,
|
||||
langfuse_version: record.version,
|
||||
langfuse_model: record.model,
|
||||
langfuse_level: record.level,
|
||||
langfuse_tags: record.trace_tags,
|
||||
langfuse_event_version: "1.0.0",
|
||||
$session_id: record.posthog_session_id ?? null,
|
||||
$set: {
|
||||
langfuse_user_url: record.user_id
|
||||
? `${baseUrl}/project/${projectId}/users/${encodeURIComponent(record.user_id as string)}`
|
||||
: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,7 +9,6 @@ import Decimal from "decimal.js";
|
||||
import { parseClickhouseUTCDateTimeFormat } from "./clickhouse";
|
||||
import { ObservationRecordReadType } from "./definitions";
|
||||
import { parseJsonPrioritised } from "../../utils/json";
|
||||
import { jsonSchema } from "../../utils/zod";
|
||||
|
||||
export const convertObservationToView = (
|
||||
record: ObservationRecordReadType,
|
||||
@@ -62,15 +61,22 @@ export const convertObservation = (
|
||||
? parseClickhouseUTCDateTimeFormat(record.end_time)
|
||||
: null,
|
||||
name: record.name ?? null,
|
||||
metadata: record.metadata,
|
||||
metadata:
|
||||
record.metadata &&
|
||||
Object.fromEntries(
|
||||
Object.entries(record.metadata ?? {}).map(([key, val]) => [
|
||||
key,
|
||||
val && parseJsonPrioritised(val),
|
||||
]),
|
||||
),
|
||||
level: record.level as ObservationLevel,
|
||||
statusMessage: record.status_message ?? null,
|
||||
version: record.version ?? null,
|
||||
input: (record.input
|
||||
? jsonSchema.parse(parseJsonPrioritised(record.input))
|
||||
? parseJsonPrioritised(record.input)
|
||||
: null) as Prisma.JsonValue | null,
|
||||
output: (record.output
|
||||
? jsonSchema.parse(parseJsonPrioritised(record.output))
|
||||
? parseJsonPrioritised(record.output)
|
||||
: null) as Prisma.JsonValue | null,
|
||||
modelParameters: record.model_parameters
|
||||
? JSON.parse(record.model_parameters)
|
||||
@@ -126,10 +132,11 @@ export const convertObservation = (
|
||||
parseClickhouseUTCDateTimeFormat(record.start_time).getTime()
|
||||
: null,
|
||||
timeToFirstToken: record.completion_start_time
|
||||
? parseClickhouseUTCDateTimeFormat(
|
||||
? (parseClickhouseUTCDateTimeFormat(
|
||||
record.completion_start_time,
|
||||
).getTime() -
|
||||
parseClickhouseUTCDateTimeFormat(record.start_time).getTime()
|
||||
parseClickhouseUTCDateTimeFormat(record.start_time).getTime()) /
|
||||
1000
|
||||
: null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
commandClickhouse,
|
||||
parseClickhouseUTCDateTimeFormat,
|
||||
queryClickhouse,
|
||||
queryClickhouseStream,
|
||||
upsertClickhouse,
|
||||
} from "./clickhouse";
|
||||
import { FilterList } from "../queries/clickhouse-sql/clickhouse-filter";
|
||||
@@ -135,13 +136,16 @@ export const upsertScore = async (score: Partial<ScoreRecordReadType>) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const getScoresForTraces = async (
|
||||
projectId: string,
|
||||
traceIds: string[],
|
||||
timestamp?: Date,
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
) => {
|
||||
export type GetScoresForTracesProps = {
|
||||
projectId: string;
|
||||
traceIds: string[];
|
||||
timestamp?: Date;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export const getScoresForTraces = async (props: GetScoresForTracesProps) => {
|
||||
const { projectId, traceIds, timestamp, limit, offset } = props;
|
||||
const query = `
|
||||
select
|
||||
*
|
||||
@@ -666,11 +670,11 @@ export const getDistinctScoreNames = async (
|
||||
return rows.map((row) => row.name);
|
||||
};
|
||||
|
||||
export const getScoresForPostHog = async (
|
||||
export const getScoresForPostHog = async function* (
|
||||
projectId: string,
|
||||
minTimestamp: Date,
|
||||
maxTimestamp: Date,
|
||||
) => {
|
||||
) {
|
||||
const query = `
|
||||
SELECT
|
||||
s.id as id,
|
||||
@@ -694,7 +698,7 @@ export const getScoresForPostHog = async (
|
||||
AND t.timestamp <= {maxTimestamp: DateTime64(3)}
|
||||
`;
|
||||
|
||||
const records = await queryClickhouse<Record<string, unknown>>({
|
||||
const records = queryClickhouseStream<Record<string, unknown>>({
|
||||
query,
|
||||
params: {
|
||||
projectId,
|
||||
@@ -704,24 +708,26 @@ export const getScoresForPostHog = async (
|
||||
});
|
||||
|
||||
const baseUrl = env.NEXTAUTH_URL?.replace("/api/auth", "");
|
||||
return records.map((record) => ({
|
||||
timestamp: record.timestamp,
|
||||
langfuse_score_name: record.name,
|
||||
langfuse_score_value: record.value,
|
||||
langfuse_score_comment: record.comment,
|
||||
langfuse_trace_name: record.trace_name,
|
||||
langfuse_id: record.id,
|
||||
langfuse_session_id: record.trace_session_id,
|
||||
langfuse_project_id: projectId,
|
||||
langfuse_user_id: record.trace_user_id || "langfuse_unknown_user",
|
||||
langfuse_release: record.trace_release,
|
||||
langfuse_tags: record.trace_tags,
|
||||
langfuse_event_version: "1.0.0",
|
||||
$session_id: record.posthog_session_id ?? null,
|
||||
$set: {
|
||||
langfuse_user_url: record.user_id
|
||||
? `${baseUrl}/project/${projectId}/users/${encodeURIComponent(record.user_id as string)}`
|
||||
: null,
|
||||
},
|
||||
}));
|
||||
for await (const record of records) {
|
||||
yield {
|
||||
timestamp: record.timestamp,
|
||||
langfuse_score_name: record.name,
|
||||
langfuse_score_value: record.value,
|
||||
langfuse_score_comment: record.comment,
|
||||
langfuse_trace_name: record.trace_name,
|
||||
langfuse_id: record.id,
|
||||
langfuse_session_id: record.trace_session_id,
|
||||
langfuse_project_id: projectId,
|
||||
langfuse_user_id: record.trace_user_id || "langfuse_unknown_user",
|
||||
langfuse_release: record.trace_release,
|
||||
langfuse_tags: record.trace_tags,
|
||||
langfuse_event_version: "1.0.0",
|
||||
$session_id: record.posthog_session_id ?? null,
|
||||
$set: {
|
||||
langfuse_user_url: record.user_id
|
||||
? `${baseUrl}/project/${projectId}/users/${encodeURIComponent(record.user_id as string)}`
|
||||
: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
commandClickhouse,
|
||||
parseClickhouseUTCDateTimeFormat,
|
||||
queryClickhouse,
|
||||
queryClickhouseStream,
|
||||
upsertClickhouse,
|
||||
} from "./clickhouse";
|
||||
import {
|
||||
@@ -16,10 +17,7 @@ import {
|
||||
} from "../queries/clickhouse-sql/clickhouse-filter";
|
||||
import { TraceRecordReadType } from "./definitions";
|
||||
import { tracesTableUiColumnDefinitions } from "../../tableDefinitions/mapTracesTable";
|
||||
import { OrderByState } from "../../interfaces/orderBy";
|
||||
import { orderByToClickhouseSql } from "../queries/clickhouse-sql/orderby-factory";
|
||||
import { UiColumnMapping } from "../../tableDefinitions";
|
||||
import { sessionCols } from "../../tableDefinitions/mapSessionTable";
|
||||
import { convertDateToClickhouseDateTime } from "../clickhouse/client";
|
||||
import { convertClickhouseToDomain } from "./traces_converters";
|
||||
import { clickhouseSearchCondition } from "../queries/clickhouse-sql/search";
|
||||
@@ -383,196 +381,6 @@ export const getTracesGroupedByTags = async (props: GroupedTracesQueryProp) => {
|
||||
return rows;
|
||||
};
|
||||
|
||||
export type SessionDataReturnType = {
|
||||
session_id: string;
|
||||
max_timestamp: string;
|
||||
min_timestamp: string;
|
||||
trace_ids: string[];
|
||||
user_ids: string[];
|
||||
trace_count: number;
|
||||
trace_tags: string[];
|
||||
total_observations: number;
|
||||
duration: number;
|
||||
session_usage_details: Record<string, number>;
|
||||
session_cost_details: Record<string, number>;
|
||||
session_input_cost: string;
|
||||
session_output_cost: string;
|
||||
session_total_cost: string;
|
||||
session_input_usage: string;
|
||||
session_output_usage: string;
|
||||
session_total_usage: string;
|
||||
};
|
||||
|
||||
export const getSessionsTableCount = async (props: {
|
||||
projectId: string;
|
||||
filter: FilterState;
|
||||
orderBy?: OrderByState;
|
||||
limit?: number;
|
||||
page?: number;
|
||||
}) => {
|
||||
const rows = await getSessionsTableGeneric<{ count: string }>({
|
||||
select: `
|
||||
count(session_id) as count
|
||||
`,
|
||||
projectId: props.projectId,
|
||||
filter: props.filter,
|
||||
orderBy: props.orderBy,
|
||||
limit: props.limit,
|
||||
page: props.page,
|
||||
});
|
||||
|
||||
return rows.length > 0 ? Number(rows[0].count) : 0;
|
||||
};
|
||||
|
||||
export const getSessionsTable = async (props: {
|
||||
projectId: string;
|
||||
filter: FilterState;
|
||||
orderBy?: OrderByState;
|
||||
limit?: number;
|
||||
page?: number;
|
||||
}) => {
|
||||
const rows = await getSessionsTableGeneric<SessionDataReturnType>({
|
||||
select: `
|
||||
session_id,
|
||||
max_timestamp,
|
||||
min_timestamp,
|
||||
trace_ids,
|
||||
user_ids,
|
||||
trace_count,
|
||||
trace_tags,
|
||||
total_observations,
|
||||
duration,
|
||||
session_usage_details,
|
||||
session_cost_details,
|
||||
session_input_cost,
|
||||
session_output_cost,
|
||||
session_total_cost,
|
||||
session_input_usage,
|
||||
session_output_usage,
|
||||
session_total_usage
|
||||
`,
|
||||
projectId: props.projectId,
|
||||
filter: props.filter,
|
||||
orderBy: props.orderBy,
|
||||
limit: props.limit,
|
||||
page: props.page,
|
||||
});
|
||||
|
||||
return rows;
|
||||
};
|
||||
|
||||
export type FetchSessionsTableProps = {
|
||||
select: string;
|
||||
projectId: string;
|
||||
filter: FilterState;
|
||||
searchQuery?: string;
|
||||
orderBy?: OrderByState;
|
||||
limit?: number;
|
||||
page?: number;
|
||||
};
|
||||
|
||||
const getSessionsTableGeneric = async <T>(props: FetchSessionsTableProps) => {
|
||||
const { select, projectId, filter, orderBy, limit, page } = props;
|
||||
|
||||
const { tracesFilter, scoresFilter, observationsFilter } =
|
||||
getProjectIdDefaultFilter(projectId, { tracesPrefix: "s" });
|
||||
|
||||
tracesFilter.push(...createFilterFromFilterState(filter, sessionCols));
|
||||
|
||||
const tracesFilterRes = tracesFilter.apply();
|
||||
const scoresAvgFilterRes = scoresFilter.apply();
|
||||
const observationsStatsRes = observationsFilter.apply();
|
||||
|
||||
const traceTimestampFilter: DateTimeFilter | undefined = tracesFilter.find(
|
||||
(f) =>
|
||||
f.field === "min_timestamp" &&
|
||||
(f.operator === ">=" || f.operator === ">"),
|
||||
) as DateTimeFilter | undefined;
|
||||
|
||||
const singleTraceFilter = traceTimestampFilter
|
||||
? new FilterList([
|
||||
new DateTimeFilter({
|
||||
clickhouseTable: "traces",
|
||||
field: "timestamp",
|
||||
operator: traceTimestampFilter.operator,
|
||||
value: traceTimestampFilter.value,
|
||||
}),
|
||||
]).apply()
|
||||
: undefined;
|
||||
|
||||
const query = `
|
||||
WITH observations_agg AS (
|
||||
SELECT o.trace_id,
|
||||
count(*) as obs_count,
|
||||
min(o.start_time) as min_start_time,
|
||||
max(o.end_time) as max_end_time,
|
||||
sumMap(usage_details) as sum_usage_details,
|
||||
sumMap(cost_details) as sum_cost_details,
|
||||
anyLast(project_id) as project_id
|
||||
FROM observations o FINAL
|
||||
WHERE o.project_id = {projectId: String}
|
||||
${traceTimestampFilter ? `AND o.start_time >= {observationsStartTime: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}` : ""}
|
||||
GROUP BY o.trace_id
|
||||
),
|
||||
session_data AS (
|
||||
SELECT
|
||||
t.session_id,
|
||||
anyLast(t.project_id) as project_id,
|
||||
max(t.timestamp) as max_timestamp,
|
||||
min(t.timestamp) as min_timestamp,
|
||||
groupArray(t.id) AS trace_ids,
|
||||
groupUniqArray(t.user_id) AS user_ids,
|
||||
count(*) as trace_count,
|
||||
groupUniqArrayArray(t.tags) as trace_tags,
|
||||
-- Aggregate observations data at session level
|
||||
sum(o.obs_count) as total_observations,
|
||||
date_diff('milliseconds', min(min_start_time), max(max_end_time)) as duration,
|
||||
sumMap(o.sum_usage_details) as session_usage_details,
|
||||
sumMap(o.sum_cost_details) as session_cost_details,
|
||||
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'input') > 0, sumMap(o.sum_cost_details)))) as session_input_cost,
|
||||
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'output') > 0, sumMap(o.sum_cost_details)))) as session_output_cost,
|
||||
sumMap(o.sum_cost_details)['total'] as session_total_cost,
|
||||
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'input') > 0, sumMap(o.sum_usage_details)))) as session_input_usage,
|
||||
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'output') > 0, sumMap(o.sum_usage_details)))) as session_output_usage,
|
||||
sumMap(o.sum_usage_details)['total'] as session_total_usage
|
||||
FROM traces t FINAL
|
||||
LEFT JOIN observations_agg o
|
||||
ON t.id = o.trace_id AND t.project_id = o.project_id
|
||||
WHERE t.session_id IS NOT NULL
|
||||
AND t.project_id = {projectId: String}
|
||||
${singleTraceFilter?.query ? ` AND ${singleTraceFilter.query}` : ""}
|
||||
GROUP BY t.session_id
|
||||
)
|
||||
SELECT ${select}
|
||||
FROM session_data s
|
||||
WHERE ${tracesFilterRes.query ? tracesFilterRes.query : ""}
|
||||
${orderByToClickhouseSql(orderBy ?? null, sessionCols)}
|
||||
${limit !== undefined && page !== undefined ? `LIMIT {limit: Int32} OFFSET {offset: Int32}` : ""}
|
||||
`;
|
||||
|
||||
const obsStartTimeValue = traceTimestampFilter
|
||||
? convertDateToClickhouseDateTime(traceTimestampFilter.value)
|
||||
: null;
|
||||
|
||||
const res = await queryClickhouse<T>({
|
||||
query: query,
|
||||
params: {
|
||||
projectId,
|
||||
limit: limit,
|
||||
offset: limit && page ? limit * page : 0,
|
||||
...tracesFilterRes.params,
|
||||
...observationsStatsRes.params,
|
||||
...scoresAvgFilterRes.params,
|
||||
...singleTraceFilter?.params,
|
||||
...(obsStartTimeValue
|
||||
? { observationsStartTime: obsStartTimeValue }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getTracesIdentifierForSession = async (
|
||||
projectId: string,
|
||||
sessionId: string,
|
||||
@@ -680,10 +488,25 @@ export const getTotalUserCount = async (
|
||||
});
|
||||
};
|
||||
|
||||
export const getUserMetrics = async (projectId: string, userIds: string[]) => {
|
||||
export const getUserMetrics = async (
|
||||
projectId: string,
|
||||
userIds: string[],
|
||||
filter: FilterState,
|
||||
) => {
|
||||
if (userIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// filter state contains date range filter for traces so far.
|
||||
const chFilter = new FilterList(
|
||||
createFilterFromFilterState(filter, tracesTableUiColumnDefinitions),
|
||||
);
|
||||
const chFilterRes = chFilter.apply();
|
||||
|
||||
const timestampFilter = chFilter.find(
|
||||
(f) => f.field === "timestamp" && f.operator === ">=",
|
||||
);
|
||||
|
||||
// this query uses window functions on observations + traces to always get only the first row and thereby remove deduplicates
|
||||
// we filter wherever possible by project id and user id
|
||||
const query = `
|
||||
@@ -713,6 +536,7 @@ export const getUserMetrics = async (projectId: string, userIds: string[]) => {
|
||||
observations o
|
||||
WHERE
|
||||
o.project_id = {projectId: String }
|
||||
${timestampFilter ? `AND o.start_time >= {traceTimestamp: DateTime64(3)} - ${OBSERVATIONS_TO_TRACE_INTERVAL}` : ""}
|
||||
AND o.trace_id in (
|
||||
SELECT
|
||||
distinct id
|
||||
@@ -721,6 +545,7 @@ export const getUserMetrics = async (projectId: string, userIds: string[]) => {
|
||||
where
|
||||
user_id IN ({userIds: Array(String) })
|
||||
AND project_id = {projectId: String }
|
||||
${filter.length > 0 ? `AND ${chFilterRes.query}` : ""}
|
||||
)
|
||||
AND o.type = 'GENERATION'
|
||||
) as o
|
||||
@@ -740,6 +565,7 @@ export const getUserMetrics = async (projectId: string, userIds: string[]) => {
|
||||
WHERE
|
||||
t.user_id IN ({userIds: Array(String) })
|
||||
AND t.project_id = {projectId: String }
|
||||
${filter.length > 0 ? `AND ${chFilterRes.query}` : ""}
|
||||
) as t on t.id = o.trace_id
|
||||
and t.project_id = o.project_id
|
||||
WHERE
|
||||
@@ -778,8 +604,17 @@ export const getUserMetrics = async (projectId: string, userIds: string[]) => {
|
||||
params: {
|
||||
projectId,
|
||||
userIds,
|
||||
...chFilterRes.params,
|
||||
...(timestampFilter
|
||||
? {
|
||||
traceTimestamp: convertDateToClickhouseDateTime(
|
||||
(timestampFilter as DateTimeFilter).value,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
return rows.map((row) => ({
|
||||
userId: row.user_id,
|
||||
maxTimestamp: parseClickhouseUTCDateTimeFormat(row.max_timestamp),
|
||||
@@ -793,18 +628,18 @@ export const getUserMetrics = async (projectId: string, userIds: string[]) => {
|
||||
}));
|
||||
};
|
||||
|
||||
export const getTracesForPostHog = async (
|
||||
export const getTracesForPostHog = async function* (
|
||||
projectId: string,
|
||||
minTimestamp: Date,
|
||||
maxTimestamp: Date,
|
||||
) => {
|
||||
) {
|
||||
const query = `
|
||||
WITH observations_agg AS (
|
||||
SELECT o.project_id,
|
||||
o.trace_id,
|
||||
sum(total_cost) as total_cost,
|
||||
count(*) as observation_count,
|
||||
date_diff('milliseconds', least(min(start_time), min(end_time)), greatest(max(start_time), max(end_time))) as latency_milliseconds
|
||||
date_diff('millisecond', least(min(start_time), min(end_time)), greatest(max(start_time), max(end_time))) as latency_milliseconds
|
||||
FROM observations o FINAL
|
||||
WHERE o.project_id = {projectId: String}
|
||||
AND o.start_time >= {minTimestamp: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}
|
||||
@@ -831,7 +666,7 @@ export const getTracesForPostHog = async (
|
||||
AND t.timestamp <= {maxTimestamp: DateTime64(3)}
|
||||
`;
|
||||
|
||||
const records = await queryClickhouse<Record<string, unknown>>({
|
||||
const records = queryClickhouseStream<Record<string, unknown>>({
|
||||
query,
|
||||
params: {
|
||||
projectId,
|
||||
@@ -841,28 +676,30 @@ export const getTracesForPostHog = async (
|
||||
});
|
||||
|
||||
const baseUrl = env.NEXTAUTH_URL?.replace("/api/auth", "");
|
||||
return records.map((record) => ({
|
||||
timestamp: record.timestamp,
|
||||
langfuse_id: record.id,
|
||||
langfuse_trace_name: record.name,
|
||||
langfuse_url: `${baseUrl}/project/${projectId}/traces/${encodeURIComponent(record.id as string)}`,
|
||||
langfuse_cost_usd: record.total_cost,
|
||||
langfuse_count_observations: record.observation_count,
|
||||
langfuse_session_id: record.session_id,
|
||||
langfuse_project_id: projectId,
|
||||
langfuse_user_id: record.user_id || "langfuse_unknown_user",
|
||||
langfuse_latency: record.latency,
|
||||
langfuse_release: record.release,
|
||||
langfuse_version: record.version,
|
||||
langfuse_tags: record.tags,
|
||||
langfuse_event_version: "1.0.0",
|
||||
$session_id: record.posthog_session_id ?? null,
|
||||
$set: {
|
||||
langfuse_user_url: record.user_id
|
||||
? `${baseUrl}/project/${projectId}/users/${encodeURIComponent(record.user_id as string)}`
|
||||
: null,
|
||||
},
|
||||
}));
|
||||
for await (const record of records) {
|
||||
yield {
|
||||
timestamp: record.timestamp,
|
||||
langfuse_id: record.id,
|
||||
langfuse_trace_name: record.name,
|
||||
langfuse_url: `${baseUrl}/project/${projectId}/traces/${encodeURIComponent(record.id as string)}`,
|
||||
langfuse_cost_usd: record.total_cost,
|
||||
langfuse_count_observations: record.observation_count,
|
||||
langfuse_session_id: record.session_id,
|
||||
langfuse_project_id: projectId,
|
||||
langfuse_user_id: record.user_id || "langfuse_unknown_user",
|
||||
langfuse_latency: record.latency,
|
||||
langfuse_release: record.release,
|
||||
langfuse_version: record.version,
|
||||
langfuse_tags: record.tags,
|
||||
langfuse_event_version: "1.0.0",
|
||||
$session_id: record.posthog_session_id ?? null,
|
||||
$set: {
|
||||
langfuse_user_url: record.user_id
|
||||
? `${baseUrl}/project/${projectId}/users/${encodeURIComponent(record.user_id as string)}`
|
||||
: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const getTracesByIdsForAnyProject = async (traceIds: string[]) => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Trace } from "@prisma/client";
|
||||
import { Prisma, Trace } from "@prisma/client";
|
||||
import { parseClickhouseUTCDateTimeFormat } from "./clickhouse";
|
||||
import { TraceRecordReadType } from "./definitions";
|
||||
import { convertDateToClickhouseDateTime } from "../clickhouse/client";
|
||||
import { parseJsonPrioritised } from "../../utils/json";
|
||||
|
||||
export const convertTraceDomainToClickhouse = (
|
||||
trace: Trace,
|
||||
@@ -43,9 +44,20 @@ export const convertClickhouseToDomain = (
|
||||
userId: record.user_id ?? null,
|
||||
sessionId: record.session_id ?? null,
|
||||
public: record.public,
|
||||
input: record.input ?? null,
|
||||
output: record.output ?? null,
|
||||
metadata: record.metadata,
|
||||
input: (record.input
|
||||
? parseJsonPrioritised(record.input)
|
||||
: null) as Prisma.JsonValue | null,
|
||||
output: (record.output
|
||||
? parseJsonPrioritised(record.output)
|
||||
: null) as Prisma.JsonValue | null,
|
||||
metadata:
|
||||
record.metadata &&
|
||||
Object.fromEntries(
|
||||
Object.entries(record.metadata ?? {}).map(([key, val]) => [
|
||||
key,
|
||||
val && parseJsonPrioritised(val),
|
||||
]),
|
||||
),
|
||||
createdAt: parseClickhouseUTCDateTimeFormat(record.created_at),
|
||||
updatedAt: parseClickhouseUTCDateTimeFormat(record.updated_at),
|
||||
externalId: null,
|
||||
|
||||
@@ -30,7 +30,7 @@ export interface StorageService {
|
||||
|
||||
download(path: string): Promise<string>;
|
||||
|
||||
listFiles(prefix: string): Promise<string[]>;
|
||||
listFiles(prefix: string): Promise<{ file: string; createdAt: Date }[]>;
|
||||
|
||||
getSignedUrl(
|
||||
fileName: string,
|
||||
@@ -201,7 +201,9 @@ class AzureBlobStorageService implements StorageService {
|
||||
}
|
||||
}
|
||||
|
||||
public async listFiles(prefix: string): Promise<string[]> {
|
||||
public async listFiles(
|
||||
prefix: string,
|
||||
): Promise<{ file: string; createdAt: Date }[]> {
|
||||
try {
|
||||
await this.createContainerIfNotExists();
|
||||
|
||||
@@ -209,7 +211,10 @@ class AzureBlobStorageService implements StorageService {
|
||||
const files = [];
|
||||
for await (const blob of result) {
|
||||
if (blob.name.startsWith(prefix)) {
|
||||
files.push(blob.name);
|
||||
files.push({
|
||||
file: blob.name,
|
||||
createdAt: blob?.properties?.createdOn ?? new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return files;
|
||||
@@ -364,7 +369,9 @@ class S3StorageService implements StorageService {
|
||||
}
|
||||
}
|
||||
|
||||
public async listFiles(prefix: string): Promise<string[]> {
|
||||
public async listFiles(
|
||||
prefix: string,
|
||||
): Promise<{ file: string; createdAt: Date }[]> {
|
||||
const listCommand = new ListObjectsV2Command({
|
||||
Bucket: this.bucketName,
|
||||
Prefix: prefix,
|
||||
@@ -373,7 +380,11 @@ class S3StorageService implements StorageService {
|
||||
try {
|
||||
const response = await this.client.send(listCommand);
|
||||
return (
|
||||
response.Contents?.flatMap((file) => (file.Key ? [file.Key] : [])) ?? []
|
||||
response.Contents?.flatMap((file) =>
|
||||
file.Key
|
||||
? [{ file: file.Key, createdAt: file.LastModified ?? new Date() }]
|
||||
: [],
|
||||
) ?? []
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to list files from S3 ${prefix}`, err);
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
import { OrderByState } from "../../interfaces/orderBy";
|
||||
import { sessionCols } from "../../tableDefinitions/mapSessionTable";
|
||||
import { FilterState } from "../../types";
|
||||
import { convertDateToClickhouseDateTime } from "../clickhouse/client";
|
||||
import { DateTimeFilter, FilterList, orderByToClickhouseSql } from "../queries";
|
||||
import {
|
||||
getProjectIdDefaultFilter,
|
||||
createFilterFromFilterState,
|
||||
} from "../queries/clickhouse-sql/factory";
|
||||
import {
|
||||
TRACE_TO_OBSERVATIONS_INTERVAL,
|
||||
queryClickhouse,
|
||||
} from "../repositories";
|
||||
|
||||
export type SessionDataReturnType = {
|
||||
session_id: string;
|
||||
max_timestamp: string;
|
||||
min_timestamp: string;
|
||||
trace_ids: string[];
|
||||
user_ids: string[];
|
||||
trace_count: number;
|
||||
trace_tags: string[];
|
||||
};
|
||||
|
||||
export type SessionWithMetricsReturnType = SessionDataReturnType & {
|
||||
total_observations: number;
|
||||
duration: number;
|
||||
session_usage_details: Record<string, number>;
|
||||
session_cost_details: Record<string, number>;
|
||||
session_input_cost: string;
|
||||
session_output_cost: string;
|
||||
session_total_cost: string;
|
||||
session_input_usage: string;
|
||||
session_output_usage: string;
|
||||
session_total_usage: string;
|
||||
};
|
||||
|
||||
export const getSessionsTableCount = async (props: {
|
||||
projectId: string;
|
||||
filter: FilterState;
|
||||
orderBy?: OrderByState;
|
||||
limit?: number;
|
||||
page?: number;
|
||||
}) => {
|
||||
const rows = await getSessionsTableGeneric<{ count: string }>({
|
||||
select: "count",
|
||||
projectId: props.projectId,
|
||||
filter: props.filter,
|
||||
orderBy: props.orderBy,
|
||||
limit: props.limit,
|
||||
page: props.page,
|
||||
});
|
||||
|
||||
return rows.length > 0 ? Number(rows[0].count) : 0;
|
||||
};
|
||||
|
||||
export const getSessionsTable = async (props: {
|
||||
projectId: string;
|
||||
filter: FilterState;
|
||||
orderBy?: OrderByState;
|
||||
limit?: number;
|
||||
page?: number;
|
||||
}) => {
|
||||
const rows = await getSessionsTableGeneric<SessionDataReturnType>({
|
||||
select: "rows",
|
||||
projectId: props.projectId,
|
||||
filter: props.filter,
|
||||
orderBy: props.orderBy,
|
||||
limit: props.limit,
|
||||
page: props.page,
|
||||
});
|
||||
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
trace_count: Number(row.trace_count),
|
||||
}));
|
||||
};
|
||||
|
||||
export const getSessionsWithMetrics = async (props: {
|
||||
projectId: string;
|
||||
filter: FilterState;
|
||||
orderBy?: OrderByState;
|
||||
limit?: number;
|
||||
page?: number;
|
||||
}) => {
|
||||
const rows = await getSessionsTableGeneric<SessionWithMetricsReturnType>({
|
||||
select: "metrics",
|
||||
projectId: props.projectId,
|
||||
filter: props.filter,
|
||||
orderBy: props.orderBy,
|
||||
limit: props.limit,
|
||||
page: props.page,
|
||||
});
|
||||
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
trace_count: Number(row.trace_count),
|
||||
total_observations: Number(row.total_observations),
|
||||
}));
|
||||
};
|
||||
|
||||
export type FetchSessionsTableProps = {
|
||||
select: "count" | "rows" | "metrics";
|
||||
projectId: string;
|
||||
filter: FilterState;
|
||||
searchQuery?: string;
|
||||
orderBy?: OrderByState;
|
||||
limit?: number;
|
||||
page?: number;
|
||||
};
|
||||
|
||||
const getSessionsTableGeneric = async <T>(props: FetchSessionsTableProps) => {
|
||||
const { select, projectId, filter, orderBy, limit, page } = props;
|
||||
|
||||
let sqlSelect: string;
|
||||
switch (select) {
|
||||
case "count":
|
||||
sqlSelect = "count(session_id) as count";
|
||||
break;
|
||||
case "rows":
|
||||
sqlSelect = `
|
||||
session_id,
|
||||
max_timestamp,
|
||||
min_timestamp,
|
||||
trace_ids,
|
||||
user_ids,
|
||||
trace_count,
|
||||
trace_tags`;
|
||||
break;
|
||||
case "metrics":
|
||||
sqlSelect = `
|
||||
session_id,
|
||||
max_timestamp,
|
||||
min_timestamp,
|
||||
trace_ids,
|
||||
user_ids,
|
||||
trace_count,
|
||||
trace_tags,
|
||||
total_observations,
|
||||
duration,
|
||||
session_usage_details,
|
||||
session_cost_details,
|
||||
session_input_cost,
|
||||
session_output_cost,
|
||||
session_total_cost,
|
||||
session_input_usage,
|
||||
session_output_usage,
|
||||
session_total_usage`;
|
||||
break;
|
||||
default:
|
||||
const exhaustiveCheckDefault: never = select;
|
||||
throw new Error(`Unknown select type: ${select}`);
|
||||
}
|
||||
|
||||
const { tracesFilter } = getProjectIdDefaultFilter(projectId, {
|
||||
tracesPrefix: "s",
|
||||
});
|
||||
|
||||
tracesFilter.push(...createFilterFromFilterState(filter, sessionCols));
|
||||
|
||||
const tracesFilterRes = tracesFilter.apply();
|
||||
|
||||
const traceTimestampFilter: DateTimeFilter | undefined = tracesFilter.find(
|
||||
(f) =>
|
||||
f.field === "min_timestamp" &&
|
||||
(f.operator === ">=" || f.operator === ">"),
|
||||
) as DateTimeFilter | undefined;
|
||||
|
||||
const filters = [];
|
||||
if (traceTimestampFilter) {
|
||||
filters.push(
|
||||
new DateTimeFilter({
|
||||
clickhouseTable: "traces",
|
||||
field: "timestamp",
|
||||
operator: traceTimestampFilter.operator,
|
||||
value: traceTimestampFilter.value,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const additionalSingleTraceFilter = tracesFilter.find(
|
||||
(f) => f.field === "bookmarked" || f.field === "session_id",
|
||||
);
|
||||
|
||||
if (additionalSingleTraceFilter) {
|
||||
filters.push(additionalSingleTraceFilter);
|
||||
}
|
||||
|
||||
const singleTraceFilter =
|
||||
filters.length > 0 ? new FilterList(filters).apply() : undefined;
|
||||
|
||||
const hasMetricsFilter = tracesFilter.find((f) =>
|
||||
[
|
||||
"session_total_cost",
|
||||
"session_input_cost",
|
||||
"session_output_cost",
|
||||
"duration",
|
||||
"session_total_usage",
|
||||
"session_output_usage",
|
||||
"session_input_usage",
|
||||
].includes(f.field),
|
||||
);
|
||||
|
||||
const selectMetrics = select === "metrics" || hasMetricsFilter;
|
||||
|
||||
// We use deduplicated traces and observations CTEs instead of final to be able to use Skip indices in Clickhouse.
|
||||
const query = `
|
||||
WITH deduplicated_traces AS (
|
||||
SELECT * EXCEPT input, output, metadata
|
||||
FROM traces t
|
||||
WHERE t.session_id IS NOT NULL
|
||||
AND t.project_id = {projectId: String}
|
||||
${singleTraceFilter?.query ? ` AND ${singleTraceFilter.query}` : ""}
|
||||
ORDER BY event_ts DESC
|
||||
LIMIT 1 BY id, project_id
|
||||
),
|
||||
deduplicated_observations AS (
|
||||
SELECT *
|
||||
FROM observations o
|
||||
WHERE o.project_id = {projectId: String}
|
||||
${traceTimestampFilter ? `AND o.start_time >= {observationsStartTime: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}` : ""}
|
||||
AND o.trace_id IN (
|
||||
SELECT id
|
||||
FROM deduplicated_traces
|
||||
)
|
||||
ORDER BY event_ts DESC
|
||||
LIMIT 1 BY id, project_id
|
||||
),
|
||||
observations_agg AS (
|
||||
SELECT o.trace_id,
|
||||
count(*) as obs_count,
|
||||
min(o.start_time) as min_start_time,
|
||||
max(o.end_time) as max_end_time,
|
||||
sumMap(usage_details) as sum_usage_details,
|
||||
sumMap(cost_details) as sum_cost_details,
|
||||
anyLast(project_id) as project_id
|
||||
FROM deduplicated_observations o
|
||||
WHERE o.project_id = {projectId: String}
|
||||
${traceTimestampFilter ? `AND o.start_time >= {observationsStartTime: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}` : ""}
|
||||
GROUP BY o.trace_id
|
||||
),
|
||||
session_data AS (
|
||||
SELECT
|
||||
t.session_id,
|
||||
anyLast(t.project_id) as project_id,
|
||||
max(t.timestamp) as max_timestamp,
|
||||
min(t.timestamp) as min_timestamp,
|
||||
groupArray(t.id) AS trace_ids,
|
||||
groupUniqArray(t.user_id) AS user_ids,
|
||||
count(*) as trace_count,
|
||||
groupUniqArrayArray(t.tags) as trace_tags
|
||||
-- Aggregate observations data at session level
|
||||
${
|
||||
selectMetrics
|
||||
? `
|
||||
,
|
||||
sum(o.obs_count) as total_observations,
|
||||
date_diff('millisecond', min(min_start_time), max(max_end_time)) as duration,
|
||||
sumMap(o.sum_usage_details) as session_usage_details,
|
||||
sumMap(o.sum_cost_details) as session_cost_details,
|
||||
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'input') > 0, sumMap(o.sum_cost_details)))) as session_input_cost,
|
||||
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'output') > 0, sumMap(o.sum_cost_details)))) as session_output_cost,
|
||||
sumMap(o.sum_cost_details)['total'] as session_total_cost,
|
||||
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'input') > 0, sumMap(o.sum_usage_details)))) as session_input_usage,
|
||||
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'output') > 0, sumMap(o.sum_usage_details)))) as session_output_usage,
|
||||
sumMap(o.sum_usage_details)['total'] as session_total_usage`
|
||||
: ""
|
||||
}
|
||||
FROM deduplicated_traces t
|
||||
${
|
||||
selectMetrics
|
||||
? `LEFT JOIN observations_agg o
|
||||
ON t.id = o.trace_id AND t.project_id = o.project_id`
|
||||
: ""
|
||||
}
|
||||
WHERE t.session_id IS NOT NULL
|
||||
AND t.project_id = {projectId: String}
|
||||
${singleTraceFilter?.query ? ` AND ${singleTraceFilter.query}` : ""}
|
||||
GROUP BY t.session_id
|
||||
)
|
||||
SELECT ${sqlSelect}
|
||||
FROM session_data s
|
||||
WHERE ${tracesFilterRes.query ? tracesFilterRes.query : ""}
|
||||
${orderByToClickhouseSql(orderBy ?? null, sessionCols)}
|
||||
${limit !== undefined && page !== undefined ? `LIMIT {limit: Int32} OFFSET {offset: Int32}` : ""}
|
||||
`;
|
||||
|
||||
const obsStartTimeValue = traceTimestampFilter
|
||||
? convertDateToClickhouseDateTime(traceTimestampFilter.value)
|
||||
: null;
|
||||
|
||||
const res = await queryClickhouse<T>({
|
||||
query: query,
|
||||
params: {
|
||||
projectId,
|
||||
limit: limit,
|
||||
offset: limit && page ? limit * page : 0,
|
||||
...tracesFilterRes.params,
|
||||
...singleTraceFilter?.params,
|
||||
...(obsStartTimeValue
|
||||
? { observationsStartTime: obsStartTimeValue }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
return res;
|
||||
};
|
||||
@@ -351,7 +351,7 @@ const getTracesTableGeneric = async <T>(props: FetchTracesTableProps) => {
|
||||
COUNT(*) AS observation_count,
|
||||
sumMap(usage_details) as usage_details,
|
||||
SUM(total_cost) AS total_cost,
|
||||
date_diff('milliseconds', least(min(start_time), min(end_time)), greatest(max(start_time), max(end_time))) as latency_milliseconds,
|
||||
date_diff('millisecond', least(min(start_time), min(end_time)), greatest(max(start_time), max(end_time))) as latency_milliseconds,
|
||||
multiIf(
|
||||
arrayExists(x -> x = 'ERROR', groupArray(level)), 'ERROR',
|
||||
arrayExists(x -> x = 'WARNING', groupArray(level)), 'WARNING',
|
||||
|
||||
@@ -68,7 +68,7 @@ export const observationsTableUiColumnDefinitions: UiColumnMapping[] = [
|
||||
uiTableId: "timeToFirstToken",
|
||||
clickhouseTableName: "observations",
|
||||
clickhouseSelect:
|
||||
"if(isNull(completion_start_time), NULL, date_diff('milliseconds', start_time, completion_start_time) / 1000)",
|
||||
"if(isNull(completion_start_time), NULL, date_diff('millisecond', start_time, completion_start_time) / 1000)",
|
||||
// If we use the default of Decimal64(12), we cannot filter for more than ~40min due to an overflow
|
||||
clickhouseTypeOverwrite: "Decimal64(3)",
|
||||
},
|
||||
@@ -77,7 +77,7 @@ export const observationsTableUiColumnDefinitions: UiColumnMapping[] = [
|
||||
uiTableId: "latency",
|
||||
clickhouseTableName: "observations",
|
||||
clickhouseSelect:
|
||||
"if(isNull(end_time), NULL, date_diff('milliseconds', start_time, end_time) / 1000)",
|
||||
"if(isNull(end_time), NULL, date_diff('millisecond', start_time, end_time) / 1000)",
|
||||
// If we use the default of Decimal64(12), we cannot filter for more than ~40min due to an overflow
|
||||
clickhouseTypeOverwrite: "Decimal64(3)",
|
||||
},
|
||||
@@ -86,7 +86,7 @@ export const observationsTableUiColumnDefinitions: UiColumnMapping[] = [
|
||||
uiTableId: "tokensPerSecond",
|
||||
clickhouseTableName: "observations",
|
||||
clickhouseSelect:
|
||||
"(arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'output') > 0, usage_details))) / (date_diff('milliseconds', start_time, end_time) / 1000))",
|
||||
"(arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'output') > 0, usage_details))) / (date_diff('millisecond', start_time, end_time) / 1000))",
|
||||
},
|
||||
{
|
||||
uiTableName: "Input Cost ($)",
|
||||
@@ -127,6 +127,12 @@ export const observationsTableUiColumnDefinitions: UiColumnMapping[] = [
|
||||
clickhouseTableName: "observations",
|
||||
clickhouseSelect: 'o."provided_model_name"',
|
||||
},
|
||||
{
|
||||
uiTableName: "Model ID",
|
||||
uiTableId: "modelId",
|
||||
clickhouseTableName: "observations",
|
||||
clickhouseSelect: 'o."internal_model_id"',
|
||||
},
|
||||
{
|
||||
uiTableName: "Input Tokens",
|
||||
uiTableId: "inputTokens",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { UiColumnMapping } from ".";
|
||||
|
||||
export const sessionCols: UiColumnMapping[] = [
|
||||
// we do not access the traces scores in clichouse. We default back to the trace timestamps.
|
||||
// we do not access the traces scores in ClickHouse. We default back to the trace timestamps.
|
||||
|
||||
{
|
||||
uiTableName: "⭐️",
|
||||
@@ -26,6 +26,8 @@ export const sessionCols: UiColumnMapping[] = [
|
||||
uiTableId: "sessionDuration",
|
||||
clickhouseTableName: "traces",
|
||||
clickhouseSelect: "duration",
|
||||
// If we use the default of Decimal64(12), we cannot filter for more than ~40min due to an overflow
|
||||
clickhouseTypeOverwrite: "Decimal64(3)",
|
||||
},
|
||||
{
|
||||
uiTableName: "Count Traces",
|
||||
@@ -86,6 +88,8 @@ export const sessionCols: UiColumnMapping[] = [
|
||||
uiTableId: "sessionDuration",
|
||||
clickhouseTableName: "traces",
|
||||
clickhouseSelect: "duration",
|
||||
// If we use the default of Decimal64(12), we cannot filter for more than ~40min due to an overflow
|
||||
clickhouseTypeOverwrite: "Decimal64(3)",
|
||||
},
|
||||
{
|
||||
uiTableName: "Traces Count",
|
||||
|
||||
@@ -55,25 +55,9 @@ export const parseJsonPrioritised = (
|
||||
): z.infer<typeof jsonSchema> | string | undefined => {
|
||||
try {
|
||||
const parsedJson = JSON.parse(json);
|
||||
|
||||
if (Object.keys(parsedJson).length === 0) {
|
||||
return parsedJson;
|
||||
}
|
||||
|
||||
const parsedArray = z.array(jsonSchemaNullable).safeParse(parsedJson);
|
||||
if (parsedArray.success) {
|
||||
return parsedArray.data;
|
||||
}
|
||||
|
||||
const parsedObject = z.record(jsonSchemaNullable).safeParse(parsedJson);
|
||||
if (parsedObject.success) {
|
||||
return parsedObject.data;
|
||||
}
|
||||
|
||||
return jsonSchema.parse(parsedJson);
|
||||
} catch (error) {
|
||||
const parsed = jsonSchema.safeParse(json);
|
||||
|
||||
return parsed.success ? parsed.data : json;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -23,18 +23,18 @@ type Json = Root | { [key: string]: JsonNested } | JsonNested[];
|
||||
// Here, you define the schema recursively
|
||||
export const jsonSchemaNullable: z.ZodType<JsonNested> = z.lazy(() =>
|
||||
z.union([
|
||||
nestedLiteralSchema,
|
||||
z.array(jsonSchemaNullable),
|
||||
z.record(jsonSchemaNullable),
|
||||
nestedLiteralSchema,
|
||||
]),
|
||||
);
|
||||
|
||||
// Root schema that does not allow nulls at the root level
|
||||
export const jsonSchema: z.ZodType<Json> = z.lazy(() =>
|
||||
z.union([
|
||||
rootLiteralSchema,
|
||||
z.array(jsonSchemaNullable),
|
||||
z.record(jsonSchemaNullable),
|
||||
rootLiteralSchema,
|
||||
]),
|
||||
);
|
||||
|
||||
|
||||
Generated
+348
-477
File diff suppressed because it is too large
Load Diff
@@ -95,6 +95,9 @@ ARG BUILDPLATFORM
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG NEXT_PUBLIC_BUILD_ID
|
||||
ENV BUILD_ID=$NEXT_PUBLIC_BUILD_ID
|
||||
|
||||
ENV NODE_ENV production
|
||||
# Uncomment the following line in case you want to disable telemetry during runtime.
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
+3
-3
@@ -12,8 +12,8 @@ import { env } from "./src/env.mjs";
|
||||
*/
|
||||
const cspHeader = `
|
||||
default-src 'self' https://*.langfuse.com https://*.langfuse.dev https://*.posthog.com https://*.sentry.io wss://*.crisp.chat https://*.crisp.chat;
|
||||
script-src 'self' 'unsafe-eval' 'unsafe-inline' https://*.langfuse.com https://*.langfuse.dev https://client.crisp.chat https://settings.crisp.chat https://challenges.cloudflare.com https://*.sentry.io https://static.cloudflareinsights.com https://*.stripe.com;
|
||||
style-src 'self' 'unsafe-inline' https://client.crisp.chat;
|
||||
script-src 'self' 'unsafe-eval' 'unsafe-inline' https://*.langfuse.com https://*.langfuse.dev https://client.crisp.chat https://settings.crisp.chat https://challenges.cloudflare.com https://*.sentry.io https://static.cloudflareinsights.com https://*.stripe.com https://uptime.betterstack.com;
|
||||
style-src 'self' 'unsafe-inline' https://client.crisp.chat https://uptime.betterstack.com;
|
||||
img-src 'self' https: blob: data: http://localhost:* https://client.crisp.chat https://image.crisp.chat https://storage.crisp.chat;
|
||||
font-src 'self' https://client.crisp.chat;
|
||||
frame-src 'self' https://challenges.cloudflare.com https://*.stripe.com https://game.crisp.chat;
|
||||
@@ -22,7 +22,7 @@ const cspHeader = `
|
||||
base-uri 'self';
|
||||
form-action 'self';
|
||||
frame-ancestors 'none';
|
||||
connect-src 'self' https://*.langfuse.com https://*.langfuse.dev https://client.crisp.chat https://storage.crisp.chat wss://client.relay.crisp.chat wss://stream.relay.crisp.chat https://*.ingest.us.sentry.io;
|
||||
connect-src 'self' https://*.langfuse.com https://*.langfuse.dev https://client.crisp.chat https://storage.crisp.chat wss://client.relay.crisp.chat wss://stream.relay.crisp.chat https://*.ingest.us.sentry.io https://uptime.betterstack.com;
|
||||
media-src 'self' https: http://localhost:* https://client.crisp.chat;
|
||||
${env.LANGFUSE_CSP_ENFORCE_HTTPS === "true" ? "upgrade-insecure-requests; block-all-mixed-content;" : ""}
|
||||
${env.SENTRY_CSP_REPORT_URI ? `report-uri ${env.SENTRY_CSP_REPORT_URI}; report-to csp-endpoint;` : ""}
|
||||
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "3.2.0",
|
||||
"version": "3.7.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -117,7 +117,7 @@
|
||||
"langchain": "^0.3.6",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.462.0",
|
||||
"next": "^14.2.15",
|
||||
"next": "^14.2.21",
|
||||
"next-auth": "^4.24.11",
|
||||
"next-query-params": "^5.0.1",
|
||||
"next-themes": "^0.3.0",
|
||||
@@ -175,10 +175,9 @@
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"node-mocks-http": "^1.14.1",
|
||||
"postcss": "^8.4.47",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.6",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.19.1",
|
||||
@@ -193,7 +192,8 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"jsonpath-plus": "10.0.7"
|
||||
"jsonpath-plus": "10.0.7",
|
||||
"nanoid": "^3.3.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -723,6 +723,9 @@ paths:
|
||||
Notes:
|
||||
|
||||
|
||||
- Introduction to data model:
|
||||
https://langfuse.com/docs/tracing-data-model
|
||||
|
||||
- Batch sizes are limited to 3.5 MB in total. You need to adjust the
|
||||
number of events per batch accordingly.
|
||||
|
||||
|
||||
@@ -558,7 +558,7 @@
|
||||
"_type": "endpoint",
|
||||
"name": "Batch",
|
||||
"request": {
|
||||
"description": "Batched ingestion for Langfuse Tracing. If you want to use tracing via the API, such as to build your own Langfuse client implementation, this is the only API route you need to implement.\n\nNotes:\n\n- Batch sizes are limited to 3.5 MB in total. You need to adjust the number of events per batch accordingly.\n- The API does not return a 4xx status code for input errors. Instead, it responds with a 207 status code, which includes a list of the encountered errors.",
|
||||
"description": "Batched ingestion for Langfuse Tracing. If you want to use tracing via the API, such as to build your own Langfuse client implementation, this is the only API route you need to implement.\n\nNotes:\n\n- Introduction to data model: https://langfuse.com/docs/tracing-data-model\n- Batch sizes are limited to 3.5 MB in total. You need to adjust the number of events per batch accordingly.\n- The API does not return a 4xx status code for input errors. Instead, it responds with a 207 status code, which includes a list of the encountered errors.",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/ingestion",
|
||||
"host": [
|
||||
|
||||
@@ -123,7 +123,7 @@ test("Unauthenticated user should not be redirected to non-relative URLs after l
|
||||
test("Unauthenticated user should be redirected to relative URL after login", async ({
|
||||
page,
|
||||
}) => {
|
||||
const relativeUrl = "/support";
|
||||
const relativeUrl = "/setup";
|
||||
await page.goto(
|
||||
`/auth/sign-in?targetPath=${encodeURIComponent(relativeUrl)}`,
|
||||
);
|
||||
|
||||
@@ -244,7 +244,7 @@ describe("Fetch datasets for UI presentation", () => {
|
||||
expect(JSON.stringify(secondRun.scores)).toEqual(JSON.stringify({}));
|
||||
});
|
||||
|
||||
it.only("should test that dataset runs can link to the same traces", async () => {
|
||||
it("should test that dataset runs can link to the same traces", async () => {
|
||||
const datasetId = v4();
|
||||
|
||||
await prisma.dataset.create({
|
||||
|
||||
@@ -215,6 +215,102 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
|
||||
expect(getDataset.body.items[0].id).toEqual("active-item-id");
|
||||
});
|
||||
|
||||
it("should correctly update dataset items", async () => {
|
||||
const datasetItemId = v4();
|
||||
const datasetName = v4();
|
||||
|
||||
await prisma.dataset.create({
|
||||
data: {
|
||||
name: datasetName,
|
||||
projectId: projectId,
|
||||
},
|
||||
});
|
||||
|
||||
await makeZodVerifiedAPICall(
|
||||
PostDatasetItemsV1Response,
|
||||
"POST",
|
||||
"/api/public/dataset-items",
|
||||
{
|
||||
datasetName: datasetName,
|
||||
id: datasetItemId,
|
||||
input: { key: "value" },
|
||||
expectedOutput: { key: "value" },
|
||||
metadata: null,
|
||||
sourceTraceId: null,
|
||||
sourceObservationId: null,
|
||||
status: null,
|
||||
},
|
||||
auth,
|
||||
);
|
||||
|
||||
await makeZodVerifiedAPICall(
|
||||
PostDatasetItemsV1Response,
|
||||
"POST",
|
||||
"/api/public/dataset-items",
|
||||
{
|
||||
datasetName: datasetName,
|
||||
id: datasetItemId,
|
||||
input: { john: "doe" },
|
||||
expectedOutput: { john: "doe" },
|
||||
metadata: null,
|
||||
sourceTraceId: null,
|
||||
sourceObservationId: null,
|
||||
status: null,
|
||||
},
|
||||
auth,
|
||||
);
|
||||
|
||||
const databaseDatasetItem = await prisma.datasetItem.findFirst({
|
||||
where: {
|
||||
id: datasetItemId,
|
||||
},
|
||||
});
|
||||
expect(databaseDatasetItem).toMatchObject({
|
||||
input: { john: "doe" },
|
||||
expectedOutput: { john: "doe" },
|
||||
});
|
||||
});
|
||||
|
||||
it("should return 404 when trying to update dataset item that exists in different dataset of the same project", async () => {
|
||||
const datasetItemId = v4();
|
||||
|
||||
const dataset = await prisma.dataset.create({
|
||||
data: {
|
||||
name: "dataset-name-1",
|
||||
projectId: projectId,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.dataset.create({
|
||||
data: {
|
||||
name: "dataset-name-2",
|
||||
projectId: projectId,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.datasetItem.create({
|
||||
data: {
|
||||
id: datasetItemId,
|
||||
datasetId: dataset.id,
|
||||
projectId: projectId,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await makeAPICall(
|
||||
"POST",
|
||||
"/api/public/dataset-items",
|
||||
{
|
||||
datasetName: "dataset-name-2",
|
||||
id: datasetItemId,
|
||||
input: { key: "new-value" },
|
||||
expectedOutput: { key: "new-value" },
|
||||
},
|
||||
auth,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it("GET datasets (v1 & v2)", async () => {
|
||||
// v1 post
|
||||
await makeZodVerifiedAPICall(
|
||||
|
||||
@@ -204,6 +204,7 @@ describe("/api/public/observations API Endpoint", () => {
|
||||
trace_id: traceId,
|
||||
name: "generation-name",
|
||||
start_time: new Date("2021-01-01T00:00:00.000Z").getTime(),
|
||||
event_ts: new Date("2021-01-01T00:00:00.000Z").getTime(),
|
||||
end_time: new Date("2021-01-01T00:00:00.000Z").getTime(),
|
||||
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
type: "GENERATION",
|
||||
@@ -214,6 +215,7 @@ describe("/api/public/observations API Endpoint", () => {
|
||||
trace_id: traceId,
|
||||
name: "generation-name",
|
||||
start_time: new Date("2021-02-01T00:00:00.000Z").getTime(),
|
||||
event_ts: new Date("2021-02-01T00:00:00.000Z").getTime(),
|
||||
end_time: new Date("2021-02-01T00:00:00.000Z").getTime(),
|
||||
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
type: "SPAN",
|
||||
@@ -223,6 +225,7 @@ describe("/api/public/observations API Endpoint", () => {
|
||||
trace_id: traceId,
|
||||
name: "generation-name",
|
||||
start_time: new Date("2021-03-01T00:00:00.000Z").getTime(),
|
||||
event_ts: new Date("2021-03-01T00:00:00.000Z").getTime(),
|
||||
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
type: "EVENT",
|
||||
});
|
||||
@@ -231,6 +234,7 @@ describe("/api/public/observations API Endpoint", () => {
|
||||
trace_id: traceId,
|
||||
name: "generation-name",
|
||||
start_time: new Date("2021-04-01T00:00:00.000Z").getTime(),
|
||||
event_ts: new Date("2021-04-01T00:00:00.000Z").getTime(),
|
||||
end_time: new Date("2021-04-01T00:00:00.000Z").getTime(),
|
||||
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
type: "GENERATION",
|
||||
|
||||
@@ -154,8 +154,9 @@ describe("Clickhouse Observations Repository Test", () => {
|
||||
expect(firstObservation.promptId).toEqual(observation.prompt_id);
|
||||
expect(firstObservation.endTime).toEqual(new Date(observation.end_time));
|
||||
expect(firstObservation.timeToFirstToken).toEqual(
|
||||
new Date(observation.completion_start_time).getTime() -
|
||||
new Date(observation.start_time).getTime(),
|
||||
(new Date(observation.completion_start_time).getTime() -
|
||||
new Date(observation.start_time).getTime()) /
|
||||
1000,
|
||||
);
|
||||
expect(firstObservation.timeToFirstToken).toBeGreaterThan(0);
|
||||
expect(firstObservation.calculatedTotalCost).toEqual(
|
||||
|
||||
@@ -35,6 +35,14 @@ describe("Clickhouse Traces Repository Test", () => {
|
||||
release: null,
|
||||
version: null,
|
||||
user_id: null,
|
||||
input: JSON.stringify({
|
||||
this: {
|
||||
is: {
|
||||
a: ["complex", "object"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
output: "regular string",
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
event_ts: Date.now(),
|
||||
@@ -63,8 +71,8 @@ describe("Clickhouse Traces Repository Test", () => {
|
||||
expect(result.userId).toEqual(trace.user_id);
|
||||
expect(result.sessionId).toEqual(trace.session_id);
|
||||
expect(result.public).toEqual(trace.public);
|
||||
expect(result.input).toEqual(null);
|
||||
expect(result.output).toEqual(null);
|
||||
expect(result.input).toEqual(JSON.parse(trace.input));
|
||||
expect(result.output).toEqual("regular string");
|
||||
expect(result.metadata).toEqual(trace.metadata);
|
||||
expect(result.createdAt).toEqual(new Date(trace.created_at));
|
||||
expect(result.updatedAt).toEqual(new Date(trace.updated_at));
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { v4 } from "uuid";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import {
|
||||
createObservation,
|
||||
createObservationsCh,
|
||||
createOrgProjectAndApiKey,
|
||||
createTracesCh,
|
||||
getSessionsWithMetrics,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { createTrace, getSessionsTable } from "@langfuse/shared/src/server";
|
||||
|
||||
describe("trpc.sessions", () => {
|
||||
describe("GET sessions.all", () => {
|
||||
it("should GET all session", async () => {
|
||||
const { projectId } = await createOrgProjectAndApiKey();
|
||||
const sessionId = v4();
|
||||
|
||||
await prisma.traceSession.create({
|
||||
data: {
|
||||
id: sessionId,
|
||||
projectId: projectId,
|
||||
},
|
||||
});
|
||||
|
||||
const traces = [
|
||||
createTrace({ session_id: sessionId, project_id: projectId }),
|
||||
createTrace({ session_id: sessionId, project_id: projectId }),
|
||||
];
|
||||
|
||||
await createTracesCh(traces);
|
||||
|
||||
const uiSessions = await getSessionsTable({
|
||||
projectId: projectId,
|
||||
filter: [],
|
||||
orderBy: null,
|
||||
limit: 10000,
|
||||
page: 0,
|
||||
});
|
||||
|
||||
expect(uiSessions.length).toBe(1);
|
||||
expect(uiSessions[0].session_id).toBe(sessionId);
|
||||
expect(uiSessions[0].trace_count).toBe(2);
|
||||
expect(uiSessions[0].trace_tags).toEqual(["doe", "john"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("should GET all session filtered by trace attribute only", async () => {
|
||||
const { projectId } = await createOrgProjectAndApiKey();
|
||||
const sessionId = v4();
|
||||
|
||||
await prisma.traceSession.create({
|
||||
data: {
|
||||
id: sessionId,
|
||||
projectId: projectId,
|
||||
},
|
||||
});
|
||||
|
||||
const traces = [
|
||||
createTrace({
|
||||
session_id: sessionId,
|
||||
project_id: projectId,
|
||||
user_id: "user1",
|
||||
}),
|
||||
createTrace({
|
||||
session_id: sessionId,
|
||||
project_id: projectId,
|
||||
user_id: undefined,
|
||||
}),
|
||||
];
|
||||
|
||||
await createTracesCh(traces);
|
||||
|
||||
const uiSessions = await getSessionsTable({
|
||||
projectId: projectId,
|
||||
filter: [
|
||||
{
|
||||
column: "userIds",
|
||||
type: "stringOptions",
|
||||
operator: "any of",
|
||||
value: ["user1"],
|
||||
},
|
||||
],
|
||||
orderBy: null,
|
||||
limit: 10000,
|
||||
page: 0,
|
||||
});
|
||||
|
||||
expect(uiSessions.length).toBe(1);
|
||||
expect(uiSessions[0].session_id).toBe(sessionId);
|
||||
expect(uiSessions[0].trace_count).toBe(2);
|
||||
expect(uiSessions[0].trace_tags).toEqual(["doe", "john"]);
|
||||
expect(uiSessions[0].user_ids).toEqual(["user1"]);
|
||||
});
|
||||
|
||||
it("should GET metrics for a list of sessions", async () => {
|
||||
const { projectId } = await createOrgProjectAndApiKey();
|
||||
const sessionId1 = v4();
|
||||
const sessionId2 = v4();
|
||||
|
||||
await prisma.traceSession.createMany({
|
||||
data: [
|
||||
{
|
||||
id: sessionId1,
|
||||
projectId: projectId,
|
||||
},
|
||||
{
|
||||
id: sessionId2,
|
||||
projectId: projectId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const traces = [
|
||||
createTrace({
|
||||
session_id: sessionId1,
|
||||
project_id: projectId,
|
||||
user_id: "user1",
|
||||
}),
|
||||
createTrace({
|
||||
session_id: sessionId1,
|
||||
project_id: projectId,
|
||||
user_id: "user2",
|
||||
}),
|
||||
createTrace({
|
||||
session_id: sessionId2,
|
||||
project_id: projectId,
|
||||
user_id: "user3",
|
||||
}),
|
||||
];
|
||||
|
||||
const observations = traces.flatMap((trace) => [
|
||||
createObservation({
|
||||
trace_id: trace.id,
|
||||
project_id: projectId,
|
||||
}),
|
||||
createObservation({
|
||||
trace_id: trace.id,
|
||||
project_id: projectId,
|
||||
}),
|
||||
]);
|
||||
|
||||
await createObservationsCh(observations);
|
||||
|
||||
await createTracesCh(traces);
|
||||
|
||||
const sessions = await getSessionsWithMetrics({
|
||||
projectId: projectId,
|
||||
filter: [
|
||||
{
|
||||
column: "id",
|
||||
type: "stringOptions",
|
||||
operator: "any of",
|
||||
value: [sessionId1, sessionId2],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(sessions.length).toBe(2);
|
||||
|
||||
// Session 1 checks
|
||||
const session1 = sessions.find((s) => s.session_id === sessionId1);
|
||||
expect(session1).toBeDefined();
|
||||
expect(session1?.trace_count).toBe(2);
|
||||
expect(session1?.user_ids).toEqual(
|
||||
expect.arrayContaining(["user1", "user2"]),
|
||||
);
|
||||
expect(session1?.trace_tags).toEqual(["doe", "john"]);
|
||||
expect(session1?.total_observations).toEqual(4);
|
||||
|
||||
expect(Number(session1?.session_input_cost)).toBeGreaterThan(0);
|
||||
expect(Number(session1?.session_output_cost)).toBeGreaterThan(0);
|
||||
expect(Number(session1?.session_total_cost)).toBeGreaterThan(0);
|
||||
expect(Number(session1?.session_input_usage)).toBeGreaterThan(0);
|
||||
expect(Number(session1?.session_output_usage)).toBeGreaterThan(0);
|
||||
expect(Number(session1?.session_total_usage)).toBeGreaterThan(0);
|
||||
|
||||
// Session 2 checks
|
||||
const session2 = sessions.find((s) => s.session_id === sessionId2);
|
||||
expect(session2).toBeDefined();
|
||||
expect(session2?.trace_count).toBe(1);
|
||||
expect(session2?.user_ids).toEqual(["user3"]);
|
||||
expect(session2?.trace_tags).toEqual(["doe", "john"]);
|
||||
expect(session2?.total_observations).toEqual(2);
|
||||
|
||||
expect(Number(session2?.session_input_cost)).toBeGreaterThan(0);
|
||||
expect(Number(session2?.session_output_cost)).toBeGreaterThan(0);
|
||||
expect(Number(session2?.session_total_cost)).toBeGreaterThan(0);
|
||||
expect(Number(session2?.session_input_usage)).toBeGreaterThan(0);
|
||||
expect(Number(session2?.session_output_usage)).toBeGreaterThan(0);
|
||||
expect(Number(session2?.session_total_usage)).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -52,7 +52,7 @@ describe("getUserMetrics function", () => {
|
||||
|
||||
await createObservationsInClickhouse([observation1, observation2]);
|
||||
|
||||
const userMetrics = await getUserMetrics(projectId, [userId]);
|
||||
const userMetrics = await getUserMetrics(projectId, [userId], []);
|
||||
|
||||
expect(userMetrics.length).toBe(1);
|
||||
expect(userMetrics[0]).toMatchObject({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { paginationZod } from "@langfuse/shared";
|
||||
import { paginationZod, parseJsonPrioritised } from "@langfuse/shared";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
// Create test cases
|
||||
@@ -24,3 +24,31 @@ describe("Pagination Zod Schema", () => {
|
||||
expect(() => paginationZod.limit.parse("abc")).toThrowError(ZodError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseJsonPrioritised", () => {
|
||||
it.each([
|
||||
["test", "test"], // Raw string
|
||||
['{"hello": "world"}', { hello: "world" }], // Simple object
|
||||
["[1, 2, 3]", [1, 2, 3]], // Array
|
||||
['{"nested": {"key": "value"}}', { nested: { key: "value" } }], // Nested object
|
||||
["[]", []], // Empty array
|
||||
["{}", {}], // Empty object
|
||||
['"simple string"', "simple string"], // Quoted string
|
||||
["42", 42], // Number
|
||||
["true", true], // Boolean true
|
||||
["false", false], // Boolean false
|
||||
["null", "null"], // Null
|
||||
["", ""], // Empty string
|
||||
["invalid{json}", "invalid{json}"], // Invalid JSON string
|
||||
['[null, 123, "abc"]', [null, 123, "abc"]], // Mixed array
|
||||
[
|
||||
'{"array": [1, 2], "nested": {"key": "value"}}',
|
||||
{ array: [1, 2], nested: { key: "value" } },
|
||||
], // Complex object
|
||||
])(
|
||||
"should parse input correctly (%s, %s)",
|
||||
(input: string, expectedOutput: any) => {
|
||||
expect(parseJsonPrioritised(input)).toEqual(expectedOutput);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -37,12 +37,17 @@ export const BatchExportTableButton: React.FC<BatchExportTableButtonProps> = (
|
||||
showSuccessToast({
|
||||
title: "Export queued",
|
||||
description: "You will receive an email when the export is ready.",
|
||||
duration: 10000,
|
||||
link: {
|
||||
href: `/project/${props.projectId}/settings/exports`,
|
||||
text: "View exports",
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
const hasAccess = useHasProjectAccess({
|
||||
projectId: props.projectId,
|
||||
scope: "batchExport:create",
|
||||
scope: "batchExports:create",
|
||||
});
|
||||
|
||||
const handleExport = async (format: BatchExportFileFormat) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/src/components/ui/select";
|
||||
@@ -33,15 +34,12 @@ export type ModelParamsContext = {
|
||||
modelParamsDescription?: string;
|
||||
};
|
||||
|
||||
export const ModelParameters: React.FC<
|
||||
ModelParamsContext & { evalModelsOnly: boolean }
|
||||
> = ({
|
||||
export const ModelParameters: React.FC<ModelParamsContext> = ({
|
||||
modelParams,
|
||||
availableProviders,
|
||||
availableModels,
|
||||
updateModelParamValue,
|
||||
setModelParamEnabled,
|
||||
evalModelsOnly,
|
||||
formDisabled = false,
|
||||
modelParamsDescription,
|
||||
}) => {
|
||||
@@ -54,11 +52,8 @@ export const ModelParameters: React.FC<
|
||||
<p className="font-semibold">Model</p>
|
||||
{availableProviders.length === 0 ? (
|
||||
<>
|
||||
<p className="text-xs">
|
||||
No LLM API key set in project.{" "}
|
||||
{evalModelsOnly && "For evals, only OpenAI models are supported."}
|
||||
</p>
|
||||
<CreateLLMApiKeyDialog evalModelsOnly={evalModelsOnly} />
|
||||
<p className="text-xs">No LLM API key set in project. </p>
|
||||
<CreateLLMApiKeyDialog />
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
@@ -186,6 +181,8 @@ const ModelParamsSelect = ({
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectSeparator />
|
||||
<CreateLLMApiKeyDialog />
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{modelParamsDescription ? (
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/src/components/ui/card";
|
||||
import { chatAvailable, openChat } from "@/src/features/support-chat/chat";
|
||||
import { Book, Github, Mail, MessageSquare, Slack } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { SiDiscord } from "react-icons/si";
|
||||
|
||||
const supportChannels = [
|
||||
{
|
||||
icon: Book,
|
||||
title: "Documentation",
|
||||
description: "Find answers in the documentation.",
|
||||
href: "https://docs.langfuse.com",
|
||||
buttonText: "Visit Docs",
|
||||
primary: true,
|
||||
},
|
||||
{
|
||||
icon: Github,
|
||||
title: "GitHub Issues",
|
||||
description:
|
||||
"Create an issue on Github to report bugs or request new features.",
|
||||
href: "https://github.com/langfuse/langfuse/issues/new/choose",
|
||||
buttonText: "Create Issue",
|
||||
primary: true,
|
||||
},
|
||||
{
|
||||
icon: Github,
|
||||
title: "GitHub Support",
|
||||
description: "Create a support ticket via GitHub discussions.",
|
||||
href: "https://github.com/orgs/langfuse/discussions/categories/support",
|
||||
buttonText: "Submit Question",
|
||||
primary: true,
|
||||
},
|
||||
{
|
||||
icon: Mail,
|
||||
title: "Email",
|
||||
description: "Send email to our shared inbox: help@langfuse.com",
|
||||
href: "mailto:help@langfuse.com",
|
||||
buttonText: "Send Email",
|
||||
available: !chatAvailable,
|
||||
},
|
||||
{
|
||||
icon: MessageSquare,
|
||||
title: "Chat",
|
||||
description: "Get support directly from the team.",
|
||||
onClick: () => openChat(),
|
||||
available: chatAvailable,
|
||||
buttonText: "Launch Chat",
|
||||
},
|
||||
{
|
||||
icon: SiDiscord,
|
||||
title: "Discord",
|
||||
description:
|
||||
"Get support from community. Follow announcements to stay up to date with new features.",
|
||||
href: "https://langfuse.com/discord",
|
||||
buttonText: "Join Discord",
|
||||
},
|
||||
{
|
||||
icon: Slack,
|
||||
title: "Slack Connect",
|
||||
description: "Get a dedicated support channel for you and your team.",
|
||||
href: "mailto:help@langfuse.com?subject=Slack%20Connect%20Request&body=I'd%20like%20to%20request%20a%20dedicated%20Slack%20Connect%20channel%20for%20me%20and%20my%20team.%0D%0A%0D%0AUsers%20(emails)%20to%20include%20besides%20mine%3A%0D%0A%0D%0A",
|
||||
buttonText: "Request via Email",
|
||||
},
|
||||
];
|
||||
|
||||
export const SupportChannels = () => (
|
||||
<div className="mt-10 grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-3">
|
||||
{supportChannels
|
||||
.filter((channel) => channel.available === undefined || channel.available)
|
||||
.map((channel) => (
|
||||
<Card key={channel.title} className="flex flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<channel.icon size={20} />
|
||||
{channel.title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1">
|
||||
<CardDescription>{channel.description}</CardDescription>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
{channel.href ? (
|
||||
<Button
|
||||
asChild
|
||||
variant={channel.primary ? "default" : "secondary"}
|
||||
>
|
||||
<Link href={channel.href}>{channel.buttonText}</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={channel.onClick}
|
||||
variant={channel.primary ? "default" : "secondary"}
|
||||
>
|
||||
{channel.buttonText}
|
||||
</Button>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -15,7 +15,7 @@ import { useUiCustomization } from "@/src/ee/features/ui-customization/useUiCust
|
||||
import { hasOrganizationAccess } from "@/src/features/rbac/utils/checkOrganizationAccess";
|
||||
import { ClickhouseAdminToggle } from "@/src/components/layouts/ClickhouseAdminToggle";
|
||||
import { SidebarInset, SidebarProvider } from "@/src/components/ui/sidebar";
|
||||
import { AppSidebar } from "@/src/components/app-sidebar";
|
||||
import { AppSidebar } from "@/src/components/nav/app-sidebar";
|
||||
|
||||
const signOutUser = async () => {
|
||||
localStorage.clear();
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
type LucideIcon,
|
||||
Settings,
|
||||
UsersIcon,
|
||||
LibraryBig,
|
||||
TerminalIcon,
|
||||
Lightbulb,
|
||||
Grid2X2,
|
||||
@@ -20,9 +19,11 @@ import { type Entitlement } from "@/src/features/entitlements/constants/entitlem
|
||||
import { type UiCustomizationOption } from "@/src/ee/features/ui-customization/useUiCustomization";
|
||||
import { type User } from "next-auth";
|
||||
import { type OrganizationScope } from "@/src/features/rbac/constants/organizationAccessRights";
|
||||
import { SupportMenuDropdown } from "@/src/components/nav/support-menu-dropdown";
|
||||
|
||||
export type Route = {
|
||||
title: string;
|
||||
menuNode?: ReactNode;
|
||||
featureFlag?: Flag;
|
||||
label?: string | ReactNode;
|
||||
projectRbacScopes?: ProjectScope[]; // array treated as OR
|
||||
@@ -40,6 +41,12 @@ export type Route = {
|
||||
};
|
||||
|
||||
export const ROUTES: Route[] = [
|
||||
{
|
||||
title: "Organizations",
|
||||
pathname: "/",
|
||||
icon: Grid2X2,
|
||||
show: ({ organization }) => organization === undefined,
|
||||
},
|
||||
{
|
||||
title: "Projects",
|
||||
pathname: "/organization/[organizationId]",
|
||||
@@ -81,7 +88,6 @@ export const ROUTES: Route[] = [
|
||||
title: "Evaluation",
|
||||
icon: Lightbulb,
|
||||
pathname: `/project/[projectId]/annotation-queues`,
|
||||
label: "Beta",
|
||||
entitlements: ["annotation-queues", "model-based-evaluations"],
|
||||
projectRbacScopes: ["annotationQueues:read", "evalJob:read"],
|
||||
items: [
|
||||
@@ -151,19 +157,11 @@ export const ROUTES: Route[] = [
|
||||
icon: Settings,
|
||||
bottom: true,
|
||||
},
|
||||
{
|
||||
title: "Docs",
|
||||
pathname: "https://langfuse.com/docs",
|
||||
icon: LibraryBig,
|
||||
bottom: true,
|
||||
newTab: true,
|
||||
customizableHref: "documentationHref",
|
||||
},
|
||||
{
|
||||
title: "Support",
|
||||
pathname: "/support",
|
||||
icon: LifeBuoy,
|
||||
bottom: true,
|
||||
customizableHref: "supportHref",
|
||||
pathname: "", // Empty pathname since this is a dropdown
|
||||
menuNode: <SupportMenuDropdown />,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -27,19 +27,18 @@ export const StatusBadge = ({
|
||||
let dotPingColor = "bg-muted-foreground";
|
||||
let showDot = isLive;
|
||||
|
||||
if (statusCategories.active.includes(type)) {
|
||||
if (statusCategories.active.includes(type.toLowerCase())) {
|
||||
badgeColor = "bg-light-green text-dark-green";
|
||||
dotColor = "animate-ping bg-dark-green";
|
||||
dotPingColor = "bg-dark-green";
|
||||
} else if (statusCategories.pending.includes(type)) {
|
||||
} else if (statusCategories.pending.includes(type.toLowerCase())) {
|
||||
badgeColor = "bg-light-yellow text-dark-yellow";
|
||||
dotColor = "animate-ping bg-dark-yellow";
|
||||
dotPingColor = "bg-dark-yellow";
|
||||
} else if (statusCategories.error.includes(type)) {
|
||||
} else if (statusCategories.error.includes(type.toLowerCase())) {
|
||||
badgeColor = "bg-light-red text-dark-red";
|
||||
dotColor = "animate-ping bg-dark-red";
|
||||
dotPingColor = "bg-dark-red";
|
||||
} else if (statusCategories.completed.includes(type)) {
|
||||
showDot = false;
|
||||
} else if (statusCategories.completed.includes(type.toLowerCase())) {
|
||||
badgeColor = "bg-light-green text-dark-green";
|
||||
showDot = false;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { NavMain, type NavMainItem } from "@/src/components/nav-main";
|
||||
import { NavUser, type UserNavigationProps } from "@/src/components/nav-user";
|
||||
import { NavMain, type NavMainItem } from "@/src/components/nav/nav-main";
|
||||
import {
|
||||
NavUser,
|
||||
type UserNavigationProps,
|
||||
} from "@/src/components/nav/nav-user";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -15,7 +18,7 @@ import { useRouter } from "next/router";
|
||||
import Link from "next/link";
|
||||
import { Alert, AlertDescription } from "@/src/components/ui/alert";
|
||||
import { LangfuseLogo } from "@/src/components/LangfuseLogo";
|
||||
import { SidebarNotifications } from "@/src/components/sidebar-notifications";
|
||||
import { SidebarNotifications } from "@/src/components/nav/sidebar-notifications";
|
||||
import { UsageTracker } from "@/src/ee/features/billing/components/UsageTracker";
|
||||
|
||||
type AppSidebarProps = {
|
||||
@@ -45,7 +48,7 @@ export function AppSidebar({
|
||||
<UsageTracker />
|
||||
<SidebarNotifications />
|
||||
</div>
|
||||
<NavMain items={secondaryNavItems} showFeedbackButton />
|
||||
<NavMain items={secondaryNavItems} />
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<NavUser {...userNavProps} />
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronRight, MessageSquarePlus, type LucideIcon } from "lucide-react";
|
||||
import { ChevronRight, type LucideIcon } from "lucide-react";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
@@ -20,18 +20,20 @@ import {
|
||||
import Link from "next/link";
|
||||
import { type ReactNode } from "react";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { FeedbackButtonWrapper } from "@/src/features/feedback/component/FeedbackButton";
|
||||
|
||||
export type NavMainItem = {
|
||||
title: string;
|
||||
menuNode?: ReactNode;
|
||||
url: string;
|
||||
icon?: LucideIcon;
|
||||
isActive?: boolean;
|
||||
label?: string | ReactNode;
|
||||
newTab?: boolean;
|
||||
items?: {
|
||||
title: string;
|
||||
url: string;
|
||||
isActive?: boolean;
|
||||
newTab?: boolean;
|
||||
}[];
|
||||
};
|
||||
|
||||
@@ -57,31 +59,11 @@ function NavItemContent({ item }: { item: NavMainItem }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function NavMain({
|
||||
items,
|
||||
showFeedbackButton,
|
||||
}: {
|
||||
items: NavMainItem[];
|
||||
showFeedbackButton?: boolean;
|
||||
}) {
|
||||
export function NavMain({ items }: { items: NavMainItem[] }) {
|
||||
const { open, setOpen } = useSidebar();
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarMenu>
|
||||
{showFeedbackButton && (
|
||||
<FeedbackButtonWrapper
|
||||
title="Provide feedback"
|
||||
description="What do you think about this project? What can be improved?"
|
||||
type="feedback"
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton tooltip="Feedback">
|
||||
<MessageSquarePlus className="h-5 w-5" aria-hidden="true" />
|
||||
Feedback
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</FeedbackButtonWrapper>
|
||||
)}
|
||||
{items.map((item) =>
|
||||
item.items && item.items.length > 0 ? (
|
||||
<Collapsible
|
||||
@@ -115,7 +97,10 @@ export function NavMain({
|
||||
asChild
|
||||
isActive={subItem.isActive}
|
||||
>
|
||||
<Link href={subItem.url}>
|
||||
<Link
|
||||
href={subItem.url}
|
||||
target={subItem.newTab ? "_blank" : undefined}
|
||||
>
|
||||
<span>{subItem.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
@@ -127,15 +112,20 @@ export function NavMain({
|
||||
</Collapsible>
|
||||
) : (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
tooltip={item.title}
|
||||
isActive={item.isActive}
|
||||
>
|
||||
<Link href={item.url}>
|
||||
<NavItemContent item={item} />
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{item.menuNode || (
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
tooltip={item.title}
|
||||
isActive={item.isActive}
|
||||
>
|
||||
<Link
|
||||
href={item.url}
|
||||
target={item.newTab ? "blank" : undefined}
|
||||
>
|
||||
<NavItemContent item={item} />
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
),
|
||||
)}
|
||||
+1
-37
@@ -8,7 +8,7 @@ import {
|
||||
} from "@/src/components/ui/card";
|
||||
import { useEffect, useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import useLocalStorage from "./useLocalStorage";
|
||||
import useLocalStorage from "../useLocalStorage";
|
||||
import Link from "next/link";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
|
||||
@@ -23,42 +23,6 @@ type SidebarNotification = {
|
||||
};
|
||||
|
||||
const notifications: SidebarNotification[] = [
|
||||
{
|
||||
id: "lw2-5",
|
||||
title: "Launch Week 2 – Day 5",
|
||||
description: "Introducing Prompt Experiments to test prompts on datasets",
|
||||
link: "https://langfuse.com/changelog/2024-11-22-prompt-experimentation",
|
||||
linkTitle: "Changelog",
|
||||
},
|
||||
{
|
||||
id: "lw2-4",
|
||||
title: "Launch Week 2 – Day 4",
|
||||
description: "All new docs for datasets, experiments, and evaluations",
|
||||
link: "https://langfuse.com/changelog/2024-11-21-all-new-datasets-and-evals-documentation",
|
||||
linkTitle: "Changelog",
|
||||
},
|
||||
{
|
||||
id: "lw2-3",
|
||||
title: "Launch Week 2 – Day 3",
|
||||
description:
|
||||
"Full multi-modal support including images, audio, and attachments",
|
||||
link: "https://langfuse.com/changelog/2024-11-20-full-multi-modal-images-audio-attachments",
|
||||
linkTitle: "Changelog",
|
||||
},
|
||||
{
|
||||
id: "lw2-2",
|
||||
title: "Launch Week 2 – Day 2",
|
||||
description: "LLM-as-a-Judge Evaluators for Dataset Experiments",
|
||||
link: "https://langfuse.com/changelog/2024-11-19-llm-as-a-judge-for-datasets",
|
||||
linkTitle: "Changelog",
|
||||
},
|
||||
{
|
||||
id: "lw2-1",
|
||||
title: "Launch Week 2 – Day 1",
|
||||
description: "New side-by-side comparison view for dataset experiment runs",
|
||||
link: "https://langfuse.com/changelog/2024-11-18-dataset-runs-comparison-view",
|
||||
linkTitle: "Changelog",
|
||||
},
|
||||
{
|
||||
id: "github-star",
|
||||
title: "Star Langfuse",
|
||||
@@ -0,0 +1,182 @@
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/src/components/ui/dropdown-menu";
|
||||
import {
|
||||
ArrowUpRight,
|
||||
Bug,
|
||||
Github,
|
||||
LibraryBig,
|
||||
LifeBuoy,
|
||||
Lightbulb,
|
||||
MessageCircle,
|
||||
MessageSquarePlus,
|
||||
Sparkles,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import {
|
||||
type UiCustomizationOption,
|
||||
useUiCustomization,
|
||||
} from "@/src/ee/features/ui-customization/useUiCustomization";
|
||||
import { SidebarMenuButton, useSidebar } from "@/src/components/ui/sidebar";
|
||||
import {
|
||||
chatAvailable,
|
||||
chatIsVisible,
|
||||
showChat,
|
||||
hideChat,
|
||||
} from "@/src/features/support-chat/chat";
|
||||
import { Switch } from "@/src/components/ui/switch";
|
||||
import { SiDiscord } from "react-icons/si";
|
||||
|
||||
type SupportMenuItem = {
|
||||
title: string;
|
||||
pathname: string;
|
||||
icon: LucideIcon | React.ElementType;
|
||||
menuNode?: ReactNode;
|
||||
customizableHref?: UiCustomizationOption;
|
||||
};
|
||||
|
||||
export const SupportMenuDropdown = () => {
|
||||
const uiCustomization = useUiCustomization();
|
||||
|
||||
const supportMenuItems: (SupportMenuItem | "separator")[] = useMemo(() => {
|
||||
const items: (SupportMenuItem | "separator")[] = [
|
||||
{
|
||||
title: "Ask AI",
|
||||
pathname: "https://langfuse.com/docs/ask-ai",
|
||||
icon: Sparkles,
|
||||
},
|
||||
];
|
||||
|
||||
const chatVisible = chatIsVisible();
|
||||
if (uiCustomization?.supportHref) {
|
||||
items.push({
|
||||
title: "Support",
|
||||
pathname: uiCustomization.supportHref,
|
||||
icon: LifeBuoy,
|
||||
});
|
||||
} else {
|
||||
if (chatAvailable) {
|
||||
items.push({
|
||||
title: "Chat",
|
||||
pathname: "#",
|
||||
menuNode: (
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
<span>Chat</span>
|
||||
<Switch
|
||||
defaultChecked={chatVisible}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
showChat();
|
||||
} else {
|
||||
hideChat();
|
||||
}
|
||||
}}
|
||||
className="ml-auto"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
icon: MessageCircle,
|
||||
});
|
||||
}
|
||||
items.push("separator");
|
||||
items.push({
|
||||
title: "GitHub Support",
|
||||
pathname: "https://langfuse.com/gh-support",
|
||||
icon: Github,
|
||||
});
|
||||
items.push({
|
||||
title: "Discord",
|
||||
pathname: "https://langfuse.com/discord",
|
||||
icon: SiDiscord,
|
||||
});
|
||||
}
|
||||
|
||||
items.push("separator");
|
||||
items.push({
|
||||
title: "Docs",
|
||||
pathname: "https://langfuse.com/docs",
|
||||
icon: LibraryBig,
|
||||
customizableHref: "documentationHref",
|
||||
});
|
||||
items.push("separator");
|
||||
|
||||
if (uiCustomization?.feedbackHref) {
|
||||
items.push({
|
||||
title: "Feedback",
|
||||
pathname: uiCustomization.feedbackHref,
|
||||
icon: MessageSquarePlus,
|
||||
});
|
||||
} else {
|
||||
items.push(
|
||||
...[
|
||||
{
|
||||
title: "Feature Request",
|
||||
pathname: "https://langfuse.com/ideas",
|
||||
icon: Lightbulb,
|
||||
},
|
||||
{
|
||||
title: "Report a Bug",
|
||||
pathname: "https://langfuse.com/issues",
|
||||
icon: Bug,
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [uiCustomization]);
|
||||
|
||||
const { isMobile } = useSidebar();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton>
|
||||
<LifeBuoy className="h-4 w-4" />
|
||||
Support
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
{supportMenuItems.map((item, index) => {
|
||||
if (item === "separator") {
|
||||
return <DropdownMenuSeparator key={`separator-${index}`} />;
|
||||
}
|
||||
const url = item.customizableHref
|
||||
? (uiCustomization?.[item.customizableHref] ?? item.pathname)
|
||||
: item.pathname;
|
||||
return (
|
||||
<DropdownMenuItem key={item.title}>
|
||||
{item.menuNode ?? (
|
||||
<a
|
||||
href={url}
|
||||
target={url.startsWith("http") ? "_blank" : undefined}
|
||||
rel={
|
||||
url.startsWith("http") ? "noopener noreferrer" : undefined
|
||||
}
|
||||
className="flex cursor-pointer items-center"
|
||||
>
|
||||
<item.icon className="mr-2 h-4 w-4" />
|
||||
{item.title}
|
||||
{url.startsWith("http") && (
|
||||
<ArrowUpRight className="ml-1 h-3 w-3" />
|
||||
)}
|
||||
</a>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
+19
-16
@@ -71,7 +71,8 @@ export function TraceTableMultiSelectAction({
|
||||
},
|
||||
});
|
||||
|
||||
const hasEntitlement = useHasEntitlement("annotation-queues");
|
||||
const hasAnnotationEntitlement = useHasEntitlement("annotation-queues");
|
||||
const hasTraceDeletionEntitlement = useHasEntitlement("trace-deletion");
|
||||
const hasAddToQueueAccess = useHasProjectAccess({
|
||||
projectId,
|
||||
scope: "annotationQueues:CUD",
|
||||
@@ -97,7 +98,7 @@ export function TraceTableMultiSelectAction({
|
||||
{
|
||||
projectId,
|
||||
},
|
||||
{ enabled: session.status === "authenticated" && hasEntitlement },
|
||||
{ enabled: session.status === "authenticated" && hasAnnotationEntitlement },
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -110,20 +111,22 @@ export function TraceTableMultiSelectAction({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
disabled={!hasDeleteAccess}
|
||||
onClick={() => {
|
||||
capture("trace:delete_form_open", {
|
||||
count: selectedTraceIds.length,
|
||||
source: "table-multi-select",
|
||||
});
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash className="mr-2 h-4 w-4" />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
{hasEntitlement && (
|
||||
{hasTraceDeletionEntitlement && (
|
||||
<DropdownMenuItem
|
||||
disabled={!hasDeleteAccess}
|
||||
onClick={() => {
|
||||
capture("trace:delete_form_open", {
|
||||
count: selectedTraceIds.length,
|
||||
source: "table-multi-select",
|
||||
});
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash className="mr-2 h-4 w-4" />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{hasAnnotationEntitlement && (
|
||||
<DropdownMenuItem
|
||||
disabled={!hasAddToQueueAccess}
|
||||
onClick={() => {
|
||||
|
||||
@@ -56,6 +56,7 @@ interface DataTableProps<TData, TValue> {
|
||||
paginationClassName?: string;
|
||||
isBorderless?: boolean;
|
||||
shouldRenderGroupHeaders?: boolean;
|
||||
onRowClick?: (row: TData) => void;
|
||||
}
|
||||
|
||||
export interface AsyncTableData<T> {
|
||||
@@ -108,6 +109,7 @@ export function DataTable<TData extends object, TValue>({
|
||||
paginationClassName,
|
||||
isBorderless = false,
|
||||
shouldRenderGroupHeaders = false,
|
||||
onRowClick,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const rowheighttw = getRowHeightTailwindClass(rowHeight);
|
||||
@@ -319,6 +321,7 @@ export function DataTable<TData extends object, TValue>({
|
||||
columns={columns}
|
||||
data={data}
|
||||
help={help}
|
||||
onRowClick={onRowClick}
|
||||
/>
|
||||
) : (
|
||||
<TableBodyComponent
|
||||
@@ -327,6 +330,7 @@ export function DataTable<TData extends object, TValue>({
|
||||
columns={columns}
|
||||
data={data}
|
||||
help={help}
|
||||
onRowClick={onRowClick}
|
||||
/>
|
||||
)}
|
||||
</Table>
|
||||
@@ -368,6 +372,7 @@ interface TableBodyComponentProps<TData> {
|
||||
columns: LangfuseColumnDef<TData, any>[];
|
||||
data: AsyncTableData<TData[]>;
|
||||
help?: { description: string; href: string };
|
||||
onRowClick?: (row: TData) => void;
|
||||
}
|
||||
|
||||
function TableBodyComponent<TData>({
|
||||
@@ -376,6 +381,7 @@ function TableBodyComponent<TData>({
|
||||
columns,
|
||||
data,
|
||||
help,
|
||||
onRowClick,
|
||||
}: TableBodyComponentProps<TData>) {
|
||||
return (
|
||||
<TableBody>
|
||||
@@ -390,7 +396,13 @@ function TableBodyComponent<TData>({
|
||||
</TableRow>
|
||||
) : table.getRowModel().rows.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableRow
|
||||
key={row.id}
|
||||
onClick={() => onRowClick?.(row.original)}
|
||||
className={
|
||||
onRowClick ? "cursor-pointer hover:bg-accent" : undefined
|
||||
}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
|
||||
@@ -42,7 +42,8 @@ import useColumnOrder from "@/src/features/column-visibility/hooks/useColumnOrde
|
||||
import { BatchExportTableButton } from "@/src/components/BatchExportTableButton";
|
||||
import { useClickhouse } from "@/src/components/layouts/ClickhouseAdminToggle";
|
||||
import { BreakdownTooltip } from "@/src/components/trace/BreakdownToolTip";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { InfoIcon, PlusCircle } from "lucide-react";
|
||||
import { UpsertModelFormDrawer } from "@/src/features/models/components/UpsertModelFormDrawer";
|
||||
|
||||
export type GenerationsTableRow = {
|
||||
id: string;
|
||||
@@ -83,6 +84,7 @@ export type GenerationsTableProps = {
|
||||
projectId: string;
|
||||
promptName?: string;
|
||||
promptVersion?: number;
|
||||
modelId?: string;
|
||||
omittedFilter?: string[];
|
||||
};
|
||||
|
||||
@@ -90,6 +92,7 @@ export default function GenerationsTable({
|
||||
projectId,
|
||||
promptName,
|
||||
promptVersion,
|
||||
modelId,
|
||||
omittedFilter = [],
|
||||
}: GenerationsTableProps) {
|
||||
const [searchQuery, setSearchQuery] = useQueryParam(
|
||||
@@ -143,6 +146,17 @@ export default function GenerationsTable({
|
||||
]
|
||||
: [];
|
||||
|
||||
const modelIdFilter: FilterState = modelId
|
||||
? [
|
||||
{
|
||||
column: "Model ID",
|
||||
type: "string",
|
||||
operator: "=",
|
||||
value: modelId,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const dateRangeFilter: FilterState = dateRange
|
||||
? [
|
||||
{
|
||||
@@ -158,6 +172,7 @@ export default function GenerationsTable({
|
||||
...dateRangeFilter,
|
||||
...promptNameFilter,
|
||||
...promptVersionFilter,
|
||||
...modelIdFilter,
|
||||
]);
|
||||
|
||||
const getCountPayload = {
|
||||
@@ -447,7 +462,56 @@ export default function GenerationsTable({
|
||||
size: 150,
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const model = row.getValue("model") as string;
|
||||
const modelId = row.getValue("modelId") as string | undefined;
|
||||
|
||||
if (!model) return null;
|
||||
|
||||
return modelId ? (
|
||||
<TableLink
|
||||
path={`/project/${projectId}/models/${modelId}`}
|
||||
value={model}
|
||||
/>
|
||||
) : (
|
||||
<UpsertModelFormDrawer
|
||||
action="create"
|
||||
projectId={projectId}
|
||||
prefilledModelData={{
|
||||
modelName: model,
|
||||
prices:
|
||||
Object.keys(row.original.usageDetails).length > 0
|
||||
? Object.keys(row.original.usageDetails)
|
||||
.filter((key) => key != "total")
|
||||
.reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = 0.000001;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
)
|
||||
: undefined,
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
<span>{model}</span>
|
||||
<PlusCircle className="h-3 w-3" />
|
||||
</span>
|
||||
</UpsertModelFormDrawer>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: "modelId",
|
||||
id: "modelId",
|
||||
header: "Model ID",
|
||||
size: 100,
|
||||
enableHiding: true,
|
||||
defaultHidden: true,
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: "inputTokens",
|
||||
id: "inputTokens",
|
||||
@@ -691,6 +755,7 @@ export default function GenerationsTable({
|
||||
name: generation.name ?? undefined,
|
||||
version: generation.version ?? "",
|
||||
model: generation.model ?? "",
|
||||
modelId: generation.modelId ?? undefined,
|
||||
level: generation.level,
|
||||
statusMessage: generation.statusMessage ?? undefined,
|
||||
usage: {
|
||||
|
||||
@@ -1,39 +1,41 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { DataTable } from "@/src/components/table/data-table";
|
||||
import { type LangfuseColumnDef } from "@/src/components/table/types";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/src/components/ui/popover";
|
||||
import { useState } from "react";
|
||||
import useColumnVisibility from "@/src/features/column-visibility/hooks/useColumnVisibility";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { usdFormatter } from "@/src/utils/numbers";
|
||||
import { type Prisma, type Model } from "@langfuse/shared/src/db";
|
||||
import Decimal from "decimal.js";
|
||||
import { Trash } from "lucide-react";
|
||||
import { type Prisma } from "@langfuse/shared/src/db";
|
||||
import { useQueryParams, withDefault, NumberParam } from "use-query-params";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { IOTableCell } from "@/src/components/ui/CodeJsonViewer";
|
||||
import { useRowHeightLocalStorage } from "@/src/components/table/data-table-row-height-switch";
|
||||
import { DataTableToolbar } from "@/src/components/table/data-table-toolbar";
|
||||
import useColumnOrder from "@/src/features/column-visibility/hooks/useColumnOrder";
|
||||
import { type GetModelResult } from "@/src/features/models/validation";
|
||||
import { DeleteModelButton } from "@/src/features/models/components/DeleteModelButton";
|
||||
import { EditModelButton } from "@/src/features/models/components/EditModelButton";
|
||||
import { CloneModelButton } from "@/src/features/models/components/CloneModelButton";
|
||||
import { PriceBreakdownTooltip } from "@/src/features/models/components/PriceBreakdownTooltip";
|
||||
import { UserCircle2Icon } from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/src/components/ui/tooltip";
|
||||
import { LangfuseIcon } from "@/src/components/LangfuseLogo";
|
||||
import { useRouter } from "next/router";
|
||||
import { PriceUnitSelector } from "@/src/features/models/components/PriceUnitSelector";
|
||||
import { usePriceUnitMultiplier } from "@/src/features/models/hooks/usePriceUnitMultiplier";
|
||||
|
||||
export type ModelTableRow = {
|
||||
modelId: string;
|
||||
maintainer: string;
|
||||
modelName: string;
|
||||
matchPattern: string;
|
||||
startDate?: Date;
|
||||
inputPrice?: Decimal;
|
||||
outputPrice?: Decimal;
|
||||
totalPrice?: Decimal;
|
||||
unit: string;
|
||||
prices?: Record<string, number>;
|
||||
tokenizerId?: string;
|
||||
config?: Prisma.JsonValue;
|
||||
lastUsed?: Date | null;
|
||||
serverResponse: GetModelResult;
|
||||
};
|
||||
|
||||
const modelConfigDescriptions = {
|
||||
@@ -43,39 +45,47 @@ const modelConfigDescriptions = {
|
||||
"Regex pattern to match `model` parameter of generations to model pricing",
|
||||
startDate:
|
||||
"Date to start pricing model. If not set, model is active unless a more recent version exists.",
|
||||
inputPrice: "Price per 1000 units of input",
|
||||
outputPrice: "Price per 1000 units of output",
|
||||
totalPrice:
|
||||
"Price per 1000 units, for models that don't have input/output specific prices",
|
||||
unit: "Unit of measurement for generative model, can be TOKENS, CHARACTERS, SECONDS, MILLISECONDS, REQUESTS or IMAGES.",
|
||||
prices: "Prices per usage type",
|
||||
tokenizerId:
|
||||
"Tokenizer used for this model to calculate token counts if none are ingested. Pick from list of supported tokenizers.",
|
||||
config:
|
||||
"Some tokenizers require additional configuration (e.g. openai tiktoken). See docs for details.",
|
||||
maintainer:
|
||||
"Maintainer of the model. Langfuse managed models can be cloned, user managed models can be edited and deleted. To supersede a Langfuse managed model, set the custom model name to the Langfuse model name.",
|
||||
lastUsed: "Start time of the latest generation using this model",
|
||||
} as const;
|
||||
|
||||
export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
const router = useRouter();
|
||||
const [paginationState, setPaginationState] = useQueryParams({
|
||||
pageIndex: withDefault(NumberParam, 0),
|
||||
pageSize: withDefault(NumberParam, 50),
|
||||
});
|
||||
const models = api.models.all.useQuery({
|
||||
page: paginationState.pageIndex,
|
||||
limit: paginationState.pageSize,
|
||||
projectId,
|
||||
});
|
||||
const models = api.models.getAll.useQuery(
|
||||
{
|
||||
page: paginationState.pageIndex,
|
||||
limit: paginationState.pageSize,
|
||||
projectId,
|
||||
},
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: false,
|
||||
staleTime: 1000 * 60 * 10,
|
||||
},
|
||||
);
|
||||
const totalCount = models.data?.totalCount ?? null;
|
||||
const { priceUnit } = usePriceUnitMultiplier();
|
||||
const [rowHeight, setRowHeight] = useRowHeightLocalStorage("models", "m");
|
||||
|
||||
const [rowHeight, setRowHeight] = useRowHeightLocalStorage("models", "s");
|
||||
// Set row height to medium if small as view is not optimized for small row heights
|
||||
useEffect(() => {
|
||||
if (rowHeight === "s") {
|
||||
setRowHeight("m");
|
||||
}
|
||||
}, [rowHeight, setRowHeight]);
|
||||
|
||||
const columns: LangfuseColumnDef<ModelTableRow>[] = [
|
||||
{
|
||||
accessorKey: "maintainer",
|
||||
id: "maintainer",
|
||||
enableColumnFilter: true,
|
||||
header: "Maintainer",
|
||||
size: 100,
|
||||
},
|
||||
{
|
||||
accessorKey: "modelName",
|
||||
id: "modelName",
|
||||
@@ -83,23 +93,40 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
headerTooltip: {
|
||||
description: modelConfigDescriptions.modelName,
|
||||
},
|
||||
size: 150,
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="font-mono text-xs font-semibold">
|
||||
{row.original.modelName}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
size: 120,
|
||||
},
|
||||
{
|
||||
accessorKey: "startDate",
|
||||
id: "startDate",
|
||||
header: "Start Date",
|
||||
accessorKey: "maintainer",
|
||||
id: "maintainer",
|
||||
header: "Maintainer",
|
||||
headerTooltip: {
|
||||
description: modelConfigDescriptions.startDate,
|
||||
description: modelConfigDescriptions.maintainer,
|
||||
},
|
||||
size: 100,
|
||||
size: 60,
|
||||
cell: ({ row }) => {
|
||||
const value: Date | undefined = row.getValue("startDate");
|
||||
|
||||
return value ? (
|
||||
<span className="text-xs">{value.toISOString().slice(0, 10)} </span>
|
||||
) : (
|
||||
<span className="text-xs">-</span>
|
||||
const isLangfuse = row.original.maintainer === "Langfuse";
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
{isLangfuse ? (
|
||||
<LangfuseIcon size={16} />
|
||||
) : (
|
||||
<UserCircle2Icon className="h-4 w-4" />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isLangfuse ? "Langfuse maintained" : "User maintained"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -115,104 +142,37 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
const value: string = row.getValue("matchPattern");
|
||||
|
||||
return value ? (
|
||||
<IOTableCell data={value} singleLine={rowHeight === "s"} />
|
||||
<span className="font-mono text-xs">{value}</span>
|
||||
) : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "inputPrice",
|
||||
id: "inputPrice",
|
||||
accessorKey: "prices",
|
||||
id: "prices",
|
||||
header: () => {
|
||||
return (
|
||||
<>
|
||||
Input Price{" "}
|
||||
<span className="text-xs text-muted-foreground">/ 1k units</span>
|
||||
</>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Prices {priceUnit}</span>
|
||||
<PriceUnitSelector />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
headerTooltip: {
|
||||
description: modelConfigDescriptions.inputPrice,
|
||||
},
|
||||
size: 170,
|
||||
size: 120,
|
||||
cell: ({ row }) => {
|
||||
const value: Decimal | undefined = row.getValue("inputPrice");
|
||||
const prices: Record<string, number> | undefined =
|
||||
row.getValue("prices");
|
||||
|
||||
return value ? (
|
||||
<span className="text-xs">
|
||||
{usdFormatter(value.toNumber() * 1000, 2, 8)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs">-</span>
|
||||
);
|
||||
},
|
||||
enableHiding: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "outputPrice",
|
||||
id: "outputPrice",
|
||||
headerTooltip: {
|
||||
description: modelConfigDescriptions.outputPrice,
|
||||
},
|
||||
header: () => {
|
||||
return (
|
||||
<>
|
||||
Output Price{" "}
|
||||
<span className="text-xs text-muted-foreground">/ 1k units</span>
|
||||
</>
|
||||
);
|
||||
},
|
||||
size: 170,
|
||||
cell: ({ row }) => {
|
||||
const value: Decimal | undefined = row.getValue("outputPrice");
|
||||
|
||||
return value ? (
|
||||
<span className="text-xs">
|
||||
{usdFormatter(value.toNumber() * 1000, 2, 8)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs">-</span>
|
||||
<PriceBreakdownTooltip
|
||||
modelName={row.original.modelName}
|
||||
prices={prices}
|
||||
priceUnit={priceUnit}
|
||||
rowHeight={rowHeight}
|
||||
/>
|
||||
);
|
||||
},
|
||||
enableHiding: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "totalPrice",
|
||||
id: "totalPrice",
|
||||
header: () => {
|
||||
return (
|
||||
<>
|
||||
Total Price{" "}
|
||||
<span className="text-xs text-muted-foreground">/ 1k units</span>
|
||||
</>
|
||||
);
|
||||
},
|
||||
headerTooltip: {
|
||||
description: modelConfigDescriptions.totalPrice,
|
||||
},
|
||||
size: 170,
|
||||
cell: ({ row }) => {
|
||||
const value: Decimal | undefined = row.getValue("totalPrice");
|
||||
|
||||
return value ? (
|
||||
<span className="text-xs">
|
||||
{usdFormatter(value.toNumber() * 1000, 2, 8)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs">-</span>
|
||||
);
|
||||
},
|
||||
enableHiding: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "unit",
|
||||
id: "unit",
|
||||
header: "Unit",
|
||||
headerTooltip: {
|
||||
description: modelConfigDescriptions.unit,
|
||||
},
|
||||
enableHiding: true,
|
||||
size: 110,
|
||||
},
|
||||
{
|
||||
accessorKey: "tokenizerId",
|
||||
id: "tokenizerId",
|
||||
@@ -221,7 +181,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
description: modelConfigDescriptions.tokenizerId,
|
||||
},
|
||||
enableHiding: true,
|
||||
size: 110,
|
||||
size: 120,
|
||||
},
|
||||
{
|
||||
accessorKey: "config",
|
||||
@@ -231,7 +191,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
description: modelConfigDescriptions.config,
|
||||
},
|
||||
enableHiding: true,
|
||||
size: 200,
|
||||
size: 120,
|
||||
cell: ({ row }) => {
|
||||
const value: Prisma.JsonValue | undefined = row.getValue("config");
|
||||
|
||||
@@ -240,17 +200,46 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
) : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "lastUsed",
|
||||
id: "lastUsed",
|
||||
header: "Last used",
|
||||
headerTooltip: {
|
||||
description: modelConfigDescriptions.lastUsed,
|
||||
},
|
||||
enableHiding: true,
|
||||
size: 120,
|
||||
cell: ({ row }) => {
|
||||
const value: Date | null | undefined = row.getValue("lastUsed");
|
||||
return value?.toLocaleString() ?? "";
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "actions",
|
||||
header: "Actions",
|
||||
size: 70,
|
||||
size: 120,
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<DeleteModelButton
|
||||
projectId={projectId}
|
||||
modelId={row.original.modelId}
|
||||
isBuiltIn={row.original.maintainer === "Langfuse"}
|
||||
/>
|
||||
return row.original.maintainer !== "Langfuse" ? (
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<EditModelButton
|
||||
projectId={projectId}
|
||||
modelData={row.original.serverResponse}
|
||||
/>
|
||||
<DeleteModelButton
|
||||
projectId={projectId}
|
||||
modelData={row.original.serverResponse}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<CloneModelButton
|
||||
projectId={projectId}
|
||||
modelData={row.original.serverResponse}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -264,21 +253,17 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
columns,
|
||||
);
|
||||
|
||||
const convertToTableRow = (model: Model): ModelTableRow => {
|
||||
const convertToTableRow = (model: GetModelResult): ModelTableRow => {
|
||||
return {
|
||||
modelId: model.id,
|
||||
maintainer: model.projectId ? "User" : "Langfuse",
|
||||
modelName: model.modelName,
|
||||
matchPattern: model.matchPattern,
|
||||
startDate: model.startDate ? new Date(model.startDate) : undefined,
|
||||
inputPrice: model.inputPrice ? new Decimal(model.inputPrice) : undefined,
|
||||
outputPrice: model.outputPrice
|
||||
? new Decimal(model.outputPrice)
|
||||
: undefined,
|
||||
totalPrice: model.totalPrice ? new Decimal(model.totalPrice) : undefined,
|
||||
unit: model.unit ?? "",
|
||||
prices: model.prices,
|
||||
tokenizerId: model.tokenizerId ?? undefined,
|
||||
config: model.tokenizerConfig,
|
||||
lastUsed: model.lastUsed,
|
||||
serverResponse: model,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -320,76 +305,10 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
columnOrder={columnOrder}
|
||||
onColumnOrderChange={setColumnOrder}
|
||||
rowHeight={rowHeight}
|
||||
onRowClick={(row) => {
|
||||
router.push(`/project/${projectId}/models/${row.modelId}`);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const DeleteModelButton = ({
|
||||
modelId,
|
||||
projectId,
|
||||
isBuiltIn,
|
||||
}: {
|
||||
modelId: string;
|
||||
projectId: string;
|
||||
isBuiltIn?: boolean;
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
const capture = usePostHogClientCapture();
|
||||
const mut = api.models.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.models.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const hasAccess = useHasProjectAccess({
|
||||
projectId,
|
||||
scope: "models:CUD",
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={() => setIsOpen(!isOpen)}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
disabled={!hasAccess || isBuiltIn}
|
||||
title={
|
||||
isBuiltIn ? "Built-in models cannot be deleted" : "Delete model"
|
||||
}
|
||||
className={cn(
|
||||
isBuiltIn &&
|
||||
"disabled:pointer-events-auto disabled:cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<h2 className="text-md mb-3 font-semibold">Please confirm</h2>
|
||||
<p className="mb-3 text-sm">
|
||||
This action permanently deletes this model definition.
|
||||
</p>
|
||||
<div className="flex justify-end space-x-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
loading={mut.isLoading}
|
||||
onClick={() => {
|
||||
capture("models:delete_button_click");
|
||||
mut.mutateAsync({
|
||||
projectId,
|
||||
modelId,
|
||||
});
|
||||
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
Delete Model
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -52,6 +52,7 @@ import { BatchExportTableButton } from "@/src/components/BatchExportTableButton"
|
||||
import { useClickhouse } from "@/src/components/layouts/ClickhouseAdminToggle";
|
||||
import { BreakdownTooltip } from "@/src/components/trace/BreakdownToolTip";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { useHasEntitlement } from "@/src/features/entitlements/hooks";
|
||||
|
||||
export type TracesTableRow = {
|
||||
bookmarked: boolean;
|
||||
@@ -241,6 +242,8 @@ export default function TracesTable({
|
||||
cellsLoading: !traceMetrics.data,
|
||||
});
|
||||
|
||||
const hasTraceDeletionEntitlement = useHasEntitlement("trace-deletion");
|
||||
|
||||
const columns: LangfuseColumnDef<TracesTableRow>[] = [
|
||||
{
|
||||
id: "select",
|
||||
@@ -708,7 +711,9 @@ export default function TracesTable({
|
||||
isPinned: true,
|
||||
cell: ({ row }) => {
|
||||
const traceId: TracesTableRow["id"] = row.getValue("id");
|
||||
return traceId && typeof traceId === "string" ? (
|
||||
return traceId &&
|
||||
typeof traceId === "string" &&
|
||||
hasTraceDeletionEntitlement ? (
|
||||
<DeleteButton
|
||||
itemId={traceId}
|
||||
projectId={projectId}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@/src/components/ui/tooltip";
|
||||
import { useState } from "react";
|
||||
import Decimal from "decimal.js";
|
||||
import { getMaxDecimals } from "@/src/features/models/utils";
|
||||
|
||||
interface Details {
|
||||
[key: string]: number | undefined;
|
||||
@@ -36,15 +37,6 @@ export const BreakdownTooltip = ({
|
||||
}, {})
|
||||
: details;
|
||||
|
||||
// For costs, calculate the maximum number of decimal places needed
|
||||
const getMaxDecimals = (value: number | undefined): number => {
|
||||
if (!value) return 0;
|
||||
const parts = value.toString().split(".");
|
||||
|
||||
// If no decimal point, return 0, else return length of decimal part
|
||||
return parts.length === 1 ? 0 : parts[1].length;
|
||||
};
|
||||
|
||||
const formatValueWithPadding = (value: number, maxDecimals: number) => {
|
||||
return !value
|
||||
? "0"
|
||||
|
||||
@@ -257,6 +257,7 @@ export const OpenAiMessageView: React.FC<{
|
||||
messages: z.infer<typeof ChatMlArraySchema>;
|
||||
title?: string;
|
||||
shouldRenderMarkdown?: boolean;
|
||||
collapseLongHistory?: boolean;
|
||||
media?: MediaReturnType[];
|
||||
additionalInput?: Record<string, unknown>;
|
||||
}> = ({
|
||||
@@ -264,11 +265,12 @@ export const OpenAiMessageView: React.FC<{
|
||||
messages,
|
||||
shouldRenderMarkdown = false,
|
||||
media,
|
||||
collapseLongHistory = true,
|
||||
additionalInput,
|
||||
}) => {
|
||||
const COLLAPSE_THRESHOLD = 3;
|
||||
const [isCollapsed, setCollapsed] = useState(
|
||||
messages.length > COLLAPSE_THRESHOLD ? true : null,
|
||||
collapseLongHistory && messages.length > COLLAPSE_THRESHOLD ? true : null,
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -39,7 +39,8 @@ import {
|
||||
} from "@/src/components/ui/tabs-bar";
|
||||
import { useClickhouse } from "@/src/components/layouts/ClickhouseAdminToggle";
|
||||
import { BreakdownTooltip } from "./BreakdownToolTip";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { InfoIcon, PlusCircle } from "lucide-react";
|
||||
import { UpsertModelFormDrawer } from "@/src/features/models/components/UpsertModelFormDrawer";
|
||||
|
||||
export const ObservationPreview = ({
|
||||
observations,
|
||||
@@ -210,7 +211,47 @@ export const ObservationPreview = ({
|
||||
</Badge>
|
||||
) : undefined}
|
||||
{preloadedObservation.model ? (
|
||||
<Badge variant="outline">{preloadedObservation.model}</Badge>
|
||||
preloadedObservation.modelId ? (
|
||||
<Badge>
|
||||
<Link
|
||||
href={`/project/${preloadedObservation.projectId}/models/${preloadedObservation.modelId}`}
|
||||
className="flex items-center"
|
||||
title="View model details"
|
||||
>
|
||||
{preloadedObservation.model}
|
||||
</Link>
|
||||
</Badge>
|
||||
) : (
|
||||
<UpsertModelFormDrawer
|
||||
action="create"
|
||||
projectId={preloadedObservation.projectId}
|
||||
prefilledModelData={{
|
||||
modelName: preloadedObservation.model,
|
||||
prices:
|
||||
Object.keys(preloadedObservation.usageDetails)
|
||||
.length > 0
|
||||
? Object.keys(preloadedObservation.usageDetails)
|
||||
.filter((key) => key != "total")
|
||||
.reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = 0.000001;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
)
|
||||
: undefined,
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<span>{preloadedObservation.model}</span>
|
||||
<PlusCircle className="h-3 w-3" />
|
||||
</Badge>
|
||||
</UpsertModelFormDrawer>
|
||||
)
|
||||
) : null}
|
||||
{thisCost ? (
|
||||
<BreakdownTooltip
|
||||
@@ -249,55 +290,58 @@ export const ObservationPreview = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{viewType === "detailed" && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<CommentDrawerButton
|
||||
projectId={preloadedObservation.projectId}
|
||||
objectId={preloadedObservation.id}
|
||||
objectType="OBSERVATION"
|
||||
count={commentCounts?.get(preloadedObservation.id)}
|
||||
/>
|
||||
<div className="flex items-start">
|
||||
<AnnotateDrawer
|
||||
key={"annotation-drawer" + preloadedObservation.id}
|
||||
projectId={projectId}
|
||||
traceId={traceId}
|
||||
observationId={preloadedObservation.id}
|
||||
scores={scores}
|
||||
emptySelectedConfigIds={emptySelectedConfigIds}
|
||||
setEmptySelectedConfigIds={setEmptySelectedConfigIds}
|
||||
type="observation"
|
||||
hasGroupedButton={hasEntitlement}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{viewType === "detailed" && (
|
||||
<>
|
||||
<CommentDrawerButton
|
||||
projectId={preloadedObservation.projectId}
|
||||
objectId={preloadedObservation.id}
|
||||
objectType="OBSERVATION"
|
||||
count={commentCounts?.get(preloadedObservation.id)}
|
||||
/>
|
||||
{hasEntitlement && (
|
||||
<CreateNewAnnotationQueueItem
|
||||
<div className="flex items-start">
|
||||
<AnnotateDrawer
|
||||
key={"annotation-drawer" + preloadedObservation.id}
|
||||
projectId={projectId}
|
||||
objectId={preloadedObservation.id}
|
||||
objectType={AnnotationQueueObjectType.OBSERVATION}
|
||||
traceId={traceId}
|
||||
observationId={preloadedObservation.id}
|
||||
scores={scores}
|
||||
emptySelectedConfigIds={emptySelectedConfigIds}
|
||||
setEmptySelectedConfigIds={setEmptySelectedConfigIds}
|
||||
type="observation"
|
||||
hasGroupedButton={hasEntitlement}
|
||||
/>
|
||||
{hasEntitlement && (
|
||||
<CreateNewAnnotationQueueItem
|
||||
projectId={projectId}
|
||||
objectId={preloadedObservation.id}
|
||||
objectType={AnnotationQueueObjectType.OBSERVATION}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{observationWithInputAndOutput.data?.type === "GENERATION" && (
|
||||
<JumpToPlaygroundButton
|
||||
source="generation"
|
||||
generation={observationWithInputAndOutput.data}
|
||||
analyticsEventName="trace_detail:test_in_playground_button_click"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{observationWithInputAndOutput.data?.type === "GENERATION" && (
|
||||
<JumpToPlaygroundButton
|
||||
source="generation"
|
||||
generation={observationWithInputAndOutput.data}
|
||||
analyticsEventName="trace_detail:test_in_playground_button_click"
|
||||
/>
|
||||
)}
|
||||
{observationWithInputAndOutput.data ? (
|
||||
<NewDatasetItemFromTrace
|
||||
traceId={preloadedObservation.traceId}
|
||||
observationId={preloadedObservation.id}
|
||||
projectId={projectId}
|
||||
input={observationWithInputAndOutput.data.input}
|
||||
output={observationWithInputAndOutput.data.output}
|
||||
metadata={observationWithInputAndOutput.data.metadata}
|
||||
key={preloadedObservation.id}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{observationWithInputAndOutput.data ? (
|
||||
<NewDatasetItemFromTrace
|
||||
traceId={preloadedObservation.traceId}
|
||||
observationId={preloadedObservation.id}
|
||||
projectId={projectId}
|
||||
input={observationWithInputAndOutput.data.input}
|
||||
output={observationWithInputAndOutput.data.output}
|
||||
metadata={observationWithInputAndOutput.data.metadata}
|
||||
key={preloadedObservation.id}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{selectedTab === "preview" && (
|
||||
|
||||
@@ -169,42 +169,45 @@ export const TracePreview = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{viewType === "detailed" && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<CommentDrawerButton
|
||||
projectId={trace.projectId}
|
||||
objectId={trace.id}
|
||||
objectType="TRACE"
|
||||
count={commentCounts?.get(trace.id)}
|
||||
/>
|
||||
<div className="flex items-start">
|
||||
<AnnotateDrawer
|
||||
key={"annotation-drawer" + trace.id}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{viewType === "detailed" && (
|
||||
<>
|
||||
<CommentDrawerButton
|
||||
projectId={trace.projectId}
|
||||
traceId={trace.id}
|
||||
scores={scores}
|
||||
emptySelectedConfigIds={emptySelectedConfigIds}
|
||||
setEmptySelectedConfigIds={setEmptySelectedConfigIds}
|
||||
hasGroupedButton={hasEntitlement}
|
||||
objectId={trace.id}
|
||||
objectType="TRACE"
|
||||
count={commentCounts?.get(trace.id)}
|
||||
/>
|
||||
{hasEntitlement && (
|
||||
<CreateNewAnnotationQueueItem
|
||||
<div className="flex items-start">
|
||||
<AnnotateDrawer
|
||||
key={"annotation-drawer" + trace.id}
|
||||
projectId={trace.projectId}
|
||||
objectId={trace.id}
|
||||
objectType={AnnotationQueueObjectType.TRACE}
|
||||
traceId={trace.id}
|
||||
scores={scores}
|
||||
emptySelectedConfigIds={emptySelectedConfigIds}
|
||||
setEmptySelectedConfigIds={setEmptySelectedConfigIds}
|
||||
hasGroupedButton={hasEntitlement}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<NewDatasetItemFromTrace
|
||||
traceId={trace.id}
|
||||
projectId={trace.projectId}
|
||||
input={trace.input}
|
||||
output={trace.output}
|
||||
metadata={trace.metadata}
|
||||
key={trace.id}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{hasEntitlement && (
|
||||
<CreateNewAnnotationQueueItem
|
||||
projectId={trace.projectId}
|
||||
objectId={trace.id}
|
||||
objectType={AnnotationQueueObjectType.TRACE}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<NewDatasetItemFromTrace
|
||||
traceId={trace.id}
|
||||
projectId={trace.projectId}
|
||||
input={trace.input}
|
||||
output={trace.output}
|
||||
metadata={trace.metadata}
|
||||
key={trace.id}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{selectedTab === "preview" && (
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
TabsBarList,
|
||||
TabsBarTrigger,
|
||||
} from "@/src/components/ui/tabs-bar";
|
||||
import { useHasEntitlement } from "@/src/features/entitlements/hooks";
|
||||
|
||||
export function Trace(props: {
|
||||
observations: Array<ObservationReturnType>;
|
||||
@@ -311,6 +312,8 @@ export function TracePage({
|
||||
withDefault(StringParam, "details"),
|
||||
);
|
||||
|
||||
const hasTraceDeletionEntitlement = useHasEntitlement("trace-deletion");
|
||||
|
||||
if (trace.error?.data?.code === "UNAUTHORIZED")
|
||||
return <ErrorPage message="You do not have access to this trace." />;
|
||||
|
||||
@@ -372,15 +375,17 @@ export function TracePage({
|
||||
}}
|
||||
listKey="traces"
|
||||
/>
|
||||
<DeleteButton
|
||||
itemId={traceId}
|
||||
projectId={trace.data.projectId}
|
||||
scope="traces:delete"
|
||||
invalidateFunc={() => void utils.traces.all.invalidate()}
|
||||
type="trace"
|
||||
redirectUrl={`/project/${router.query.projectId as string}/traces`}
|
||||
deleteConfirmation={trace.data.name ?? ""}
|
||||
/>
|
||||
{hasTraceDeletionEntitlement && (
|
||||
<DeleteButton
|
||||
itemId={traceId}
|
||||
projectId={trace.data.projectId}
|
||||
scope="traces:delete"
|
||||
invalidateFunc={() => void utils.traces.all.invalidate()}
|
||||
type="trace"
|
||||
redirectUrl={`/project/${router.query.projectId as string}/traces`}
|
||||
deleteConfirmation={trace.data.name ?? ""}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
|
||||
|
||||
/**
|
||||
* useLocalStorage is a hook for managing data with the localStorage API.
|
||||
* It provides cross-tab synchronization and safe interaction with localStorage.
|
||||
*
|
||||
* @param {string} localStorageKey - The key under which the value is stored in localStorage.
|
||||
* @param {T} initialValue - The initial value of the data to be stored.
|
||||
@@ -10,49 +11,148 @@ import { useState, useEffect } from "react";
|
||||
*
|
||||
* @return An array with three elements:
|
||||
* value: Current value
|
||||
* setValue: Function to update the value
|
||||
* setValue: Function to update the value and sync across tabs
|
||||
* clearValue: Function to remove value from the local storage.
|
||||
* This function will also reset the value to initial value
|
||||
*
|
||||
* @template T - The type of the data to be stored in localStorage. It should be a type that can be stringified.
|
||||
*
|
||||
* @throws Will throw an error if the stringifying the value or accessing local storage fails.
|
||||
*
|
||||
* @example
|
||||
* const [theme, setTheme, clearTheme] = useLocalStorage('theme', 'light');
|
||||
* // Use theme value
|
||||
* // Call setTheme to update
|
||||
* // Call clearTheme to reset to 'light'
|
||||
*/
|
||||
function useLocalStorage<T>(
|
||||
localStorageKey: string,
|
||||
initialValue: T,
|
||||
): [T, React.Dispatch<React.SetStateAction<T>>, () => void] {
|
||||
// Initialize state with value from localStorage or initial value
|
||||
// This initialization is only run once when the component mounts
|
||||
const [value, setValue] = useState<T>(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return initialValue;
|
||||
}
|
||||
// Return initial value if running on server-side
|
||||
if (typeof window === "undefined") return initialValue;
|
||||
|
||||
try {
|
||||
const storedValue = localStorage.getItem(localStorageKey);
|
||||
return storedValue ? (JSON.parse(storedValue) as T) : initialValue;
|
||||
const stored = localStorage.getItem(localStorageKey);
|
||||
// Parse stored value if it exists, otherwise use initial value
|
||||
return stored ? (JSON.parse(stored) as T) : initialValue;
|
||||
} catch (error) {
|
||||
console.error("Error reading from local storage", error);
|
||||
return initialValue;
|
||||
}
|
||||
});
|
||||
|
||||
const clearValue = () => {
|
||||
try {
|
||||
localStorage.removeItem(localStorageKey);
|
||||
setValue(initialValue);
|
||||
} catch (error) {
|
||||
console.error("Error clearing local storage", error);
|
||||
}
|
||||
// Helper object to safely interact with localStorage
|
||||
// Handles all error cases and provides consistent interface
|
||||
const safeLocalStorage = {
|
||||
set: (value: T) => {
|
||||
try {
|
||||
const stringified = JSON.stringify(value);
|
||||
localStorage.setItem(localStorageKey, stringified);
|
||||
return stringified;
|
||||
} catch (error) {
|
||||
console.error("Error writing to local storage", error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
remove: () => {
|
||||
try {
|
||||
localStorage.removeItem(localStorageKey);
|
||||
} catch (error) {
|
||||
console.error("Error clearing local storage", error);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Function to clear both localStorage and state
|
||||
const clearValue = () => {
|
||||
safeLocalStorage.remove();
|
||||
setValue(initialValue);
|
||||
};
|
||||
|
||||
// Sync to localStorage whenever value changes
|
||||
// This ensures localStorage always has the latest value
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(localStorageKey, JSON.stringify(value));
|
||||
} catch (error) {
|
||||
console.error("Error writing to local storage", error);
|
||||
}
|
||||
safeLocalStorage.set(value);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [localStorageKey, value]);
|
||||
|
||||
return [value, setValue, clearValue] as const;
|
||||
// Handle cross-tab synchronization
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
// Handler for native localStorage events (triggered by other tabs)
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
if (e.key === localStorageKey) {
|
||||
try {
|
||||
setValue(e.newValue ? (JSON.parse(e.newValue) as T) : initialValue);
|
||||
} catch (error) {
|
||||
console.error("Error parsing storage change", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handler for custom events (triggered within same tab)
|
||||
const handleCustomEvent = (
|
||||
e: CustomEvent<{ key: string; newValue: string }>,
|
||||
) => {
|
||||
if (e.detail.key === localStorageKey) {
|
||||
try {
|
||||
setValue(
|
||||
e.detail.newValue
|
||||
? (JSON.parse(e.detail.newValue) as T)
|
||||
: initialValue,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error parsing custom event", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for both storage events and custom events
|
||||
window.addEventListener("storage", handleStorageChange);
|
||||
window.addEventListener(
|
||||
"localStorageChange",
|
||||
handleCustomEvent as EventListener,
|
||||
);
|
||||
|
||||
// Cleanup listeners on unmount
|
||||
return () => {
|
||||
window.removeEventListener("storage", handleStorageChange);
|
||||
window.removeEventListener(
|
||||
"localStorageChange",
|
||||
handleCustomEvent as EventListener,
|
||||
);
|
||||
};
|
||||
}, [localStorageKey, initialValue]);
|
||||
|
||||
// Enhanced setValue function that also notifies other tabs
|
||||
const setValueAndNotify: React.Dispatch<React.SetStateAction<T>> = (
|
||||
newValue,
|
||||
) => {
|
||||
setValue((prev) => {
|
||||
// Handle both direct values and updater functions
|
||||
const resolvedValue =
|
||||
newValue instanceof Function ? newValue(prev) : newValue;
|
||||
const stringified = safeLocalStorage.set(resolvedValue);
|
||||
|
||||
// Dispatch custom event to notify other instances in the same tab
|
||||
if (stringified) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("localStorageChange", {
|
||||
detail: { key: localStorageKey, newValue: stringified },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return resolvedValue;
|
||||
});
|
||||
};
|
||||
|
||||
return [value, setValueAndNotify, clearValue] as const;
|
||||
}
|
||||
|
||||
export default useLocalStorage;
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const VERSION = "v3.2.0";
|
||||
export const VERSION = "v3.7.0";
|
||||
|
||||
+3
-3
@@ -31,7 +31,6 @@ import {
|
||||
import { api } from "@/src/utils/api";
|
||||
import { getScoreDataTypeIcon } from "@/src/features/scores/components/ScoreDetailColumnHelpers";
|
||||
import { MultiSelectKeyValues } from "@/src/features/scores/components/multi-select-key-values";
|
||||
import { CommandItem } from "@/src/components/ui/command";
|
||||
import { useRouter } from "next/router";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import {
|
||||
@@ -39,6 +38,7 @@ import {
|
||||
useEntitlementLimit,
|
||||
} from "@/src/features/entitlements/hooks";
|
||||
import { ActionButton } from "@/src/components/ActionButton";
|
||||
import { DropdownMenuItem } from "@/src/components/ui/dropdown-menu";
|
||||
|
||||
export const CreateOrEditAnnotationQueueButton = ({
|
||||
projectId,
|
||||
@@ -257,7 +257,7 @@ export const CreateOrEditAnnotationQueueButton = ({
|
||||
};
|
||||
})}
|
||||
controlButtons={
|
||||
<CommandItem
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
capture(
|
||||
"score_configs:manage_configs_item_click",
|
||||
@@ -269,7 +269,7 @@ export const CreateOrEditAnnotationQueueButton = ({
|
||||
}}
|
||||
>
|
||||
Manage score configs
|
||||
</CommandItem>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -545,7 +545,6 @@ export const InnerEvalTemplateForm = (props: {
|
||||
modelParamsDescription:
|
||||
"Select a model which supports function calling.",
|
||||
}}
|
||||
evalModelsOnly
|
||||
formDisabled={!props.isEditing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -77,6 +77,7 @@ import {
|
||||
} from "@/src/components/ui/dialog";
|
||||
import Link from "next/link";
|
||||
import { useHasEntitlement } from "@/src/features/entitlements/hooks";
|
||||
import { DropdownMenuItem } from "@/src/components/ui/dropdown-menu";
|
||||
|
||||
const CreateExperimentData = z.object({
|
||||
name: z
|
||||
@@ -682,10 +683,7 @@ export const CreateExperimentsForm = ({
|
||||
availableProviders,
|
||||
updateModelParamValue: updateModelParamValue,
|
||||
setModelParamEnabled,
|
||||
modelParamsDescription:
|
||||
"Select a model which supports function calling.",
|
||||
}}
|
||||
evalModelsOnly
|
||||
/>
|
||||
</Card>
|
||||
{form.formState.errors.modelConfig && (
|
||||
@@ -790,13 +788,13 @@ export const CreateExperimentsForm = ({
|
||||
}
|
||||
hideClearButton
|
||||
controlButtons={
|
||||
<CommandItem
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
window.open(`/project/${projectId}/evals`, "_blank");
|
||||
}}
|
||||
>
|
||||
Manage evaluators
|
||||
</CommandItem>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
@@ -62,16 +62,15 @@ export const JumpToPlaygroundButton: React.FC<JumpToPlaygroundButtonProps> = (
|
||||
return (
|
||||
<Button
|
||||
variant={props.variant ?? "secondary"}
|
||||
size={props.source === "prompt" ? "icon" : "default"}
|
||||
title="Test in LLM playground"
|
||||
onClick={handleClick}
|
||||
asChild
|
||||
>
|
||||
<Link href={`/project/${projectId}/playground`}>
|
||||
<Terminal className="h-4 w-4" />
|
||||
{props.source === "generation" && (
|
||||
<span className="ml-2">Test in playground</span>
|
||||
)}
|
||||
<span className="ml-2">
|
||||
{props.source === "generation" ? "Test in playground" : "Playground"}
|
||||
</span>
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@ export default function Playground() {
|
||||
<div className="max-h-full min-h-0 basis-1/4 pr-2">
|
||||
<div className="grid h-full grid-rows-[minmax(20dvh,max-content),minmax(20dvh,auto)] overflow-auto">
|
||||
<div className="mb-4 max-h-[80dvh] min-h-[20dvh] overflow-y-auto">
|
||||
<ModelParameters {...playgroundContext} evalModelsOnly={false} />
|
||||
<ModelParameters {...playgroundContext} />
|
||||
</div>
|
||||
<div className="min-h-[20dvh]">
|
||||
<Variables />
|
||||
|
||||
+21
-1
@@ -9,6 +9,7 @@ export const env = createEnv({
|
||||
server: {
|
||||
DATABASE_URL: z.string().url(),
|
||||
NODE_ENV: z.enum(["development", "test", "production"]),
|
||||
BUILD_ID: z.string().optional(),
|
||||
NEXTAUTH_SECRET:
|
||||
process.env.NODE_ENV === "production"
|
||||
? z.string().min(1)
|
||||
@@ -95,8 +96,20 @@ export const env = createEnv({
|
||||
AUTH_CUSTOM_ISSUER: z.string().url().optional(),
|
||||
AUTH_CUSTOM_NAME: z.string().optional(),
|
||||
AUTH_CUSTOM_SCOPE: z.string().optional(),
|
||||
AUTH_CUSTOM_CLIENT_AUTH_METHOD: z
|
||||
.enum([
|
||||
"client_secret_basic",
|
||||
"client_secret_post",
|
||||
"client_secret_jwt",
|
||||
"private_key_jwt",
|
||||
"tls_client_auth",
|
||||
"self_signed_tls_client_auth",
|
||||
"none",
|
||||
])
|
||||
.optional(),
|
||||
AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING: z.enum(["true", "false"]).optional(),
|
||||
AUTH_DOMAINS_WITH_SSO_ENFORCEMENT: z.string().optional(),
|
||||
AUTH_IGNORE_ACCOUNT_FIELDS: z.string().optional(),
|
||||
AUTH_DISABLE_USERNAME_PASSWORD: z.enum(["true", "false"]).optional(),
|
||||
AUTH_DISABLE_SIGNUP: z.enum(["true", "false"]).optional(),
|
||||
AUTH_SESSION_MAX_AGE: z.coerce
|
||||
@@ -138,9 +151,11 @@ export const env = createEnv({
|
||||
|
||||
// clickhouse
|
||||
CLICKHOUSE_URL: z.string().url(),
|
||||
CLICKHOUSE_CLUSTER_NAME: z.string().default("default"),
|
||||
CLICKHOUSE_DB: z.string().default("default"),
|
||||
CLICKHOUSE_USER: z.string(),
|
||||
CLICKHOUSE_PASSWORD: z.string(),
|
||||
CLICKHOUSE_CLUSTER_ENABLED: z.enum(["true", "false"]).default("false"),
|
||||
CLICKHOUSE_CLUSTER_ENABLED: z.enum(["true", "false"]).default("true"),
|
||||
|
||||
// EE ui customization
|
||||
LANGFUSE_UI_API_HOST: z.string().optional(),
|
||||
@@ -289,6 +304,7 @@ export const env = createEnv({
|
||||
NEXT_PUBLIC_DEMO_ORG_ID: process.env.NEXT_PUBLIC_DEMO_ORG_ID,
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
BUILD_ID: process.env.BUILD_ID,
|
||||
NEXT_PUBLIC_BUILD_ID: process.env.NEXT_PUBLIC_BUILD_ID,
|
||||
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
|
||||
NEXTAUTH_COOKIE_DOMAIN: process.env.NEXTAUTH_COOKIE_DOMAIN,
|
||||
@@ -366,8 +382,10 @@ export const env = createEnv({
|
||||
AUTH_CUSTOM_ISSUER: process.env.AUTH_CUSTOM_ISSUER,
|
||||
AUTH_CUSTOM_NAME: process.env.AUTH_CUSTOM_NAME,
|
||||
AUTH_CUSTOM_SCOPE: process.env.AUTH_CUSTOM_SCOPE,
|
||||
AUTH_CUSTOM_CLIENT_AUTH_METHOD: process.env.AUTH_CUSTOM_CLIENT_AUTH_METHOD,
|
||||
AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING:
|
||||
process.env.AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING,
|
||||
AUTH_IGNORE_ACCOUNT_FIELDS: process.env.AUTH_IGNORE_ACCOUNT_FIELDS,
|
||||
AUTH_DOMAINS_WITH_SSO_ENFORCEMENT:
|
||||
process.env.AUTH_DOMAINS_WITH_SSO_ENFORCEMENT,
|
||||
AUTH_DISABLE_USERNAME_PASSWORD: process.env.AUTH_DISABLE_USERNAME_PASSWORD,
|
||||
@@ -429,6 +447,8 @@ export const env = createEnv({
|
||||
NEXT_PUBLIC_CRISP_WEBSITE_ID: process.env.NEXT_PUBLIC_CRISP_WEBSITE_ID,
|
||||
// clickhouse
|
||||
CLICKHOUSE_URL: process.env.CLICKHOUSE_URL,
|
||||
CLICKHOUSE_CLUSTER_NAME: process.env.CLICKHOUSE_CLUSTER_NAME,
|
||||
CLICKHOUSE_DB: process.env.CLICKHOUSE_DB,
|
||||
CLICKHOUSE_USER: process.env.CLICKHOUSE_USER,
|
||||
CLICKHOUSE_PASSWORD: process.env.CLICKHOUSE_PASSWORD,
|
||||
CLICKHOUSE_CLUSTER_ENABLED: process.env.CLICKHOUSE_CLUSTER_ENABLED,
|
||||
|
||||
@@ -59,8 +59,8 @@ export function CloudRegionSwitch({
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex w-full flex-col gap-2">
|
||||
<div className="-mb-10 mt-8 rounded-lg bg-card px-6 py-6 text-sm sm:mx-auto sm:w-full sm:max-w-[480px] sm:rounded-lg sm:px-10">
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<div>
|
||||
<span className="text-sm font-medium leading-none">
|
||||
Data Region
|
||||
@@ -126,8 +126,12 @@ const DataRegionInfo = () => (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p>Langfuse Cloud is available in two data regions:</p>
|
||||
<ul className="list-disc pl-5">
|
||||
<li>US: Northern California (AWS us-west-1) & Virginia (AWS us-east-1)</li>
|
||||
<li>EU: Germany/Frankfurt (AWS eu-central-1) & Ireland (AWS eu-west-1)</li>
|
||||
<li>
|
||||
US: Northern California (AWS us-west-1) & Virginia (AWS us-east-1)
|
||||
</li>
|
||||
<li>
|
||||
EU: Germany/Frankfurt (AWS eu-central-1) & Ireland (AWS eu-west-1)
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Regions are strictly separated, and no data is shared across regions.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Batch Exports
|
||||
|
||||
- Find types in shared
|
||||
- Actual export logic in worker
|
||||
@@ -0,0 +1,33 @@
|
||||
import Header from "@/src/components/layouts/header";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/src/components/ui/alert";
|
||||
import { BatchExportsTable } from "@/src/features/batch-exports/components/BatchExportsTable";
|
||||
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
|
||||
export function BatchExportsSettingsPage(props: { projectId: string }) {
|
||||
const hasAccess = useHasProjectAccess({
|
||||
projectId: props.projectId,
|
||||
scope: "batchExports:read",
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="Exports" level="h3" />
|
||||
<p className="mb-4 text-sm">
|
||||
Export large datasets in your preferred format via the export buttons
|
||||
across Langfuse. Exports are processed asynchronously and remain
|
||||
available for download for one hour. You will receive an email
|
||||
notification once your export is ready.
|
||||
</p>
|
||||
{hasAccess ? (
|
||||
<BatchExportsTable projectId={props.projectId} />
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertTitle>Access Denied</AlertTitle>
|
||||
<AlertDescription>
|
||||
You do not have permission to view batch exports.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { DataTable } from "@/src/components/table/data-table";
|
||||
import { type LangfuseColumnDef } from "@/src/components/table/types";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { type BatchExport } from "@langfuse/shared";
|
||||
import { StatusBadge } from "@/src/components/layouts/status-badge";
|
||||
import { NumberParam, useQueryParams, withDefault } from "use-query-params";
|
||||
import { ActionButton } from "@/src/components/ActionButton";
|
||||
import { DownloadIcon, InfoIcon } from "lucide-react";
|
||||
import { Avatar, AvatarImage } from "@/src/components/ui/avatar";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/src/components/ui/tooltip";
|
||||
|
||||
export function BatchExportsTable(props: { projectId: string }) {
|
||||
const [paginationState, setPaginationState] = useQueryParams({
|
||||
pageIndex: withDefault(NumberParam, 0),
|
||||
pageSize: withDefault(NumberParam, 10),
|
||||
});
|
||||
|
||||
const batchExports = api.batchExport.all.useQuery({
|
||||
projectId: props.projectId,
|
||||
limit: paginationState.pageSize,
|
||||
page: paginationState.pageIndex,
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
id: "name",
|
||||
header: "Name",
|
||||
size: 200,
|
||||
cell: ({ row }) => {
|
||||
const name = row.getValue("name") as string;
|
||||
const { createdAt, finishedAt } = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="whitespace-break-spaces">{name}</span>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="size-3 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="space-y-1">
|
||||
<div>Created: {new Date(createdAt).toLocaleString()}</div>
|
||||
<div>
|
||||
Finished:{" "}
|
||||
{finishedAt ? new Date(finishedAt).toLocaleString() : "-"}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
id: "status",
|
||||
header: "Status",
|
||||
size: 90,
|
||||
cell: (row) => {
|
||||
const status = row.getValue() as string;
|
||||
return (
|
||||
<StatusBadge type={status.toLowerCase()} className="capitalize" />
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "url",
|
||||
id: "url",
|
||||
header: "Download URL",
|
||||
size: 130,
|
||||
cell: (info) => {
|
||||
const url = info.getValue() as string | null;
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
if (url === "expired") {
|
||||
return <span className="text-muted-foreground">Expired</span>;
|
||||
}
|
||||
return (
|
||||
<ActionButton href={url} icon={<DownloadIcon size={16} />} size="sm">
|
||||
Download
|
||||
</ActionButton>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "format",
|
||||
id: "format",
|
||||
header: "Format",
|
||||
size: 70,
|
||||
},
|
||||
{
|
||||
accessorKey: "user",
|
||||
id: "user",
|
||||
header: "Created By",
|
||||
size: 150,
|
||||
cell: ({ row }) => {
|
||||
const user = row.getValue("user") as {
|
||||
name: string | null;
|
||||
image: string | null;
|
||||
} | null;
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Avatar className="h-7 w-7">
|
||||
<AvatarImage
|
||||
src={user?.image ?? undefined}
|
||||
alt={user?.name ?? "User Avatar"}
|
||||
/>
|
||||
</Avatar>
|
||||
<span>{user?.name ?? "Unknown"}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "log",
|
||||
id: "log",
|
||||
header: "Log",
|
||||
size: 300,
|
||||
cell: (row) => {
|
||||
const log = row.getValue() as string | null;
|
||||
return log ?? null;
|
||||
},
|
||||
},
|
||||
] as LangfuseColumnDef<BatchExport>[];
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={
|
||||
batchExports.isLoading
|
||||
? { isLoading: true, isError: false }
|
||||
: batchExports.isError
|
||||
? {
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
error: batchExports.error.message,
|
||||
}
|
||||
: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: batchExports.data.exports,
|
||||
}
|
||||
}
|
||||
pagination={{
|
||||
totalCount: batchExports.data?.totalCount ?? null,
|
||||
onChange: setPaginationState,
|
||||
state: paginationState,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { auditLog } from "@/src/features/audit-logs/auditLog";
|
||||
import { throwIfNoProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import {
|
||||
createTRPCRouter,
|
||||
protectedProjectProcedure,
|
||||
} from "@/src/server/api/trpc";
|
||||
import {
|
||||
BatchExportStatus,
|
||||
CreateBatchExportSchema,
|
||||
paginationZod,
|
||||
} from "@langfuse/shared";
|
||||
import {
|
||||
BatchExportQueue,
|
||||
logger,
|
||||
QueueJobs,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
|
||||
export const batchExportRouter = createTRPCRouter({
|
||||
create: protectedProjectProcedure
|
||||
.input(CreateBatchExportSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
// Check permissions, esp. projectId
|
||||
throwIfNoProjectAccess({
|
||||
session: ctx.session,
|
||||
projectId: input.projectId,
|
||||
scope: "batchExports:create",
|
||||
});
|
||||
|
||||
const { projectId, query, format, name } = input;
|
||||
console.log(
|
||||
"[TRPC] Creating export job",
|
||||
JSON.stringify(input, null, 2),
|
||||
);
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Create export job
|
||||
const exportJob = await ctx.prisma.batchExport.create({
|
||||
data: {
|
||||
projectId,
|
||||
userId,
|
||||
status: BatchExportStatus.QUEUED,
|
||||
name,
|
||||
format,
|
||||
query,
|
||||
},
|
||||
});
|
||||
|
||||
// Create audit log
|
||||
await auditLog({
|
||||
session: ctx.session,
|
||||
resourceType: "batchExport",
|
||||
resourceId: exportJob.id,
|
||||
projectId,
|
||||
action: "create",
|
||||
after: exportJob,
|
||||
});
|
||||
|
||||
// Notify worker
|
||||
await BatchExportQueue.getInstance()?.add(QueueJobs.BatchExportJob, {
|
||||
id: exportJob.id, // Use the batchExportId to deduplicate when the same job is sent multiple times
|
||||
name: QueueJobs.BatchExportJob,
|
||||
timestamp: new Date(),
|
||||
payload: {
|
||||
batchExportId: exportJob.id,
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
if (e instanceof TRPCError) {
|
||||
throw e;
|
||||
}
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Creating export job failed.",
|
||||
});
|
||||
}
|
||||
}),
|
||||
all: protectedProjectProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
...paginationZod,
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
throwIfNoProjectAccess({
|
||||
session: ctx.session,
|
||||
projectId: input.projectId,
|
||||
scope: "batchExports:read",
|
||||
});
|
||||
|
||||
const [exports, totalCount] = await Promise.all([
|
||||
ctx.prisma.batchExport.findMany({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
},
|
||||
take: input.limit,
|
||||
skip: input.page * input.limit,
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
}),
|
||||
ctx.prisma.batchExport.count({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// Look up users for each export
|
||||
const userIds = [...new Set(exports.map((e) => e.userId))];
|
||||
const users = await ctx.prisma.user.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: userIds,
|
||||
},
|
||||
organizationMemberships: {
|
||||
some: {
|
||||
organization: {
|
||||
projects: {
|
||||
some: {
|
||||
id: input.projectId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
image: true,
|
||||
},
|
||||
});
|
||||
|
||||
const userMap = new Map(users.map((u) => [u.id, u]));
|
||||
|
||||
const exportsWithExpiration = exports.map((e) => {
|
||||
const { finishedAt, url, ...rest } = e;
|
||||
|
||||
let isExpired = false;
|
||||
if (finishedAt) {
|
||||
const finishTime = new Date(finishedAt).getTime();
|
||||
const now = new Date().getTime();
|
||||
const oneHourInMs = 60 * 60 * 1000;
|
||||
isExpired = now - finishTime > oneHourInMs;
|
||||
}
|
||||
|
||||
return {
|
||||
...rest,
|
||||
finishedAt,
|
||||
url: isExpired ? "expired" : url,
|
||||
user: userMap.get(e.userId) ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
exports: exportsWithExpiration,
|
||||
totalCount,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -151,7 +151,9 @@ export const ModelUsageChart = ({
|
||||
});
|
||||
|
||||
const usageData = Array.from(usageTypeMap.values()).flat();
|
||||
const currentModels = [...new Set(usageData.map((row) => row.model))];
|
||||
const currentModels = [
|
||||
...new Set(usageData.map((row) => row.model).filter(Boolean)),
|
||||
];
|
||||
|
||||
const unitsByType =
|
||||
usageData && allModels.length > 0
|
||||
@@ -207,13 +209,19 @@ export const ModelUsageChart = ({
|
||||
|
||||
const totalCost = usageData?.reduce(
|
||||
(acc, curr) =>
|
||||
acc + (curr.usageType === "total" ? (curr.cost as number) : 0),
|
||||
acc +
|
||||
(curr.usageType === "total" && !isNaN(curr.cost as number)
|
||||
? (curr.cost as number)
|
||||
: 0),
|
||||
0,
|
||||
);
|
||||
|
||||
const totalTokens = usageData?.reduce(
|
||||
(acc, curr) =>
|
||||
acc + (curr.usageType === "total" ? (curr.units as number) : 0),
|
||||
acc +
|
||||
(curr.usageType === "total" && !isNaN(curr.units as number)
|
||||
? (curr.units as number)
|
||||
: 0),
|
||||
0,
|
||||
);
|
||||
|
||||
@@ -280,19 +288,18 @@ export const ModelUsageChart = ({
|
||||
<CommandInput placeholder="Search models..." />
|
||||
<CommandEmpty>No model found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
onSelect={handleSelectAll}
|
||||
className="border-b border-gray-200 bg-gray-50 font-medium hover:bg-gray-100"
|
||||
>
|
||||
<CommandItem onSelect={handleSelectAll}>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
isAllSelected ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span>Select All</span>
|
||||
<span>
|
||||
<p className="font-semibold">Select All</p>
|
||||
</span>
|
||||
</CommandItem>
|
||||
<CommandSeparator />
|
||||
<CommandSeparator className="my-1" />
|
||||
<CommandList>
|
||||
{allModels.map((model) => (
|
||||
<CommandItem
|
||||
@@ -313,7 +320,7 @@ export const ModelUsageChart = ({
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{model}
|
||||
{!model || model === "" ? <i>none</i> : model}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandList>
|
||||
|
||||
@@ -1,31 +1,19 @@
|
||||
import { api } from "@/src/utils/api";
|
||||
|
||||
import {
|
||||
type ScoreSource,
|
||||
type FilterState,
|
||||
type ScoreDataType,
|
||||
} from "@langfuse/shared";
|
||||
import { type FilterState } from "@langfuse/shared";
|
||||
import { createTracesTimeFilter } from "@/src/features/dashboard/lib/dashboard-utils";
|
||||
import {
|
||||
type DashboardDateRangeAggregationOption,
|
||||
dashboardDateRangeAggregationSettings,
|
||||
} from "@/src/utils/date-range-utils";
|
||||
import React, { useMemo } from "react";
|
||||
import { BarChart } from "@tremor/react";
|
||||
import { Card } from "@/src/components/ui/card";
|
||||
import { getColorsForCategories } from "@/src/features/dashboard/utils/getColorsForCategories";
|
||||
import {
|
||||
isEmptyBarChart,
|
||||
transformCategoricalScoresToChartData,
|
||||
} from "@/src/features/dashboard/lib/score-analytics-utils";
|
||||
import { NoDataOrLoading } from "@/src/components/NoDataOrLoading";
|
||||
import { useClickhouse } from "@/src/components/layouts/ClickhouseAdminToggle";
|
||||
import { DashboardCategoricalScoreAdapter } from "@/src/features/scores/adapters";
|
||||
import { type ScoreData } from "@/src/features/scores/types";
|
||||
import { CategoricalChart } from "@/src/features/scores/components/ScoreChart";
|
||||
|
||||
export function CategoricalScoreChart(props: {
|
||||
projectId: string;
|
||||
name: string;
|
||||
source: ScoreSource;
|
||||
dataType: ScoreDataType;
|
||||
scoreData: ScoreData;
|
||||
globalFilterState: FilterState;
|
||||
agg?: DashboardDateRangeAggregationOption;
|
||||
}) {
|
||||
@@ -45,19 +33,19 @@ export function CategoricalScoreChart(props: {
|
||||
{
|
||||
type: "string",
|
||||
column: "scoreName",
|
||||
value: props.name,
|
||||
value: props.scoreData.name,
|
||||
operator: "=",
|
||||
},
|
||||
{
|
||||
type: "string",
|
||||
column: "scoreSource",
|
||||
value: props.source,
|
||||
value: props.scoreData.source,
|
||||
operator: "=",
|
||||
},
|
||||
{
|
||||
type: "string",
|
||||
column: "scoreDataType",
|
||||
value: props.dataType,
|
||||
value: props.scoreData.dataType,
|
||||
operator: "=",
|
||||
},
|
||||
],
|
||||
@@ -99,40 +87,23 @@ export function CategoricalScoreChart(props: {
|
||||
);
|
||||
|
||||
const { chartData, chartLabels } = useMemo(() => {
|
||||
return scores.data
|
||||
? transformCategoricalScoresToChartData(
|
||||
scores.data,
|
||||
"scoreTimestamp",
|
||||
props.agg,
|
||||
)
|
||||
: { chartData: [], chartLabels: [] };
|
||||
if (!scores.data) return { chartData: [], chartLabels: [] };
|
||||
|
||||
const adapter = new DashboardCategoricalScoreAdapter(
|
||||
scores.data,
|
||||
"scoreTimestamp",
|
||||
props.agg,
|
||||
);
|
||||
return adapter.toChartData();
|
||||
}, [scores.data, props.agg]);
|
||||
|
||||
const barCategoryGap = (chartLength: number): string => {
|
||||
if (chartLength > 7) return "10%";
|
||||
if (chartLength > 5) return "20%";
|
||||
if (chartLength > 3) return "30%";
|
||||
else return "40%";
|
||||
};
|
||||
const colors = getColorsForCategories(chartLabels);
|
||||
|
||||
return isEmptyBarChart({ data: chartData }) ? (
|
||||
<NoDataOrLoading isLoading={scores.isLoading} />
|
||||
) : (
|
||||
<Card className="min-h-[9rem] w-full flex-1 rounded-tremor-default border">
|
||||
<BarChart
|
||||
className="mt-4"
|
||||
data={chartData}
|
||||
index="binLabel"
|
||||
categories={chartLabels}
|
||||
colors={colors}
|
||||
valueFormatter={(number: number) =>
|
||||
Intl.NumberFormat("en-US").format(number).toString()
|
||||
}
|
||||
yAxisWidth={48}
|
||||
barCategoryGap={barCategoryGap(chartData.length)}
|
||||
stack={!!props.agg}
|
||||
/>
|
||||
</Card>
|
||||
return (
|
||||
<CategoricalChart
|
||||
chartData={chartData}
|
||||
chartLabels={chartLabels}
|
||||
isLoading={scores.isLoading}
|
||||
className="min-h-[9rem] flex-1"
|
||||
stack={!!props.agg}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
isBooleanDataType,
|
||||
isCategoricalDataType,
|
||||
isNumericDataType,
|
||||
toOrderedScoresList,
|
||||
} from "@/src/features/scores/lib/helpers";
|
||||
import { getScoreDataTypeIcon } from "@/src/features/scores/components/ScoreDetailColumnHelpers";
|
||||
import { NumericScoreTimeSeriesChart } from "@/src/features/dashboard/components/score-analytics/NumericScoreTimeSeriesChart";
|
||||
@@ -46,11 +47,14 @@ export function ScoreAnalytics(props: {
|
||||
);
|
||||
|
||||
const { scoreAnalyticsOptions, scoreKeyToData } = useMemo(() => {
|
||||
const scoreAnalyticsOptions =
|
||||
scoreKeysAndProps.data?.map(({ key, name, dataType, source }) => ({
|
||||
key,
|
||||
value: `${getScoreDataTypeIcon(dataType)} ${name} (${source.toLowerCase()})`,
|
||||
})) ?? [];
|
||||
const scoreAnalyticsOptions = scoreKeysAndProps.data
|
||||
? toOrderedScoresList(scoreKeysAndProps.data).map(
|
||||
({ key, name, dataType, source }) => ({
|
||||
key,
|
||||
value: `${getScoreDataTypeIcon(dataType)} ${name} (${source.toLowerCase()})`,
|
||||
}),
|
||||
)
|
||||
: [];
|
||||
|
||||
return {
|
||||
scoreAnalyticsOptions,
|
||||
@@ -123,19 +127,17 @@ export function ScoreAnalytics(props: {
|
||||
{(isCategoricalDataType(dataType) ||
|
||||
isBooleanDataType(dataType)) && (
|
||||
<CategoricalScoreChart
|
||||
source={source}
|
||||
name={name}
|
||||
dataType={dataType}
|
||||
projectId={props.projectId}
|
||||
scoreData={scoreData}
|
||||
globalFilterState={props.globalFilterState}
|
||||
/>
|
||||
)}
|
||||
{isNumericDataType(dataType) && (
|
||||
<NumericScoreHistogram
|
||||
projectId={props.projectId}
|
||||
source={source}
|
||||
name={name}
|
||||
dataType={dataType}
|
||||
projectId={props.projectId}
|
||||
globalFilterState={props.globalFilterState}
|
||||
/>
|
||||
)}
|
||||
@@ -150,11 +152,9 @@ export function ScoreAnalytics(props: {
|
||||
{(isCategoricalDataType(dataType) ||
|
||||
isBooleanDataType(dataType)) && (
|
||||
<CategoricalScoreChart
|
||||
agg={props.agg}
|
||||
source={source}
|
||||
name={name}
|
||||
dataType={dataType}
|
||||
projectId={props.projectId}
|
||||
agg={props.agg}
|
||||
scoreData={scoreData}
|
||||
globalFilterState={props.globalFilterState}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,16 +1,36 @@
|
||||
import { type DashboardDateRangeAggregationOption } from "@/src/utils/date-range-utils";
|
||||
import { type DatabaseRow } from "@/src/server/api/services/queryBuilder";
|
||||
import {
|
||||
type CategoryCounts,
|
||||
type ChartBin,
|
||||
type HistogramBin,
|
||||
} from "@/src/features/scores/types";
|
||||
import { type RouterOutputs } from "@/src/utils/api";
|
||||
|
||||
// types
|
||||
type HistogramBin = { binLabel: string; count: number };
|
||||
type CategoryCounts = Record<string, number>;
|
||||
type ChartBin = { binLabel: string } & CategoryCounts;
|
||||
export const RESOURCE_METRICS = [
|
||||
{
|
||||
key: "latency",
|
||||
value: "Latency",
|
||||
objectKey: "avgLatency",
|
||||
label: "Latency (ms)",
|
||||
},
|
||||
{
|
||||
key: "cost",
|
||||
value: "Cost",
|
||||
objectKey: "avgTotalCost",
|
||||
label: "Average Total Cost ($)",
|
||||
},
|
||||
];
|
||||
|
||||
// numeric score analytics helpers
|
||||
function round(value: number, precision = 2) {
|
||||
return parseFloat(value.toFixed(precision));
|
||||
}
|
||||
|
||||
export function uniqueAndSort(labels: string[]): string[] {
|
||||
return Array.from(new Set(labels)).sort();
|
||||
}
|
||||
|
||||
function computeBinSize(
|
||||
minBins: number,
|
||||
maxBins: number,
|
||||
@@ -125,8 +145,112 @@ function groupCategoricalScoreDataByTimestamp(
|
||||
);
|
||||
}
|
||||
|
||||
function uniqueAndSort(labels: string[]): string[] {
|
||||
return Array.from(new Set(labels)).sort();
|
||||
type ChartAccumulator = Map<
|
||||
string,
|
||||
{ chartData: ChartBin[]; chartLabels: string[] }
|
||||
>;
|
||||
|
||||
function initializeOrGetChartData(acc: ChartAccumulator, key: string) {
|
||||
if (!acc.has(key)) {
|
||||
acc.set(key, { chartData: [], chartLabels: [] });
|
||||
}
|
||||
return acc.get(key)!;
|
||||
}
|
||||
|
||||
function createNumericScoreData(run: string, score: number, scoreName: string) {
|
||||
return {
|
||||
chartLabels: [scoreName],
|
||||
chartBin: {
|
||||
binLabel: run,
|
||||
[scoreName]: score,
|
||||
} as ChartBin,
|
||||
};
|
||||
}
|
||||
|
||||
function createCategoricalScoreData(
|
||||
run: string,
|
||||
valueCounts: Array<{ value: string; count: number }>,
|
||||
values: string[],
|
||||
) {
|
||||
const categoryCounts = valueCounts.reduce(
|
||||
(counts, { value, count }) => ({
|
||||
...counts,
|
||||
[value]: count,
|
||||
}),
|
||||
{} as CategoryCounts,
|
||||
);
|
||||
|
||||
return {
|
||||
chartLabels: values,
|
||||
chartBin: {
|
||||
binLabel: run,
|
||||
...categoryCounts,
|
||||
} as ChartBin,
|
||||
};
|
||||
}
|
||||
|
||||
function addMetricToAccumulator(
|
||||
acc: ChartAccumulator,
|
||||
key: string,
|
||||
chartBin: ChartBin,
|
||||
chartLabels: string[],
|
||||
) {
|
||||
const current = initializeOrGetChartData(acc, key);
|
||||
acc.set(key, {
|
||||
chartData: [...current.chartData, chartBin],
|
||||
chartLabels,
|
||||
});
|
||||
}
|
||||
|
||||
export function transformAggregatedRunMetricsToChartData(
|
||||
runMetrics: RouterOutputs["datasets"]["runsByDatasetIdMetrics"]["runs"],
|
||||
scoreIdToName: Map<string, string>,
|
||||
) {
|
||||
const reversedMetrics = runMetrics.slice().reverse();
|
||||
|
||||
return reversedMetrics.reduce((acc, run) => {
|
||||
// Handle scores
|
||||
Object.entries(run.scores ?? {}).forEach(([scoreId, score]) => {
|
||||
const scoreData =
|
||||
score.type === "NUMERIC"
|
||||
? createNumericScoreData(
|
||||
run.name,
|
||||
score.average,
|
||||
scoreIdToName.get(scoreId) ?? scoreId,
|
||||
)
|
||||
: createCategoricalScoreData(
|
||||
run.name,
|
||||
score.valueCounts,
|
||||
score.values,
|
||||
);
|
||||
|
||||
addMetricToAccumulator(
|
||||
acc,
|
||||
scoreId,
|
||||
scoreData.chartBin,
|
||||
scoreData.chartLabels,
|
||||
);
|
||||
});
|
||||
|
||||
// Handle resource metrics
|
||||
RESOURCE_METRICS.forEach(({ key, objectKey }) => {
|
||||
const resourceValue = run[objectKey as keyof typeof run];
|
||||
const resourceData = createNumericScoreData(
|
||||
run.name,
|
||||
!!resourceValue ? Number(resourceValue) : 0,
|
||||
key,
|
||||
);
|
||||
|
||||
addMetricToAccumulator(
|
||||
acc,
|
||||
key,
|
||||
resourceData.chartBin,
|
||||
resourceData.chartLabels,
|
||||
);
|
||||
});
|
||||
|
||||
return acc;
|
||||
}, new Map());
|
||||
}
|
||||
|
||||
export function transformCategoricalScoresToChartData(
|
||||
@@ -155,11 +279,11 @@ export function transformCategoricalScoresToChartData(
|
||||
chartData.push({ ...categoryCounts, binLabel: timestamp } as ChartBin);
|
||||
});
|
||||
|
||||
return { chartData, chartLabels: uniqueAndSort(chartLabels) };
|
||||
return { chartData, chartLabels };
|
||||
}
|
||||
}
|
||||
|
||||
export function isEmptyBarChart({ data }: { data: ChartBin[] }) {
|
||||
export function isEmptyChart({ data }: { data: ChartBin[] }) {
|
||||
return (
|
||||
data.length === 0 || data.every((item) => Object.keys(item).length === 1)
|
||||
);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { RESOURCE_METRICS } from "@/src/features/dashboard/lib/score-analytics-utils";
|
||||
import { MultiSelectKeyValues } from "@/src/features/scores/components/multi-select-key-values";
|
||||
import { ChartColumnBig } from "lucide-react";
|
||||
|
||||
export function DatasetAnalytics(props: {
|
||||
projectId: string;
|
||||
scoreOptions: { key: string; value: string }[];
|
||||
selectedMetrics: string[];
|
||||
setSelectedMetrics: (metrics: string[]) => void;
|
||||
}) {
|
||||
return (
|
||||
<MultiSelectKeyValues
|
||||
className="max-w-fit"
|
||||
placeholder="Search..."
|
||||
title="Charts"
|
||||
iconLeft={<ChartColumnBig className="mr-1 h-4 w-4" />}
|
||||
hideClearButton
|
||||
onValueChange={(values, changedValue, selectedKeys) => {
|
||||
if (values.length === 0) props.setSelectedMetrics([]);
|
||||
|
||||
if (changedValue) {
|
||||
if (selectedKeys?.has(changedValue)) {
|
||||
props.setSelectedMetrics([...props.selectedMetrics, changedValue]);
|
||||
} else {
|
||||
props.setSelectedMetrics(
|
||||
props.selectedMetrics.filter((key) => key !== changedValue),
|
||||
);
|
||||
}
|
||||
}
|
||||
}}
|
||||
values={props.selectedMetrics}
|
||||
options={RESOURCE_METRICS}
|
||||
groupedOptions={[{ label: "Scores", options: props.scoreOptions }]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,7 @@ import { DatasetCompareRunPeekView } from "@/src/features/datasets/components/Da
|
||||
import { useClickhouse } from "@/src/components/layouts/ClickhouseAdminToggle";
|
||||
import { getQueryKey } from "@trpc/react-query";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import _ from "lodash";
|
||||
export type RunMetrics = {
|
||||
id: string;
|
||||
scores: ScoreAggregate;
|
||||
@@ -182,7 +182,7 @@ export function DatasetCompareRunsTable(props: {
|
||||
({ datasetItemId, trace, observation, scores }) => {
|
||||
if (!itemsAcc[datasetItemId]) itemsAcc[datasetItemId] = {};
|
||||
|
||||
itemsAcc[datasetItemId][runId] = {
|
||||
_.set(itemsAcc[datasetItemId], runId, {
|
||||
id: runId,
|
||||
traceId: trace?.id ?? "",
|
||||
observationId: observation?.id ?? undefined,
|
||||
@@ -196,7 +196,7 @@ export function DatasetCompareRunsTable(props: {
|
||||
: usdFormatter(trace?.totalCost)) ?? undefined,
|
||||
},
|
||||
scores,
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { api } from "@/src/utils/api";
|
||||
import { formatIntervalSeconds } from "@/src/utils/dates";
|
||||
import { useQueryParams, withDefault, NumberParam } from "use-query-params";
|
||||
import { type RouterOutput } from "@/src/utils/types";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { usdFormatter } from "../../../utils/numbers";
|
||||
import { DataTableToolbar } from "@/src/components/table/data-table-toolbar";
|
||||
import useColumnVisibility from "@/src/features/column-visibility/hooks/useColumnVisibility";
|
||||
@@ -14,6 +14,7 @@ import { type Prisma } from "@langfuse/shared";
|
||||
import { useRowHeightLocalStorage } from "@/src/components/table/data-table-row-height-switch";
|
||||
import { IOTableCell } from "@/src/components/ui/CodeJsonViewer";
|
||||
import {
|
||||
getScoreDataTypeIcon,
|
||||
getScoreGroupColumnProps,
|
||||
verifyAndPrefixScoreDataAgainstKeys,
|
||||
} from "@/src/features/scores/components/ScoreDetailColumnHelpers";
|
||||
@@ -36,6 +37,14 @@ import Link from "next/link";
|
||||
import { useClickhouse } from "@/src/components/layouts/ClickhouseAdminToggle";
|
||||
import { joinTableCoreAndMetrics } from "@/src/components/table/utils/joinTableCoreAndMetrics";
|
||||
import { Skeleton } from "@/src/components/ui/skeleton";
|
||||
import {
|
||||
RESOURCE_METRICS,
|
||||
transformAggregatedRunMetricsToChartData,
|
||||
} from "@/src/features/dashboard/lib/score-analytics-utils";
|
||||
import { TimeseriesChart } from "@/src/features/scores/components/TimeseriesChart";
|
||||
import { Card, CardContent } from "@/src/components/ui/card";
|
||||
import { CompareViewAdapter } from "@/src/features/scores/adapters";
|
||||
import { isNumericDataType } from "@/src/features/scores/lib/helpers";
|
||||
|
||||
export type DatasetRunRowData = {
|
||||
id: string;
|
||||
@@ -93,6 +102,8 @@ const DatasetRunTableMultiSelectAction = ({
|
||||
export function DatasetRunsTable(props: {
|
||||
projectId: string;
|
||||
datasetId: string;
|
||||
selectedMetrics: string[];
|
||||
setScoreOptions: (options: { key: string; value: string }[]) => void;
|
||||
menuItems?: React.ReactNode;
|
||||
}) {
|
||||
const [paginationState, setPaginationState] = useQueryParams({
|
||||
@@ -105,6 +116,8 @@ export function DatasetRunsTable(props: {
|
||||
"datasetRuns",
|
||||
"s",
|
||||
);
|
||||
const { setScoreOptions } = props;
|
||||
|
||||
const runs = api.datasets.runsByDatasetId.useQuery({
|
||||
projectId: props.projectId,
|
||||
datasetId: props.datasetId,
|
||||
@@ -149,6 +162,37 @@ export function DatasetRunsTable(props: {
|
||||
showAggregateViewOnly: true,
|
||||
});
|
||||
|
||||
const scoreIdToName = useMemo(() => {
|
||||
return new Map(scoreKeysAndProps.map((obj) => [obj.key, obj.name]) ?? []);
|
||||
}, [scoreKeysAndProps]);
|
||||
|
||||
const runAggregatedMetrics = useMemo(() => {
|
||||
return transformAggregatedRunMetricsToChartData(
|
||||
runsMetrics.data?.runs ?? [],
|
||||
scoreIdToName,
|
||||
);
|
||||
}, [runsMetrics.data, scoreIdToName]);
|
||||
|
||||
const { scoreAnalyticsOptions, scoreKeyToData } = useMemo(() => {
|
||||
const scoreAnalyticsOptions = scoreKeysAndProps
|
||||
? scoreKeysAndProps.map(({ key, name, dataType, source }) => ({
|
||||
key,
|
||||
value: `${getScoreDataTypeIcon(dataType)} ${name} (${source.toLowerCase()})`,
|
||||
}))
|
||||
: [];
|
||||
|
||||
return {
|
||||
scoreAnalyticsOptions,
|
||||
scoreKeyToData: new Map(
|
||||
scoreKeysAndProps.map((obj) => [obj.key, obj]) ?? [],
|
||||
),
|
||||
};
|
||||
}, [scoreKeysAndProps]);
|
||||
|
||||
useEffect(() => {
|
||||
setScoreOptions(scoreAnalyticsOptions);
|
||||
}, [scoreAnalyticsOptions, setScoreOptions]);
|
||||
|
||||
const columns: LangfuseColumnDef<DatasetRunRowData>[] = [
|
||||
{
|
||||
id: "select",
|
||||
@@ -348,6 +392,51 @@ export function DatasetRunsTable(props: {
|
||||
|
||||
return (
|
||||
<>
|
||||
{Boolean(props.selectedMetrics.length) &&
|
||||
Boolean(runAggregatedMetrics?.size) && (
|
||||
<Card className="my-4 max-h-[25dvh] md:max-h-[30dvh]">
|
||||
<CardContent className="mt-2 h-full">
|
||||
<div className="flex h-full w-full gap-4 overflow-x-auto">
|
||||
{props.selectedMetrics.map((key) => {
|
||||
const adapter = new CompareViewAdapter(
|
||||
runAggregatedMetrics,
|
||||
key,
|
||||
);
|
||||
const { chartData, chartLabels } = adapter.toChartData();
|
||||
|
||||
const scoreData = scoreKeyToData.get(key);
|
||||
if (!scoreData)
|
||||
return (
|
||||
<TimeseriesChart
|
||||
key={key}
|
||||
chartData={chartData}
|
||||
chartLabels={chartLabels}
|
||||
title={
|
||||
RESOURCE_METRICS.find((metric) => metric.key === key)
|
||||
?.label ?? key
|
||||
}
|
||||
type="numeric"
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<TimeseriesChart
|
||||
key={key}
|
||||
chartData={chartData}
|
||||
chartLabels={chartLabels}
|
||||
title={`${getScoreDataTypeIcon(scoreData.dataType)} ${scoreData.name} (${scoreData.source.toLowerCase()})`}
|
||||
type={
|
||||
isNumericDataType(scoreData.dataType)
|
||||
? "numeric"
|
||||
: "categorical"
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<DataTableToolbar
|
||||
columns={columns}
|
||||
columnVisibility={columnVisibility}
|
||||
|
||||
@@ -858,8 +858,9 @@ async function runsByDatasetIdPg(
|
||||
projectId: string;
|
||||
datasetId: string;
|
||||
queryClickhouse: boolean;
|
||||
page: number;
|
||||
limit: number;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
runIds?: string[];
|
||||
},
|
||||
) {
|
||||
const scoresByRunId = await prisma.$queryRaw<
|
||||
@@ -889,10 +890,11 @@ async function runsByDatasetIdPg(
|
||||
runs.dataset_id = ${input.datasetId}
|
||||
AND runs.project_id = ${input.projectId}
|
||||
AND s.score IS NOT NULL
|
||||
${input.runIds ? Prisma.sql`AND runs.id IN (${Prisma.join(input.runIds)})` : Prisma.empty}
|
||||
GROUP BY
|
||||
runs.id
|
||||
LIMIT ${input.limit}
|
||||
OFFSET ${input.page * input.limit}
|
||||
${input.limit ? Prisma.sql`LIMIT ${input.limit}` : Prisma.empty}
|
||||
${input.page && input.limit ? Prisma.sql`OFFSET ${input.page * input.limit}` : Prisma.empty}
|
||||
`);
|
||||
|
||||
const runs = await prisma.$queryRaw<
|
||||
@@ -983,10 +985,11 @@ async function runsByDatasetIdPg(
|
||||
WHERE
|
||||
runs.dataset_id = ${input.datasetId}
|
||||
AND runs.project_id = ${input.projectId}
|
||||
${input.runIds ? Prisma.sql`AND runs.id IN (${Prisma.join(input.runIds)})` : Prisma.empty}
|
||||
ORDER BY
|
||||
runs.created_at DESC
|
||||
LIMIT ${input.limit}
|
||||
OFFSET ${input.page * input.limit}
|
||||
${input.limit ? Prisma.sql`LIMIT ${input.limit}` : Prisma.empty}
|
||||
${input.page && input.limit ? Prisma.sql`OFFSET ${input.page * input.limit}` : Prisma.empty}
|
||||
`);
|
||||
|
||||
const totalRuns = await prisma.datasetRuns.count({
|
||||
@@ -1002,7 +1005,7 @@ async function runsByDatasetIdPg(
|
||||
...run,
|
||||
scores: aggregateScores(
|
||||
scoresByRunId.flatMap((s) => (s.runId === run.id ? s.scores : [])),
|
||||
) as ScoreAggregate | undefined
|
||||
) as ScoreAggregate | undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
filterAndValidateDbScoreList,
|
||||
paginationZod,
|
||||
Prisma,
|
||||
type PrismaClient,
|
||||
type DatasetRunItems,
|
||||
optionalPaginationZod,
|
||||
} from "@langfuse/shared";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { v4 } from "uuid";
|
||||
@@ -32,7 +32,8 @@ export const datasetRunsTableSchema = z.object({
|
||||
projectId: z.string(),
|
||||
datasetId: z.string(),
|
||||
queryClickhouse: z.boolean().optional().default(false),
|
||||
...paginationZod,
|
||||
runIds: z.array(z.string()).optional(),
|
||||
...optionalPaginationZod,
|
||||
});
|
||||
|
||||
type PostgresRunItem = {
|
||||
@@ -175,7 +176,7 @@ export const createTempTableInClickhouse = async (
|
||||
clickhouseSession: string,
|
||||
) => {
|
||||
const query = `
|
||||
CREATE TABLE IF NOT EXISTS ${tableName} ${env.CLICKHOUSE_CLUSTER_ENABLED === "true" ? "ON CLUSTER default" : ""}
|
||||
CREATE TABLE IF NOT EXISTS ${tableName} ${env.CLICKHOUSE_CLUSTER_ENABLED === "true" ? "ON CLUSTER " + env.CLICKHOUSE_CLUSTER_NAME : ""}
|
||||
(
|
||||
project_id String,
|
||||
run_id String,
|
||||
@@ -235,10 +236,11 @@ export const getDatasetRunsFromPostgres = async (
|
||||
WHERE
|
||||
d.id = ${input.datasetId}
|
||||
AND d.project_id = ${input.projectId}
|
||||
${input.runIds?.length ? Prisma.sql`AND runs.id IN (${Prisma.join(input.runIds)})` : Prisma.empty}
|
||||
GROUP BY runs.id, runs.name, runs.description, runs.metadata, runs.created_at, runs.updated_at
|
||||
ORDER BY runs.created_at DESC
|
||||
LIMIT ${input.limit}
|
||||
OFFSET ${input.page * input.limit}
|
||||
${input.limit ? Prisma.sql`LIMIT ${input.limit}` : Prisma.empty}
|
||||
${input.page && input.limit ? Prisma.sql`OFFSET ${input.page * input.limit}` : Prisma.empty}
|
||||
`,
|
||||
);
|
||||
};
|
||||
@@ -287,7 +289,7 @@ const getObservationLatencyAndCostForDataset = async (
|
||||
const query = `
|
||||
WITH agg AS (
|
||||
SELECT
|
||||
dateDiff('milliseconds', start_time, end_time) AS latency_ms,
|
||||
dateDiff('millisecond', start_time, end_time) AS latency_ms,
|
||||
total_cost AS cost,
|
||||
run_id
|
||||
FROM observations AS o
|
||||
@@ -340,7 +342,7 @@ const getTraceLatencyAndCostForDataset = async (
|
||||
SELECT
|
||||
o.trace_id,
|
||||
run_id,
|
||||
dateDiff('milliseconds', min(start_time), max(end_time)) AS latency_ms,
|
||||
dateDiff('millisecond', min(start_time), max(end_time)) AS latency_ms,
|
||||
sum(total_cost) AS cost
|
||||
FROM observations o JOIN ${tableName} tmp
|
||||
ON tmp.project_id = o.project_id
|
||||
@@ -529,10 +531,10 @@ export const getRunItemsByRunIdOrItemId = async (
|
||||
) => {
|
||||
const [traceScores, observationAggregates, traceAggregate] =
|
||||
await Promise.all([
|
||||
getScoresForTraces(
|
||||
getScoresForTraces({
|
||||
projectId,
|
||||
runItems.map((ri) => ri.traceId),
|
||||
),
|
||||
traceIds: runItems.map((ri) => ri.traceId),
|
||||
}),
|
||||
getLatencyAndTotalCostForObservations(
|
||||
projectId,
|
||||
runItems
|
||||
|
||||
@@ -12,6 +12,7 @@ const entitlements = [
|
||||
"self-host-ui-customization",
|
||||
"self-host-allowed-organization-creators",
|
||||
"prompt-experiments",
|
||||
"trace-deletion",
|
||||
] as const;
|
||||
export type Entitlement = (typeof entitlements)[number];
|
||||
|
||||
@@ -24,6 +25,8 @@ const cloudAllPlansEntitlements: Entitlement[] = [
|
||||
"prompt-experiments",
|
||||
];
|
||||
|
||||
const selfHostedAllPlansEntitlements: Entitlement[] = ["trace-deletion"];
|
||||
|
||||
// Entitlement Limits: Limits on the number of resources that can be created/used
|
||||
const entitlementLimits = [
|
||||
"annotation-queue-count",
|
||||
@@ -78,7 +81,7 @@ export const entitlementAccess: Record<
|
||||
},
|
||||
},
|
||||
oss: {
|
||||
entitlements: [],
|
||||
entitlements: [...selfHostedAllPlansEntitlements],
|
||||
entitlementLimits: {
|
||||
"annotation-queue-count": 0,
|
||||
"organization-member-count": false,
|
||||
@@ -89,6 +92,7 @@ export const entitlementAccess: Record<
|
||||
},
|
||||
"self-hosted:pro": {
|
||||
entitlements: [
|
||||
...selfHostedAllPlansEntitlements,
|
||||
"annotation-queues",
|
||||
"model-based-evaluations",
|
||||
"playground",
|
||||
@@ -105,6 +109,7 @@ export const entitlementAccess: Record<
|
||||
},
|
||||
"self-hosted:enterprise": {
|
||||
entitlements: [
|
||||
...selfHostedAllPlansEntitlements,
|
||||
"annotation-queues",
|
||||
"model-based-evaluations",
|
||||
"playground",
|
||||
|
||||
@@ -5,123 +5,57 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogDescription,
|
||||
DialogPortal,
|
||||
} from "@/src/components/ui/dialog";
|
||||
import * as z from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@/src/components/ui/form";
|
||||
import { useState } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { Textarea } from "@/src/components/ui/textarea";
|
||||
import Link from "next/link";
|
||||
import { Bug, Sparkles } from "lucide-react";
|
||||
|
||||
interface FeedbackDialogProps {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
type: "feedback" | "dashboard";
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
const formSchema = z.object({
|
||||
feedback: z.string().min(3, "Must have at least 3 characters"),
|
||||
});
|
||||
|
||||
export function FeedbackButtonWrapper({
|
||||
className,
|
||||
children,
|
||||
title,
|
||||
description,
|
||||
type,
|
||||
description = "What do you think about Langfuse? What can be improved? Please share it with the community on GitHub to shape the future of Langfuse.",
|
||||
title = "Provide Feedback",
|
||||
}: FeedbackDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const session = useSession();
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
feedback: "",
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
try {
|
||||
const res = await fetch("https://cloud.langfuse.com/api/feedback", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type,
|
||||
...values,
|
||||
url: window.location.href,
|
||||
user: session.data?.user,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
form.reset();
|
||||
setOpen(false);
|
||||
} else {
|
||||
const data = res.json();
|
||||
console.error(data);
|
||||
form.setError("feedback", {
|
||||
type: "manual",
|
||||
message: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
form.setError("feedback", {
|
||||
type: "manual",
|
||||
message:
|
||||
"Failed to submit feedback, please email us: founders@langfuse.com",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger className={className} asChild>
|
||||
<DialogTrigger
|
||||
className={className}
|
||||
asChild
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="feedback"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormDescription>{description}</FormDescription>
|
||||
<FormControl>
|
||||
<Textarea {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={form.formState.isSubmitting}
|
||||
className="w-full"
|
||||
>
|
||||
{form.formState.isSubmitting ? "Loading ..." : "Submit"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
<DialogPortal>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-row flex-wrap items-center justify-center gap-3 sm:justify-start">
|
||||
<Link href="https://langfuse.com/ideas" target="_blank">
|
||||
<Button variant="secondary">
|
||||
<Sparkles className="mr-2 h-4 w-4" /> Submit Feature Request
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="https://langfuse.com/issues" target="_blank">
|
||||
<Button variant="secondary">
|
||||
<Bug className="mr-2 h-4 w-4" /> Report a Bug
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import { UpsertModelFormDrawer } from "@/src/features/models/components/UpsertModelFormDrawer";
|
||||
import { type GetModelResult } from "@/src/features/models/validation";
|
||||
|
||||
export const CloneModelButton = ({
|
||||
modelData,
|
||||
projectId,
|
||||
}: {
|
||||
modelData: GetModelResult;
|
||||
projectId: string;
|
||||
}) => {
|
||||
const hasAccess = useHasProjectAccess({
|
||||
projectId,
|
||||
scope: "models:CUD",
|
||||
});
|
||||
|
||||
return (
|
||||
<UpsertModelFormDrawer {...{ modelData, projectId, action: "clone" }}>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!hasAccess}
|
||||
title="Clone model"
|
||||
className="flex items-center"
|
||||
>
|
||||
<span>Clone</span>
|
||||
</Button>
|
||||
</UpsertModelFormDrawer>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user