Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8738e7594 | ||
|
|
b4f7aded4f | ||
|
|
dd1ac129d1 | ||
|
|
7fd8c03aca | ||
|
|
95d67d65d7 | ||
|
|
8b7b3570e8 | ||
|
|
7fa738693a | ||
|
|
b554068713 | ||
|
|
3396cc86a2 | ||
|
|
e41ca6baa1 | ||
|
|
2001630e4d | ||
|
|
e9da31f26a | ||
|
|
6dd1a12bfc | ||
|
|
922f846ce8 | ||
|
|
7f16fc353d | ||
|
|
becae4c720 | ||
|
|
fede3480d1 | ||
|
|
9bf5b829a7 | ||
|
|
92d5591171 | ||
|
|
40f7849444 | ||
|
|
9f21b1044f | ||
|
|
030e759c0d | ||
|
|
90a0b67ea2 | ||
|
|
731958ca11 | ||
|
|
9d37cc4d34 | ||
|
|
55e0ff4169 | ||
|
|
304c4fcb7e | ||
|
|
8ede19d263 | ||
|
|
eda925c686 | ||
|
|
57e8de852e | ||
|
|
7d77c36817 | ||
|
|
c821d1b2fe | ||
|
|
0ea80605b1 | ||
|
|
05e16e7d00 | ||
|
|
67876d4e4f | ||
|
|
179751db91 | ||
|
|
e2164c2f00 | ||
|
|
778dc24324 | ||
|
|
d48da06274 | ||
|
|
185cb10d7c | ||
|
|
92d5f3524b | ||
|
|
e490147d73 | ||
|
|
5a7ba1fcbc | ||
|
|
8204fb2a24 | ||
|
|
2559a388aa | ||
|
|
27e1785362 | ||
|
|
3fc9cee53d | ||
|
|
0dfefd2793 | ||
|
|
25bc0fc660 | ||
|
|
ccd5ab97a4 | ||
|
|
bf68a7edd5 | ||
|
|
2a697a9048 | ||
|
|
87a961be47 | ||
|
|
3a52c59f2a | ||
|
|
1722703623 | ||
|
|
129c94c455 | ||
|
|
23a62e3a03 | ||
|
|
d2a8d4e4b1 | ||
|
|
431f8ed941 | ||
|
|
f50b69cbc8 | ||
|
|
cdde86d96e | ||
|
|
9e84150ce0 | ||
|
|
a6fbceff84 | ||
|
|
3ca6dd48c0 | ||
|
|
03e80bac76 | ||
|
|
005ba31740 | ||
|
|
727797e0ec | ||
|
|
123f513199 | ||
|
|
89896c70ee | ||
|
|
e52f9dd1e2 | ||
|
|
ace69419b3 | ||
|
|
6f3cc8982a | ||
|
|
c612109948 | ||
|
|
2359d7f9ae | ||
|
|
1baa0aaaed | ||
|
|
c4186102eb | ||
|
|
016993c117 | ||
|
|
909bdde9d8 | ||
|
|
6f074dc59f | ||
|
|
b8bc474b83 | ||
|
|
334d34ac15 | ||
|
|
1efb37f3c1 | ||
|
|
5fc87cabeb | ||
|
|
a14fb003e6 | ||
|
|
e1521b4ef1 | ||
|
|
fd8756ff4c | ||
|
|
d69808747c | ||
|
|
854e2c60c8 | ||
|
|
4567ed473e | ||
|
|
2abc9fed7a | ||
|
|
f6de06d695 | ||
|
|
02b9220c14 | ||
|
|
e15a26d1de | ||
|
|
1c5c072eed | ||
|
|
b805b9c93f | ||
|
|
da147ea5aa | ||
|
|
8e15c9770b | ||
|
|
799c7cf940 | ||
|
|
64d784e16e | ||
|
|
096467d7dd | ||
|
|
dc995ff377 | ||
|
|
935aa20dda | ||
|
|
543ee3fb77 | ||
|
|
95c6047724 | ||
|
|
6e31e559f0 | ||
|
|
4ec1fc53d1 | ||
|
|
7e24f5f42c | ||
|
|
fe00afa627 | ||
|
|
3e5dc4a551 | ||
|
|
fa75fb9245 | ||
|
|
a640a6a139 | ||
|
|
b0c12f02ec | ||
|
|
774efa4eaa | ||
|
|
a866cba228 | ||
|
|
92b6963347 | ||
|
|
ba55837b71 | ||
|
|
966efa1b92 | ||
|
|
dbe5b93a8a | ||
|
|
22371b4c32 | ||
|
|
8d1c381d46 | ||
|
|
1efce634e7 | ||
|
|
ec46e7d68e | ||
|
|
b302e8eb96 | ||
|
|
e04e21bf13 | ||
|
|
273bcddc0f | ||
|
|
e2099de653 | ||
|
|
435a09f7df | ||
|
|
037dbd8c9d | ||
|
|
02a3d91c56 | ||
|
|
8821ad5b30 | ||
|
|
ae1ba493aa | ||
|
|
5d2735ee14 | ||
|
|
38f3336ad5 | ||
|
|
6224d7ad0f | ||
|
|
30c8fc2239 | ||
|
|
edbf16f3a6 | ||
|
|
8d050717a9 | ||
|
|
74703f62c4 | ||
|
|
09603a1f23 | ||
|
|
34eb3e3d76 | ||
|
|
b307a75a9a | ||
|
|
9f20b3e1cd | ||
|
|
4ae24f97f5 | ||
|
|
58858de930 | ||
|
|
b8a9630731 | ||
|
|
aec58b1149 | ||
|
|
d3634917d4 | ||
|
|
52fa9eb1ff | ||
|
|
e27f0bdc1d | ||
|
|
0576f01641 | ||
|
|
9f6fccfbeb | ||
|
|
7cb9b75897 | ||
|
|
5857a9e61d | ||
|
|
a9a9136010 | ||
|
|
e5c45e381e | ||
|
|
cb00d463c7 | ||
|
|
e18ac03b44 | ||
|
|
d54cbc6c9b | ||
|
|
332f6658d9 | ||
|
|
25a559293b | ||
|
|
9ff8643489 | ||
|
|
dd7e4f344e | ||
|
|
fe089e15f2 | ||
|
|
9f8793e26a | ||
|
|
a1cb4c727b | ||
|
|
b39eb5445a | ||
|
|
b611301658 | ||
|
|
c3fd8a74dc |
+21
-1
@@ -65,37 +65,55 @@ OTEL_SERVICE_NAME="langfuse"
|
||||
# AUTH_GOOGLE_CLIENT_SECRET=
|
||||
# AUTH_GOOGLE_ALLOW_ACCOUNT_LINKING=false
|
||||
# AUTH_GOOGLE_ALLOWED_DOMAINS=langfuse.com,google.com # optional allowlist of workspace domains that can sign in via Google
|
||||
# AUTH_GOOGLE_CLIENT_AUTH_METHOD=
|
||||
# AUTH_GOOGLE_CHECKS=
|
||||
# AUTH_GITHUB_CLIENT_ID=
|
||||
# AUTH_GITHUB_CLIENT_SECRET=
|
||||
# AUTH_GITHUB_ALLOW_ACCOUNT_LINKING=false
|
||||
# AUTH_GITHUB_CLIENT_AUTH_METHOD=
|
||||
# AUTH_GITHUB_CHECKS=
|
||||
# AUTH_GITHUB_ENTERPRISE_CLIENT_ID=
|
||||
# AUTH_GITHUB_ENTERPRISE_CLIENT_SECRET=
|
||||
# AUTH_GITHUB_ENTERPRISE_BASE_URL=
|
||||
# AUTH_GITHUB_ENTERPRISE_ALLOW_ACCOUNT_LINKING=false
|
||||
# AUTH_GITHUB_ENTERPRISE_CLIENT_AUTH_METHOD=
|
||||
# AUTH_GITHUB_ENTERPRISE_CHECKS=
|
||||
# AUTH_GITLAB_CLIENT_ID=
|
||||
# AUTH_GITLAB_CLIENT_SECRET=
|
||||
# AUTH_GITLAB_ALLOW_ACCOUNT_LINKING=false
|
||||
# AUTH_GITLAB_ISSUER=
|
||||
# AUTH_GITLAB_CLIENT_AUTH_METHOD=
|
||||
# AUTH_GITLAB_CHECKS=
|
||||
# AUTH_AZURE_AD_CLIENT_ID=
|
||||
# AUTH_AZURE_AD_CLIENT_SECRET=
|
||||
# AUTH_AZURE_AD_TENANT_ID=
|
||||
# AUTH_AZURE_ALLOW_ACCOUNT_LINKING=false
|
||||
# AUTH_AZURE_AD_ALLOW_ACCOUNT_LINKING=false
|
||||
# AUTH_AZURE_AD_CLIENT_AUTH_METHOD=
|
||||
# AUTH_AZURE_AD_CHECKS=
|
||||
# AUTH_OKTA_CLIENT_ID=
|
||||
# AUTH_OKTA_CLIENT_SECRET=
|
||||
# AUTH_OKTA_ISSUER=
|
||||
# AUTH_OKTA_ALLOW_ACCOUNT_LINKING=false
|
||||
# AUTH_OKTA_CLIENT_AUTH_METHOD=
|
||||
# AUTH_OKTA_CHECKS=
|
||||
# AUTH_AUTH0_CLIENT_ID=
|
||||
# AUTH_AUTH0_CLIENT_SECRET=
|
||||
# AUTH_AUTH0_ISSUER=
|
||||
# AUTH_AUTH0_ALLOW_ACCOUNT_LINKING=false
|
||||
# AUTH_AUTH0_CLIENT_AUTH_METHOD=
|
||||
# AUTH_AUTH0_CHECKS=
|
||||
# AUTH_COGNITO_CLIENT_ID=
|
||||
# AUTH_COGNITO_CLIENT_SECRET=
|
||||
# AUTH_COGNITO_ISSUER=
|
||||
# AUTH_COGNITO_ALLOW_ACCOUNT_LINKING=false
|
||||
# AUTH_COGNITO_CLIENT_AUTH_METHOD=
|
||||
# AUTH_COGNITO_CHECKS=
|
||||
# AUTH_KEYCLOAK_CLIENT_ID=
|
||||
# AUTH_KEYCLOAK_CLIENT_SECRET=
|
||||
# AUTH_KEYCLOAK_ISSUER=
|
||||
# AUTH_KEYCLOAK_ALLOW_ACCOUNT_LINKING=false
|
||||
# AUTH_KEYCLOAK_CLIENT_AUTH_METHOD=
|
||||
# AUTH_KEYCLOAK_CHECKS=
|
||||
# AUTH_CUSTOM_CLIENT_ID=
|
||||
# AUTH_CUSTOM_CLIENT_SECRET=
|
||||
# AUTH_CUSTOM_ISSUER=
|
||||
@@ -104,6 +122,8 @@ OTEL_SERVICE_NAME="langfuse"
|
||||
# AUTH_CUSTOM_CLIENT_AUTH_METHOD="client_secret_basic" # optional
|
||||
# AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING=false
|
||||
# AUTH_CUSTOM_ID_TOKEN=false # optional, default is true
|
||||
# AUTH_CUSTOM_CLIENT_AUTH_METHOD=
|
||||
# AUTH_CUSTOM_CHECKS=
|
||||
|
||||
# Transactional email, optional
|
||||
# Defines the email address to use as the from address.
|
||||
|
||||
@@ -3,9 +3,13 @@ name: Codespell
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches:
|
||||
- "main"
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches:
|
||||
- "**"
|
||||
merge_group:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install golang-migrate for Clickhouse migrations
|
||||
run: |
|
||||
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.16.2/migrate.linux-amd64.tar.gz | tar xvz
|
||||
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.2/migrate.linux-amd64.tar.gz | tar xvz
|
||||
sudo mv migrate /usr/bin/migrate
|
||||
which migrate
|
||||
- uses: pnpm/action-setup@v3
|
||||
@@ -184,7 +184,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install golang-migrate for Clickhouse migrations
|
||||
run: |
|
||||
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.16.2/migrate.linux-amd64.tar.gz | tar xvz
|
||||
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.2/migrate.linux-amd64.tar.gz | tar xvz
|
||||
sudo mv migrate /usr/bin/migrate
|
||||
which migrate
|
||||
- uses: pnpm/action-setup@v3
|
||||
@@ -277,7 +277,7 @@ jobs:
|
||||
pnpm install
|
||||
- name: Install golang-migrate for Clickhouse migrations
|
||||
run: |
|
||||
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.16.2/migrate.linux-amd64.tar.gz | tar xvz
|
||||
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.2/migrate.linux-amd64.tar.gz | tar xvz
|
||||
sudo mv migrate /usr/bin/migrate
|
||||
which migrate
|
||||
- name: Load default env
|
||||
@@ -378,7 +378,7 @@ jobs:
|
||||
pnpm install
|
||||
- name: Install golang-migrate for Clickhouse migrations
|
||||
run: |
|
||||
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.16.2/migrate.linux-amd64.tar.gz | tar xvz
|
||||
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.2/migrate.linux-amd64.tar.gz | tar xvz
|
||||
sudo mv migrate /usr/bin/migrate
|
||||
which migrate
|
||||
- name: Load default env
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
merge_group:
|
||||
# Snyk cannot upload results in merge group. Hence, we only run on PRs and when pushingon the main branch https://github.com/github/codeql-action/issues/1572
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -29,6 +29,13 @@ jobs:
|
||||
image: langfuse-langfuse-web
|
||||
args: --file=web/Dockerfile
|
||||
|
||||
# Workaround for https://github.com/github/codeql-action/issues/2187
|
||||
- name: Replace security-severity undefined for license-related findings
|
||||
run: "sed -i 's/\"security-severity\": \"undefined\"/\"security-severity\": \"0\"/g' snyk.sarif"
|
||||
|
||||
- name: Replace security-severity null for license-related findings
|
||||
run: "sed -i 's/\"security-severity\": \"null\"/\"security-severity\": \"0\"/g' snyk.sarif"
|
||||
|
||||
- name: Upload result to GitHub Code Scanning
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
with:
|
||||
@@ -44,6 +51,13 @@ jobs:
|
||||
image: langfuse-langfuse-worker
|
||||
args: --file=worker/Dockerfile
|
||||
|
||||
# Workaround for https://github.com/github/codeql-action/issues/2187
|
||||
- name: Replace security-severity undefined for license-related findings
|
||||
run: "sed -i 's/\"security-severity\": \"undefined\"/\"security-severity\": \"0\"/g' snyk.sarif"
|
||||
|
||||
- name: Replace security-severity null for license-related findings
|
||||
run: "sed -i 's/\"security-severity\": \"null\"/\"security-severity\": \"0\"/g' snyk.sarif"
|
||||
|
||||
- name: Upload result to GitHub Code Scanning
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
with:
|
||||
|
||||
+354
@@ -0,0 +1,354 @@
|
||||

|
||||
|
||||
<div align="center">
|
||||
<div>
|
||||
<h3>
|
||||
<a href="https://langfuse.com/cn">
|
||||
<strong>🇨🇳 🤝 🪢</strong>
|
||||
</a> ·
|
||||
<a href="https://cloud.langfuse.com">
|
||||
<strong>Langfuse Cloud</strong>
|
||||
</a> ·
|
||||
<a href="https://langfuse.com/docs/deployment/self-host">
|
||||
<strong>自托管</strong>
|
||||
</a> ·
|
||||
<a href="https://langfuse.com/demo">
|
||||
<strong>演示</strong>
|
||||
</a>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<a href="https://langfuse.com/docs"><strong>文档</strong></a> ·
|
||||
<a href="https://langfuse.com/issues"><strong>报告问题</strong></a> ·
|
||||
<a href="https://langfuse.com/ideas"><strong>功能请求</strong></a> ·
|
||||
<a href="https://langfuse.com/changelog"><strong>更新日志</strong></a> ·
|
||||
<a href="https://langfuse.com/roadmap"><strong>路线图</strong></a> ·
|
||||
</div>
|
||||
<br/>
|
||||
<span>Langfuse 使用 <a href="https://github.com/orgs/langfuse/discussions"><strong>Github Discussions</strong></a> 作为支持和功能请求的平台。</span>
|
||||
<br/>
|
||||
<span><b>我们正在招聘。</b> <a href="https://langfuse.com/careers"><strong>加入我们</strong></a>,从事产品工程和技术市场职位。</span>
|
||||
<br/>
|
||||
<br/>
|
||||
<div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/langfuse/langfuse/blob/main/LICENSE">
|
||||
<img src="https://img.shields.io/badge/License-MIT-E11311.svg" alt="MIT License">
|
||||
</a>
|
||||
<a href="https://www.ycombinator.com/companies/langfuse">
|
||||
<img src="https://img.shields.io/badge/Y%20Combinator-W23-orange" alt="Y Combinator W23">
|
||||
</a>
|
||||
<a href="https://hub.docker.com/u/langfuse" target="_blank">
|
||||
<img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/langfuse/langfuse?labelColor=%20%23FDB062&logo=Docker&labelColor=%20%23528bff">
|
||||
</a>
|
||||
<a href="https://pypi.python.org/pypi/langfuse">
|
||||
<img src="https://img.shields.io/pypi/dm/langfuse?logo=python&logoColor=white&label=pypi%20langfuse&color=blue" alt="langfuse Python package on PyPi">
|
||||
</a>
|
||||
<a href="https://www.npmjs.com/package/langfuse">
|
||||
<img src="https://img.shields.io/npm/dm/langfuse?logo=npm&logoColor=white&label=npm%20langfuse&color=blue" alt="langfuse npm package">
|
||||
</a>
|
||||
<br/>
|
||||
<a href="https://discord.com/invite/7NXusRtqYU" target="_blank">
|
||||
<img src="https://img.shields.io/discord/1111061815649124414?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb"
|
||||
alt="在 Discord 上聊天">
|
||||
</a>
|
||||
<a href="https://twitter.com/intent/follow?screen_name=langfuse" target="_blank">
|
||||
<img src="https://img.shields.io/twitter/follow/langfuse?logo=X&color=%20%23f5f5f5"
|
||||
alt="在 X (Twitter) 上关注">
|
||||
</a>
|
||||
<a href="https://www.linkedin.com/company/langfuse/" target="_blank">
|
||||
<img src="https://custom-icon-badges.demolab.com/badge/LinkedIn-0A66C2?logo=linkedin-white&logoColor=fff"
|
||||
alt="在 LinkedIn 上关注">
|
||||
</a>
|
||||
<a href="https://github.com/langfuse/langfuse/graphs/commit-activity" target="_blank">
|
||||
<img alt="过去一个月的提交" src="https://img.shields.io/github/commit-activity/m/langfuse/langfuse?labelColor=%20%2332b583&color=%20%2312b76a">
|
||||
</a>
|
||||
<a href="https://github.com/langfuse/langfuse/" target="_blank">
|
||||
<img alt="已关闭的问题" src="https://img.shields.io/github/issues-search?query=repo%3Alangfuse%2Flangfuse%20is%3Aclosed&label=issues%20closed&labelColor=%20%237d89b0&color=%20%235d6b98">
|
||||
</a>
|
||||
<a href="https://github.com/langfuse/langfuse/discussions/" target="_blank">
|
||||
<img alt="讨论帖数量" src="https://img.shields.io/github/discussions/langfuse/langfuse?labelColor=%20%239b8afb&color=%20%237a5af8">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="./README.md"><img alt="README in English" src="https://img.shields.io/badge/English-d9d9d9"></a>
|
||||
<a href="./README.cn.md"><img alt="简体中文版自述文件" src="https://img.shields.io/badge/简体中文-d9d9d9"></a>
|
||||
<a href="./README.ja.md"><img alt="日本語のREADME" src="https://img.shields.io/badge/日本語-d9d9d9"></a>
|
||||
<a href="./README.kr.md"><img alt="README in Korean" src="https://img.shields.io/badge/한국어-d9d9d9"></a>
|
||||
</p>
|
||||
|
||||
Langfuse 是一个 **开源 LLM 工程** 平台。它帮助团队协作 **开发、监控、评估** 以及 **调试** AI 应用。Langfuse 可在几分钟内 **自托管**,并且经过 **实战考验**。
|
||||
|
||||
[](https://langfuse.com/watch-demo)
|
||||
|
||||
## ✨ 核心特性
|
||||
|
||||

|
||||
|
||||
- [LLM 应用可观察性](https://langfuse.com/docs/tracing):为你的应用插入仪表代码,并开始将追踪数据传送到 Langfuse,从而追踪 LLM 调用及应用中其他相关逻辑(如检索、嵌入或代理操作)。检查并调试复杂日志及用户会话。试试互动的 [演示](https://langfuse.com/docs/demo) 看看效果。
|
||||
|
||||
- [提示管理](https://langfuse.com/docs/prompts/get-started) 帮助你集中管理、版本控制并协作迭代提示。得益于服务器和客户端的高效缓存,你可以在不增加延迟的情况下反复迭代提示。
|
||||
|
||||
- [评估](https://langfuse.com/docs/scores/overview) 是 LLM 应用开发流程的关键组成部分,Langfuse 能够满足你的多样需求。它支持 LLM 作为“裁判”、用户反馈收集、手动标注以及通过 API/SDK 实现自定义评估流程。
|
||||
|
||||
- [数据集](https://langfuse.com/docs/datasets/overview) 为评估你的 LLM 应用提供测试集和基准。它们支持持续改进、部署前测试、结构化实验、灵活评估,并能与 LangChain、LlamaIndex 等框架无缝整合。
|
||||
|
||||
- [LLM 试玩平台](https://langfuse.com/docs/playground) 是用于测试和迭代提示及模型配置的工具,缩短反馈周期,加速开发。当你在追踪中发现异常结果时,可以直接跳转至试玩平台进行调整。
|
||||
|
||||
- [综合 API](https://langfuse.com/docs/api):Langfuse 常用于驱动定制化的 LLMOps 工作流程,同时利用 Langfuse 提供的构建模块和 API。我们提供 OpenAPI 规格、Postman 集合以及针对 Python 和 JS/TS 的类型化 SDK。
|
||||
|
||||
## 📦 部署 Langfuse
|
||||
|
||||

|
||||
|
||||
### Langfuse Cloud
|
||||
|
||||
由 Langfuse 团队管理的部署,提供慷慨的免费额度(爱好者计划),无需信用卡。
|
||||
|
||||
<div align="center">
|
||||
<a href="https://cloud.langfuse.com" target="_blank">
|
||||
<img alt="注册 Langfuse Cloud" src="https://img.shields.io/badge/»%20Sign%20up%20for%20Langfuse%20Cloud-8A2BE2?&color=orange">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
### 自托管 Langfuse
|
||||
|
||||
在你自己的基础设施上运行 Langfuse:
|
||||
|
||||
- [本地(docker compose)](https://langfuse.com/self-hosting/local):使用 Docker Compose 在你的机器上于 5 分钟内运行 Langfuse。
|
||||
|
||||
````bash:README.md/docker-compose
|
||||
# 获取最新的 Langfuse 仓库副本
|
||||
git clone https://github.com/langfuse/langfuse.git
|
||||
cd langfuse
|
||||
|
||||
# 运行 Langfuse 的 docker compose
|
||||
docker compose up
|
||||
`````
|
||||
|
||||
- [Kubernetes(Helm)](https://langfuse.com/self-hosting/kubernetes-helm):使用 Helm 在 Kubernetes 集群上部署 Langfuse。这是推荐的生产环境部署方式。
|
||||
|
||||
- [虚拟机](https://langfuse.com/self-hosting/docker-compose):使用 Docker Compose 在单台虚拟机上部署 Langfuse。
|
||||
|
||||
- 【计划中】:针对各云平台的部署指南,欢迎在以下讨论中投票和评论:[AWS](https://github.com/orgs/langfuse/discussions/4645)、[Google Cloud](https://github.com/langfuse/discussions/4646)、[Azure](https://github.com/orgs/langfuse/discussions/4647)。
|
||||
|
||||
请参阅 [自托管文档](https://langfuse.com/self-hosting) 了解更多关于架构和配置选项的信息。
|
||||
|
||||
## 🔌 集成
|
||||
|
||||

|
||||
|
||||
### 主要集成:
|
||||
|
||||
| 集成 | 支持语言/平台 | 描述 |
|
||||
| ---------------------------------------------------------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [SDK](https://langfuse.com/docs/sdk) | Python, JS/TS | 使用 SDK 进行手动仪表化,实现全面灵活性。 |
|
||||
| [OpenAI](https://langfuse.com/docs/integrations/openai) | Python, JS/TS | 通过直接替换 OpenAI SDK 实现自动仪表化。 |
|
||||
| [Langchain](https://langfuse.com/docs/integrations/langchain) | Python, JS/TS | 通过传入回调处理器至 Langchain 应用实现自动仪表化。 |
|
||||
| [LlamaIndex](https://langfuse.com/docs/integrations/llama-index/get-started) | Python | 通过 LlamaIndex 回调系统实现自动仪表化。 |
|
||||
| [Haystack](https://langfuse.com/docs/integrations/haystack) | Python | 通过 Haystack 内容追踪系统实现自动仪表化。 |
|
||||
| [LiteLLM](https://langfuse.com/docs/integrations/litellm) | Python, JS/TS (仅代理) | 允许使用任何 LLM 替代 GPT。支持 Azure、OpenAI、Cohere、Anthropic、Ollama、VLLM、Sagemaker、HuggingFace、Replicate(100+ LLMs)。 |
|
||||
| [Vercel AI SDK](https://langfuse.com/docs/integrations/vercel-ai-sdk) | JS/TS | 基于 TypeScript 的工具包,帮助开发者使用 React、Next.js、Vue、Svelte 和 Node.js 构建 AI 驱动的应用。 |
|
||||
| [API](https://langfuse.com/docs/api) | | 直接调用公共 API。提供 OpenAPI 规格。 |
|
||||
|
||||
### 与 Langfuse 集成的软件包:
|
||||
|
||||
| 名称 | 类型 | 描述 |
|
||||
| ---------------------------------------------------------------------- | ------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| [Instructor](https://langfuse.com/docs/integrations/instructor) | 库 | 用于获取结构化 LLM 输出(JSON、Pydantic)的库。 |
|
||||
| [DSPy](https://langfuse.com/docs/integrations/dspy) | 库 | 一个系统性优化语言模型提示和权重的框架。 |
|
||||
| [Mirascope](https://langfuse.com/docs/integrations/mirascope) | 库 | 构建 LLM 应用的 Python 工具包。 |
|
||||
| [Ollama](https://langfuse.com/docs/integrations/ollama) | 模型(本地) | 在你的机器上轻松运行开源 LLM。 |
|
||||
| [Amazon Bedrock](https://langfuse.com/docs/integrations/amazon-bedrock) | 模型 | 在 AWS 上运行基础模型和微调模型。 |
|
||||
| [AutoGen](https://langfuse.com/docs/integrations/autogen) | 代理框架 | 用于构建分布式代理的开源 LLM 平台。 |
|
||||
| [Flowise](https://langfuse.com/docs/integrations/flowise) | 聊天/代理界面 | 基于 JS/TS 的无代码构建器,用于定制化 LLM 流程。 |
|
||||
| [Langflow](https://langfuse.com/docs/integrations/langflow) | 聊天/代理界面 | 基于 Python 的 LangChain 用户界面,采用 react-flow 设计,提供便捷的实验与原型构建体验。 |
|
||||
| [Dify](https://langfuse.com/docs/integrations/dify) | 聊天/代理界面 | 带有无代码构建器的开源 LLM 应用开发平台。 |
|
||||
| [OpenWebUI](https://langfuse.com/docs/integrations/openwebui) | 聊天/代理界面 | 自托管的 LLM 聊天网页界面,支持包括自托管和本地模型在内的多种 LLM 运行器。 |
|
||||
| [Promptfoo](https://langfuse.com/docs/integrations/promptfoo) | 工具 | 开源 LLM 测试平台。 |
|
||||
| [LobeChat](https://langfuse.com/docs/integrations/lobechat) | 聊天/代理界面 | 开源聊天机器人平台。 |
|
||||
| [Vapi](https://langfuse.com/docs/integrations/vapi) | 平台 | 开源语音 AI 平台。 |
|
||||
| [Inferable](https://langfuse.com/docs/integrations/other/inferable) | 代理 | 构建分布式代理的开源 LLM 平台。 |
|
||||
| [Gradio](https://langfuse.com/docs/integrations/other/gradio) | 聊天/代理界面 | 开源 Python 库,可用于构建类似聊天 UI 的网页界面。 |
|
||||
| [Goose](https://langfuse.com/docs/integrations/goose) | 代理 | 构建分布式代理的开源 LLM 平台。 |
|
||||
| [smolagents](https://langfuse.com/docs/integrations/smolagents) | 代理 | 开源 AI 代理框架。 |
|
||||
| [CrewAI](https://langfuse.com/docs/integrations/crewai) | 代理 | 多代理框架,用于实现代理之间的协作与工具调用。 |
|
||||
|
||||
## 🚀 快速入门
|
||||
|
||||
为你的应用增加仪表代码,并开始将追踪数据上传到 Langfuse,从而记录 LLM 调用及应用中其他相关逻辑(如检索、嵌入或代理操作)。
|
||||
|
||||
### 1️⃣ 创建新项目
|
||||
|
||||
1. [创建 Langfuse 账户](https://cloud.langfuse.com/auth/sign-up) 或 [自托管](https://langfuse.com/self-hosting)
|
||||
2. 创建一个新项目
|
||||
3. 在项目设置中创建新的 API 凭证
|
||||
|
||||
### 2️⃣ 记录你的第一个 LLM 调用
|
||||
|
||||
使用 [<code>@observe()</code> 装饰器](https://langfuse.com/docs/sdk/python/decorators) 可轻松跟踪任何 Python LLM 应用。在本快速入门中,我们还使用了 Langfuse 的 [OpenAI 集成](https://langfuse.com/docs/integrations/openai) 来自动捕获所有模型参数。
|
||||
|
||||
> [!提示]
|
||||
> 不使用 OpenAI?请访问 [我们的文档](https://langfuse.com/docs/get-started#log-your-first-llm-call-to-langfuse) 了解如何记录其他模型和框架。
|
||||
|
||||
安装依赖:
|
||||
|
||||
````bash
|
||||
pip install langfuse openai
|
||||
````
|
||||
|
||||
配置环境变量(创建名为 **.env** 的文件):
|
||||
|
||||
````bash:.env
|
||||
LANGFUSE_SECRET_KEY="sk-lf-..."
|
||||
LANGFUSE_PUBLIC_KEY="pk-lf-..."
|
||||
LANGFUSE_HOST="https://cloud.langfuse.com" # 🇪🇺 欧盟区域
|
||||
# LANGFUSE_HOST="https://us.cloud.langfuse.com" # 🇺🇸 美洲区域
|
||||
````
|
||||
|
||||
创建示例代码(文件名:**main.py**):
|
||||
|
||||
````python:main.py
|
||||
from langfuse.decorators import observe
|
||||
from langfuse.openai import openai # OpenAI 集成
|
||||
|
||||
@observe()
|
||||
def story():
|
||||
return openai.chat.completions.create(
|
||||
model="gpt-4o",
|
||||
messages=[{"role": "user", "content": "What is Langfuse?"}],
|
||||
).choices[0].message.content
|
||||
|
||||
@observe()
|
||||
def main():
|
||||
return story()
|
||||
|
||||
main()
|
||||
````
|
||||
|
||||
### 3️⃣ 在 Langfuse 中查看追踪记录
|
||||
|
||||
在 Langfuse 中查看你的语言模型调用及其他应用逻辑。
|
||||
|
||||

|
||||
|
||||
_[Langfuse 中的公共示例追踪](https://cloud.langfuse.com/project/cloramnkj0002jz088vzn1ja4/traces/2cec01e3-3dc2-472f-afcf-3b968cf0c1f4?timestamp=2025-02-10T14%3A27%3A30.275Z&observation=cb5ff844-07ef-41e6-b8e2-6c64344bc13b)_
|
||||
|
||||
> [!提示]
|
||||
>
|
||||
> [了解更多](https://langfuse.com/docs/tracing) 关于 Langfuse 中的追踪,或试试 [互动演示](https://langfuse.com/docs/demo)。
|
||||
|
||||
## ⭐️ 给我们加星
|
||||
|
||||

|
||||
|
||||
## 💭 支持
|
||||
|
||||
查找问题答案:
|
||||
|
||||
- 我们的 [文档](https://langfuse.com/docs) 是查找答案的最佳起点。内容全面,我们投入大量时间进行维护。你也可以通过 GitHub 提出文档修改建议。
|
||||
- [Langfuse 常见问题](https://langfuse.com/faq) 解答了最常见的问题。
|
||||
- 使用 “[Ask AI](https://langfuse.com/docs/ask-ai)” 立即获取问题答案。
|
||||
|
||||
支持渠道:
|
||||
|
||||
- **在 GitHub Discussions 的 [公共问答](https://github.com/orgs/langfuse/discussions/categories/support) 中提出任何问题。** 请尽量提供详细信息(如代码片段、截图、背景信息)以帮助我们理解你的问题。
|
||||
- 在 GitHub Discussions 中 [提出功能请求](https://github.com/orgs/langfuse/discussions/categories/ideas)。
|
||||
- 在 GitHub Issues 中 [报告 Bug](https://github.com/langfuse/langfuse/issues)。
|
||||
- 对于时效性较强的问题,请通过应用内聊天小部件联系我们。
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
欢迎你的贡献!
|
||||
|
||||
- 在 GitHub Discussions 中为 [想法](https://github.com/orgs/langfuse/discussions/categories/ideas)投票。
|
||||
- 提出并评论 [问题](https://github.com/langfuse/langfuse/issues)。
|
||||
- 提交 PR —— 详情请参见 [CONTRIBUTING.md](CONTRIBUTING.md),了解如何搭建开发环境。
|
||||
|
||||
## 🥇 许可证
|
||||
|
||||
除 `ee` 文件夹外,本仓库采用 MIT 许可证。详情请参见 [LICENSE](LICENSE) 以及 [文档](https://langfuse.com/docs/open-source)。
|
||||
|
||||
## ⭐️ 星标历史
|
||||
|
||||
<a href="https://star-history.com/#langfuse/langfuse&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=langfuse/langfuse&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=langfuse/langfuse&type=Date" />
|
||||
<img alt="星标历史图表" src="https://api.star-history.com/svg?repos=langfuse/langfuse&type=Date" style="border-radius: 15px;" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## ❤️ 使用 Langfuse 的开源项目
|
||||
|
||||
以下是使用 Langfuse 的顶级开源 Python 项目,按星标数排名([来源](https://github.com/langfuse/langfuse-docs/blob/main/components-mdx/dependents)):
|
||||
|
||||
| 仓库 | 星数 |
|
||||
| :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----: |
|
||||
| <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) | 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 |
|
||||
|
||||
## 🔒 安全与隐私
|
||||
|
||||
我们非常重视数据安全和隐私。更多信息请参阅我们的 [安全与隐私](https://langfuse.com/security) 页面。
|
||||
|
||||
### 遥测
|
||||
|
||||
默认情况下,Langfuse 会自动将自托管实例的基础使用统计数据上传至集中服务器(PostHog)。
|
||||
|
||||
这有助于我们:
|
||||
|
||||
1. 了解 Langfuse 的使用情况,并改进最关键的功能。
|
||||
2. 跟踪整体使用数据,以便内部及外部(例如筹款)报告。
|
||||
|
||||
所有数据均不会与第三方共享,也不包含任何敏感信息。我们对这一过程保持高度透明,你可以在 [此处](/web/src/features/telemetry/index.ts) 查看我们收集的具体数据。
|
||||
|
||||
你可以通过设置 `TELEMETRY_ENABLED=false` 来选择退出。
|
||||
````
|
||||
+361
@@ -0,0 +1,361 @@
|
||||

|
||||
|
||||
<div align="center">
|
||||
<div>
|
||||
<h3>
|
||||
<a href="https://langfuse.com/jp">
|
||||
<strong>🇯🇵 🤝 🪢</strong>
|
||||
</a> ·
|
||||
<a href="https://cloud.langfuse.com">
|
||||
<strong>Langfuse Cloud</strong>
|
||||
</a> ·
|
||||
<a href="https://langfuse.com/docs/deployment/self-host">
|
||||
<strong>セルフホスティング</strong>
|
||||
</a> ·
|
||||
<a href="https://langfuse.com/demo">
|
||||
<strong>デモ</strong>
|
||||
</a>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<a href="https://langfuse.com/docs"><strong>ドキュメント</strong></a> ·
|
||||
<a href="https://langfuse.com/issues"><strong>バグ報告</strong></a> ·
|
||||
<a href="https://langfuse.com/ideas"><strong>機能リクエスト</strong></a> ·
|
||||
<a href="https://langfuse.com/changelog"><strong>変更履歴</strong></a> ·
|
||||
<a href="https://langfuse.com/roadmap"><strong>ロードマップ</strong></a> ·
|
||||
</div>
|
||||
<br/>
|
||||
<span>Langfuseは、サポートと機能リクエストのために <a href="https://github.com/orgs/langfuse/discussions"><strong>Github Discussions</strong></a> を利用しています。</span>
|
||||
<br/>
|
||||
<span><b>採用中です。</b> <a href="https://langfuse.com/careers"><strong>当社で働く</strong></a> (製品エンジニアリングおよびテクニカル・ゴートゥーマーケットのポジション)への応募をお待ちしています。</span>
|
||||
<br/>
|
||||
<br/>
|
||||
<div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/langfuse/langfuse/blob/main/LICENSE">
|
||||
<img src="https://img.shields.io/badge/License-MIT-E11311.svg" alt="MIT License">
|
||||
</a>
|
||||
<a href="https://www.ycombinator.com/companies/langfuse"><img src="https://img.shields.io/badge/Y%20Combinator-W23-orange" alt="Y Combinator W23"></a>
|
||||
<a href="https://hub.docker.com/u/langfuse" target="_blank">
|
||||
<img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/langfuse/langfuse?labelColor=%20%23FDB062&logo=Docker&labelColor=%20%23528bff"></a>
|
||||
<a href="https://pypi.python.org/pypi/langfuse"><img src="https://img.shields.io/pypi/dm/langfuse?logo=python&logoColor=white&label=pypi%20langfuse&color=blue" alt="langfuse Python package on PyPi"></a>
|
||||
<a href="https://www.npmjs.com/package/langfuse"><img src="https://img.shields.io/npm/dm/langfuse?logo=npm&logoColor=white&label=npm%20langfuse&color=blue" alt="langfuse npm package"></a>
|
||||
<br/>
|
||||
<a href="https://discord.com/invite/7NXusRtqYU" target="_blank">
|
||||
<img src="https://img.shields.io/discord/1111061815649124414?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb"
|
||||
alt="chat on Discord"></a>
|
||||
<a href="https://twitter.com/intent/follow?screen_name=langfuse" target="_blank">
|
||||
<img src="https://img.shields.io/twitter/follow/langfuse?logo=X&color=%20%23f5f5f5"
|
||||
alt="follow on X(Twitter)"></a>
|
||||
<a href="https://www.linkedin.com/company/langfuse/" target="_blank">
|
||||
<img src="https://custom-icon-badges.demolab.com/badge/LinkedIn-0A66C2?logo=linkedin-white&logoColor=fff"
|
||||
alt="follow on LinkedIn"></a>
|
||||
<a href="https://github.com/langfuse/langfuse/graphs/commit-activity" target="_blank">
|
||||
<img alt="Commits last month" src="https://img.shields.io/github/commit-activity/m/langfuse/langfuse?labelColor=%20%2332b583&color=%20%2312b76a"></a>
|
||||
<a href="https://github.com/langfuse/langfuse/" target="_blank">
|
||||
<img alt="Issues closed" src="https://img.shields.io/github/issues-search?query=repo%3Alangfuse%2Flangfuse%20is%3Aclosed&label=issues%20closed&labelColor=%20%237d89b0&color=%20%235d6b98"></a>
|
||||
<a href="https://github.com/langfuse/langfuse/discussions/" target="_blank">
|
||||
<img alt="Discussion posts" src="https://img.shields.io/github/discussions/langfuse/langfuse?labelColor=%20%239b8afb&color=%20%237a5af8"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="./README.md"><img alt="README in English" src="https://img.shields.io/badge/English-d9d9d9"></a>
|
||||
<a href="./README.cn.md"><img alt="简体中文版自述文件" src="https://img.shields.io/badge/简体中文-d9d9d9"></a>
|
||||
<a href="./README.ja.md"><img alt="日本語のREADME" src="https://img.shields.io/badge/日本語-d9d9d9"></a>
|
||||
<a href="./README.kr.md"><img alt="README in Korean" src="https://img.shields.io/badge/한국어-d9d9d9"></a>
|
||||
</p>
|
||||
|
||||
Langfuseは**オープンソースのLLMエンジニアリング**プラットフォームです。
|
||||
チームが共同でAIアプリケーションを**開発、監視、評価**、および**デバッグ**するのを支援します。
|
||||
Langfuseは**数分でセルフホスト可能**で、**実績に裏打ちされた**システムです。
|
||||
|
||||
[](https://langfuse.com/watch-demo)
|
||||
|
||||
## ✨ コア機能
|
||||
|
||||

|
||||
|
||||
- **[LLMアプリケーションの可観測性](https://langfuse.com/docs/tracing):**
|
||||
アプリケーションにインストゥルメンテーションを導入し、Langfuseへトレースを取り込むことで、LLM呼び出しやリトリーバル、埋め込み、エージェントアクションなどの関連ロジックを追跡できます。
|
||||
複雑なログやユーザーセッションを解析・デバッグできます。
|
||||
インタラクティブな[デモ](https://langfuse.com/docs/demo)で動作を確認してください。
|
||||
|
||||
- **[プロンプト管理](https://langfuse.com/docs/prompts/get-started):**
|
||||
プロンプトを一元管理し、バージョン管理しながら共同で改善を行えます。
|
||||
サーバーおよびクライアント側で強力なキャッシングを行うため、アプリケーションのレイテンシを増やすことなくプロンプトの改良が可能です。
|
||||
|
||||
- **[評価](https://langfuse.com/docs/scores/overview):**
|
||||
評価はLLMアプリケーション開発ワークフローの要であり、Langfuseは多様なニーズに対応します。
|
||||
LLMを判定者として用いる方法、ユーザーフィードバックの収集、手動によるラベリング、API/SDKを通じたカスタム評価パイプラインをサポートします。
|
||||
|
||||
- **[データセット](https://langfuse.com/docs/datasets/overview):**
|
||||
LLMアプリケーション評価用のテストセットやベンチマークを構築できます。
|
||||
継続的な改善、事前デプロイテスト、構造化された実験、柔軟な評価、さらにLangChainやLlamaIndexなどとのシームレスな統合をサポートします。
|
||||
|
||||
- **[LLMプレイグラウンド](https://langfuse.com/docs/playground):**
|
||||
プロンプトやモデル設定のテスト・反復作業を支援するツールで、フィードバックループを短縮し開発を加速します。
|
||||
トレースで不具合が見つかった場合、直接プレイグラウンドへ飛び、迅速に改善できます。
|
||||
|
||||
- **[包括的なAPI](https://langfuse.com/docs/api):**
|
||||
LangfuseはAPIを通じて提供されるビルディングブロックを用い、カスタムLLMOpsワークフローの基盤として頻繁に利用されます。
|
||||
OpenAPI仕様、Postmanコレクション、PythonやJS/TS向けの型付きSDKが利用可能です。
|
||||
|
||||
## 📦 Langfuseのデプロイ
|
||||
|
||||

|
||||
|
||||
### Langfuse Cloud
|
||||
|
||||
Langfuseチームによるマネージドデプロイメント。充実した無料プラン(ホビープラン)で、クレジットカード不要です。
|
||||
|
||||
<div align="center">
|
||||
<a href="https://cloud.langfuse.com" target="_blank">
|
||||
<img alt="Static Badge" src="https://img.shields.io/badge/»%20Sign%20up%20for%20Langfuse%20Cloud-8A2BE2?&color=orange">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
### セルフホスティング Langfuse
|
||||
|
||||
自身のインフラ上でLangfuseを実行できます:
|
||||
|
||||
- **[Local (docker compose)](https://langfuse.com/self-hosting/local):**
|
||||
Docker Composeを使用して、たった5分で自分のマシン上でLangfuseを実行できます.
|
||||
|
||||
```bash
|
||||
# 最新のLangfuseリポジトリのコピーを取得
|
||||
git clone https://github.com/langfuse/langfuse.git
|
||||
cd langfuse
|
||||
|
||||
# Langfuseのdocker composeを起動
|
||||
docker compose up
|
||||
```
|
||||
|
||||
- **[Kubernetes (Helm)](https://langfuse.com/self-hosting/kubernetes-helm):**
|
||||
Helmを使用してKubernetesクラスター上でLangfuseを実行します。
|
||||
こちらが推奨される本番環境でのデプロイ方法です。
|
||||
|
||||
- **[VM](https://langfuse.com/self-hosting/docker-compose):**
|
||||
Docker Composeを使用して、単一の仮想マシン上でLangfuseを実行します。
|
||||
|
||||
- **Planned:**
|
||||
クラウド固有のデプロイガイドは計画中です。以下のスレッドに対して投票やコメントをお願いします:
|
||||
[AWS](https://github.com/orgs/langfuse/discussions/4645), [Google Cloud](https://github.com/orgs/langfuse/discussions/4646), [Azure](https://github.com/orgs/langfuse/discussions/4647).
|
||||
|
||||
[セルフホスティングのドキュメント](https://langfuse.com/self-hosting)を参照し、アーキテクチャや設定オプションの詳細をご確認ください。
|
||||
|
||||
## 🔌 インテグレーション
|
||||
|
||||

|
||||
|
||||
### 主なインテグレーション:
|
||||
|
||||
| インテグレーション | 対応言語・環境 | 説明 |
|
||||
| ----------------------------------------------------------------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [SDK](https://langfuse.com/docs/sdk) | Python, JS/TS | SDKを利用して手動でインストゥルメンテーションを実装し、完全な柔軟性を提供します。 |
|
||||
| [OpenAI](https://langfuse.com/docs/integrations/openai) | Python, JS/TS | OpenAI SDKのドロップイン置換による自動インストゥルメンテーションを実現します。 |
|
||||
| [Langchain](https://langfuse.com/docs/integrations/langchain) | Python, JS/TS | Langchainアプリケーションにコールバックハンドラーを渡すことで自動的に計測します。 |
|
||||
| [LlamaIndex](https://langfuse.com/docs/integrations/llama-index/get-started) | Python | LlamaIndexのコールバックシステムを介して自動的にインストゥルメントします。 |
|
||||
| [Haystack](https://langfuse.com/docs/integrations/haystack) | Python | Haystackのコンテンツトレースシステムを利用した自動インストゥルメンテーションを実現します。 |
|
||||
| [LiteLLM](https://langfuse.com/docs/integrations/litellm) | Python, JS/TS (proxy only)| GPTのドロップイン置換として任意のLLMを使用できます。Azure、OpenAI、Cohere、Anthropic、Ollama、VLLM、Sagemaker、HuggingFace、Replicate(100+ LLM)に対応。 |
|
||||
| [Vercel AI SDK](https://langfuse.com/docs/integrations/vercel-ai-sdk) | JS/TS | React、Next.js、Vue、Svelte、Node.jsを使用してAI搭載アプリケーションの構築を支援するTypeScriptツールキットです。 |
|
||||
| [API](https://langfuse.com/docs/api) | | 公開APIを直接呼び出すことが可能です。OpenAPI仕様も利用できます。 |
|
||||
|
||||
### Langfuseと統合されているパッケージ:
|
||||
|
||||
| 名前 | タイプ | 説明 |
|
||||
| ------------------------------------------------------------------------- | ------------------ | --------------------------------------------------------------------------------------------------------- |
|
||||
| [Instructor](https://langfuse.com/docs/integrations/instructor) | ライブラリ | 構造化されたLLM出力(JSON、Pydantic)を取得するためのライブラリ |
|
||||
| [DSPy](https://langfuse.com/docs/integrations/dspy) | ライブラリ | LLMプロンプトや重み付けを体系的に最適化するためのフレームワーク |
|
||||
| [Mirascope](https://langfuse.com/docs/integrations/mirascope) | ライブラリ | LLMアプリケーション構築用のPythonツールキット |
|
||||
| [Ollama](https://langfuse.com/docs/integrations/ollama) | モデル(ローカル) | オープンソースLLMを手軽にローカルで実行するためのツール |
|
||||
| [Amazon Bedrock](https://langfuse.com/docs/integrations/amazon-bedrock) | モデル | AWS上でファウンデーションモデルやファインチューニング済みモデルを実行 |
|
||||
| [AutoGen](https://langfuse.com/docs/integrations/autogen) | エージェントフレームワーク | 分散型エージェント構築のためのオープンソースLLMプラットフォーム |
|
||||
| [Flowise](https://langfuse.com/docs/integrations/flowise) | チャット/エージェント UI | JS/TSのノーコードビルダーで、カスタマイズ可能なLLMフローを構築 |
|
||||
| [Langflow](https://langfuse.com/docs/integrations/langflow) | チャット/エージェント UI | PythonベースのUIで、react-flowを用いてLangChainの実験やプロトタイピングを容易に実現 |
|
||||
| [Dify](https://langfuse.com/docs/integrations/dify) | チャット/エージェント UI | ノーコードでLLMアプリ開発が可能なオープンソースプラットフォーム |
|
||||
| [OpenWebUI](https://langfuse.com/docs/integrations/openwebui) | チャット/エージェント UI | 自前ホストおよびローカルモデルに対応するLLMチャットWeb UI |
|
||||
| [Promptfoo](https://langfuse.com/docs/integrations/promptfoo) | ツール | オープンソースのLLMテストプラットフォーム |
|
||||
| [LobeChat](https://langfuse.com/docs/integrations/lobechat) | チャット/エージェント UI | オープンソースのチャットボットプラットフォーム |
|
||||
| [Vapi](https://langfuse.com/docs/integrations/vapi) | プラットフォーム | オープンソースの音声AIプラットフォーム |
|
||||
| [Inferable](https://langfuse.com/docs/integrations/other/inferable) | エージェント | 分散型エージェント構築のためのオープンソースLLMプラットフォーム |
|
||||
| [Gradio](https://langfuse.com/docs/integrations/other/gradio) | チャット/エージェント UI | チャットUIなどのWebインターフェース構築のためのオープンソースPythonライブラリ |
|
||||
| [Goose](https://langfuse.com/docs/integrations/goose) | エージェント | 分散型エージェント構築のためのオープンソースLLMプラットフォーム |
|
||||
| [smolagents](https://langfuse.com/docs/integrations/smolagents) | エージェント | オープンソースのAIエージェントフレームワーク |
|
||||
| [CrewAI](https://langfuse.com/docs/integrations/crewai) | エージェント | エージェントの協調とツール利用を実現するマルチエージェントフレームワーク |
|
||||
|
||||
## 🚀 クイックスタート
|
||||
|
||||
アプリケーションにインストゥルメンテーションを導入し、LLM呼び出しやリトリーバル、埋め込み、エージェントアクションなどの動作をLangfuseに記録しましょう。
|
||||
複雑なログやユーザーセッションの解析・デバッグが可能になります。
|
||||
|
||||
### 1️⃣ 新規プロジェクトの作成
|
||||
|
||||
1. [Langfuseアカウント作成](https://cloud.langfuse.com/auth/sign-up) または [セルフホスト](https://langfuse.com/self-hosting)
|
||||
2. 新規プロジェクトを作成
|
||||
3. プロジェクト設定で新しいAPIクレデンシャルを作成
|
||||
|
||||
### 2️⃣ 初めてのLLM呼び出しのログ記録
|
||||
|
||||
[`@observe()` デコレーター](https://langfuse.com/docs/sdk/python/decorators)を利用することで、任意のPython製LLMアプリケーションのトレースが簡単に行えます。
|
||||
このクイックスタートでは、Langfuseの[OpenAI統合](https://langfuse.com/docs/integrations/openai)を使用して、全てのモデルパラメータを自動で取得します。
|
||||
|
||||
> [!TIP]
|
||||
> OpenAIを利用していない場合は、[こちらのドキュメント](https://langfuse.com/docs/get-started#log-your-first-llm-call-to-langfuse)で、他のモデルやフレームワークのログ記録方法をご確認ください。
|
||||
|
||||
```bash
|
||||
pip install langfuse openai
|
||||
```
|
||||
|
||||
```bash filename=".env"
|
||||
LANGFUSE_SECRET_KEY="sk-lf-..."
|
||||
LANGFUSE_PUBLIC_KEY="pk-lf-..."
|
||||
LANGFUSE_HOST="https://cloud.langfuse.com" # 🇪🇺 EUリージョン
|
||||
# LANGFUSE_HOST="https://us.cloud.langfuse.com" # 🇺🇸 USリージョン
|
||||
```
|
||||
|
||||
```python:/@observe()/ /from langfuse.openai import openai/ filename="main.py"
|
||||
from langfuse.decorators import observe
|
||||
from langfuse.openai import openai # OpenAI統合
|
||||
|
||||
@observe()
|
||||
def story():
|
||||
return openai.chat.completions.create(
|
||||
model="gpt-4o",
|
||||
messages=[{"role": "user", "content": "What is Langfuse?"}],
|
||||
).choices[0].message.content
|
||||
|
||||
@observe()
|
||||
def main():
|
||||
return story()
|
||||
|
||||
main()
|
||||
```
|
||||
|
||||
### 3️⃣ Langfuseでトレースを確認する
|
||||
|
||||
Langfuse上で、LLM呼び出しおよびその他のアプリケーションロジックのトレースを確認できます。
|
||||
|
||||

|
||||
|
||||
_[Langfuseの公開トレース例](https://cloud.langfuse.com/project/cloramnkj0002jz088vzn1ja4/traces/2cec01e3-3dc2-472f-afcf-3b968cf0c1f4?timestamp=2025-02-10T14%3A27%3A30.275Z&observation=cb5ff844-07ef-41e6-b8e2-6c64344bc13b)_
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
> Langfuseでのトレースの詳細については、[こちら](https://langfuse.com/docs/tracing)をご参照いただくか、[インタラクティブデモ](https://langfuse.com/docs/demo)でお試しください。
|
||||
|
||||
## ⭐️ Star Langfuse
|
||||
|
||||

|
||||
|
||||
## 💭 サポート
|
||||
|
||||
質問の回答をお探しの場合は:
|
||||
|
||||
- 当社の[ドキュメント](https://langfuse.com/docs)は、回答を探すための最良の出発点です。内容が充実しており、継続的なメンテナンスに努めています。GitHubを通じてドキュメントへの修正提案も可能です。
|
||||
- よくある質問は[Langfuse FAQ](https://langfuse.com/faq)にまとめられています。
|
||||
- [Ask AI](https://langfuse.com/docs/ask-ai)を利用すれば、質問に対して即座に回答を得ることができます。
|
||||
|
||||
サポートチャネル:
|
||||
|
||||
- **GitHub Discussionsの[パブリックQ&A](https://github.com/orgs/langfuse/discussions/categories/support)で質問してください。**
|
||||
質問には、コードスニペット、スクリーンショット、背景情報など、できるだけ詳細な情報を含めるとスムーズな対応が可能です。
|
||||
- GitHub Discussionsで[機能リクエスト](https://github.com/orgs/langfuse/discussions/categories/ideas)を投稿してください。
|
||||
- GitHub Issuesにて[バグ報告](https://github.com/langfuse/langfuse/issues)を行ってください。
|
||||
- 緊急の問い合わせの場合は、アプリ内チャットウィジェットでご連絡ください。
|
||||
|
||||
## 🤝 貢献
|
||||
|
||||
皆様からの貢献を歓迎します!
|
||||
|
||||
- GitHub Discussionsの[アイデア](https://github.com/orgs/langfuse/discussions/categories/ideas)に投票してください。
|
||||
- [Issues](https://github.com/langfuse/langfuse/issues)を作成・コメントしてください。
|
||||
- プルリクエストを送信してください。開発環境のセットアップ方法については[CONTRIBUTING.md](CONTRIBUTING.md)をご参照ください。
|
||||
|
||||
## 🥇 ライセンス
|
||||
|
||||
このリポジトリは、`ee`フォルダを除き、MITライセンスの下で公開されています。
|
||||
詳細は[LICENSE](LICENSE)および[オープンソースに関するドキュメント](https://langfuse.com/docs/open-source)をご確認ください。
|
||||
|
||||
## ⭐️ スターの履歴
|
||||
|
||||
<a href="https://star-history.com/#langfuse/langfuse&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=langfuse/langfuse&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=langfuse/langfuse&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=langfuse/langfuse&type=Date" style="border-radius: 15px;" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## ❤️ Langfuseを利用しているオープンソースプロジェクト
|
||||
|
||||
Langfuseを利用している主要なオープンソースPythonプロジェクト(スター数順): ([出典](https://github.com/langfuse/langfuse-docs/blob/main/components-mdx/dependents))
|
||||
|
||||
| リポジトリ | スター |
|
||||
| :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----: |
|
||||
| <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) | 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 |
|
||||
|
||||
## 🔒 セキュリティとプライバシー
|
||||
|
||||
データのセキュリティとプライバシーは非常に重要です。
|
||||
詳細につきましては、[セキュリティとプライバシー](https://langfuse.com/security)ページをご参照ください。
|
||||
|
||||
### テレメトリー
|
||||
|
||||
デフォルトでは、Langfuseはセルフホストされたインスタンスの基本的な使用統計情報を中央サーバ(PostHog)へ自動的に報告します。
|
||||
|
||||
これにより、以下が可能となります:
|
||||
|
||||
1. Langfuseの利用状況を把握し、最も重要な機能の改善に役立てる
|
||||
2. 内部および外部(例:資金調達)のレポートのために全体の利用状況を追跡する
|
||||
|
||||
収集されたデータは第三者と共有されず、機微な情報は一切含まれていません。
|
||||
当社はこの点について極力透明性を保っており、収集される具体的なデータの詳細は[こちら](/web/src/features/telemetry/index.ts)で確認できます。
|
||||
|
||||
`TELEMETRY_ENABLED=false` を設定することで、テレメトリーの報告をオプトアウトできます.
|
||||
+339
@@ -0,0 +1,339 @@
|
||||

|
||||
|
||||
<div align="center">
|
||||
<div>
|
||||
<h3>
|
||||
<a href="https://langfuse.com/kr">
|
||||
<strong>🇰🇷 🤝 🪢</strong>
|
||||
</a> ·
|
||||
<a href="https://cloud.langfuse.com">
|
||||
<strong>Langfuse Cloud</strong>
|
||||
</a> ·
|
||||
<a href="https://langfuse.com/docs/deployment/self-host">
|
||||
<strong>셀프 호스트</strong>
|
||||
</a> ·
|
||||
<a href="https://langfuse.com/demo">
|
||||
<strong>데모</strong>
|
||||
</a>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<a href="https://langfuse.com/docs"><strong>문서</strong></a> ·
|
||||
<a href="https://langfuse.com/issues"><strong>버그 신고</strong></a> ·
|
||||
<a href="https://langfuse.com/ideas"><strong>기능 요청</strong></a> ·
|
||||
<a href="https://langfuse.com/changelog"><strong>변경 내역</strong></a> ·
|
||||
<a href="https://langfuse.com/roadmap"><strong>로드맵</strong></a> ·
|
||||
</div>
|
||||
<br/>
|
||||
<span>Langfuse는 지원 및 기능 요청을 위해 <a href="https://github.com/orgs/langfuse/discussions"><strong>GitHub Discussions</strong></a>를 사용합니다.</span>
|
||||
<br/>
|
||||
<span><b>채용 중입니다.</b> <a href="https://langfuse.com/careers"><strong>함께 하세요</strong></a> – 제품 엔지니어링 및 기술 go-to-market 역할의 인재를 찾고 있습니다.</span>
|
||||
<br/>
|
||||
<br/>
|
||||
<div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/langfuse/langfuse/blob/main/LICENSE">
|
||||
<img src="https://img.shields.io/badge/License-MIT-E11311.svg" alt="MIT License">
|
||||
</a>
|
||||
<a href="https://www.ycombinator.com/companies/langfuse"><img src="https://img.shields.io/badge/Y%20Combinator-W23-orange" alt="Y Combinator W23"></a>
|
||||
<a href="https://hub.docker.com/u/langfuse" target="_blank">
|
||||
<img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/langfuse/langfuse?labelColor=%20%23FDB062&logo=Docker&labelColor=%20%23528bff"></a>
|
||||
<a href="https://pypi.python.org/pypi/langfuse"><img src="https://img.shields.io/pypi/dm/langfuse?logo=python&logoColor=white&label=pypi%20langfuse&color=blue" alt="langfuse Python package on PyPi"></a>
|
||||
<a href="https://www.npmjs.com/package/langfuse"><img src="https://img.shields.io/npm/dm/langfuse?logo=npm&logoColor=white&label=npm%20langfuse&color=blue" alt="langfuse npm package"></a>
|
||||
<br/>
|
||||
<a href="https://discord.com/invite/7NXusRtqYU" target="_blank">
|
||||
<img src="https://img.shields.io/discord/1111061815649124414?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb"
|
||||
alt="chat on Discord"></a>
|
||||
<a href="https://twitter.com/intent/follow?screen_name=langfuse" target="_blank">
|
||||
<img src="https://img.shields.io/twitter/follow/langfuse?logo=X&color=%20%23f5f5f5"
|
||||
alt="follow on X(Twitter)"></a>
|
||||
<a href="https://www.linkedin.com/company/langfuse/" target="_blank">
|
||||
<img src="https://custom-icon-badges.demolab.com/badge/LinkedIn-0A66C2?logo=linkedin-white&logoColor=fff"
|
||||
alt="follow on LinkedIn"></a>
|
||||
<a href="https://github.com/langfuse/langfuse/graphs/commit-activity" target="_blank">
|
||||
<img alt="Commits last month" src="https://img.shields.io/github/commit-activity/m/langfuse/langfuse?labelColor=%20%2332b583&color=%20%2312b76a"></a>
|
||||
<a href="https://github.com/langfuse/langfuse/" target="_blank">
|
||||
<img alt="Issues closed" src="https://img.shields.io/github/issues-search?query=repo%3Alangfuse%2Flangfuse%20is%3Aclosed&label=issues%20closed&labelColor=%20%237d89b0&color=%20%235d6b98"></a>
|
||||
<a href="https://github.com/langfuse/langfuse/discussions/" target="_blank">
|
||||
<img alt="Discussion posts" src="https://img.shields.io/github/discussions/langfuse/langfuse?labelColor=%20%239b8afb&color=%20%237a5af8"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="./README.md"><img alt="README in English" src="https://img.shields.io/badge/English-d9d9d9"></a>
|
||||
<a href="./README.cn.md"><img alt="简体中文版自述文件" src="https://img.shields.io/badge/简体中文-d9d9d9"></a>
|
||||
<a href="./README.ja.md"><img alt="日本語のREADME" src="https://img.shields.io/badge/日本語-d9d9d9"></a>
|
||||
<a href="./README.kr.md"><img alt="README in Korean" src="https://img.shields.io/badge/한국어-d9d9d9"></a>
|
||||
</p>
|
||||
|
||||
Langfuse는 **오픈 소스 LLM 엔지니어링** 플랫폼입니다.
|
||||
팀이 협업하여 AI 애플리케이션을 **개발, 모니터링, 평가** 및 **디버그**할 수 있도록 도와줍니다.
|
||||
Langfuse는 몇 분 안에 셀프 호스팅할 수 있으며, 검증된(battle-tested) 솔루션입니다.
|
||||
|
||||
[](https://langfuse.com/watch-demo)
|
||||
|
||||
## ✨ 주요 기능
|
||||
|
||||

|
||||
|
||||
- **LLM 애플리케이션 관측**
|
||||
앱에 계측(instrumentation)을 추가하여 Langfuse로 trace 데이터를 수집함으로써, 검색, 임베딩, 또는 에이전트 동작과 같은 LLM 호출 및 기타 관련 로직을 추적할 수 있습니다. 복잡한 로그와 사용자 세션을 확인 및 디버깅 해보세요. 인터랙티브 데모를 통해 실제 작동 예를 확인할 수 있습니다.
|
||||
|
||||
- **프롬프트 관리**
|
||||
프롬프트를 중앙에서 관리하고 버전 관리하며 협업으로 수정할 수 있도록 도와줍니다. 서버와 클라이언트 측의 강력한 캐싱 덕분에 애플리케이션에 지연(latency)을 추가하지 않고도 프롬프트를 반복 개선할 수 있습니다.
|
||||
|
||||
- **평가**
|
||||
LLM 애플리케이션 개발 워크플로우에서 핵심적인 역할을 하며, Langfuse는 여러분의 필요에 맞게 유연하게 대응합니다. LLM을 심사자로 활용하는 기능, 사용자 피드백 수집, 수동 라벨링 및 API/SDK를 통한 맞춤 평가 파이프라인을 지원합니다.
|
||||
|
||||
- **데이터셋**
|
||||
LLM 애플리케이션 평가를 위한 테스트 세트와 벤치마크를 제공하여, 지속적인 개선, 배포 전 테스트, 구조화된 실험, 유연한 평가 및 LangChain과 LlamaIndex와 같은 프레임워크와의 원활한 통합을 지원합니다.
|
||||
|
||||
- **LLM 플레이그라운드**
|
||||
프롬프트와 모델 구성에 대해 테스트 및 반복 개선할 수 있는 도구로, 피드백 루프를 단축하여 개발 속도를 높여줍니다. trace에서 이상한 결과가 발생하면 플레이그라운드로 바로 이동해 개선할 수 있습니다.
|
||||
|
||||
- **종합 API**
|
||||
Langfuse는 API를 통해 제공되는 구성 요소들을 활용하여 맞춤형 LLMOps 워크플로우를 강화하는 데 자주 사용됩니다. OpenAPI 명세, Postman 컬렉션, Python 및 JS/TS용 타입드 SDK가 제공됩니다.
|
||||
|
||||
## 📦 Langfuse 배포
|
||||
|
||||

|
||||
|
||||
### Langfuse Cloud
|
||||
|
||||
Langfuse 팀이 관리하는 배포 방식으로, 후한 무료 플랜(취미 플랜)을 제공하며 신용카드가 필요하지 않습니다.
|
||||
|
||||
<div align="center">
|
||||
<a href="https://cloud.langfuse.com" target="_blank">
|
||||
<img alt="Static Badge" src="https://img.shields.io/badge/»%20Sign%20up%20for%20Langfuse%20Cloud-8A2BE2?&color=orange">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
### Langfuse 셀프 호스트
|
||||
|
||||
자체 인프라에서 Langfuse를 실행하세요:
|
||||
|
||||
- [로컬 (docker compose)](https://langfuse.com/self-hosting/local): Docker Compose를 사용하여 본인의 컴퓨터에서 5분 안에 Langfuse를 실행할 수 있습니다.
|
||||
|
||||
```bash
|
||||
# 최신 Langfuse 저장소 클론
|
||||
git clone https://github.com/langfuse/langfuse.git
|
||||
cd langfuse
|
||||
|
||||
# langfuse docker compose 실행
|
||||
docker compose up
|
||||
```
|
||||
|
||||
- [Kubernetes (Helm)](https://langfuse.com/self-hosting/kubernetes-helm): Helm을 사용해 Kubernetes 클러스터에서 Langfuse를 실행합니다. 이는 권장되는 프로덕션 배포 방식입니다.
|
||||
- [VM](https://langfuse.com/self-hosting/docker-compose): Docker Compose를 사용해 단일 가상 머신에서 Langfuse를 실행합니다.
|
||||
- 예정: 클라우드별 배포 가이드 – 아래 스레드에서 투표 및 댓글을 남겨주세요: [AWS](https://github.com/orgs/langfuse/discussions/4645), [Google Cloud](https://github.com/orgs/langfuse/discussions/4646), [Azure](https://github.com/orgs/langfuse/discussions/4647).
|
||||
|
||||
자세한 내용은 [자체 호스팅 문서](https://langfuse.com/self-hosting)를 참조하세요.
|
||||
|
||||
## 🔌 통합 기능
|
||||
|
||||

|
||||
|
||||
### 주요 통합:
|
||||
|
||||
| 통합 | 지원 | 설명 |
|
||||
| --------------------------------------------------------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [SDK](https://langfuse.com/docs/sdk) | Python, JS/TS | SDK를 사용하여 완전한 유연성을 갖춘 수동 계측(manual instrumentation)을 수행합니다. |
|
||||
| [OpenAI](https://langfuse.com/docs/integrations/openai) | Python, JS/TS | OpenAI SDK의 드롭인 대체(drop-in replacement)를 통해 자동 계측(automated instrumentation)을 수행합니다. |
|
||||
| [Langchain](https://langfuse.com/docs/integrations/langchain) | Python, JS/TS | Langchain 애플리케이션에 callback 핸들러를 전달하여 자동 계측합니다. |
|
||||
| [LlamaIndex](https://langfuse.com/docs/integrations/llama-index/get-started) | Python | LlamaIndex 콜백 시스템을 통한 자동 계측을 지원합니다. |
|
||||
| [Haystack](https://langfuse.com/docs/integrations/haystack) | Python | Haystack 콘텐츠 추적 시스템을 통한 자동 계측을 지원합니다. |
|
||||
| [LiteLLM](https://langfuse.com/docs/integrations/litellm) | Python, JS/TS (proxy only) | GPT의 드롭인 대체품으로 어떤 LLM도 사용할 수 있습니다. Azure, OpenAI, Cohere, Anthropic, Ollama, VLLM, Sagemaker, HuggingFace, Replicate 등 100개 이상의 LLM 지원. |
|
||||
| [Vercel AI SDK](https://langfuse.com/docs/integrations/vercel-ai-sdk) | JS/TS | React, Next.js, Vue, Svelte, Node.js와 함께 AI 기반 애플리케이션 구축을 돕는 TypeScript 툴킷입니다. |
|
||||
| [API](https://langfuse.com/docs/api) | | 공개 API를 직접 호출합니다. OpenAPI 명세가 제공됩니다. |
|
||||
|
||||
### Langfuse와 통합된 패키지:
|
||||
|
||||
| 이름 | 유형 | 설명 |
|
||||
| ----------------------------------------------------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| [Instructor](https://langfuse.com/docs/integrations/instructor) | 라이브러리 | 구조화된 LLM 출력을(JSON, Pydantic) 얻기 위한 라이브러리입니다. |
|
||||
| [DSPy](https://langfuse.com/docs/integrations/dspy) | 라이브러리 | 언어 모델 프롬프트와 가중치를 체계적으로 최적화하는 프레임워크입니다. |
|
||||
| [Mirascope](https://langfuse.com/docs/integrations/mirascope) | 라이브러리 | LLM 애플리케이션 구축을 위한 Python 툴킷입니다. |
|
||||
| [Ollama](https://langfuse.com/docs/integrations/ollama) | 모델 (로컬) | 자신의 컴퓨터에서 오픈 소스 LLM을 손쉽게 실행할 수 있습니다. |
|
||||
| [Amazon Bedrock](https://langfuse.com/docs/integrations/amazon-bedrock) | 모델 | AWS에서 기본 및 파인튜닝된 모델을 실행합니다. |
|
||||
| [AutoGen](https://langfuse.com/docs/integrations/autogen) | 에이전트 프레임워크 | 분산 에이전트 구축을 위한 오픈 소스 LLM 플랫폼입니다. |
|
||||
| [Flowise](https://langfuse.com/docs/integrations/flowise) | 채팅/에이전트 UI | 맞춤형 LLM 플로우를 위한 JS/TS 코드 없는(no-code) 빌더입니다. |
|
||||
| [Langflow](https://langfuse.com/docs/integrations/langflow) | 채팅/에이전트 UI | react-flow를 활용하여 실험 및 프로토타이핑을 손쉽게 할 수 있도록 디자인된 LangChain용 Python 기반 UI입니다. |
|
||||
| [Dify](https://langfuse.com/docs/integrations/dify) | 채팅/에이전트 UI | 코드 없는 빌더와 함께 제공되는 오픈 소스 LLM 애플리케이션 개발 플랫폼입니다. |
|
||||
| [OpenWebUI](https://langfuse.com/docs/integrations/openwebui) | 채팅/에이전트 UI | 셀프 호스팅 및 로컬 모델 등 다양한 LLM 실행기를 지원하는 셀프 호스팅 LLM 채팅 웹 UI입니다. |
|
||||
| [Promptfoo](https://langfuse.com/docs/integrations/promptfoo) | 도구 | 오픈 소스 LLM 테스트 플랫폼입니다. |
|
||||
| [LobeChat](https://langfuse.com/docs/integrations/lobechat) | 채팅/에이전트 UI | 오픈 소스 챗봇 플랫폼입니다. |
|
||||
| [Vapi](https://langfuse.com/docs/integrations/vapi) | 플랫폼 | 오픈 소스 음성 AI 플랫폼입니다. |
|
||||
| [Inferable](https://langfuse.com/docs/integrations/other/inferable) | 에이전트 | 분산 에이전트 구축을 위한 오픈 소스 LLM 플랫폼입니다. |
|
||||
| [Gradio](https://langfuse.com/docs/integrations/other/gradio) | 채팅/에이전트 UI | 채팅 UI와 같은 웹 인터페이스 구축을 위한 오픈 소스 Python 라이브러리입니다. |
|
||||
| [Goose](https://langfuse.com/docs/integrations/goose) | 에이전트 | 분산 에이전트 구축을 위한 오픈 소스 LLM 플랫폼입니다. |
|
||||
| [smolagents](https://langfuse.com/docs/integrations/smolagents) | 에이전트 | 오픈 소스 AI 에이전트 프레임워크입니다. |
|
||||
| [CrewAI](https://langfuse.com/docs/integrations/crewai) | 에이전트 | 에이전트 간 협업 및 도구 사용을 위한 다중 에이전트 프레임워크입니다. |
|
||||
|
||||
## 🚀 빠른 시작
|
||||
|
||||
앱에 계측을 추가하고 Langfuse에 trace 데이터를 수집하여, LLM 호출 및 검색, 임베딩, 에이전트 동작과 같은 애플리케이션 로직을 추적해보세요. 복잡한 로그와 사용자 세션을 확인하여 디버깅할 수 있습니다.
|
||||
|
||||
### 1️⃣ 새 프로젝트 생성
|
||||
|
||||
1. [Langfuse 계정 생성](https://cloud.langfuse.com/auth/sign-up) 또는 [셀프 호스트](https://langfuse.com/self-hosting)
|
||||
2. 새 프로젝트를 생성합니다.
|
||||
3. 프로젝트 설정에서 새로운 API 자격 증명을 생성합니다.
|
||||
|
||||
### 2️⃣ 첫 번째 LLM 호출 기록하기
|
||||
|
||||
[`@observe()` 데코레이터](https://langfuse.com/docs/sdk/python/decorators)를 사용하면 Python LLM 애플리케이션의 추적이 매우 간편해집니다. 이 빠른 시작 예제에서는 Langfuse [OpenAI 통합](https://langfuse.com/docs/integrations/openai)을 사용하여 모든 모델 파라미터를 자동으로 캡처합니다.
|
||||
|
||||
> [!TIP]
|
||||
> OpenAI를 사용하지 않으시다면, 다른 모델 및 프레임워크의 로그 기록 방법은 [문서](https://langfuse.com/docs/get-started#log-your-first-llm-call-to-langfuse)를 참조하세요.
|
||||
|
||||
```bash
|
||||
pip install langfuse openai
|
||||
```
|
||||
|
||||
```bash filename=".env"
|
||||
LANGFUSE_SECRET_KEY="sk-lf-..."
|
||||
LANGFUSE_PUBLIC_KEY="pk-lf-..."
|
||||
LANGFUSE_HOST="https://cloud.langfuse.com" # 🇪🇺 EU region
|
||||
# LANGFUSE_HOST="https://us.cloud.langfuse.com" # 🇺🇸 US region
|
||||
```
|
||||
|
||||
```python:main.py
|
||||
from langfuse.decorators import observe
|
||||
from langfuse.openai import openai # OpenAI integration
|
||||
|
||||
@observe()
|
||||
def story():
|
||||
return openai.chat.completions.create(
|
||||
model="gpt-4o",
|
||||
messages=[{"role": "user", "content": "What is Langfuse?"}],
|
||||
).choices[0].message.content
|
||||
|
||||
@observe()
|
||||
def main():
|
||||
return story()
|
||||
|
||||
main()
|
||||
```
|
||||
|
||||
### 3️⃣ Langfuse에서 trace 확인하기
|
||||
|
||||
Langfuse에서 LLM 호출 및 애플리케이션의 기타 로직에 대한 trace를 확인할 수 있습니다.
|
||||
|
||||

|
||||
|
||||
_[Langfuse의 공개 예제 trace](https://cloud.langfuse.com/project/cloramnkj0002jz088vzn1ja4/traces/2cec01e3-3dc2-472f-afcf-3b968cf0c1f4?timestamp=2025-02-10T14%3A27%3A30.275Z&observation=cb5ff844-07ef-41e6-b8e2-6c64344bc13b)_
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
> Langfuse의 trace에 대해 더 알아보거나 [인터랙티브 데모](https://langfuse.com/docs/demo)에서 직접 체험해보세요.
|
||||
|
||||
## ⭐️ 별을 눌러주세요
|
||||
|
||||

|
||||
|
||||
## 💭 지원
|
||||
|
||||
질문에 대한 답변을 찾는 방법:
|
||||
|
||||
- 우리의 [문서](https://langfuse.com/docs)는 답을 찾기 위한 최적의 장소입니다. 문서가 매우 포괄적이며, 유지보수에 많은 노력을 기울이고 있습니다. GitHub를 통해 문서 수정 제안도 가능합니다.
|
||||
- [Langfuse FAQ](https://langfuse.com/faq)에서는 가장 흔한 질문에 대해 답변하고 있습니다.
|
||||
- 질문에 즉각적인 답변이 필요하다면 [Ask AI](https://langfuse.com/docs/ask-ai)를 사용해보세요.
|
||||
|
||||
지원 채널:
|
||||
|
||||
- **GitHub Discussions의 [공개 Q&A](https://github.com/orgs/langfuse/discussions/categories/support)** 에 질문을 남겨주세요. 가능한 한 많은 세부 사항(예: 코드 스니펫, 스크린샷, 배경 정보)을 포함해 질문해 주시기 바랍니다.
|
||||
- [기능 요청](https://github.com/orgs/langfuse/discussions/categories/ideas)을 남겨주세요.
|
||||
- [버그 신고](https://github.com/langfuse/langfuse/issues)는 GitHub Issues를 통해 해주세요.
|
||||
- 긴급한 문의는 앱 내 채팅 위젯을 통해 연락 바랍니다.
|
||||
|
||||
## 🤝 기여하기
|
||||
|
||||
여러분의 기여를 환영합니다!
|
||||
|
||||
- GitHub Discussions의 [아이디어](https://github.com/orgs/langfuse/discussions/categories/ideas)에 투표해보세요.
|
||||
- GitHub Issues에서 이슈를 제기하고 댓글을 남겨주세요.
|
||||
- PR을 제출하세요 – 개발 환경 설정 방법 등 자세한 내용은 [CONTRIBUTING.md](CONTRIBUTING.md)를 참조하세요.
|
||||
|
||||
## 🥇 라이선스
|
||||
|
||||
이 저장소는 `ee` 폴더를 제외하고 MIT 라이선스가 적용됩니다. 자세한 내용은 [LICENSE](LICENSE)와 [문서](https://langfuse.com/docs/open-source)를 확인하세요.
|
||||
|
||||
## ⭐️ 별(Star) 히스토리
|
||||
|
||||
<a href="https://star-history.com/#langfuse/langfuse&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=langfuse/langfuse&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=langfuse/langfuse&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=langfuse/langfuse&type=Date" style="border-radius: 15px;" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## ❤️ Langfuse를 사용하는 오픈 소스 프로젝트
|
||||
|
||||
별(star) 수를 기준으로 순위가 매겨진 Langfuse를 사용하는 상위 오픈 소스 Python 프로젝트들 ([출처](https://github.com/langfuse/langfuse-docs/blob/main/components-mdx/dependents)):
|
||||
|
||||
| 저장소 | 별 |
|
||||
| :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---: |
|
||||
| <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) | 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 |
|
||||
|
||||
## 🔒 보안 & 개인정보 보호
|
||||
|
||||
우리는 데이터 보안과 개인정보 보호를 매우 중요하게 생각합니다. 자세한 내용은 [Security and Privacy](https://langfuse.com/security) 페이지를 참조하세요.
|
||||
|
||||
### 텔레메트리
|
||||
|
||||
기본적으로 Langfuse는 자체 호스팅 인스턴스의 기본 사용 통계를 중앙 서버(PostHog)로 자동 보고합니다.
|
||||
|
||||
이를 통해:
|
||||
1. Langfuse가 어떻게 사용되는지 이해하고, 가장 관련성 높은 기능을 개선할 수 있습니다.
|
||||
2. 내부 및 외부(예: 자금 조달) 보고를 위한 전체 사용량을 추적할 수 있습니다.
|
||||
|
||||
수집된 데이터는 제3자와 공유되지 않으며 민감한 정보를 포함하지 않습니다. 이에 대해 매우 투명하게 공개하고 있으며, 수집되는 정확한 데이터는 [여기](/web/src/features/telemetry/index.ts)에서 확인할 수 있습니다.
|
||||
|
||||
환경 변수 `TELEMETRY_ENABLED=false`를 설정하여 옵트아웃할 수 있습니다.
|
||||
@@ -1,113 +1,132 @@
|
||||

|
||||
|
||||
<div align="center"><h1>Langfuse: Open Source LLM Engineering Platform</h1></div>
|
||||
<div align="center"><h4>LLM Observability, Prompt Management, LLM Evaluations,<br/>Datasets, LLM Metrics, and Prompt Playground</h4></div>
|
||||

|
||||
|
||||
<div align="center">
|
||||
<div>
|
||||
<h3>
|
||||
<a href="https://cloud.langfuse.com">
|
||||
<strong>Sign up</strong>
|
||||
<strong>Langfuse Cloud</strong>
|
||||
</a> ·
|
||||
<a href="https://langfuse.com/docs/deployment/self-host">
|
||||
<strong>Self Host</strong>
|
||||
</a> ·
|
||||
<a href="https://langfuse.com/demo">
|
||||
<strong>Demo (live data)</strong>
|
||||
<strong>Demo</strong>
|
||||
</a>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<a href="https://langfuse.com/docs"><strong>Docs</strong></a> ·
|
||||
<a href="https://langfuse.com/issues"><strong>Report Bug</strong></a> ·
|
||||
<a href="https://langfuse.com/ideas"><strong>Feature Request</strong></a> ·
|
||||
<a href="https://langfuse.com/changelog"><strong>Changelog</strong></a> ·
|
||||
<a href="https://langfuse.com/roadmap"><strong>Roadmap</strong></a> ·
|
||||
<a href="https://langfuse.com/discord"><strong>Discord</strong></a>
|
||||
</div>
|
||||
<br/>
|
||||
<span>Langfuse uses <a href="https://github.com/orgs/langfuse/discussions"><strong>Github Discussions</strong></a> for Support and Feature Requests.</span>
|
||||
<br/>
|
||||
<span>We're hiring. <a href="https://langfuse.com/careers"><strong>Join us</strong></a> in Product Engineering and Developer Relations.</span>
|
||||
<span><b>We're hiring.</b> <a href="https://langfuse.com/careers"><strong>Join us</strong></a> in product engineering and technical go-to-market roles.</span>
|
||||
<br/>
|
||||
<br/>
|
||||
<div>
|
||||
<a href="https://github.com/langfuse/langfuse/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-MIT-red.svg?style=flat-square" alt="MIT License"></a>
|
||||
<a href="https://www.ycombinator.com/companies/langfuse"><img src="https://img.shields.io/badge/Y%20Combinator-W23-orange?style=flat-square" alt="Y Combinator W23"></a>
|
||||
<a href="https://github.com/langfuse/langfuse/pkgs/container/langfuse"><img alt="Docker Image" src="https://img.shields.io/badge/docker-langfuse-blue?logo=Docker&logoColor=white&style=flat-square"></a>
|
||||
<a href="https://pypi.python.org/pypi/langfuse"><img src="https://img.shields.io/pypi/dm/langfuse?style=flat-square&logo=python&logoColor=white&label=pypi%20langfuse&color=blue" alt="langfuse Python package on PyPi"></a>
|
||||
<a href="https://www.npmjs.com/package/langfuse"><img src="https://img.shields.io/npm/dm/langfuse?style=flat-square&logo=npm&logoColor=white&label=npm%20langfuse&color=blue" alt="langfuse npm package"></a>
|
||||
</div>
|
||||
</div>
|
||||
</br>
|
||||
|
||||
## Langfuse Overview
|
||||
<p align="center">
|
||||
<a href="https://github.com/langfuse/langfuse/blob/main/LICENSE">
|
||||
<img src="https://img.shields.io/badge/License-MIT-E11311.svg" alt="MIT License">
|
||||
</a>
|
||||
<a href="https://www.ycombinator.com/companies/langfuse"><img src="https://img.shields.io/badge/Y%20Combinator-W23-orange" alt="Y Combinator W23"></a>
|
||||
<a href="https://hub.docker.com/u/langfuse" target="_blank">
|
||||
<img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/langfuse/langfuse?labelColor=%20%23FDB062&logo=Docker&labelColor=%20%23528bff"></a>
|
||||
<a href="https://pypi.python.org/pypi/langfuse"><img src="https://img.shields.io/pypi/dm/langfuse?logo=python&logoColor=white&label=pypi%20langfuse&color=blue" alt="langfuse Python package on PyPi"></a>
|
||||
<a href="https://www.npmjs.com/package/langfuse"><img src="https://img.shields.io/npm/dm/langfuse?logo=npm&logoColor=white&label=npm%20langfuse&color=blue" alt="langfuse npm package"></a>
|
||||
<br/>
|
||||
<a href="https://discord.com/invite/7NXusRtqYU" target="_blank">
|
||||
<img src="https://img.shields.io/discord/1111061815649124414?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb"
|
||||
alt="chat on Discord"></a>
|
||||
<a href="https://twitter.com/intent/follow?screen_name=langfuse" target="_blank">
|
||||
<img src="https://img.shields.io/twitter/follow/langfuse?logo=X&color=%20%23f5f5f5"
|
||||
alt="follow on X(Twitter)"></a>
|
||||
<a href="https://www.linkedin.com/company/langfuse/" target="_blank">
|
||||
<img src="https://custom-icon-badges.demolab.com/badge/LinkedIn-0A66C2?logo=linkedin-white&logoColor=fff"
|
||||
alt="follow on LinkedIn"></a>
|
||||
<a href="https://github.com/langfuse/langfuse/graphs/commit-activity" target="_blank">
|
||||
<img alt="Commits last month" src="https://img.shields.io/github/commit-activity/m/langfuse/langfuse?labelColor=%20%2332b583&color=%20%2312b76a"></a>
|
||||
<a href="https://github.com/langfuse/langfuse/" target="_blank">
|
||||
<img alt="Issues closed" src="https://img.shields.io/github/issues-search?query=repo%3Alangfuse%2Flangfuse%20is%3Aclosed&label=issues%20closed&labelColor=%20%237d89b0&color=%20%235d6b98"></a>
|
||||
<a href="https://github.com/langfuse/langfuse/discussions/" target="_blank">
|
||||
<img alt="Discussion posts" src="https://img.shields.io/github/discussions/langfuse/langfuse?labelColor=%20%239b8afb&color=%20%237a5af8"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="./README.md"><img alt="README in English" src="https://img.shields.io/badge/English-d9d9d9"></a>
|
||||
<a href="./README.cn.md"><img alt="简体中文版自述文件" src="https://img.shields.io/badge/简体中文-d9d9d9"></a>
|
||||
<a href="./README.ja.md"><img alt="日本語のREADME" src="https://img.shields.io/badge/日本語-d9d9d9"></a>
|
||||
<a href="./README.kr.md"><img alt="README in Korean" src="https://img.shields.io/badge/한국어-d9d9d9"></a>
|
||||
</p>
|
||||
|
||||
Langfuse is an **open source LLM engineering** platform. It helps teams collaboratively
|
||||
**develop, monitor, evaluate,** and **debug** AI applications. Langfuse can be **self-hosted in minutes** and is **battle-tested**.
|
||||
|
||||
[](https://langfuse.com/watch-demo)
|
||||
|
||||
### Develop
|
||||
## ✨ Core Features
|
||||
|
||||
- **LLM Observability:** Instrument your app and start ingesting traces to Langfuse ([Quickstart](https://langfuse.com/docs/get-started), [Integrations](https://langfuse.com/docs/integrations) [Tracing](https://langfuse.com/docs/tracing))
|
||||
- **Langfuse UI:** Inspect and debug complex logs ([Demo](https://langfuse.com/docs/demo), [Tracing](https://langfuse.com/docs/tracing))
|
||||
- **Prompt Management:** Manage, version and deploy prompts from within Langfuse ([Prompt Management](https://langfuse.com/docs/prompts/get-started))
|
||||
- **Prompt Engineering:** Test and iterate on your prompts with the [LLM Playground](https://langfuse.com/docs/playground)
|
||||

|
||||
|
||||
### Monitor
|
||||
- [LLM Application Observability](https://langfuse.com/docs/tracing): Instrument your app and start ingesting traces to Langfuse, thereby tracking LLM calls and other relevant logic in your app such as retrieval, embedding, or agent actions. Inspect and debug complex logs and user sessions. Try the interactive [demo](https://langfuse.com/docs/demo) to see this in action.
|
||||
|
||||
- **LLM Analytics:** Track metrics (cost, latency, quality) and gain insights from dashboards & data exports ([Analytics](https://langfuse.com/docs/analytics))
|
||||
- **LLM Evaluations:** Collect and calculate scores for your LLM completions ([Scores & Evaluations](https://langfuse.com/docs/scores))
|
||||
- Run ([Model-based evaluations](https://langfuse.com/docs/scores/model-based-evals)) and LLM-as-a-Judge within Langfuse
|
||||
- Collect user feedback ([User Feedback](https://langfuse.com/docs/scores/user-feedback))
|
||||
- Manually score LLM outputs in Langfuse ([Manual Scores](https://langfuse.com/docs/scores/manually))
|
||||
- [Prompt Management](https://langfuse.com/docs/prompts/get-started) helps you centrally manage, version control, and collaboratively iterate on your prompts. Thanks to strong caching on server and client side, you can iterate on prompts without adding latency to your application.
|
||||
|
||||
### Test
|
||||
- [Evaluations](https://langfuse.com/docs/scores/overview) are key to the LLM application development workflow, and Langfuse adapts to your needs. It supports LLM-as-a-judge, user feedback collection, manual labeling, and custom evaluation pipelines via APIs/SDKs.
|
||||
|
||||
- **Experiments:** Track and test app behaviour before deploying a new version
|
||||
- Datasets let you test expected in and output pairs and benchmark performance before deploying ([Datasets](https://langfuse.com/docs/datasets))
|
||||
- Track versions and releases in your application ([Experimentation](https://langfuse.com/docs/experimentation), [Prompt Management](https://langfuse.com/docs/prompts))
|
||||
- [Datasets](https://langfuse.com/docs/datasets/overview) enable test sets and benchmarks for evaluating your LLM application. They support continuous improvement, pre-deployment testing, structured experiments, flexible evaluation, and seamless integration with frameworks like LangChain and LlamaIndex.
|
||||
|
||||
## Get started
|
||||
- [LLM Playground](https://langfuse.com/docs/playground) is a tool for testing and iterating on your prompts and model configurations, shortening the feedback loop and accelerating development. When you see a bad result in tracing, you can directly jump to the playground to iterate on it.
|
||||
|
||||
- [Comprehensive API](https://langfuse.com/docs/api): Langfuse is frequently used to power bespoke LLMOps workflows while using the building blocks provided by Langfuse via the API. OpenAPI spec, Postman collection, and typed SDKs for Python, JS/TS are available.
|
||||
|
||||
## 📦 Deploy Langfuse
|
||||
|
||||

|
||||
|
||||
### Langfuse Cloud
|
||||
|
||||
Managed deployment by the Langfuse team, generous free-tier (hobby plan), no credit card required.
|
||||
|
||||
**[» Langfuse Cloud](https://cloud.langfuse.com)**
|
||||
<div align="center">
|
||||
<a href="https://cloud.langfuse.com" target="_blank">
|
||||
<img alt="Static Badge" src="https://img.shields.io/badge/»%20Sign%20up%20for%20Langfuse%20Cloud-8A2BE2?&color=orange">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
## Self-Hosting Open Source LLM Observability with Langfuse
|
||||
### Self-Host Langfuse
|
||||
|
||||
### Localhost (docker)
|
||||
Run Langfuse on your own infrastructure:
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://github.com/langfuse/langfuse.git
|
||||
cd langfuse
|
||||
- [Local (docker compose)](https://langfuse.com/self-hosting/local): Run Langfuse on your own machine in 5 minutes using Docker Compose.
|
||||
|
||||
# Run server and database
|
||||
docker compose up -d
|
||||
```
|
||||
```bash
|
||||
# Get a copy of the latest Langfuse repository
|
||||
git clone https://github.com/langfuse/langfuse.git
|
||||
cd langfuse
|
||||
|
||||
[→ Learn more about deploying locally](https://langfuse.com/docs/deployment/local)
|
||||
# Run the langfuse docker compose
|
||||
docker compose up
|
||||
```
|
||||
|
||||
### Self-host (docker)
|
||||
- [Kubernetes (Helm)](https://langfuse.com/self-hosting/kubernetes-helm): Run Langfuse on a Kubernetes cluster using Helm. This is the preferred production deployment.
|
||||
- [VM](https://langfuse.com/self-hosting/docker-compose): Run Langfuse on a single Virtual Machine using Docker Compose.
|
||||
- Planned: Cloud-specific deployment guides, please upvote and comment on the following threads: [AWS](https://github.com/orgs/langfuse/discussions/4645), [Google Cloud](https://github.com/orgs/langfuse/discussions/4646), [Azure](https://github.com/orgs/langfuse/discussions/4647).
|
||||
|
||||
Langfuse is simple to self-host and keep updated. It currently requires only a single docker container and a postgres database.
|
||||
[→ Self Hosting Instructions](https://langfuse.com/docs/deployment/self-host)
|
||||
See [self-hosting documentation](https://langfuse.com/self-hosting) to learn more about the architecture and configuration options.
|
||||
|
||||
Templated deployments: [Railway, GCP, AWS, Azure, Kubernetes and others](https://langfuse.com/docs/deployment/self-host#platform-specific-information)
|
||||
## 🔌 Integrations
|
||||
|
||||
## Get Started
|
||||

|
||||
|
||||
### API Keys
|
||||
|
||||
You need a Langfuse public and secret key to get started. Sign up [here](https://cloud.langfuse.com) and find them in your project settings.
|
||||
|
||||
### Ingesting Data · Instrumenting Your Application · LLM Observability with Langfuse
|
||||
|
||||
Note: We recommend using our fully async, typed [SDKs](https://langfuse.com/docs/sdk) that allow you to instrument any LLM application with any underlying model. They are available in [Python (Decorators)](https://langfuse.com/docs/sdk/python) & [JS/TS](https://langfuse.com/docs/sdk/typescript). The SDKs will always be the most fully featured and stable way to ingest data into Langfuse.
|
||||
|
||||
See the [→ Quickstart](https://langfuse.com/docs/get-started) to integrate Langfuse.
|
||||
|
||||
### LLM Observability Integrations
|
||||
### Main Integrations:
|
||||
|
||||
| Integration | Supports | Description |
|
||||
| ---------------------------------------------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
@@ -120,77 +139,129 @@ See the [→ Quickstart](https://langfuse.com/docs/get-started) to integrate Lan
|
||||
| [Vercel AI SDK](https://langfuse.com/docs/integrations/vercel-ai-sdk) | JS/TS | TypeScript toolkit designed to help developers build AI-powered applications with React, Next.js, Vue, Svelte, Node.js. |
|
||||
| [API](https://langfuse.com/docs/api) | | Directly call the public API. OpenAPI spec available. |
|
||||
|
||||
Packages integrated with Langfuse:
|
||||
### Packages integrated with Langfuse:
|
||||
|
||||
| Name | Description |
|
||||
| --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| [Instructor](https://langfuse.com/docs/integrations/instructor) | Library to get structured LLM outputs (JSON, Pydantic) |
|
||||
| [Dify](https://langfuse.com/docs/integrations/dify) | Open source LLM app development platform with no-code builder. |
|
||||
| [Ollama](https://langfuse.com/docs/integrations/ollama) | Easily run open source LLMs on your own machine. |
|
||||
| [Mirascope](https://langfuse.com/docs/integrations/mirascope) | Python toolkit for building LLM applications. |
|
||||
| [Flowise](https://langfuse.com/docs/integrations/flowise) | JS/TS no-code builder for customized LLM flows. |
|
||||
| [Langflow](https://langfuse.com/docs/integrations/langflow) | Python-based UI for LangChain, designed with react-flow to provide an effortless way to experiment and prototype flows. |
|
||||
| Name | Type | Description |
|
||||
| ----------------------------------------------------------------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| [Instructor](https://langfuse.com/docs/integrations/instructor) | Library | Library to get structured LLM outputs (JSON, Pydantic) |
|
||||
| [DSPy](https://langfuse.com/docs/integrations/dspy) | Library | Framework that systematically optimizes language model prompts and weights |
|
||||
| [Mirascope](https://langfuse.com/docs/integrations/mirascope) | Library | Python toolkit for building LLM applications. |
|
||||
| [Ollama](https://langfuse.com/docs/integrations/ollama) | Model (local) | Easily run open source LLMs on your own machine. |
|
||||
| [Amazon Bedrock](https://langfuse.com/docs/integrations/amazon-bedrock) | Model | Run foundation and fine-tuned models on AWS. |
|
||||
| [AutoGen](https://langfuse.com/docs/integrations/autogen) | Agent Framework | Open source LLM platform for building distributed agents. |
|
||||
| [Flowise](https://langfuse.com/docs/integrations/flowise) | Chat/Agent UI | JS/TS no-code builder for customized LLM flows. |
|
||||
| [Langflow](https://langfuse.com/docs/integrations/langflow) | Chat/Agent UI | Python-based UI for LangChain, designed with react-flow to provide an effortless way to experiment and prototype flows. |
|
||||
| [Dify](https://langfuse.com/docs/integrations/dify) | Chat/Agent UI | Open source LLM app development platform with no-code builder. |
|
||||
| [OpenWebUI](https://langfuse.com/docs/integrations/openwebui) | Chat/Agent UI | Self-hosted LLM Chat web ui supporting various LLM runners including self-hosted and local models. |
|
||||
| [Promptfoo](https://langfuse.com/docs/integrations/promptfoo) | Tool | Open source LLM testing platform. |
|
||||
| [LobeChat](https://langfuse.com/docs/integrations/lobechat) | Chat/Agent UI | Open source chatbot platform. |
|
||||
| [Vapi](https://langfuse.com/docs/integrations/vapi) | Platform | Open source voice AI platform. |
|
||||
| [Inferable](https://langfuse.com/docs/integrations/other/inferable) | Agents | Open source LLM platform for building distributed agents. |
|
||||
| [Gradio](https://langfuse.com/docs/integrations/other/gradio) | Chat/Agent UI | Open source Python library to build web interfaces like Chat UI. |
|
||||
| [Goose](https://langfuse.com/docs/integrations/goose) | Agents | Open source LLM platform for building distributed agents. |
|
||||
| [smolagents](https://langfuse.com/docs/integrations/smolagents) | Agents | Open source AI agents framework. |
|
||||
| [CrewAI](https://langfuse.com/docs/integrations/crewai) | Agents | Multi agent framework for agent collaboration and tool use. |
|
||||
|
||||
## Questions and feedback
|
||||
## 🚀 Quickstart
|
||||
|
||||
### Ideas and roadmap
|
||||
Instrument your app and start ingesting traces to Langfuse, thereby tracking LLM calls and other relevant logic in your app such as retrieval, embedding, or agent actions. Inspect and debug complex logs and user sessions.
|
||||
|
||||
- [Roadmap](https://langfuse.com/roadmap)
|
||||
- [GitHub Discussions](https://github.com/orgs/langfuse/discussions)
|
||||
- [Feature Requests](https://langfuse.com/ideas)
|
||||
### 1️⃣ Create new project
|
||||
|
||||
### Support and feedback
|
||||
1. [Create Langfuse account](https://cloud.langfuse.com/auth/sign-up) or [self-host](https://langfuse.com/self-hosting)
|
||||
2. Create a new project
|
||||
3. Create new API credentials in the project settings
|
||||
|
||||
In order of preference the best way to communicate with us:
|
||||
### 2️⃣ Log your first LLM call
|
||||
|
||||
- [GitHub Discussions](https://github.com/orgs/langfuse/discussions) (preferred): Contribute [ideas](https://langfuse.com/ideas), [support requests](https://langfuse.com/gh-support) and [report bugs](https://langfuse.com/issues)
|
||||
- [Discord](https://langfuse.com/discord): community support
|
||||
- Privately: contact at langfuse dot com
|
||||
The [`@observe()` decorator](https://langfuse.com/docs/sdk/python/decorators) makes it easy to trace any Python LLM application. In this quickstart we also use the Langfuse [OpenAI integration](https://langfuse.com/docs/integrations/openai) to automatically capture all model parameters.
|
||||
|
||||
## Contributing to Langfuse
|
||||
> [!TIP]
|
||||
> Not using OpenAI? Visit [our documentation](https://langfuse.com/docs/get-started#log-your-first-llm-call-to-langfuse) to learn how to log other models and frameworks.
|
||||
|
||||
- Vote on [Ideas](https://github.com/orgs/langfuse/discussions/categories/ideas)
|
||||
- Raise and comment on [Issues](https://github.com/langfuse/langfuse/issues)
|
||||
```bash
|
||||
pip install langfuse openai
|
||||
```
|
||||
|
||||
```bash filename=".env"
|
||||
LANGFUSE_SECRET_KEY="sk-lf-..."
|
||||
LANGFUSE_PUBLIC_KEY="pk-lf-..."
|
||||
LANGFUSE_HOST="https://cloud.langfuse.com" # 🇪🇺 EU region
|
||||
# LANGFUSE_HOST="https://us.cloud.langfuse.com" # 🇺🇸 US region
|
||||
```
|
||||
|
||||
```python /@observe()/ /from langfuse.openai import openai/ filename="main.py"
|
||||
from langfuse.decorators import observe
|
||||
from langfuse.openai import openai # OpenAI integration
|
||||
|
||||
@observe()
|
||||
def story():
|
||||
return openai.chat.completions.create(
|
||||
model="gpt-4o",
|
||||
messages=[{"role": "user", "content": "What is Langfuse?"}],
|
||||
).choices[0].message.content
|
||||
|
||||
@observe()
|
||||
def main():
|
||||
return story()
|
||||
|
||||
main()
|
||||
```
|
||||
|
||||
### 3️⃣ See traces in Langfuse
|
||||
|
||||
See your language model calls and other application logic in Langfuse.
|
||||
|
||||

|
||||
|
||||
_[Public example trace in Langfuse](https://cloud.langfuse.com/project/cloramnkj0002jz088vzn1ja4/traces/2cec01e3-3dc2-472f-afcf-3b968cf0c1f4?timestamp=2025-02-10T14%3A27%3A30.275Z&observation=cb5ff844-07ef-41e6-b8e2-6c64344bc13b)_
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
> [Learn more](https://langfuse.com/docs/tracing) about tracing in Langfuse or play with the [interactive demo](https://langfuse.com/docs/demo).
|
||||
|
||||
## ⭐️ Star Us
|
||||
|
||||

|
||||
|
||||
## 💭 Support
|
||||
|
||||
Finding an answer to your question:
|
||||
|
||||
- Our [documentation](https://langfuse.com/docs) is the best place to start looking for answers. It is comprehensive, and we invest significant time into maintaining it. You can also suggest edits to the docs via GitHub.
|
||||
- [Langfuse FAQs](https://langfuse.com/faq) where the most common questions are answered.
|
||||
- Use "[Ask AI](https://langfuse.com/docs/ask-ai)" to get instant answers to your questions.
|
||||
|
||||
Support Channels:
|
||||
|
||||
- **Ask any question in our [public Q&A](https://github.com/orgs/langfuse/discussions/categories/support) on GitHub Discussions.** Please include as much detail as possible (e.g. code snippets, screenshots, background information) to help us understand your question.
|
||||
- [Request a feature](https://github.com/orgs/langfuse/discussions/categories/ideas) on GitHub Discussions.
|
||||
- [Report a Bug](https://github.com/langfuse/langfuse/issues) on GitHub Issues.
|
||||
- For time-sensitive queries, ping us via the in-app chat widget.
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Your contributions are welcome!
|
||||
|
||||
- Vote on [Ideas](https://github.com/orgs/langfuse/discussions/categories/ideas) in GitHub Discussions.
|
||||
- Raise and comment on [Issues](https://github.com/langfuse/langfuse/issues).
|
||||
- Open a PR - see [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to setup a development environment.
|
||||
|
||||
## License
|
||||
## 🥇 License
|
||||
|
||||
This repository is MIT licensed, except for the `ee` folders. See [LICENSE](LICENSE) and [docs](https://langfuse.com/docs/open-source) for more details.
|
||||
|
||||
## Misc
|
||||
|
||||
### GET API to export your data
|
||||
|
||||
[**GET routes**](https://langfuse.com/docs/integrations/api) to use data in downstream applications (e.g. embedded analytics). You can also access them conveniently via the SDKs ([docs](https://langfuse.com/docs/query-traces)).
|
||||
|
||||
### Security & Privacy
|
||||
|
||||
We take data security and privacy seriously. Please refer to our [Security and Privacy](https://langfuse.com/security) page for more information.
|
||||
|
||||
### Telemetry
|
||||
|
||||
By default, Langfuse automatically reports basic usage statistics of self-hosted instances to a centralized server (PostHog).
|
||||
|
||||
This helps us to:
|
||||
|
||||
1. Understand how Langfuse is used and improve the most relevant features.
|
||||
2. Track overall usage for internal and external (e.g. fundraising) reporting.
|
||||
|
||||
None of the data is shared with third parties and does not include any sensitive information. We want to be super transparent about this and you can find the exact data we collect [here](/web/src/features/telemetry/index.ts).
|
||||
|
||||
You can opt-out by setting `TELEMETRY_ENABLED=false`.
|
||||
|
||||
### Star History
|
||||
## ⭐️ Star History
|
||||
|
||||
<a href="https://star-history.com/#langfuse/langfuse&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=langfuse/langfuse&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=langfuse/langfuse&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=langfuse/langfuse&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=langfuse/langfuse&type=Date" style="border-radius: 15px;" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
### Open Source Projects Using Langfuse
|
||||
## ❤️ 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)):
|
||||
|
||||
@@ -240,3 +311,20 @@ Top open-source Python projects that use Langfuse, ranked by stars ([Source](htt
|
||||
| <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 |
|
||||
|
||||
## 🔒 Security & Privacy
|
||||
|
||||
We take data security and privacy seriously. Please refer to our [Security and Privacy](https://langfuse.com/security) page for more information.
|
||||
|
||||
### Telemetry
|
||||
|
||||
By default, Langfuse automatically reports basic usage statistics of self-hosted instances to a centralized server (PostHog).
|
||||
|
||||
This helps us to:
|
||||
|
||||
1. Understand how Langfuse is used and improve the most relevant features.
|
||||
2. Track overall usage for internal and external (e.g. fundraising) reporting.
|
||||
|
||||
None of the data is shared with third parties and does not include any sensitive information. We want to be super transparent about this and you can find the exact data we collect [here](/web/src/features/telemetry/index.ts).
|
||||
|
||||
You can opt-out by setting `TELEMETRY_ENABLED=false`.
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
_We are Hiring_
|
||||
Join us in building out Langfuse in Berlin, Germany. Langfuse is the open source LLM engineering platform: we build tooling to help developers [build & improve LLM applications](https://langfuse.com/docs).
|
||||
|
||||
We are an open source company, we hire in person (4+ days a week), we only hire excellent technical talent. Find more information on our [careers page](https://langfuse.com/careers)
|
||||
|
||||
_Open Roles_
|
||||
|
||||
- Product Engineer, 70-130k EUR, 0.1-0.35% Equity, https://www.ycombinator.com/companies/langfuse/jobs/aAvmoFB-product-engineer
|
||||
- Backend Engineer, 70-130k EUR, 0.1-0.35% Equity, https://www.ycombinator.com/companies/langfuse/jobs/1bO16H6-backend-engineer
|
||||
- Design Engineer, 70-130k EUR, 0.1-0.35% Equity, https://www.ycombinator.com/companies/langfuse/jobs/mDquP95-design-engineer
|
||||
- Developer Advocate, 70-130k EUR, 0.1-0.35% Equity, https://www.ycombinator.com/companies/langfuse/jobs/uHysbKH-developer-advocate-devrel
|
||||
|
||||
|
||||
_More Info_
|
||||
|
||||
- https://langfuse.com/careers
|
||||
- https://langfuse.com/docs
|
||||
- https://langfuse.com/why
|
||||
- https://langfuse.com/changelog
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 70 KiB |
+25
-11
@@ -1,24 +1,36 @@
|
||||
Langfuse Enterprise License (the “Enterprise License”)
|
||||
Copyright (c) 2024 Langfuse GmbH ('Langfuse')
|
||||
START NOTE
|
||||
Langfuse is an open core project. Langfuse's core
|
||||
is permissively licensed (MIT license).
|
||||
Certain parts of the periphery of Langfuse are commercially
|
||||
licensed and governed by this Enterprise License.
|
||||
For the avoidance of doubt, this license does not apply
|
||||
to the core of Langfuse as it is defined in its license in
|
||||
the directory "/LICENSE" (as opposed to this file "ee/LICENSE")
|
||||
which can be used and run without infringing the below license
|
||||
and its licensed materials.
|
||||
END NOTE
|
||||
|
||||
With regard to the Langfuse Software:
|
||||
START OF LICENSE
|
||||
Langfuse Enterprise license (the “Enterprise License” or "EE license")
|
||||
|
||||
Copyright (c) 2023-2025 Langfuse GmbH
|
||||
|
||||
With regard to the Langfuse Enterprise Software:
|
||||
This software and associated documentation files (the "Software") may only be
|
||||
used in production, if you (and any entity that you represent) have agreed to,
|
||||
and are in compliance with, the Langfuse Terms of Service, available
|
||||
used, if you (and any entity that you represent) have agreed to,
|
||||
and are in compliance with, the applicable Langfuse Terms of Service, available
|
||||
at https://langfuse.com/terms (the “Enterprise Terms”), or other
|
||||
agreement governing the use of the Software, as agreed by you and Langfuse,
|
||||
and otherwise have a valid Langfuse Enterprise license.
|
||||
and otherwise have a valid Langfuse Enterprise License.
|
||||
|
||||
Subject to the foregoing sentence, you are free to
|
||||
modify this Software and publish patches to the Software. You agree that Langfuse
|
||||
and/or its licensors (as applicable) retain all right, title and interest in and
|
||||
to all such modifications and/or patches, and all such modifications and/or
|
||||
patches may only be used, copied, modified, displayed, distributed, or otherwise
|
||||
exploited with a valid Langfuse Enterprise license.
|
||||
|
||||
Notwithstanding the foregoing, you may copy and modify
|
||||
exploited with a valid Langfuse Enterprise License. Notwithstanding the foregoing, you may copy and modify
|
||||
the Software for development and testing purposes, without requiring a
|
||||
subscription. You agree that Langfuse and/or its licensors (as applicable) retain
|
||||
subscription. You agree that Langfuse GmbH and/or its licensors (as applicable) retain
|
||||
all right, title and interest in and to all such modifications. You are not
|
||||
granted any other rights beyond what is expressly stated herein. Subject to the
|
||||
foregoing, it is forbidden to copy, merge, publish, distribute, sublicense,
|
||||
@@ -33,5 +45,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
For all third party components incorporated into the Langfuse Software, those
|
||||
components are licensed under the original license provided by the owner of the
|
||||
components are licensed under the original license provided by the owner of the
|
||||
applicable component.
|
||||
|
||||
END OF LICENSE
|
||||
|
||||
@@ -48,7 +48,6 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"jsonpath-plus": "10.0.7",
|
||||
"nanoid": "^3.3.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ groups:
|
||||
# namespaceExport: Langfuse
|
||||
# allowCustomFetcher: true
|
||||
- name: fernapi/fern-openapi
|
||||
version: 0.0.26
|
||||
version: 0.1.7
|
||||
output:
|
||||
location: local-file-system
|
||||
path: ../../../web/public/generated/api-client
|
||||
|
||||
@@ -128,7 +128,7 @@ types:
|
||||
modelParameters: optional<map<string, commons.MapValue>>
|
||||
usage: optional<IngestionUsage>
|
||||
usageDetails: optional<UsageDetails>
|
||||
costDetails: optional<map<string, float>>
|
||||
costDetails: optional<map<string, double>>
|
||||
promptName: optional<string>
|
||||
promptVersion: optional<integer>
|
||||
|
||||
@@ -141,7 +141,7 @@ types:
|
||||
usage: optional<IngestionUsage>
|
||||
promptName: optional<string>
|
||||
usageDetails: optional<UsageDetails>
|
||||
costDetails: optional<map<string, float>>
|
||||
costDetails: optional<map<string, double>>
|
||||
promptVersion: optional<integer>
|
||||
|
||||
ObservationBody:
|
||||
|
||||
@@ -9,9 +9,9 @@ service:
|
||||
update:
|
||||
docs: Update labels for a specific prompt version
|
||||
method: PATCH
|
||||
path: /prompts/{promptName}/version/{version}
|
||||
path: /prompts/{name}/versions/{version}
|
||||
path-parameters:
|
||||
promptName:
|
||||
name:
|
||||
type: string
|
||||
docs: The name of the prompt
|
||||
version:
|
||||
|
||||
@@ -90,6 +90,9 @@ types:
|
||||
tags:
|
||||
type: optional<list<string>>
|
||||
docs: List of tags to apply to all versions of this prompt.
|
||||
commitMessage:
|
||||
type: optional<string>
|
||||
docs: Commit message for this prompt version.
|
||||
|
||||
CreateTextPromptRequest:
|
||||
properties:
|
||||
@@ -102,6 +105,10 @@ types:
|
||||
tags:
|
||||
type: optional<list<string>>
|
||||
docs: List of tags to apply to all versions of this prompt.
|
||||
commitMessage:
|
||||
type: optional<string>
|
||||
docs: Commit message for this prompt version.
|
||||
|
||||
Prompt:
|
||||
union:
|
||||
chat: ChatPrompt
|
||||
@@ -118,6 +125,9 @@ types:
|
||||
tags:
|
||||
type: list<string>
|
||||
docs: List of tags. Used to filter via UI and API. The same across versions of a prompt.
|
||||
commitMessage:
|
||||
type: optional<string>
|
||||
docs: Commit message for this prompt version.
|
||||
|
||||
ChatMessage:
|
||||
properties:
|
||||
|
||||
@@ -59,7 +59,7 @@ service:
|
||||
type: optional<commons.ScoreDataType>
|
||||
docs: Retrieve only scores with a specific dataType.
|
||||
traceTags:
|
||||
type: optional<list<string>>
|
||||
type: optional<string>
|
||||
allow-multiple: true
|
||||
docs: Only scores linked to traces that include all of these tags will be returned.
|
||||
response: GetScoresResponse
|
||||
|
||||
@@ -3,7 +3,7 @@ groups:
|
||||
local:
|
||||
generators:
|
||||
- name: fernapi/fern-openapi
|
||||
version: 0.0.31
|
||||
version: 0.1.7
|
||||
output:
|
||||
location: local-file-system
|
||||
path: ../../../web/public/generated/api
|
||||
|
||||
+1
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "langfuse",
|
||||
"version": "3.16.0",
|
||||
"version": "3.27.2",
|
||||
"author": "engineering@langfuse.com",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
@@ -83,7 +83,6 @@
|
||||
"packageManager": "pnpm@9.5.0",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"jsonpath-plus": "10.0.7",
|
||||
"nanoid": "^3.3.8",
|
||||
"katex": "^0.16.21"
|
||||
},
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE event_log ON CLUSTER default;
|
||||
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE event_log ON CLUSTER default
|
||||
(
|
||||
`id` String,
|
||||
`project_id` String,
|
||||
`entity_type` String,
|
||||
`entity_id` String,
|
||||
`event_id` Nullable(String),
|
||||
|
||||
`bucket_name` String,
|
||||
`bucket_path` String,
|
||||
|
||||
`created_at` DateTime64(3) DEFAULT now(),
|
||||
`updated_at` DateTime64(3) DEFAULT now()
|
||||
) ENGINE = MergeTree()
|
||||
ORDER BY (
|
||||
project_id,
|
||||
entity_type,
|
||||
entity_id
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE event_log;
|
||||
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE event_log
|
||||
(
|
||||
`id` String,
|
||||
`project_id` String,
|
||||
`entity_type` String,
|
||||
`entity_id` String,
|
||||
`event_id` Nullable(String),
|
||||
|
||||
`bucket_name` String,
|
||||
`bucket_path` String,
|
||||
|
||||
`created_at` DateTime64(3) DEFAULT now(),
|
||||
`updated_at` DateTime64(3) DEFAULT now()
|
||||
) ENGINE = MergeTree()
|
||||
ORDER BY (
|
||||
project_id,
|
||||
entity_type,
|
||||
entity_id
|
||||
);
|
||||
@@ -33,7 +33,7 @@
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
|
||||
"lint": "eslint . --ext .js,.jsx,.ts,.tsx --max-warnings 110",
|
||||
"lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix",
|
||||
"db:migrate": "DISABLE_ERD=false dotenv -e ../../.env -- npx prisma migrate dev",
|
||||
"db:push": "DISABLE_ERD=false dotenv -e ../../.env -- npx prisma db push",
|
||||
@@ -61,26 +61,26 @@
|
||||
"@aws-sdk/s3-request-presigner": "^3.679.0",
|
||||
"@azure/storage-blob": "^12.26.0",
|
||||
"@clickhouse/client": "^1.4.0",
|
||||
"@langchain/anthropic": "^0.3.8",
|
||||
"@langchain/aws": "^0.1.2",
|
||||
"@langchain/core": "^0.3.18",
|
||||
"@langchain/google-vertexai": "^0.1.3",
|
||||
"@langchain/openai": "^0.3.14",
|
||||
"@langchain/anthropic": "^0.3.12",
|
||||
"@langchain/aws": "^0.1.3",
|
||||
"@langchain/core": "^0.3.37",
|
||||
"@langchain/google-vertexai": "^0.1.8",
|
||||
"@langchain/openai": "^0.3.17",
|
||||
"@opentelemetry/api": ">=1.0.0 <1.10.0",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"@prisma/client": "^6.3.0",
|
||||
"@react-email/components": "^0.0.19",
|
||||
"@react-email/render": "^0.0.15",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"axios": "^1.7.7",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bullmq": "^5.34.10",
|
||||
"dd-trace": "^5.23.1",
|
||||
"dd-trace": "^5.36.0",
|
||||
"decimal.js": "^10.4.3",
|
||||
"exponential-backoff": "^3.1.1",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"ioredis": "^5.4.1",
|
||||
"kysely": "^0.27.4",
|
||||
"langchain": "^0.3.6",
|
||||
"langchain": "^0.3.15",
|
||||
"langfuse-langchain": "3.30.3",
|
||||
"lodash": "^4.17.21",
|
||||
"next-auth": "^4.24.11",
|
||||
@@ -107,7 +107,7 @@
|
||||
"kysely-codegen": "^0.16.8",
|
||||
"nodemon": "^3.1.7",
|
||||
"prettier": "^3.3.3",
|
||||
"prisma": "^5.22.0",
|
||||
"prisma": "^6.3.0",
|
||||
"prisma-erd-generator": "^1.11.2",
|
||||
"prisma-kysely": "^1.8.0",
|
||||
"ts-node": "^10.9.2",
|
||||
@@ -122,7 +122,6 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"jsonpath-plus": "10.0.7",
|
||||
"nanoid": "^3.3.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,16 +253,6 @@ export type EvalTemplate = {
|
||||
vars: Generated<string[]>;
|
||||
output_schema: unknown;
|
||||
};
|
||||
export type Events = {
|
||||
id: string;
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
project_id: string;
|
||||
data: unknown;
|
||||
headers: Generated<unknown>;
|
||||
url: string | null;
|
||||
method: string | null;
|
||||
};
|
||||
export type JobConfiguration = {
|
||||
id: string;
|
||||
created_at: Generated<Timestamp>;
|
||||
@@ -492,6 +482,7 @@ export type Prompt = {
|
||||
config: Generated<unknown>;
|
||||
tags: Generated<string[]>;
|
||||
labels: Generated<string[]>;
|
||||
commit_message: string | null;
|
||||
};
|
||||
export type QueueBackUp = {
|
||||
id: string;
|
||||
@@ -633,7 +624,6 @@ export type DB = {
|
||||
dataset_runs: DatasetRuns;
|
||||
datasets: Dataset;
|
||||
eval_templates: EvalTemplate;
|
||||
events: Events;
|
||||
job_configurations: JobConfiguration;
|
||||
job_executions: JobExecution;
|
||||
llm_api_keys: LlmApiKeys;
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
UPDATE "llm_api_keys" SET adapter = 'google-vertex-ai' WHERE adapter = 'vertex-ai';
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "prompts" ADD COLUMN "commit_message" TEXT;
|
||||
@@ -0,0 +1,20 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "event_log" (
|
||||
"id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"bucket_name" TEXT NOT NULL,
|
||||
"bucket_path" TEXT NOT NULL,
|
||||
"project_id" TEXT NOT NULL,
|
||||
"entity_type" TEXT NOT NULL,
|
||||
"entity_id" TEXT NOT NULL,
|
||||
"event_id" TEXT NOT NULL,
|
||||
|
||||
"trace_id" TEXT,
|
||||
|
||||
CONSTRAINT "event_log_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "event_log_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "event_log_project_id_entity_type_entity_id_idx" ON "event_log"("project_id", "entity_type", "entity_id");
|
||||
@@ -0,0 +1,2 @@
|
||||
-- DropTable
|
||||
DROP TABLE event_log;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- DropTable
|
||||
DROP TABLE events;
|
||||
@@ -122,7 +122,6 @@ model Project {
|
||||
observations Observation[]
|
||||
apiKeys ApiKey[]
|
||||
dataset Dataset[]
|
||||
RawEvents Events[]
|
||||
invitations MembershipInvitation[]
|
||||
sessions TraceSession[]
|
||||
Prompt Prompt[]
|
||||
@@ -691,21 +690,6 @@ model DatasetRunItems {
|
||||
@@map("dataset_run_items")
|
||||
}
|
||||
|
||||
model Events {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
projectId String @map("project_id")
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
data Json
|
||||
headers Json @default("{}")
|
||||
url String?
|
||||
method String?
|
||||
|
||||
@@index(projectId)
|
||||
@@map("events")
|
||||
}
|
||||
|
||||
model Comment {
|
||||
id String @id @default(cuid())
|
||||
projectId String @map("project_id")
|
||||
@@ -738,14 +722,15 @@ model Prompt {
|
||||
|
||||
createdBy String @map("created_by")
|
||||
|
||||
prompt Json
|
||||
name String
|
||||
version Int
|
||||
type String @default("text")
|
||||
isActive Boolean? @map("is_active") // Deprecated. To be removed once 'production' labels work as expected.
|
||||
config Json @default("{}") @db.Json
|
||||
tags String[] @default([])
|
||||
labels String[] @default([])
|
||||
prompt Json
|
||||
name String
|
||||
version Int
|
||||
type String @default("text")
|
||||
isActive Boolean? @map("is_active") // Deprecated. To be removed once 'production' labels work as expected.
|
||||
config Json @default("{}") @db.Json
|
||||
tags String[] @default([])
|
||||
labels String[] @default([])
|
||||
commitMessage String? @map("commit_message")
|
||||
|
||||
@@unique([projectId, name, version])
|
||||
@@index([projectId, id])
|
||||
|
||||
@@ -96,18 +96,14 @@ export const prepareClickhouse = async (
|
||||
'version' AS version,
|
||||
repeat('input', toInt64(randExponential(1 / 100))) AS input,
|
||||
repeat('output', toInt64(randExponential(1 / 100))) AS output,
|
||||
if("type" = 'GENERATION',
|
||||
case
|
||||
when number % 2 = 0 then 'claude-3-haiku-20240307'
|
||||
else 'gpt-4'
|
||||
end,
|
||||
NULL) as provided_model_name,
|
||||
if("type" = 'GENERATION',
|
||||
case
|
||||
when number % 2 = 0 then 'cltr0w45b000008k1407o9qv1'
|
||||
else 'clrntkjgy000f08jx79v9g1xj'
|
||||
end,
|
||||
NULL) as internal_model_id,
|
||||
case
|
||||
when number % 2 = 0 then 'clause-3-haiku-20230407'
|
||||
else 'gpt-4'
|
||||
end as provided_model_name,
|
||||
case
|
||||
when number % 2 = 0 then 'cltra4wbs0000k1407g0ya3'
|
||||
else '1cmtk9y0000y3y79x9jgxj'
|
||||
end as internal_model_id,
|
||||
if("type" = 'GENERATION',
|
||||
'{"temperature": 0.7, "max_tokens": 150}',
|
||||
'{}') AS model_parameters,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import z from "zod";
|
||||
import { singleFilter } from "../../interfaces/filters";
|
||||
import { orderBy } from "../../interfaces/orderBy";
|
||||
import { BatchExportTableName } from "../batchExport/types";
|
||||
|
||||
/* eslint-disable no-unused-vars */
|
||||
export enum BatchActionType {
|
||||
Create = "create",
|
||||
Delete = "delete",
|
||||
}
|
||||
|
||||
const ActionIdSchema = z.enum([
|
||||
"trace-delete",
|
||||
"trace-add-to-annotation-queue",
|
||||
]);
|
||||
|
||||
export type ActionId = z.infer<typeof ActionIdSchema>;
|
||||
|
||||
export const BatchActionQuerySchema = z.object({
|
||||
filter: z.array(singleFilter).nullable(),
|
||||
orderBy,
|
||||
});
|
||||
|
||||
export type BatchActionQuery = z.infer<typeof BatchActionQuerySchema>;
|
||||
|
||||
export const CreateBatchActionSchema = z.object({
|
||||
projectId: z.string(),
|
||||
actionId: ActionIdSchema,
|
||||
targetId: z.string().optional(),
|
||||
query: BatchActionQuerySchema,
|
||||
tableName: z.nativeEnum(BatchExportTableName),
|
||||
});
|
||||
|
||||
export const GetIsBatchActionInProgressSchema = z.object({
|
||||
projectId: z.string(),
|
||||
actionId: ActionIdSchema,
|
||||
tableName: z.nativeEnum(BatchExportTableName),
|
||||
});
|
||||
@@ -17,6 +17,7 @@ export const variableMapping = z
|
||||
objectName: z.string().nullish(),
|
||||
langfuseObject: z.enum(langfuseObjects),
|
||||
selectedColumnId: z.string(),
|
||||
jsonSelector: z.string().nullish(),
|
||||
})
|
||||
.refine(
|
||||
(value) => value.langfuseObject === "trace" || value.objectName !== null,
|
||||
@@ -32,6 +33,7 @@ export const wipVariableMapping = z.object({
|
||||
objectName: z.string().nullish(),
|
||||
langfuseObject: z.enum(langfuseObjects),
|
||||
selectedColumnId: z.string().nullish(),
|
||||
jsonSelector: z.string().nullish(),
|
||||
});
|
||||
|
||||
const observationCols = [
|
||||
|
||||
@@ -21,7 +21,10 @@ export * from "./server/llm/types";
|
||||
|
||||
// evals
|
||||
export * from "./features/evals/types";
|
||||
|
||||
// table actions
|
||||
export * from "./features/batchExport/types";
|
||||
export * from "./features/batchAction/types";
|
||||
|
||||
// annotation
|
||||
export * from "./features/annotation/types";
|
||||
|
||||
@@ -1,27 +1,34 @@
|
||||
import { createClient } from "@clickhouse/client";
|
||||
import { env } from "../../env";
|
||||
import { NodeClickHouseClientConfigOptions } from "@clickhouse/client/dist/config";
|
||||
import { getCurrentSpan } from "../instrumentation";
|
||||
import { propagation, context } from "@opentelemetry/api";
|
||||
|
||||
export type ClickhouseClientType = ReturnType<typeof createClient>;
|
||||
|
||||
export const clickhouseClient = (opts?: NodeClickHouseClientConfigOptions) =>
|
||||
createClient({
|
||||
export const clickhouseClient = (opts?: NodeClickHouseClientConfigOptions) => {
|
||||
const headers = opts?.http_headers ?? {};
|
||||
const activeSpan = getCurrentSpan();
|
||||
if (activeSpan) {
|
||||
propagation.inject(context.active(), headers);
|
||||
}
|
||||
return createClient({
|
||||
...opts,
|
||||
url: env.CLICKHOUSE_URL,
|
||||
username: env.CLICKHOUSE_USER,
|
||||
password: env.CLICKHOUSE_PASSWORD,
|
||||
database: env.CLICKHOUSE_DB,
|
||||
http_headers: headers,
|
||||
clickhouse_settings: {
|
||||
async_insert: 1,
|
||||
wait_for_async_insert: 1, // if disabled, we won't get errors from clickhouse
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const defaultClickhouseClient = clickhouseClient();
|
||||
/**
|
||||
* Accepts a JavaScript date and returns the DateTime in format YYYY-MM-DD HH:MM:SS
|
||||
*/
|
||||
|
||||
export const convertDateToClickhouseDateTime = (date: Date): string => {
|
||||
// 2024-11-06T20:37:00.123Z -> 2024-11-06 21:37:00.123
|
||||
return date.toISOString().replace("T", " ").replace("Z", "");
|
||||
|
||||
@@ -29,6 +29,7 @@ export * from "./redis/traceDelete";
|
||||
export * from "./redis/projectDelete";
|
||||
export * from "./redis/datasetRunItemUpsert";
|
||||
export * from "./redis/batchExport";
|
||||
export * from "./redis/batchActionQueue";
|
||||
export * from "./redis/ingestionQueue";
|
||||
export * from "./redis/postHogIntegrationQueue";
|
||||
export * from "./redis/postHogIntegrationProcessingQueue";
|
||||
|
||||
@@ -4,7 +4,6 @@ import { z } from "zod";
|
||||
import { type Model } from "../../db";
|
||||
import { env } from "../../env";
|
||||
import {
|
||||
ForbiddenError,
|
||||
InvalidRequestError,
|
||||
LangfuseNotFoundError,
|
||||
UnauthorizedError,
|
||||
@@ -22,16 +21,11 @@ import { logger } from "../logger";
|
||||
import { QueueJobs } from "../queues";
|
||||
import { IngestionQueue } from "../redis/ingestionQueue";
|
||||
import { redis } from "../redis/redis";
|
||||
import { eventTypes, ingestionEvent, IngestionEventType } from "./types";
|
||||
import {
|
||||
StorageService,
|
||||
StorageServiceFactory,
|
||||
} from "../services/StorageService";
|
||||
import { eventTypes, ingestionEvent, IngestionEventType } from "./types";
|
||||
|
||||
export type TokenCountDelegate = (p: {
|
||||
model: Model;
|
||||
text: unknown;
|
||||
}) => number | undefined;
|
||||
|
||||
let s3StorageServiceClient: StorageService;
|
||||
|
||||
@@ -49,9 +43,45 @@ const getS3StorageServiceClient = (bucketName: string): StorageService => {
|
||||
return s3StorageServiceClient;
|
||||
};
|
||||
|
||||
export type TokenCountDelegate = (p: {
|
||||
model: Model;
|
||||
text: unknown;
|
||||
}) => number | undefined;
|
||||
|
||||
/**
|
||||
* Get the delay for the event based on the event type. Uses delay if set, 0 if current UTC timestamp is not between
|
||||
* 23:45 and 00:15, and env.LANGFUSE_INGESTION_QUEUE_DELAY_MS otherwise.
|
||||
* We need the delay around date boundaries to avoid duplicates for out-of-order processing of events.
|
||||
* @param delay - Delay overwrite. Used if non-null.
|
||||
*/
|
||||
const getDelay = (delay: number | null) => {
|
||||
if (delay !== null) {
|
||||
return delay;
|
||||
}
|
||||
const now = new Date();
|
||||
const hours = now.getUTCHours();
|
||||
const minutes = now.getUTCMinutes();
|
||||
|
||||
if ((hours === 23 && minutes >= 45) || (hours === 0 && minutes <= 15)) {
|
||||
return env.LANGFUSE_INGESTION_QUEUE_DELAY_MS;
|
||||
}
|
||||
|
||||
// Use 5s here to avoid duplicate processing on the worker. If the ingestion delay is set to a lower value,
|
||||
// we use this instead.
|
||||
// Values should be revisited based on a cost/performance trade-off.
|
||||
return Math.min(5000, env.LANGFUSE_INGESTION_QUEUE_DELAY_MS);
|
||||
};
|
||||
|
||||
/**
|
||||
* Processes a batch of events.
|
||||
* @param input - Batch of IngestionEventType. Will validate the types first thing and return errors if they are invalid.
|
||||
* @param authCheck - AuthHeaderValidVerificationResult
|
||||
* @param delay - (Optional) Delay in ms to wait before processing events in the batch.
|
||||
*/
|
||||
export const processEventBatch = async (
|
||||
input: unknown[],
|
||||
authCheck: AuthHeaderValidVerificationResult,
|
||||
delay: number | null = null,
|
||||
): Promise<{
|
||||
successes: { id: string; status: number }[];
|
||||
errors: {
|
||||
@@ -64,7 +94,10 @@ export const processEventBatch = async (
|
||||
// add context of api call to the span
|
||||
const currentSpan = getCurrentSpan();
|
||||
recordIncrement("langfuse.ingestion.event", input.length);
|
||||
currentSpan?.setAttribute("event_count", input.length);
|
||||
currentSpan?.setAttribute("langfuse.ingestion.batch_size", input.length);
|
||||
currentSpan?.setAttribute("langfuse.project.id", authCheck.scope.projectId);
|
||||
currentSpan?.setAttribute("langfuse.org.id", authCheck.scope.orgId);
|
||||
currentSpan?.setAttribute("langfuse.org.plan", authCheck.scope.plan);
|
||||
|
||||
/**************
|
||||
* VALIDATION *
|
||||
@@ -79,7 +112,10 @@ export const processEventBatch = async (
|
||||
(span) => {
|
||||
const parsedBody = ingestionEvent.safeParse(event);
|
||||
if (parsedBody.data?.id !== undefined) {
|
||||
span.setAttribute("object.id", parsedBody.data.id);
|
||||
span.setAttribute(
|
||||
"langfuse.ingestion.entity.id",
|
||||
parsedBody.data.id,
|
||||
);
|
||||
}
|
||||
return parsedBody;
|
||||
},
|
||||
@@ -154,9 +190,6 @@ export const processEventBatch = async (
|
||||
********************/
|
||||
let s3UploadErrored = false;
|
||||
await instrumentAsync({ name: "s3-upload-events" }, async () => {
|
||||
const s3Client = getS3StorageServiceClient(
|
||||
env.LANGFUSE_S3_EVENT_UPLOAD_BUCKET,
|
||||
);
|
||||
// S3 Event Upload is blocking, but non-failing.
|
||||
// If a promise rejects, we log it below, but do not throw an error.
|
||||
// In this case, we upload the full batch into the Redis queue.
|
||||
@@ -166,10 +199,10 @@ export const processEventBatch = async (
|
||||
// That way we batch updates from the same invocation into a single file and reduce
|
||||
// write operations on S3.
|
||||
const { data, key, type, eventBodyId } = sortedBatchByEventBodyId[id];
|
||||
return s3Client.uploadJson(
|
||||
`${env.LANGFUSE_S3_EVENT_UPLOAD_PREFIX}${authCheck.scope.projectId}/${getClickhouseEntityType(type)}/${eventBodyId}/${key}.json`,
|
||||
data,
|
||||
);
|
||||
const bucketPath = `${env.LANGFUSE_S3_EVENT_UPLOAD_PREFIX}${authCheck.scope.projectId}/${getClickhouseEntityType(type)}/${eventBodyId}/${key}.json`;
|
||||
return getS3StorageServiceClient(
|
||||
env.LANGFUSE_S3_EVENT_UPLOAD_BUCKET,
|
||||
).uploadJson(bucketPath, data);
|
||||
}),
|
||||
);
|
||||
results.forEach((result) => {
|
||||
@@ -207,13 +240,12 @@ export const processEventBatch = async (
|
||||
data: {
|
||||
type: sortedBatchByEventBodyId[id].type,
|
||||
eventBodyId: sortedBatchByEventBodyId[id].eventBodyId,
|
||||
fileKey: sortedBatchByEventBodyId[id].key,
|
||||
},
|
||||
authCheck,
|
||||
},
|
||||
},
|
||||
{
|
||||
delay: env.LANGFUSE_INGESTION_QUEUE_DELAY_MS,
|
||||
},
|
||||
{ delay: getDelay(delay) },
|
||||
)
|
||||
: Promise.reject("Failed to instantiate queue"),
|
||||
),
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { ZodSchema } from "zod";
|
||||
|
||||
import { CallbackHandler } from "langfuse-langchain";
|
||||
|
||||
import { ChatAnthropic } from "@langchain/anthropic";
|
||||
import { ChatVertexAI } from "@langchain/google-vertexai";
|
||||
import { ChatBedrockConverse } from "@langchain/aws";
|
||||
@@ -21,27 +19,20 @@ import GCPServiceAccountKeySchema, {
|
||||
BedrockConfigSchema,
|
||||
BedrockCredentialSchema,
|
||||
} from "../../interfaces/customLLMProviderConfigSchemas";
|
||||
import { AuthHeaderValidVerificationResult } from "../auth/types";
|
||||
import {
|
||||
processEventBatch,
|
||||
type TokenCountDelegate,
|
||||
} from "../ingestion/processEventBatch";
|
||||
import { processEventBatch } from "../ingestion/processEventBatch";
|
||||
import { logger } from "../logger";
|
||||
import { ChatMessage, ChatMessageRole, LLMAdapter, ModelParams } from "./types";
|
||||
|
||||
import {
|
||||
ChatMessage,
|
||||
ChatMessageRole,
|
||||
LLMAdapter,
|
||||
ModelParams,
|
||||
TraceParams,
|
||||
} from "./types";
|
||||
import { CallbackHandler } from "langfuse-langchain";
|
||||
import type { BaseCallbackHandler } from "@langchain/core/callbacks/base";
|
||||
|
||||
type ProcessTracedEvents = () => Promise<void>;
|
||||
|
||||
export type TraceParams = {
|
||||
traceName: string;
|
||||
traceId: string;
|
||||
projectId: string;
|
||||
tags: string[];
|
||||
tokenCountDelegate: TokenCountDelegate;
|
||||
authCheck: AuthHeaderValidVerificationResult;
|
||||
};
|
||||
|
||||
type LLMCompletionParams = {
|
||||
messages: ChatMessage[];
|
||||
modelParams: ModelParams;
|
||||
@@ -53,6 +44,7 @@ type LLMCompletionParams = {
|
||||
maxRetries?: number;
|
||||
config?: Record<string, string> | null;
|
||||
traceParams?: TraceParams;
|
||||
throwOnError?: boolean; // default is true
|
||||
};
|
||||
|
||||
type FetchLLMCompletionParams = LLMCompletionParams & {
|
||||
@@ -102,6 +94,7 @@ export async function fetchLLMCompletion(
|
||||
config,
|
||||
traceParams,
|
||||
extraHeaders,
|
||||
throwOnError = true,
|
||||
} = params;
|
||||
|
||||
let finalCallbacks: BaseCallbackHandler[] | undefined = callbacks ?? [];
|
||||
@@ -113,7 +106,6 @@ export async function fetchLLMCompletion(
|
||||
_isLocalEventExportEnabled: true,
|
||||
tags: traceParams.tags,
|
||||
});
|
||||
|
||||
finalCallbacks.push(handler);
|
||||
|
||||
processTracedEvents = async () => {
|
||||
@@ -239,17 +231,18 @@ export async function fetchLLMCompletion(
|
||||
runName: traceParams?.traceName,
|
||||
};
|
||||
|
||||
if (params.structuredOutputSchema) {
|
||||
return {
|
||||
completion: await (chatModel as ChatOpenAI) // Typecast necessary due to https://github.com/langchain-ai/langchainjs/issues/6795
|
||||
.withStructuredOutput(params.structuredOutputSchema)
|
||||
.invoke(finalMessages, runConfig),
|
||||
processTracedEvents,
|
||||
};
|
||||
}
|
||||
try {
|
||||
if (params.structuredOutputSchema) {
|
||||
return {
|
||||
completion: await (chatModel as ChatOpenAI) // Typecast necessary due to https://github.com/langchain-ai/langchainjs/issues/6795
|
||||
.withStructuredOutput(params.structuredOutputSchema)
|
||||
.invoke(finalMessages, runConfig),
|
||||
processTracedEvents,
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
Workaround OpenAI o1 while in beta:
|
||||
/*
|
||||
Workaround OpenAI reasoning models:
|
||||
|
||||
This is a temporary workaround to avoid sending system messages to OpenAI's O1 models.
|
||||
O1 models do not support in beta:
|
||||
@@ -260,43 +253,55 @@ export async function fetchLLMCompletion(
|
||||
|
||||
Reference: https://platform.openai.com/docs/guides/reasoning/beta-limitations
|
||||
*/
|
||||
if (modelParams.model.startsWith("o1-")) {
|
||||
return {
|
||||
completion: await new ChatOpenAI({
|
||||
openAIApiKey: apiKey,
|
||||
modelName: modelParams.model,
|
||||
temperature: 1,
|
||||
maxTokens: undefined,
|
||||
topP: undefined,
|
||||
callbacks,
|
||||
maxRetries,
|
||||
configuration: {
|
||||
baseURL,
|
||||
},
|
||||
timeout: 1000 * 60 * 2, // 2 minutes timeout
|
||||
})
|
||||
.pipe(new StringOutputParser())
|
||||
.invoke(
|
||||
finalMessages.filter((message) => message._getType() !== "system"),
|
||||
runConfig,
|
||||
),
|
||||
processTracedEvents,
|
||||
};
|
||||
}
|
||||
if (
|
||||
modelParams.model.startsWith("o1-") ||
|
||||
modelParams.model.startsWith("o3-")
|
||||
) {
|
||||
return {
|
||||
completion: await new ChatOpenAI({
|
||||
openAIApiKey: apiKey,
|
||||
modelName: modelParams.model,
|
||||
temperature: 1,
|
||||
maxTokens: undefined,
|
||||
topP: undefined,
|
||||
callbacks,
|
||||
maxRetries,
|
||||
modelKwargs: {
|
||||
max_completion_tokens: modelParams.max_tokens,
|
||||
},
|
||||
configuration: {
|
||||
baseURL,
|
||||
},
|
||||
timeout: 1000 * 60 * 2, // 2 minutes timeout
|
||||
})
|
||||
.pipe(new StringOutputParser())
|
||||
.invoke(
|
||||
finalMessages.filter((message) => message._getType() !== "system"),
|
||||
runConfig,
|
||||
),
|
||||
processTracedEvents,
|
||||
};
|
||||
}
|
||||
|
||||
if (streaming) {
|
||||
return {
|
||||
completion: await chatModel
|
||||
.pipe(new BytesOutputParser())
|
||||
.stream(finalMessages, runConfig),
|
||||
processTracedEvents,
|
||||
};
|
||||
}
|
||||
|
||||
if (streaming) {
|
||||
return {
|
||||
completion: await chatModel
|
||||
.pipe(new BytesOutputParser())
|
||||
.stream(finalMessages, runConfig),
|
||||
.pipe(new StringOutputParser())
|
||||
.invoke(finalMessages, runConfig),
|
||||
processTracedEvents,
|
||||
};
|
||||
} catch (error) {
|
||||
if (throwOnError) {
|
||||
throw error;
|
||||
}
|
||||
return { completion: null, processTracedEvents };
|
||||
}
|
||||
|
||||
return {
|
||||
completion: await chatModel
|
||||
.pipe(new StringOutputParser())
|
||||
.invoke(finalMessages, runConfig),
|
||||
processTracedEvents,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { LlmApiKeys } from "@prisma/client";
|
||||
import z from "zod";
|
||||
import { BedrockConfigSchema } from "../../interfaces/customLLMProviderConfigSchemas";
|
||||
import { TokenCountDelegate } from "../ingestion/processEventBatch";
|
||||
import { AuthHeaderValidVerificationResult } from "../auth/types";
|
||||
|
||||
export type PromptVariable = { name: string; value: string; isUsed: boolean };
|
||||
|
||||
@@ -16,7 +18,7 @@ export enum LLMAdapter {
|
||||
OpenAI = "openai",
|
||||
Azure = "azure",
|
||||
Bedrock = "bedrock",
|
||||
VertexAI = "vertex-ai",
|
||||
VertexAI = "google-vertex-ai",
|
||||
}
|
||||
|
||||
export enum ChatMessageRole {
|
||||
@@ -71,6 +73,7 @@ export const ExperimentMetadataSchema = z
|
||||
provider: z.string(),
|
||||
model: z.string(),
|
||||
model_params: ZodModelConfig,
|
||||
error: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
export type ExperimentMetadata = z.infer<typeof ExperimentMetadataSchema>;
|
||||
@@ -82,6 +85,8 @@ export const openAIModels = [
|
||||
"gpt-4o-2024-05-13",
|
||||
"gpt-4o-mini",
|
||||
"gpt-4o-mini-2024-07-18",
|
||||
"o3-mini",
|
||||
"o3-mini-2025-01-31",
|
||||
"o1-preview",
|
||||
"o1-preview-2024-09-12",
|
||||
"o1-mini",
|
||||
@@ -116,6 +121,9 @@ export const anthropicModels = [
|
||||
] as const;
|
||||
|
||||
export const vertexAIModels = [
|
||||
"gemini-2.0-pro-exp-02-05",
|
||||
"gemini-2.0-flash-001",
|
||||
"gemini-2.0-flash-lite-preview-02-05",
|
||||
"gemini-2.0-flash-exp",
|
||||
"gemini-1.5-pro",
|
||||
"gemini-1.5-flash",
|
||||
@@ -163,3 +171,12 @@ export type LLMApiKey =
|
||||
z.infer<typeof LLMApiKeySchema> extends LlmApiKeys
|
||||
? z.infer<typeof LLMApiKeySchema>
|
||||
: never;
|
||||
|
||||
export type TraceParams = {
|
||||
traceName: string;
|
||||
traceId: string;
|
||||
projectId: string;
|
||||
tags: string[];
|
||||
tokenCountDelegate: TokenCountDelegate;
|
||||
authCheck: AuthHeaderValidVerificationResult;
|
||||
};
|
||||
|
||||
@@ -9,5 +9,5 @@ export function decryptAndParseExtraHeaders(
|
||||
) {
|
||||
if (!extraHeaders) return;
|
||||
|
||||
return ExtraHeaderSchema.parse(decrypt(extraHeaders));
|
||||
return ExtraHeaderSchema.parse(JSON.parse(decrypt(extraHeaders)));
|
||||
}
|
||||
|
||||
@@ -383,6 +383,10 @@ export class FilterList {
|
||||
return this.filters.find(predicate);
|
||||
}
|
||||
|
||||
some(predicate: (filter: Filter) => boolean) {
|
||||
return this.filters.some(predicate);
|
||||
}
|
||||
|
||||
length() {
|
||||
return this.filters.length;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { z } from "zod";
|
||||
import { eventTypes, ingestionBatchEvent } from ".";
|
||||
import { eventTypes } from ".";
|
||||
import {
|
||||
BatchActionQuerySchema,
|
||||
BatchActionType,
|
||||
} from "../features/batchAction/types";
|
||||
import { BatchExportTableName } from "../features/batchExport/types";
|
||||
|
||||
export const IngestionEvent = z.object({
|
||||
data: z.object({
|
||||
type: z.nativeEnum(eventTypes),
|
||||
eventBodyId: z.string(),
|
||||
fileKey: z.string().optional(),
|
||||
}),
|
||||
authCheck: z.object({
|
||||
validKey: z.literal(true),
|
||||
@@ -55,6 +61,15 @@ export const DataRetentionProcessingEventSchema = z.object({
|
||||
projectId: z.string(),
|
||||
retention: z.number(),
|
||||
});
|
||||
export const BatchActionProcessingEventSchema = z.object({
|
||||
projectId: z.string(),
|
||||
actionId: z.string(),
|
||||
query: BatchActionQuerySchema,
|
||||
tableName: z.nativeEnum(BatchExportTableName),
|
||||
cutoffCreatedAt: z.date(),
|
||||
targetId: z.string().optional(),
|
||||
type: z.nativeEnum(BatchActionType),
|
||||
});
|
||||
|
||||
export type BatchExportJobType = z.infer<typeof BatchExportJobSchema>;
|
||||
export type TraceQueueEventType = z.infer<typeof TraceQueueEventSchema>;
|
||||
@@ -74,6 +89,9 @@ export type PostHogIntegrationProcessingEventType = z.infer<
|
||||
export type DataRetentionProcessingEventType = z.infer<
|
||||
typeof DataRetentionProcessingEventSchema
|
||||
>;
|
||||
export type BatchActionProcessingEventType = z.infer<
|
||||
typeof BatchActionProcessingEventSchema
|
||||
>;
|
||||
|
||||
export enum QueueName {
|
||||
TraceUpsert = "trace-upsert", // Ingestion pipeline adds events on each Trace upsert
|
||||
@@ -92,6 +110,7 @@ export enum QueueName {
|
||||
MeteringDataPostgresExportQueue = "metering-data-postgres-export-queue",
|
||||
DataRetentionQueue = "data-retention-queue",
|
||||
DataRetentionProcessingQueue = "data-retention-processing-queue",
|
||||
BatchActionQueue = "batch-action-queue",
|
||||
}
|
||||
|
||||
export enum QueueJobs {
|
||||
@@ -111,6 +130,7 @@ export enum QueueJobs {
|
||||
MeteringDataPostgresExportJob = "metering-data-postgres-export-job",
|
||||
DataRetentionJob = "data-retention-job",
|
||||
DataRetentionProcessingJob = "data-retention-processing-job",
|
||||
BatchActionProcessingJob = "batch-action-processing-job",
|
||||
}
|
||||
|
||||
export type TQueueJobTypes = {
|
||||
@@ -180,4 +200,10 @@ export type TQueueJobTypes = {
|
||||
payload: DataRetentionProcessingEventType;
|
||||
name: QueueJobs.DataRetentionProcessingJob;
|
||||
};
|
||||
[QueueName.BatchActionQueue]: {
|
||||
timestamp: Date;
|
||||
id: string;
|
||||
payload: BatchActionProcessingEventType;
|
||||
name: QueueJobs.BatchActionProcessingJob;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Queue } from "bullmq";
|
||||
import { QueueName, TQueueJobTypes } from "../queues";
|
||||
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
|
||||
import { logger } from "../logger";
|
||||
|
||||
export class BatchActionQueue {
|
||||
private static instance: Queue<
|
||||
TQueueJobTypes[QueueName.BatchActionQueue]
|
||||
> | null = null;
|
||||
|
||||
public static getInstance(): Queue<
|
||||
TQueueJobTypes[QueueName.BatchActionQueue]
|
||||
> | null {
|
||||
if (BatchActionQueue.instance) return BatchActionQueue.instance;
|
||||
|
||||
const newRedis = createNewRedisInstance({
|
||||
enableOfflineQueue: false,
|
||||
...redisQueueRetryOptions,
|
||||
});
|
||||
|
||||
BatchActionQueue.instance = newRedis
|
||||
? new Queue<TQueueJobTypes[QueueName.BatchActionQueue]>(
|
||||
QueueName.BatchActionQueue,
|
||||
{
|
||||
connection: newRedis,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 10_000,
|
||||
attempts: 2,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 5000,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
: null;
|
||||
|
||||
BatchActionQueue.instance?.on("error", (err) => {
|
||||
logger.error("BatchActionQueue error", err);
|
||||
});
|
||||
|
||||
return BatchActionQueue.instance;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { CoreDataS3ExportQueue } from "./coreDataS3ExportQueue";
|
||||
import { MeteringDataPostgresExportQueue } from "./meteringDataPostgresExportQueue";
|
||||
import { DataRetentionQueue } from "./dataRetentionQueue";
|
||||
import { DataRetentionProcessingQueue } from "./dataRetentionProcessingQueue";
|
||||
import { BatchActionQueue } from "./batchActionQueue";
|
||||
|
||||
export function getQueue(queueName: QueueName): Queue | null {
|
||||
switch (queueName) {
|
||||
@@ -50,6 +51,8 @@ export function getQueue(queueName: QueueName): Queue | null {
|
||||
return DataRetentionQueue.getInstance();
|
||||
case QueueName.DataRetentionProcessingQueue:
|
||||
return DataRetentionProcessingQueue.getInstance();
|
||||
case QueueName.BatchActionQueue:
|
||||
return BatchActionQueue.getInstance();
|
||||
default:
|
||||
const exhaustiveCheckDefault: never = queueName;
|
||||
throw new Error(`Queue ${queueName} not found`);
|
||||
|
||||
@@ -27,6 +27,7 @@ export class ProjectDeleteQueue {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 100_000,
|
||||
attempts: 5,
|
||||
delay: 60_000, // 1 minute
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 5000,
|
||||
|
||||
@@ -5,14 +5,14 @@ import {
|
||||
} from "../clickhouse/client";
|
||||
import { logger } from "../logger";
|
||||
import { getTracer, instrumentAsync } from "../instrumentation";
|
||||
import {
|
||||
StorageService,
|
||||
StorageServiceFactory,
|
||||
} from "../services/StorageService";
|
||||
import { randomUUID } from "crypto";
|
||||
import { getClickhouseEntityType } from "../clickhouse/schemaUtils";
|
||||
import { NodeClickHouseClientConfigOptions } from "@clickhouse/client/dist/config";
|
||||
import { context, trace } from "@opentelemetry/api";
|
||||
import {
|
||||
StorageService,
|
||||
StorageServiceFactory,
|
||||
} from "../services/StorageService";
|
||||
|
||||
let s3StorageServiceClient: StorageService;
|
||||
|
||||
@@ -41,11 +41,8 @@ export async function upsertClickhouse<
|
||||
// https://opentelemetry.io/docs/specs/semconv/database/database-spans/
|
||||
span.setAttribute("ch.query.table", opts.table);
|
||||
|
||||
const s3Client = getS3StorageServiceClient(
|
||||
env.LANGFUSE_S3_EVENT_UPLOAD_BUCKET,
|
||||
);
|
||||
await Promise.all(
|
||||
opts.records.map((record) => {
|
||||
opts.records.map(async (record) => {
|
||||
// drop trailing s and pretend it's always a create.
|
||||
// Only applicable to scores and traces.
|
||||
let eventType = `${opts.table.slice(0, -1)}-create`;
|
||||
@@ -53,17 +50,38 @@ export async function upsertClickhouse<
|
||||
// @ts-ignore - If it's an observation we now that `type` is a string
|
||||
eventType = `${record["type"].toLowerCase()}-create`;
|
||||
}
|
||||
s3Client.uploadJson(
|
||||
`${env.LANGFUSE_S3_EVENT_UPLOAD_PREFIX}${record.project_id}/${getClickhouseEntityType(eventType)}/${record.id}/${randomUUID()}.json`,
|
||||
[
|
||||
|
||||
const eventId = randomUUID();
|
||||
const bucketPath = `${env.LANGFUSE_S3_EVENT_UPLOAD_PREFIX}${record.project_id}/${getClickhouseEntityType(eventType)}/${record.id}/${eventId}.json`;
|
||||
|
||||
// Write new file directly to ClickHouse. We don't use the ClickHouse writer here as we expect more limited traffic
|
||||
// and are not worried that much about latency.
|
||||
await clickhouseClient().insert({
|
||||
table: "event_log",
|
||||
values: [
|
||||
{
|
||||
id: randomUUID(),
|
||||
timestamp: new Date().toISOString(),
|
||||
type: eventType,
|
||||
body: opts.eventBodyMapper(record),
|
||||
project_id: record.project_id,
|
||||
entity_type: getClickhouseEntityType(eventType),
|
||||
entity_id: record.id,
|
||||
event_id: eventId,
|
||||
bucket_name: env.LANGFUSE_S3_EVENT_UPLOAD_BUCKET,
|
||||
bucket_path: bucketPath,
|
||||
},
|
||||
],
|
||||
);
|
||||
format: "JSONEachRow",
|
||||
});
|
||||
|
||||
return getS3StorageServiceClient(
|
||||
env.LANGFUSE_S3_EVENT_UPLOAD_BUCKET,
|
||||
).uploadJson(bucketPath, [
|
||||
{
|
||||
id: eventId,
|
||||
timestamp: new Date().toISOString(),
|
||||
type: eventType,
|
||||
body: opts.eventBodyMapper(record),
|
||||
},
|
||||
]);
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -59,7 +59,6 @@ export const getObservationsCostGroupedByName = async (
|
||||
const appliedFilter = chFilter.apply();
|
||||
|
||||
const hasTraceFilter = chFilter.find((f) => f.clickhouseTable === "traces");
|
||||
// TODO: Validate whether we can filter traces on timestamp here.
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
@@ -68,9 +67,10 @@ export const getObservationsCostGroupedByName = async (
|
||||
sumMap(usage_details)['total'] as sum_usage_details
|
||||
FROM observations o FINAL ${hasTraceFilter ? "LEFT JOIN traces t ON o.trace_id = t.id AND o.project_id = t.project_id" : ""}
|
||||
WHERE project_id = {projectId: String}
|
||||
AND ${appliedFilter.query}
|
||||
${appliedFilter.query ? `AND ${appliedFilter.query}` : ""}
|
||||
GROUP BY provided_model_name
|
||||
ORDER BY sumMap(cost_details)['total'] DESC
|
||||
LIMIT 50
|
||||
`;
|
||||
|
||||
const result = await queryClickhouse<{
|
||||
@@ -145,23 +145,26 @@ export const getScoreAggregate = async (
|
||||
export const groupTracesByTime = async (
|
||||
projectId: string,
|
||||
filter: FilterState,
|
||||
groupBy: DateTrunc,
|
||||
) => {
|
||||
const chFilter = new FilterList(
|
||||
createFilterFromFilterState(filter, dashboardColumnDefinitions),
|
||||
).apply();
|
||||
|
||||
const [orderByQuery, orderByParams, bucketSizeInSeconds] = orderByTimeSeries(
|
||||
filter,
|
||||
"timestamp",
|
||||
);
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
${selectTimeseriesColumn(groupBy, "timestamp", "timestamp")},
|
||||
${selectTimeseriesColumn(bucketSizeInSeconds, "timestamp", "timestamp")},
|
||||
count(*) as count
|
||||
FROM traces t FINAL
|
||||
WHERE project_id = {projectId: String}
|
||||
AND ${chFilter.query}
|
||||
GROUP BY timestamp
|
||||
${orderByTimeSeries(groupBy, "timestamp")}
|
||||
${orderByQuery}
|
||||
`;
|
||||
|
||||
const result = await queryClickhouse<{
|
||||
timestamp: string;
|
||||
count: string;
|
||||
@@ -170,11 +173,12 @@ export const groupTracesByTime = async (
|
||||
params: {
|
||||
projectId,
|
||||
...chFilter.params,
|
||||
...orderByParams,
|
||||
},
|
||||
});
|
||||
|
||||
return result.map((row) => ({
|
||||
timestamp: new Date(row.timestamp),
|
||||
timestamp: parseClickhouseUTCDateTimeFormat(row.timestamp),
|
||||
countTraceId: Number(row.count),
|
||||
}));
|
||||
};
|
||||
@@ -182,7 +186,6 @@ export const groupTracesByTime = async (
|
||||
export const getObservationUsageByTime = async (
|
||||
projectId: string,
|
||||
filter: FilterState,
|
||||
groupBy: DateTrunc,
|
||||
) => {
|
||||
const chFilter = new FilterList(
|
||||
createFilterFromFilterState(filter, dashboardColumnDefinitions),
|
||||
@@ -200,9 +203,14 @@ export const getObservationUsageByTime = async (
|
||||
) as DateTimeFilter | undefined)
|
||||
: undefined;
|
||||
|
||||
const [orderByQuery, orderByParams, bucketSizeInSeconds] = orderByTimeSeries(
|
||||
filter,
|
||||
"start_time",
|
||||
);
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
${selectTimeseriesColumn(groupBy, "start_time", "start_time")},
|
||||
${selectTimeseriesColumn(bucketSizeInSeconds, "start_time", "start_time")},
|
||||
sumMap(usage_details) as units,
|
||||
sumMap(cost_details) as cost,
|
||||
provided_model_name
|
||||
@@ -212,7 +220,7 @@ export const getObservationUsageByTime = async (
|
||||
AND ${appliedFilter.query}
|
||||
${timeFilter ? `AND t.timestamp >= {traceTimestamp: DateTime64(3)} - ${OBSERVATIONS_TO_TRACE_INTERVAL}` : ""}
|
||||
GROUP BY start_time, provided_model_name
|
||||
${orderByTimeSeries(groupBy, "start_time")}
|
||||
${orderByQuery}
|
||||
`;
|
||||
|
||||
const result = await queryClickhouse<{
|
||||
@@ -225,6 +233,7 @@ export const getObservationUsageByTime = async (
|
||||
params: {
|
||||
projectId,
|
||||
...appliedFilter.params,
|
||||
...orderByParams,
|
||||
...(timeFilter
|
||||
? { traceTimestamp: convertDateToClickhouseDateTime(timeFilter.value) }
|
||||
: {}),
|
||||
@@ -232,7 +241,7 @@ export const getObservationUsageByTime = async (
|
||||
});
|
||||
|
||||
return result.map((row) => ({
|
||||
start_time: new Date(row.start_time),
|
||||
start_time: parseClickhouseUTCDateTimeFormat(row.start_time),
|
||||
units: Object.fromEntries(
|
||||
Object.entries(row.units ?? {}).map(([key, value]) => [
|
||||
key,
|
||||
@@ -271,12 +280,15 @@ export const getDistinctModels = async (
|
||||
|
||||
// No need for final as duplicates are caught by distinct anyway.
|
||||
const query = `
|
||||
SELECT distinct(provided_model_name) as model
|
||||
SELECT distinct(provided_model_name) as model, count(*) as count
|
||||
FROM observations o
|
||||
${tracesFilter ? "LEFT JOIN traces t ON o.trace_id = t.id AND o.project_id = t.project_id" : ""}
|
||||
WHERE project_id = {projectId: String}
|
||||
AND ${appliedFilter.query}
|
||||
${timeFilter ? `AND t.timestamp >= {traceTimestamp: DateTime64(3)} - ${OBSERVATIONS_TO_TRACE_INTERVAL}` : ""}
|
||||
GROUP BY provided_model_name
|
||||
ORDER BY count(*) DESC
|
||||
LIMIT 1000
|
||||
`;
|
||||
|
||||
const result = await queryClickhouse<{ model: string }>({
|
||||
@@ -296,7 +308,6 @@ export const getDistinctModels = async (
|
||||
export const getScoresAggregateOverTime = async (
|
||||
projectId: string,
|
||||
filter: FilterState,
|
||||
groupBy: DateTrunc,
|
||||
) => {
|
||||
const chFilter = new FilterList(
|
||||
createFilterFromFilterState(filter, dashboardColumnDefinitions),
|
||||
@@ -307,9 +318,14 @@ export const getScoresAggregateOverTime = async (
|
||||
const traceFilter = chFilter.find((f) => f.clickhouseTable === "traces");
|
||||
// TODO: Validate whether we can filter traces on timestamp here.
|
||||
|
||||
const [orderByQuery, orderByParams, bucketSizeInSeconds] = orderByTimeSeries(
|
||||
filter,
|
||||
"timestamp",
|
||||
);
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
${selectTimeseriesColumn(groupBy, "timestamp", "timestamp")},
|
||||
${selectTimeseriesColumn(bucketSizeInSeconds, "timestamp", "timestamp")},
|
||||
name,
|
||||
data_type,
|
||||
source,
|
||||
@@ -324,7 +340,7 @@ export const getScoresAggregateOverTime = async (
|
||||
name,
|
||||
data_type,
|
||||
source
|
||||
${orderByTimeSeries(groupBy, "timestamp")};
|
||||
${orderByQuery};
|
||||
`;
|
||||
|
||||
const result = await queryClickhouse<{
|
||||
@@ -338,11 +354,12 @@ export const getScoresAggregateOverTime = async (
|
||||
params: {
|
||||
projectId,
|
||||
...appliedFilter.params,
|
||||
...orderByParams,
|
||||
},
|
||||
});
|
||||
|
||||
return result.map((row) => ({
|
||||
scoreTimestamp: new Date(row.timestamp),
|
||||
scoreTimestamp: parseClickhouseUTCDateTimeFormat(row.timestamp),
|
||||
scoreName: row.name,
|
||||
scoreDataType: row.data_type,
|
||||
scoreSource: row.source,
|
||||
@@ -506,7 +523,6 @@ export const getTracesLatencies = async (
|
||||
export const getModelLatenciesOverTime = async (
|
||||
projectId: string,
|
||||
filter: FilterState,
|
||||
groupBy: DateTrunc,
|
||||
) => {
|
||||
const chFilter = new FilterList(
|
||||
createFilterFromFilterState(filter, dashboardColumnDefinitions),
|
||||
@@ -516,10 +532,15 @@ export const getModelLatenciesOverTime = async (
|
||||
|
||||
const traceFilter = chFilter.find((f) => f.clickhouseTable === "traces");
|
||||
|
||||
const [orderByQuery, orderByParams, bucketSizeInSeconds] = orderByTimeSeries(
|
||||
filter,
|
||||
"start_time_bucket",
|
||||
);
|
||||
|
||||
// Skipping FINAL here, as the quantiles are approximate to begin with.
|
||||
const query = `
|
||||
SELECT
|
||||
${selectTimeseriesColumn(groupBy, "o.start_time", "start_time_bucket")},
|
||||
${selectTimeseriesColumn(bucketSizeInSeconds, "o.start_time", "start_time_bucket")},
|
||||
provided_model_name,
|
||||
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
|
||||
@@ -527,14 +548,17 @@ export const getModelLatenciesOverTime = async (
|
||||
WHERE project_id = {projectId: String}
|
||||
AND ${appliedFilter.query}
|
||||
GROUP BY provided_model_name, start_time_bucket
|
||||
${orderByTimeSeries(groupBy, "start_time_bucket")};
|
||||
${orderByQuery};
|
||||
`;
|
||||
|
||||
const result = await queryClickhouse<{
|
||||
start_time_bucket: string;
|
||||
provided_model_name: string;
|
||||
quantiles: string[];
|
||||
}>({ query, params: { projectId, ...appliedFilter.params } });
|
||||
}>({
|
||||
query,
|
||||
params: { projectId, ...appliedFilter.params, ...orderByParams },
|
||||
});
|
||||
|
||||
return result.map((row) => ({
|
||||
p50: Number(row.quantiles[0]) / 1000,
|
||||
@@ -543,14 +567,13 @@ export const getModelLatenciesOverTime = async (
|
||||
p95: Number(row.quantiles[3]) / 1000,
|
||||
p99: Number(row.quantiles[4]) / 1000,
|
||||
model: row.provided_model_name,
|
||||
start_time: new Date(row.start_time_bucket),
|
||||
start_time: parseClickhouseUTCDateTimeFormat(row.start_time_bucket),
|
||||
}));
|
||||
};
|
||||
|
||||
export const getNumericScoreTimeSeries = async (
|
||||
projectId: string,
|
||||
filter: FilterState,
|
||||
groupBy: DateTrunc,
|
||||
) => {
|
||||
const chFilter = new FilterList(
|
||||
createFilterFromFilterState(filter, dashboardColumnDefinitions),
|
||||
@@ -559,9 +582,14 @@ export const getNumericScoreTimeSeries = async (
|
||||
|
||||
const traceFilter = chFilter.find((f) => f.clickhouseTable === "traces");
|
||||
|
||||
const [orderByQuery, orderByParams, bucketSizeInSeconds] = orderByTimeSeries(
|
||||
filter,
|
||||
"score_timestamp",
|
||||
);
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
${selectTimeseriesColumn(groupBy, "s.timestamp", "score_timestamp")},
|
||||
${selectTimeseriesColumn(bucketSizeInSeconds, "s.timestamp", "score_timestamp")},
|
||||
s.name as score_name,
|
||||
AVG(s.value) as avg_value
|
||||
FROM scores s final
|
||||
@@ -569,11 +597,11 @@ export const getNumericScoreTimeSeries = async (
|
||||
WHERE s.project_id = {projectId: String}
|
||||
${chFilterRes?.query ? `AND ${chFilterRes.query}` : ""}
|
||||
GROUP BY score_name, score_timestamp
|
||||
${orderByTimeSeries(groupBy, "score_timestamp")}
|
||||
${orderByQuery}
|
||||
`;
|
||||
|
||||
return queryClickhouse<{
|
||||
score_timestamp: Date;
|
||||
const result = await queryClickhouse<{
|
||||
score_timestamp: string;
|
||||
score_name: string;
|
||||
avg_value: number;
|
||||
}>({
|
||||
@@ -581,14 +609,20 @@ export const getNumericScoreTimeSeries = async (
|
||||
params: {
|
||||
projectId,
|
||||
...(chFilterRes ? chFilterRes.params : {}),
|
||||
...orderByParams,
|
||||
},
|
||||
});
|
||||
|
||||
return result.map((row) => ({
|
||||
scoreTimestamp: parseClickhouseUTCDateTimeFormat(row.score_timestamp),
|
||||
scoreName: row.score_name,
|
||||
avgValue: Number(row.avg_value),
|
||||
}));
|
||||
};
|
||||
|
||||
export const getCategoricalScoreTimeSeries = async (
|
||||
projectId: string,
|
||||
filter: FilterState,
|
||||
groupBy: DateTrunc | undefined,
|
||||
) => {
|
||||
const chFilter = new FilterList(
|
||||
createFilterFromFilterState(filter, dashboardColumnDefinitions),
|
||||
@@ -597,9 +631,14 @@ export const getCategoricalScoreTimeSeries = async (
|
||||
|
||||
const traceFilter = chFilter.find((f) => f.clickhouseTable === "traces");
|
||||
|
||||
const [orderByQuery, orderByParams, bucketSizeInSeconds] = orderByTimeSeries(
|
||||
filter,
|
||||
"score_timestamp",
|
||||
);
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
${groupBy ? selectTimeseriesColumn(groupBy, "s.timestamp", "score_timestamp") + ", " : ""}
|
||||
${bucketSizeInSeconds ? selectTimeseriesColumn(bucketSizeInSeconds, "s.timestamp", "score_timestamp") + ", " : ""}
|
||||
s.name as score_name,
|
||||
s.data_type as score_data_type,
|
||||
s.source as score_source,
|
||||
@@ -609,12 +648,12 @@ export const getCategoricalScoreTimeSeries = async (
|
||||
${traceFilter ? "JOIN traces t ON s.trace_id = t.id AND s.project_id = t.project_id" : ""}
|
||||
WHERE s.project_id = {projectId: String}
|
||||
${chFilterRes?.query ? `AND ${chFilterRes.query}` : ""}
|
||||
GROUP BY score_name, score_data_type, score_source, score_value ${groupBy ? ", score_timestamp" : ""}
|
||||
${groupBy ? orderByTimeSeries(groupBy, "score_timestamp") : ""}
|
||||
GROUP BY score_name, score_data_type, score_source, score_value ${bucketSizeInSeconds ? ", score_timestamp" : ""}
|
||||
${orderByQuery}
|
||||
`;
|
||||
|
||||
return queryClickhouse<{
|
||||
score_timestamp?: Date;
|
||||
const result = await queryClickhouse<{
|
||||
score_timestamp?: string;
|
||||
score_name: string;
|
||||
score_data_type: string;
|
||||
score_source: string;
|
||||
@@ -625,14 +664,25 @@ export const getCategoricalScoreTimeSeries = async (
|
||||
params: {
|
||||
projectId,
|
||||
...(chFilterRes ? chFilterRes.params : {}),
|
||||
...orderByParams,
|
||||
},
|
||||
});
|
||||
|
||||
return result.map((row) => ({
|
||||
scoreTimestamp: row.score_timestamp
|
||||
? parseClickhouseUTCDateTimeFormat(row.score_timestamp)
|
||||
: undefined,
|
||||
scoreName: row.score_name,
|
||||
scoreDataType: row.score_data_type,
|
||||
scoreSource: row.score_source,
|
||||
scoreValue: row.score_value,
|
||||
count: Number(row.count),
|
||||
}));
|
||||
};
|
||||
|
||||
export const getObservationsStatusTimeSeries = async (
|
||||
projectId: string,
|
||||
filter: FilterState,
|
||||
groupBy: DateTrunc | undefined,
|
||||
) => {
|
||||
const chFilter = new FilterList(
|
||||
createFilterFromFilterState(filter, dashboardColumnDefinitions),
|
||||
@@ -641,9 +691,14 @@ export const getObservationsStatusTimeSeries = async (
|
||||
|
||||
const traceFilter = chFilter.find((f) => f.clickhouseTable === "traces");
|
||||
|
||||
const [orderByQuery, orderByParams, bucketSizeInSeconds] = orderByTimeSeries(
|
||||
filter,
|
||||
"start_time_bucket",
|
||||
);
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
${groupBy ? selectTimeseriesColumn(groupBy, "o.start_time", "start_time_bucket") + ", " : ""}
|
||||
${bucketSizeInSeconds ? selectTimeseriesColumn(bucketSizeInSeconds, "o.start_time", "start_time_bucket") + ", " : ""}
|
||||
count(*) as observation_count,
|
||||
level as level
|
||||
FROM observations o
|
||||
@@ -651,8 +706,8 @@ export const getObservationsStatusTimeSeries = async (
|
||||
WHERE project_id = {projectId: String}
|
||||
AND o.level IS NOT NULL
|
||||
AND ${chFilterRes?.query}
|
||||
GROUP BY level ${groupBy ? ", start_time_bucket" : ""}
|
||||
${groupBy ? orderByTimeSeries(groupBy, "start_time_bucket") : ""}
|
||||
GROUP BY level ${bucketSizeInSeconds ? ", start_time_bucket" : ""}
|
||||
${orderByQuery}
|
||||
`;
|
||||
|
||||
const result = await queryClickhouse<{
|
||||
@@ -661,7 +716,11 @@ export const getObservationsStatusTimeSeries = async (
|
||||
level: string;
|
||||
}>({
|
||||
query,
|
||||
params: { projectId, ...(chFilterRes ? chFilterRes.params : {}) },
|
||||
params: {
|
||||
projectId,
|
||||
...(chFilterRes ? chFilterRes.params : {}),
|
||||
...orderByParams,
|
||||
},
|
||||
});
|
||||
|
||||
return result.map((row) => ({
|
||||
@@ -673,61 +732,70 @@ export const getObservationsStatusTimeSeries = async (
|
||||
}));
|
||||
};
|
||||
|
||||
const orderByTimeSeries = (dateTrunc: DateTrunc, col: string) => {
|
||||
let interval;
|
||||
switch (dateTrunc) {
|
||||
case "year":
|
||||
interval = "toIntervalYear(1)";
|
||||
break;
|
||||
case "month":
|
||||
interval = "toIntervalMonth(1)";
|
||||
break;
|
||||
case "week":
|
||||
interval = "toIntervalWeek(1)";
|
||||
break;
|
||||
case "day":
|
||||
interval = "toIntervalDay(1)";
|
||||
break;
|
||||
case "hour":
|
||||
interval = "toIntervalHour(1)";
|
||||
break;
|
||||
case "minute":
|
||||
interval = "toIntervalMinute(1)";
|
||||
break;
|
||||
default:
|
||||
return undefined;
|
||||
export const orderByTimeSeries = (
|
||||
filter: FilterState,
|
||||
col: string,
|
||||
): [string, { fromTime: number; toTime: number }, number] => {
|
||||
const potentialBucketSizesSeconds = [
|
||||
5, 10, 30, 60, 300, 600, 1800, 3600, 18000, 36000, 86400, 604800, 2592000,
|
||||
];
|
||||
|
||||
// Calculate time difference in seconds
|
||||
const [from, to] = extractFromAndToTimestampsFromFilter(filter);
|
||||
|
||||
if (!from || !to) {
|
||||
throw new Error("Time Filter is required for time series queries");
|
||||
}
|
||||
|
||||
return `ORDER BY ${col} ASC WITH FILL STEP ${interval}`;
|
||||
const fromDate = new Date(from.value as Date);
|
||||
const toDate = new Date(to.value as Date);
|
||||
|
||||
const diffInSeconds = Math.abs(toDate.getTime() - fromDate.getTime()) / 1000;
|
||||
|
||||
// choose the bucket size that is the closest to the desired number of buckets
|
||||
const bucketSizeInSeconds = potentialBucketSizesSeconds.reduce(
|
||||
(closest, size) => {
|
||||
const diffFromDesiredBuckets = Math.abs(diffInSeconds / size - 50);
|
||||
return diffFromDesiredBuckets < closest.diffFromDesiredBuckets
|
||||
? { size, diffFromDesiredBuckets }
|
||||
: closest;
|
||||
},
|
||||
{ size: 0, diffFromDesiredBuckets: Infinity },
|
||||
).size;
|
||||
|
||||
// Convert to interval string
|
||||
const interval = `toIntervalSecond(${bucketSizeInSeconds})`;
|
||||
|
||||
return [
|
||||
`ORDER BY ${col} ASC
|
||||
WITH FILL
|
||||
FROM toStartOfInterval(toDateTime({fromTime: DateTime64(3)}), INTERVAL ${bucketSizeInSeconds} SECOND)
|
||||
TO toDateTime({toTime: DateTime64(3)}) + INTERVAL ${bucketSizeInSeconds} SECOND
|
||||
STEP ${interval}`,
|
||||
{ fromTime: fromDate.getTime(), toTime: toDate.getTime() },
|
||||
bucketSizeInSeconds,
|
||||
];
|
||||
};
|
||||
|
||||
const selectTimeseriesColumn = (
|
||||
dateTrunc: DateTrunc,
|
||||
export const selectTimeseriesColumn = (
|
||||
bucketSizeInSeconds: number,
|
||||
col: string,
|
||||
as: String,
|
||||
) => {
|
||||
let interval;
|
||||
switch (dateTrunc) {
|
||||
case "year":
|
||||
interval = "toStartOfYear";
|
||||
break;
|
||||
case "month":
|
||||
interval = "toStartOfMonth";
|
||||
break;
|
||||
case "week":
|
||||
interval = "toStartOfWeek";
|
||||
break;
|
||||
case "day":
|
||||
interval = "toStartOfDay";
|
||||
break;
|
||||
case "hour":
|
||||
interval = "toStartOfHour";
|
||||
break;
|
||||
case "minute":
|
||||
interval = "toStartOfMinute";
|
||||
break;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
return `${interval}(${col}) as ${as}`;
|
||||
return `toStartOfInterval(${col}, INTERVAL ${bucketSizeInSeconds} SECOND) as ${as}`;
|
||||
};
|
||||
|
||||
export const extractFromAndToTimestampsFromFilter = (filter?: FilterState) => {
|
||||
if (!filter)
|
||||
throw new Error("Time Filter is required for time series queries");
|
||||
|
||||
const fromTimestamp = filter.filter(
|
||||
(f) => f.type === "datetime" && (f.operator === ">" || f.operator === ">="),
|
||||
);
|
||||
|
||||
const toTimestamp = filter.filter(
|
||||
(f) => f.type === "datetime" && (f.operator === "<" || f.operator === "<="),
|
||||
);
|
||||
|
||||
return [fromTimestamp[0], toTimestamp[0]];
|
||||
};
|
||||
|
||||
@@ -158,6 +158,30 @@ export const scoreRecordInsertSchema = scoreRecordBaseSchema.extend({
|
||||
});
|
||||
export type ScoreRecordInsertType = z.infer<typeof scoreRecordInsertSchema>;
|
||||
|
||||
export const eventLogRecordBaseSchema = z.object({
|
||||
id: z.string(),
|
||||
project_id: z.string(),
|
||||
entity_type: z.string(),
|
||||
entity_id: z.string(),
|
||||
// event_id is nullable to be compatible with legacy queue events.
|
||||
// It still allows us to delete things by prefix, but requires an additional list call.
|
||||
event_id: z.string().nullable(),
|
||||
bucket_name: z.string(),
|
||||
bucket_path: z.string(),
|
||||
});
|
||||
export const eventLogRecordReadSchema = eventLogRecordBaseSchema.extend({
|
||||
created_at: clickhouseStringDateSchema,
|
||||
updated_at: clickhouseStringDateSchema,
|
||||
});
|
||||
export type EventLogRecordReadType = z.infer<typeof eventLogRecordReadSchema>;
|
||||
export const eventLogRecordInsertSchema = eventLogRecordBaseSchema.extend({
|
||||
created_at: z.number(),
|
||||
updated_at: z.number(),
|
||||
});
|
||||
export type EventLogRecordInsertType = z.infer<
|
||||
typeof eventLogRecordInsertSchema
|
||||
>;
|
||||
|
||||
export const convertTraceReadToInsert = (
|
||||
record: TraceRecordReadType,
|
||||
): TraceRecordInsertType => {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
commandClickhouse,
|
||||
queryClickhouse,
|
||||
queryClickhouseStream,
|
||||
} from "./clickhouse";
|
||||
import { EventLogRecordReadType } from "./definitions";
|
||||
import { convertDateToClickhouseDateTime } from "../clickhouse/client";
|
||||
|
||||
export const getEventLogByProjectAndEntityId = async (
|
||||
projectId: string,
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
): Promise<EventLogRecordReadType[]> => {
|
||||
const query = `
|
||||
select *
|
||||
from event_log
|
||||
where project_id = {projectId: String}
|
||||
and entity_type = {entityType: String}
|
||||
and entity_id = {entityId: String}
|
||||
`;
|
||||
|
||||
return queryClickhouse<EventLogRecordReadType>({
|
||||
query,
|
||||
params: {
|
||||
projectId,
|
||||
entityType,
|
||||
entityId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const getEventLogByProjectId = (
|
||||
projectId: string,
|
||||
): AsyncGenerator<EventLogRecordReadType> => {
|
||||
const query = `
|
||||
select *
|
||||
from event_log
|
||||
where project_id = {projectId: String}
|
||||
`;
|
||||
|
||||
return queryClickhouseStream<EventLogRecordReadType>({
|
||||
query,
|
||||
params: {
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const getEventLogByProjectIdBeforeDate = (
|
||||
projectId: string,
|
||||
beforeDate: Date,
|
||||
): AsyncGenerator<EventLogRecordReadType> => {
|
||||
const query = `
|
||||
select *
|
||||
from event_log
|
||||
where project_id = {projectId: String}
|
||||
and created_at <= {beforeDate: DateTime64(3)}
|
||||
`;
|
||||
|
||||
return queryClickhouseStream<EventLogRecordReadType>({
|
||||
query,
|
||||
params: {
|
||||
projectId,
|
||||
beforeDate: convertDateToClickhouseDateTime(beforeDate),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteEventLogByProjectId = async (
|
||||
projectId: string,
|
||||
): Promise<void> => {
|
||||
const query = `
|
||||
DELETE FROM event_log
|
||||
WHERE project_id = {projectId: String};
|
||||
`;
|
||||
await commandClickhouse({
|
||||
query: query,
|
||||
params: {
|
||||
projectId,
|
||||
},
|
||||
clickhouseConfigs: {
|
||||
request_timeout: 120_000, // 2 minutes
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteEventLogByProjectIdBeforeDate = async (
|
||||
projectId: string,
|
||||
beforeDate: Date,
|
||||
): Promise<void> => {
|
||||
const query = `
|
||||
DELETE FROM event_log
|
||||
WHERE project_id = {projectId: String}
|
||||
AND created_at <= {beforeDate: DateTime64(3)};
|
||||
`;
|
||||
await commandClickhouse({
|
||||
query: query,
|
||||
params: {
|
||||
projectId,
|
||||
beforeDate: convertDateToClickhouseDateTime(beforeDate),
|
||||
},
|
||||
clickhouseConfigs: {
|
||||
request_timeout: 120_000, // 2 minutes
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -9,3 +9,4 @@ export * from "./observations_converters";
|
||||
export * from "./clickhouse";
|
||||
export * from "./constants";
|
||||
export * from "./trace-sessions";
|
||||
export * from "./eventLog";
|
||||
|
||||
@@ -908,6 +908,7 @@ export const getObservationsGroupedByPromptName = async (
|
||||
|
||||
export const getCostForTraces = async (
|
||||
projectId: string,
|
||||
timestamp: Date,
|
||||
traceIds: string[],
|
||||
) => {
|
||||
// Wrapping the query in a CTE allows us to skip FINAL which allows Clickhouse to use skip indexes.
|
||||
@@ -917,6 +918,7 @@ export const getCostForTraces = async (
|
||||
FROM observations o
|
||||
WHERE o.project_id = {projectId: String}
|
||||
AND o.trace_id IN ({traceIds: Array(String)})
|
||||
AND o.start_time >= {timestamp: DateTime64(3)} - ${OBSERVATIONS_TO_TRACE_INTERVAL}
|
||||
ORDER BY o.event_ts DESC
|
||||
LIMIT 1 BY o.id, o.project_id
|
||||
)
|
||||
@@ -930,6 +932,7 @@ export const getCostForTraces = async (
|
||||
params: {
|
||||
projectId,
|
||||
traceIds,
|
||||
timestamp: convertDateToClickhouseDateTime(timestamp),
|
||||
},
|
||||
});
|
||||
return res.length > 0 ? Number(res[0].total_cost) : undefined;
|
||||
@@ -972,6 +975,27 @@ export const deleteObservationsByProjectId = async (projectId: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteObservationsOlderThanDays = async (
|
||||
projectId: string,
|
||||
days: number,
|
||||
) => {
|
||||
const query = `
|
||||
DELETE FROM observations
|
||||
WHERE project_id = {projectId: String}
|
||||
AND start_time < now() - INTERVAL {numDays: Int} DAYS;
|
||||
`;
|
||||
await commandClickhouse({
|
||||
query: query,
|
||||
params: {
|
||||
projectId,
|
||||
numDays: days,
|
||||
},
|
||||
clickhouseConfigs: {
|
||||
request_timeout: 120_000, // 2 minutes
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const getObservationsWithPromptName = async (
|
||||
projectId: string,
|
||||
promptNames: string[],
|
||||
|
||||
@@ -536,6 +536,27 @@ export const deleteScoresByProjectId = async (projectId: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteScoresOlderThanDays = async (
|
||||
projectId: string,
|
||||
days: number,
|
||||
) => {
|
||||
const query = `
|
||||
DELETE FROM scores
|
||||
WHERE project_id = {projectId: String}
|
||||
AND timestamp < now() - INTERVAL {numDays: Int} DAYS;
|
||||
`;
|
||||
await commandClickhouse({
|
||||
query: query,
|
||||
params: {
|
||||
projectId,
|
||||
numDays: days,
|
||||
},
|
||||
clickhouseConfigs: {
|
||||
request_timeout: 120_000, // 2 minutes
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const getNumericScoreHistogram = async (
|
||||
projectId: string,
|
||||
filter: FilterState,
|
||||
|
||||
@@ -269,7 +269,7 @@ export const getTracesGroupedByName = async (
|
||||
select
|
||||
name as name,
|
||||
count(*) as count
|
||||
from traces t
|
||||
from traces t FINAL
|
||||
WHERE t.project_id = {projectId: String}
|
||||
AND t.name IS NOT NULL
|
||||
${timestampFilterRes?.query ? `AND ${timestampFilterRes.query}` : ""}
|
||||
@@ -442,6 +442,27 @@ export const deleteTraces = async (projectId: string, traceIds: string[]) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteTracesOlderThanDays = async (
|
||||
projectId: string,
|
||||
days: number,
|
||||
) => {
|
||||
const query = `
|
||||
DELETE FROM traces
|
||||
WHERE project_id = {projectId: String}
|
||||
AND timestamp < now() - INTERVAL {numDays: Int} DAYS;
|
||||
`;
|
||||
await commandClickhouse({
|
||||
query: query,
|
||||
params: {
|
||||
projectId,
|
||||
numDays: days,
|
||||
},
|
||||
clickhouseConfigs: {
|
||||
request_timeout: 120_000, // 2 minutes
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteTracesByProjectId = async (projectId: string) => {
|
||||
const query = `
|
||||
DELETE FROM traces
|
||||
|
||||
@@ -354,6 +354,7 @@ const getTracesTableGeneric = async <T>(props: FetchTracesTableProps) => {
|
||||
column: "timestamp_to_date",
|
||||
order: orderBy.order,
|
||||
},
|
||||
{ column: "timestamp", order: orderBy.order },
|
||||
{ column: "event_ts", order: "DESC" as "DESC" },
|
||||
]
|
||||
: null,
|
||||
|
||||
@@ -145,6 +145,7 @@ export const observationsTableUiColumnDefinitions: UiColumnMapping[] = [
|
||||
clickhouseTableName: "observations",
|
||||
clickhouseSelect:
|
||||
"arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'input') > 0, usage_details)))",
|
||||
clickhouseTypeOverwrite: "Decimal64(3)",
|
||||
},
|
||||
{
|
||||
uiTableName: "Output Tokens",
|
||||
@@ -152,6 +153,7 @@ export const observationsTableUiColumnDefinitions: UiColumnMapping[] = [
|
||||
clickhouseTableName: "observations",
|
||||
clickhouseSelect:
|
||||
"arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'output') > 0, usage_details)))",
|
||||
clickhouseTypeOverwrite: "Decimal64(3)",
|
||||
},
|
||||
{
|
||||
uiTableName: "Total Tokens",
|
||||
@@ -159,6 +161,7 @@ export const observationsTableUiColumnDefinitions: UiColumnMapping[] = [
|
||||
clickhouseTableName: "observations",
|
||||
clickhouseSelect:
|
||||
"if(mapExists((k, v) -> (k = 'total'), usage_details), usage_details['total'], NULL)",
|
||||
clickhouseTypeOverwrite: "Decimal64(3)",
|
||||
},
|
||||
{
|
||||
uiTableName: "Usage",
|
||||
@@ -166,6 +169,7 @@ export const observationsTableUiColumnDefinitions: UiColumnMapping[] = [
|
||||
clickhouseTableName: "observations",
|
||||
clickhouseSelect:
|
||||
"if(mapExists((k, v) -> (k = 'total'), usage_details), usage_details['total'], NULL)",
|
||||
clickhouseTypeOverwrite: "Decimal64(3)",
|
||||
},
|
||||
{
|
||||
uiTableName: "Metadata",
|
||||
|
||||
@@ -98,6 +98,7 @@ export const tracesTableUiColumnDefinitions: UiColumnMapping[] = [
|
||||
clickhouseTableName: "observations",
|
||||
clickhouseSelect:
|
||||
"arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'input') > 0, usage_details)))",
|
||||
clickhouseTypeOverwrite: "Decimal64(3)",
|
||||
},
|
||||
{
|
||||
uiTableName: "Output Tokens",
|
||||
@@ -105,6 +106,7 @@ export const tracesTableUiColumnDefinitions: UiColumnMapping[] = [
|
||||
clickhouseTableName: "observations",
|
||||
clickhouseSelect:
|
||||
"arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'output') > 0, usage_details)))",
|
||||
clickhouseTypeOverwrite: "Decimal64(3)",
|
||||
},
|
||||
{
|
||||
uiTableName: "Total Tokens",
|
||||
@@ -112,6 +114,7 @@ export const tracesTableUiColumnDefinitions: UiColumnMapping[] = [
|
||||
clickhouseTableName: "observations",
|
||||
clickhouseSelect:
|
||||
"if(mapExists((k, v) -> (k = 'total'), usage_details), usage_details['total'], NULL)",
|
||||
clickhouseTypeOverwrite: "Decimal64(3)",
|
||||
},
|
||||
{
|
||||
uiTableName: "Usage",
|
||||
@@ -119,6 +122,7 @@ export const tracesTableUiColumnDefinitions: UiColumnMapping[] = [
|
||||
clickhouseTableName: "observations",
|
||||
clickhouseSelect:
|
||||
"if(mapExists((k, v) -> (k = 'total'), usage_details), usage_details['total'], NULL)",
|
||||
clickhouseTypeOverwrite: "Decimal64(3)",
|
||||
},
|
||||
{
|
||||
uiTableName: "Scores",
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import { merge } from "lodash";
|
||||
import { JsonNested, jsonSchema } from "./zod";
|
||||
import { JsonNested } from "./zod";
|
||||
|
||||
/**
|
||||
* Deeply parses a JSON string or object for nested stringified JSON
|
||||
@@ -10,7 +8,6 @@ import { JsonNested, jsonSchema } from "./zod";
|
||||
export function deepParseJson(json: unknown): unknown {
|
||||
if (typeof json === "string") {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const parsed = JSON.parse(json);
|
||||
if (typeof parsed === "number") return json; // numbers that were strings in the input should remain as strings
|
||||
return deepParseJson(parsed); // Recursively parse parsed value
|
||||
@@ -40,16 +37,6 @@ export function deepParseJson(json: unknown): unknown {
|
||||
return json;
|
||||
}
|
||||
|
||||
export const mergeJson = (
|
||||
json1?: z.infer<typeof jsonSchema>,
|
||||
json2?: z.infer<typeof jsonSchema>,
|
||||
) => {
|
||||
if (json1 === undefined) {
|
||||
return json2;
|
||||
}
|
||||
return merge(json1, json2);
|
||||
};
|
||||
|
||||
export const parseJsonPrioritised = (
|
||||
json: string,
|
||||
): JsonNested | string | undefined => {
|
||||
@@ -59,24 +46,3 @@ export const parseJsonPrioritised = (
|
||||
return json;
|
||||
}
|
||||
};
|
||||
|
||||
export const convertRecordToJsonSchema = (
|
||||
record: Record<string, string>,
|
||||
): JsonNested | undefined => {
|
||||
const jsonSchema: JsonNested = {};
|
||||
|
||||
// if record is empty, return undefined
|
||||
if (Object.keys(record).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const key in record) {
|
||||
try {
|
||||
jsonSchema[key] = JSON.parse(record[key]);
|
||||
} catch (e) {
|
||||
jsonSchema[key] = record[key];
|
||||
}
|
||||
}
|
||||
|
||||
return jsonSchema;
|
||||
};
|
||||
|
||||
@@ -4,22 +4,27 @@ export function getIsCharOrUnderscore(value: string): boolean {
|
||||
return charOrUnderscore.test(value);
|
||||
}
|
||||
|
||||
// Regex for valid variable names (letters, underscores, starting with letter)
|
||||
export const VARIABLE_REGEX = /^[a-zA-Z][a-zA-Z_]*$/;
|
||||
|
||||
// Regex to find variables in mustache syntax
|
||||
export const MUSTACHE_REGEX = /{{([^{}]*)}}+/g;
|
||||
|
||||
// Regex to find multiline variables
|
||||
export const MULTILINE_VARIABLE_REGEX = /{{[^}]*\n[^}]*}}/g;
|
||||
|
||||
// Regex to find unclosed variables
|
||||
export const UNCLOSED_VARIABLE_REGEX = /{{(?![^{]*}})/g;
|
||||
|
||||
export function isValidVariableName(variable: string): boolean {
|
||||
return VARIABLE_REGEX.test(variable);
|
||||
}
|
||||
|
||||
export function extractVariables(mustacheString: string): string[] {
|
||||
const mustacheRegex = /\{\{(.*?)\}\}/g;
|
||||
const uniqueVariables = new Set<string>();
|
||||
|
||||
for (const match of mustacheString.matchAll(mustacheRegex)) {
|
||||
uniqueVariables.add(match[1]);
|
||||
}
|
||||
|
||||
for (const variable of uniqueVariables) {
|
||||
// if validated fails, remove from set
|
||||
if (!getIsCharOrUnderscore(variable)) {
|
||||
uniqueVariables.delete(variable);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(uniqueVariables);
|
||||
const matches = Array.from(mustacheString.matchAll(MUSTACHE_REGEX))
|
||||
.map((match) => match[1])
|
||||
.filter(isValidVariableName);
|
||||
return [...new Set(matches)];
|
||||
}
|
||||
|
||||
export function stringifyValue(value: unknown) {
|
||||
|
||||
Generated
+1556
-1533
File diff suppressed because it is too large
Load Diff
+7
-4
@@ -108,13 +108,16 @@ ENV NEXT_MANUAL_SIG_HANDLE true
|
||||
|
||||
RUN apk add --no-cache dumb-init tzdata
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
# Don't run production as root
|
||||
ARG UID=1001
|
||||
ARG GID=1001
|
||||
RUN addgroup --system --gid ${GID} nodejs
|
||||
RUN adduser --system --uid ${UID} nextjs
|
||||
|
||||
RUN npm install -g --no-package-lock --no-save prisma@5.22.0
|
||||
RUN npm install -g --no-package-lock --no-save prisma@6.3.0
|
||||
|
||||
RUN MIGRATE_TARGET_ARCH=$(echo ${TARGETPLATFORM:-linux/amd64} | sed 's/\//-/g') && \
|
||||
wget -q -O- https://github.com/golang-migrate/migrate/releases/download/v4.18.0/migrate.$MIGRATE_TARGET_ARCH.tar.gz | tar xvz && \
|
||||
wget -q -O- https://github.com/golang-migrate/migrate/releases/download/v4.18.2/migrate.$MIGRATE_TARGET_ARCH.tar.gz | tar xvz && \
|
||||
mv migrate /usr/bin/migrate
|
||||
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/web/next.config.mjs .
|
||||
|
||||
@@ -47,6 +47,7 @@ const reportToHeader = {
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const nextConfig = {
|
||||
staticPageGenerationTimeout: 120, // default is 60. Required for build process for amd
|
||||
transpilePackages: ["@langfuse/shared"],
|
||||
reactStrictMode: true,
|
||||
experimental: {
|
||||
|
||||
+16
-11
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "3.16.0",
|
||||
"version": "3.27.2",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -26,7 +26,8 @@
|
||||
"@appsignal/opentelemetry-instrumentation-bullmq": "^0.7.3",
|
||||
"@baselime/trpc-opentelemetry-middleware": "^0.1.2",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lint": "^6.8.0",
|
||||
"@codemirror/language": "^6.10.1",
|
||||
"@codemirror/lint": "^6.4.2",
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/modifiers": "^7.0.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
@@ -40,6 +41,7 @@
|
||||
"@langchain/openai": "^0.3.14",
|
||||
"@langfuse/ee": "workspace:*",
|
||||
"@langfuse/shared": "workspace:*",
|
||||
"@lezer/highlight": "^1.2.1",
|
||||
"@marsidev/react-turnstile": "^0.5.4",
|
||||
"@mui/x-tree-view": "^7.19.0",
|
||||
"@next-auth/prisma-adapter": "^1.0.7",
|
||||
@@ -57,7 +59,7 @@
|
||||
"@opentelemetry/sdk-node": "^0.53.0",
|
||||
"@opentelemetry/sdk-trace-base": "^1.26.0",
|
||||
"@opentelemetry/sdk-trace-node": "^1.26.0",
|
||||
"@prisma/instrumentation": "^5.22.0",
|
||||
"@prisma/instrumentation": "^6.3.0",
|
||||
"@radix-ui/react-accordion": "^1.2.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.2",
|
||||
"@radix-ui/react-avatar": "^1.1.1",
|
||||
@@ -84,7 +86,7 @@
|
||||
"@remixicon/react": "^4.2.0",
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@sentry/nextjs": "^8.39.0",
|
||||
"@sentry/nextjs": "^8.52.0",
|
||||
"@t3-oss/env-nextjs": "^0.11.1",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tanstack/react-query": "^4.36.1",
|
||||
@@ -94,20 +96,19 @@
|
||||
"@trpc/next": "^10.45.0",
|
||||
"@trpc/react-query": "^10.45.0",
|
||||
"@trpc/server": "^10.45.0",
|
||||
"@uiw/codemirror-theme-github": "^4.23.0",
|
||||
"@uiw/codemirror-theme-tokyo-night": "^4.23.5",
|
||||
"@uiw/react-codemirror": "^4.21.25",
|
||||
"@uiw/codemirror-themes": "^4.23.7",
|
||||
"@uiw/react-codemirror": "^4.21.24",
|
||||
"ai": "^3.4.9",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bullmq": "^5.34.10",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"cmdk": "^1.0.4",
|
||||
"core-js": "^3.38.1",
|
||||
"cors": "^2.8.5",
|
||||
"csv-parse": "^5.6.0",
|
||||
"date-fns": "^3.3.1",
|
||||
"dd-trace": "^5.23.1",
|
||||
"dd-trace": "^5.36.0",
|
||||
"decimal.js": "^10.4.3",
|
||||
"diff": "^7.0.0",
|
||||
"dompurify": "^3.1.5",
|
||||
@@ -127,7 +128,8 @@
|
||||
"posthog-js": "^1.176.0",
|
||||
"posthog-node": "^4.3.1",
|
||||
"prexit": "^2.2.0",
|
||||
"prisma": "^5.22.0",
|
||||
"prisma": "^6.3.0",
|
||||
"protobufjs": "^7.4.0",
|
||||
"rate-limiter-flexible": "^5.0.3",
|
||||
"react": "18.2.0",
|
||||
"react-day-picker": "^8.10.1",
|
||||
@@ -141,11 +143,15 @@
|
||||
"react18-json-view": "^0.2.8-canary.6",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remark-math": "^6.0.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-rehype": "^11.1.1",
|
||||
"remark-stringify": "^11.0.0",
|
||||
"sonner": "^1.4.41",
|
||||
"stripe": "^17.4.0",
|
||||
"superjson": "2.2.1",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"unified": "^11.0.5",
|
||||
"use-query-params": "^2.2.1",
|
||||
"uuid": "^9.0.1",
|
||||
"vaul": "^0.9.1",
|
||||
@@ -196,7 +202,6 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"jsonpath-plus": "10.0.7",
|
||||
"nanoid": "^3.3.8",
|
||||
"katex": "0.16.21"
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ components:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
nullable: true
|
||||
traceId:
|
||||
type: string
|
||||
example: cdef-1234-5678-90ab
|
||||
@@ -67,15 +68,19 @@ components:
|
||||
values must equal either 1 or 0 (true or false)
|
||||
observationId:
|
||||
type: string
|
||||
nullable: true
|
||||
comment:
|
||||
type: string
|
||||
nullable: true
|
||||
dataType:
|
||||
$ref: '#/components/schemas/ScoreDataType'
|
||||
nullable: true
|
||||
description: >-
|
||||
When set, must match the score value's type. If not set, will be
|
||||
inferred from the score value or config
|
||||
configId:
|
||||
type: string
|
||||
nullable: true
|
||||
description: >-
|
||||
Reference a score config on a score. When set, the score name must
|
||||
equal the config name and scores must comply with the config's range
|
||||
@@ -100,6 +105,7 @@ components:
|
||||
$ref: '#/components/schemas/ScoreSource'
|
||||
observationId:
|
||||
type: string
|
||||
nullable: true
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -111,10 +117,13 @@ components:
|
||||
format: date-time
|
||||
authorUserId:
|
||||
type: string
|
||||
nullable: true
|
||||
comment:
|
||||
type: string
|
||||
nullable: true
|
||||
configId:
|
||||
type: string
|
||||
nullable: true
|
||||
description: >-
|
||||
Reference a score config on a score. When set, config and score name
|
||||
must be equal and value must comply to optionally defined numerical
|
||||
@@ -166,6 +175,7 @@ components:
|
||||
value:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
description: >-
|
||||
Only defined if a config is linked. Represents the numeric category
|
||||
mapping of the stringValue
|
||||
|
||||
@@ -737,7 +737,7 @@ paths:
|
||||
- Ingestion
|
||||
parameters: []
|
||||
responses:
|
||||
'200':
|
||||
'207':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
@@ -1426,14 +1426,14 @@ paths:
|
||||
schema: {}
|
||||
security:
|
||||
- BasicAuth: []
|
||||
/api/public/v2/prompts/{promptName}/version/{version}:
|
||||
/api/public/v2/prompts/{name}/versions/{version}:
|
||||
patch:
|
||||
description: Update labels for a specific prompt version
|
||||
operationId: promptVersion_update
|
||||
tags:
|
||||
- PromptVersion
|
||||
parameters:
|
||||
- name: promptName
|
||||
- name: name
|
||||
in: path
|
||||
description: The name of the prompt
|
||||
required: true
|
||||
@@ -4523,6 +4523,10 @@ components:
|
||||
type: string
|
||||
nullable: true
|
||||
description: List of tags to apply to all versions of this prompt.
|
||||
commitMessage:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Commit message for this prompt version.
|
||||
required:
|
||||
- name
|
||||
- prompt
|
||||
@@ -4548,6 +4552,10 @@ components:
|
||||
type: string
|
||||
nullable: true
|
||||
description: List of tags to apply to all versions of this prompt.
|
||||
commitMessage:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Commit message for this prompt version.
|
||||
required:
|
||||
- name
|
||||
- prompt
|
||||
@@ -4597,6 +4605,10 @@ components:
|
||||
description: >-
|
||||
List of tags. Used to filter via UI and API. The same across
|
||||
versions of a prompt.
|
||||
commitMessage:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Commit message for this prompt version.
|
||||
required:
|
||||
- name
|
||||
- version
|
||||
|
||||
@@ -1068,7 +1068,7 @@
|
||||
"request": {
|
||||
"description": "Update labels for a specific prompt version",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/v2/prompts/:promptName/version/:version",
|
||||
"raw": "{{baseUrl}}/api/public/v2/prompts/:name/versions/:version",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
@@ -1077,14 +1077,14 @@
|
||||
"public",
|
||||
"v2",
|
||||
"prompts",
|
||||
":promptName",
|
||||
"version",
|
||||
":name",
|
||||
"versions",
|
||||
":version"
|
||||
],
|
||||
"query": [],
|
||||
"variable": [
|
||||
{
|
||||
"key": "promptName",
|
||||
"key": "name",
|
||||
"value": "",
|
||||
"description": "The name of the prompt"
|
||||
},
|
||||
@@ -1247,7 +1247,7 @@
|
||||
"auth": null,
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"type\": \"chat\",\n \"name\": \"example\",\n \"prompt\": [\n {\n \"role\": \"example\",\n \"content\": \"example\"\n }\n ],\n \"config\": \"UNKNOWN\",\n \"labels\": [\n \"example\"\n ],\n \"tags\": [\n \"example\"\n ]\n}",
|
||||
"raw": "{\n \"type\": \"chat\",\n \"name\": \"example\",\n \"prompt\": [\n {\n \"role\": \"example\",\n \"content\": \"example\"\n }\n ],\n \"config\": \"UNKNOWN\",\n \"labels\": [\n \"example\"\n ],\n \"tags\": [\n \"example\"\n ],\n \"commitMessage\": \"example\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
|
||||
@@ -23,11 +23,9 @@ describe("/api/public/metrics/daily API Endpoint", () => {
|
||||
createObservation({
|
||||
trace_id: createdTrace.id,
|
||||
project_id: createdTrace.project_id,
|
||||
name: "observation-name",
|
||||
name: "observation-name-1",
|
||||
end_time: new Date().getTime(),
|
||||
start_time: new Date().getTime() - 1000,
|
||||
input: "input",
|
||||
output: "output",
|
||||
provided_model_name: "model-1",
|
||||
}),
|
||||
createObservation({
|
||||
@@ -35,9 +33,23 @@ describe("/api/public/metrics/daily API Endpoint", () => {
|
||||
project_id: createdTrace.project_id,
|
||||
name: "observation-name-2",
|
||||
end_time: new Date().getTime(),
|
||||
start_time: new Date().getTime() - 1000,
|
||||
provided_model_name: "model-1",
|
||||
}),
|
||||
createObservation({
|
||||
trace_id: createdTrace.id,
|
||||
project_id: createdTrace.project_id,
|
||||
name: "observation-name-3",
|
||||
end_time: new Date().getTime(),
|
||||
start_time: new Date().getTime() - 100000,
|
||||
provided_model_name: "model-2",
|
||||
}),
|
||||
createObservation({
|
||||
trace_id: createdTrace.id,
|
||||
project_id: createdTrace.project_id,
|
||||
name: "observation-name-4",
|
||||
end_time: new Date().getTime(),
|
||||
start_time: new Date().getTime() - 100000,
|
||||
input: "input-2",
|
||||
output: "output-2",
|
||||
provided_model_name: "model-2",
|
||||
}),
|
||||
];
|
||||
@@ -56,17 +68,17 @@ describe("/api/public/metrics/daily API Endpoint", () => {
|
||||
|
||||
const metric = metrics.body.data[0];
|
||||
expect(metric.countTraces).toBe(1);
|
||||
expect(metric.countObservations).toBe(2);
|
||||
expect(metric.countObservations).toBe(4);
|
||||
expect(metric.usage).toHaveLength(2);
|
||||
expect(metric.totalCost).toBe(600);
|
||||
expect(metric.totalCost).toBe(1200);
|
||||
for (const usage of metric.usage) {
|
||||
expect(usage.model).toMatch(/model-\d/g);
|
||||
expect(usage.inputUsage).toBe(1234);
|
||||
expect(usage.outputUsage).toBe(5678);
|
||||
expect(usage.totalUsage).toBe(6912);
|
||||
expect(usage.countObservations).toBe(1);
|
||||
expect(usage.inputUsage).toBe(1234 * 2);
|
||||
expect(usage.outputUsage).toBe(5678 * 2);
|
||||
expect(usage.totalUsage).toBe(6912 * 2);
|
||||
expect(usage.countObservations).toBe(2);
|
||||
expect(usage.countTraces).toBe(1);
|
||||
expect(usage.totalCost).toBe(300);
|
||||
expect(usage.totalCost).toBe(600);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -237,7 +237,8 @@ describe("Fetch datasets for UI presentation", () => {
|
||||
expect(secondRun.run_id).toEqual(datasetRun2Id);
|
||||
expect(secondRun.run_description).toBeNull();
|
||||
expect(secondRun.run_metadata).toEqual({});
|
||||
expect(secondRun.avgLatency).toEqual(1);
|
||||
expect(secondRun.avgLatency).toBeGreaterThanOrEqual(1);
|
||||
expect(secondRun.avgLatency).toBeLessThanOrEqual(1.002);
|
||||
expect(secondRun.avgTotalCost.toString()).toStrictEqual("300");
|
||||
|
||||
expect(JSON.stringify(secondRun.scores)).toEqual(JSON.stringify({}));
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomUUID } from "crypto";
|
||||
import { makeAPICall } from "@/src/__tests__/test-utils";
|
||||
import waitForExpect from "wait-for-expect";
|
||||
import {
|
||||
getEventLogByProjectAndEntityId,
|
||||
getObservationById,
|
||||
getScoreById,
|
||||
getTraceById,
|
||||
@@ -217,6 +218,43 @@ describe("/api/public/ingestion API Endpoint", () => {
|
||||
expect(response.body.errors[0].message).toBe("Invalid request data");
|
||||
});
|
||||
|
||||
// Disabled until eventLog becomes the default behaviour.
|
||||
it("should create a log entry for the S3 file", async () => {
|
||||
const traceId = v4();
|
||||
const eventId = v4();
|
||||
|
||||
const response = await makeAPICall("POST", "/api/public/ingestion", {
|
||||
batch: [
|
||||
{
|
||||
id: eventId,
|
||||
type: "trace-create",
|
||||
timestamp: new Date().toISOString(),
|
||||
body: {
|
||||
id: traceId,
|
||||
name: "Foo Bar",
|
||||
userId: "user-1",
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "2.0.0",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(response.status).toBe(207);
|
||||
|
||||
await waitForExpect(async () => {
|
||||
const logs = await getEventLogByProjectAndEntityId(
|
||||
projectId,
|
||||
"trace",
|
||||
traceId,
|
||||
);
|
||||
expect(logs.length).toBeGreaterThan(0);
|
||||
expect(logs[0].bucket_path).toBe(
|
||||
`events/${projectId}/trace/${traceId}/${eventId}.json`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("#4900: should clear score comment on update with `null`", async () => {
|
||||
const scoreId = randomUUID();
|
||||
const score1 = {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { prepareUsageDataForTimeseriesChart } from "@/src/features/dashboard/components/ModelUsageChart";
|
||||
import { orderByTimeSeries } from "@langfuse/shared/src/server";
|
||||
|
||||
describe("orderByTimeSeries", () => {
|
||||
test("should return correct bucket size and query for 1 hour time range", () => {
|
||||
const filter = [
|
||||
{
|
||||
type: "datetime" as const,
|
||||
column: "timestamp",
|
||||
operator: ">=" as const,
|
||||
value: new Date("2024-01-01T00:00:00Z"),
|
||||
},
|
||||
{
|
||||
type: "datetime" as const,
|
||||
column: "timestamp",
|
||||
operator: "<=" as const,
|
||||
value: new Date("2024-01-01T01:00:00Z"),
|
||||
},
|
||||
];
|
||||
|
||||
const [query, params, bucketSize] = orderByTimeSeries(filter, "timestamp");
|
||||
|
||||
// For 1 hour difference, should pick 60 second buckets to get ~60 data points
|
||||
expect(bucketSize).toBe(60);
|
||||
expect(query).toBe(
|
||||
"ORDER BY timestamp ASC \n WITH FILL\n FROM toStartOfInterval(toDateTime({fromTime: DateTime64(3)}), INTERVAL 60 SECOND)\n TO toDateTime({toTime: DateTime64(3)}) + INTERVAL 60 SECOND\n STEP toIntervalSecond(60)",
|
||||
);
|
||||
expect(params.fromTime).toBe(new Date("2024-01-01T00:00:00Z").getTime());
|
||||
expect(params.toTime).toBe(new Date("2024-01-01T01:00:00Z").getTime());
|
||||
});
|
||||
|
||||
test("should return correct bucket size and query for 1 day time range", () => {
|
||||
const filter = [
|
||||
{
|
||||
type: "datetime" as const,
|
||||
column: "timestamp",
|
||||
operator: ">=" as const,
|
||||
value: new Date("2024-01-01T00:00:00Z"),
|
||||
},
|
||||
{
|
||||
type: "datetime" as const,
|
||||
column: "timestamp",
|
||||
operator: "<=" as const,
|
||||
value: new Date("2024-01-02T00:00:00Z"),
|
||||
},
|
||||
];
|
||||
|
||||
const [query, params, bucketSize] = orderByTimeSeries(filter, "timestamp");
|
||||
|
||||
// For 24 hour difference, should pick 1800 second (30 min) buckets
|
||||
expect(bucketSize).toBe(1800);
|
||||
expect(query).toBe(
|
||||
"ORDER BY timestamp ASC \n WITH FILL\n FROM toStartOfInterval(toDateTime({fromTime: DateTime64(3)}), INTERVAL 1800 SECOND)\n TO toDateTime({toTime: DateTime64(3)}) + INTERVAL 1800 SECOND\n STEP toIntervalSecond(1800)",
|
||||
);
|
||||
expect(params.fromTime).toBe(new Date("2024-01-01T00:00:00Z").getTime());
|
||||
expect(params.toTime).toBe(new Date("2024-01-02T00:00:00Z").getTime());
|
||||
});
|
||||
|
||||
test("should handle empty filter by using default time range", () => {
|
||||
expect(() => orderByTimeSeries([], "timestamp")).toThrow(
|
||||
"Time Filter is required for time series queries",
|
||||
);
|
||||
});
|
||||
|
||||
describe("aggregate time series for model cost and usage", () => {
|
||||
it("should aggregate time series for model cost and usage", async () => {
|
||||
const metricHistory = prepareUsageDataForTimeseriesChart(
|
||||
["gpt-4o-mini", "text-embedding-ada-002"],
|
||||
[
|
||||
{
|
||||
startTime: "2025-02-10T13:30:00.000Z",
|
||||
units: {
|
||||
input: 422,
|
||||
output: 61,
|
||||
total: 483,
|
||||
},
|
||||
cost: {
|
||||
input: 0.0000633,
|
||||
output: 0.0000366,
|
||||
total: 0.0000999,
|
||||
},
|
||||
model: "gpt-4o-mini",
|
||||
},
|
||||
{
|
||||
startTime: "2025-02-10T13:30:00.000Z",
|
||||
units: {
|
||||
input: 6,
|
||||
total: 6,
|
||||
},
|
||||
cost: {
|
||||
total: 6e-7,
|
||||
},
|
||||
model: "text-embedding-ada-002",
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(metricHistory.get("total")).toEqual([
|
||||
{
|
||||
startTime: "2025-02-10T13:30:00.000Z",
|
||||
units: 483,
|
||||
cost: 0.0000999,
|
||||
model: "gpt-4o-mini",
|
||||
usageType: "total",
|
||||
},
|
||||
{
|
||||
startTime: "2025-02-10T13:30:00.000Z",
|
||||
units: 6,
|
||||
cost: 6e-7,
|
||||
model: "text-embedding-ada-002",
|
||||
usageType: "total",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
/** @jest-environment node */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
|
||||
import type { Session } from "next-auth";
|
||||
import { pruneDatabase } from "@/src/__tests__/test-utils";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { appRouter } from "@/src/server/api/root";
|
||||
import { createInnerTRPCContext } from "@/src/server/api/trpc";
|
||||
import {
|
||||
createObservation,
|
||||
createObservationsCh,
|
||||
createTrace,
|
||||
createTracesCh,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
describe("traces trps", () => {
|
||||
const projectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
|
||||
|
||||
beforeEach(async () => await pruneDatabase());
|
||||
|
||||
const session: Session = {
|
||||
expires: "1",
|
||||
user: {
|
||||
id: "user-1",
|
||||
canCreateOrganizations: true,
|
||||
name: "Demo User",
|
||||
organizations: [
|
||||
{
|
||||
id: "seed-org-id",
|
||||
name: "Test Organization",
|
||||
role: "OWNER",
|
||||
plan: "cloud:hobby",
|
||||
cloudConfig: undefined,
|
||||
projects: [
|
||||
{
|
||||
id: projectId,
|
||||
role: "ADMIN",
|
||||
retentionDays: 30,
|
||||
deletedAt: null,
|
||||
name: "Test Project",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
featureFlags: {
|
||||
excludeClickhouseRead: false,
|
||||
templateFlag: true,
|
||||
},
|
||||
admin: true,
|
||||
},
|
||||
environment: {} as any,
|
||||
};
|
||||
|
||||
const ctx = createInnerTRPCContext({ session });
|
||||
const caller = appRouter.createCaller({ ...ctx, prisma });
|
||||
|
||||
describe("sessions.byId", () => {
|
||||
it("access private session", async () => {
|
||||
const sessionId = randomUUID();
|
||||
|
||||
await prisma.traceSession.create({
|
||||
data: {
|
||||
id: sessionId,
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
|
||||
const trace = createTrace({
|
||||
project_id: projectId,
|
||||
session_id: sessionId,
|
||||
});
|
||||
|
||||
const trace2 = createTrace({
|
||||
project_id: projectId,
|
||||
session_id: sessionId,
|
||||
});
|
||||
|
||||
await createTracesCh([trace, trace2]);
|
||||
|
||||
const observation = createObservation({
|
||||
project_id: projectId,
|
||||
trace_id: trace.id,
|
||||
});
|
||||
|
||||
const observation2 = createObservation({
|
||||
project_id: projectId,
|
||||
trace_id: trace2.id,
|
||||
});
|
||||
|
||||
const observation3 = createObservation({
|
||||
project_id: projectId,
|
||||
trace_id: trace2.id,
|
||||
});
|
||||
|
||||
await createObservationsCh([observation, observation2, observation3]);
|
||||
|
||||
const sessionRes = await caller.sessions.byId({
|
||||
projectId,
|
||||
sessionId,
|
||||
});
|
||||
|
||||
expect(sessionRes).toEqual({
|
||||
id: sessionId,
|
||||
createdAt: expect.any(Date),
|
||||
updatedAt: expect.any(Date),
|
||||
projectId: projectId,
|
||||
bookmarked: false,
|
||||
public: false,
|
||||
traces: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: trace.id,
|
||||
userId: trace.user_id,
|
||||
name: trace.name,
|
||||
timestamp: new Date(trace.timestamp),
|
||||
scores: [],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: trace2.id,
|
||||
userId: trace2.user_id,
|
||||
name: trace2.name,
|
||||
timestamp: new Date(trace2.timestamp),
|
||||
scores: [],
|
||||
}),
|
||||
]),
|
||||
totalCost: expect.any(Number),
|
||||
users: expect.arrayContaining([trace.user_id, trace2.user_id]),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -60,7 +60,7 @@ describe("/api/public/traces API Endpoint", () => {
|
||||
expect(trace.body.externalId).toBeNull();
|
||||
expect(trace.body.version).toBe("2.0.0");
|
||||
expect(trace.body.projectId).toBe("7a88fb47-b4e2-43b8-a06c-a5ce950dc53a");
|
||||
expect(trace.body.latency).toBe(100);
|
||||
expect(trace.body.latency).toBeCloseTo(100, 2);
|
||||
expect(trace.body.observations.length).toBe(2);
|
||||
expect(trace.body.scores.length).toBe(0);
|
||||
expect(trace.body.observations).toEqual(
|
||||
@@ -271,4 +271,76 @@ describe("/api/public/traces API Endpoint", () => {
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("LFE-3699: should fetch a single trace with unescaped metadata via traces list", async () => {
|
||||
const traceId = randomUUID();
|
||||
const trace = createTrace({
|
||||
id: traceId,
|
||||
name: "trace-name1",
|
||||
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
metadata: { key: JSON.stringify({ foo: "bar" }) },
|
||||
input: JSON.stringify({
|
||||
args: [
|
||||
{
|
||||
foo: "bar",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
await createTracesCh([trace]);
|
||||
|
||||
const traces = await makeZodVerifiedAPICall(
|
||||
GetTracesV1Response,
|
||||
"GET",
|
||||
`/api/public/traces`,
|
||||
);
|
||||
|
||||
const traceResponse = traces.body.data.find((t) => t.id === traceId);
|
||||
expect(traceResponse).toBeDefined();
|
||||
expect(traceResponse!.name).toBe("trace-name1");
|
||||
expect(traceResponse!.metadata).toEqual({ key: { foo: "bar" } });
|
||||
expect(traceResponse!.input).toEqual({
|
||||
args: [
|
||||
{
|
||||
foo: "bar",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("LFE-3699: should fetch a single trace with unescaped metadata via single trace endpoint", async () => {
|
||||
const traceId = randomUUID();
|
||||
const trace = createTrace({
|
||||
id: traceId,
|
||||
name: "trace-name1",
|
||||
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
metadata: { key: JSON.stringify({ foo: "bar" }) },
|
||||
input: JSON.stringify({
|
||||
args: [
|
||||
{
|
||||
foo: "bar",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
await createTracesCh([trace]);
|
||||
|
||||
const traceResponse = await makeZodVerifiedAPICall(
|
||||
GetTraceV1Response,
|
||||
"GET",
|
||||
`/api/public/traces/${traceId}`,
|
||||
);
|
||||
|
||||
expect(traceResponse.body.name).toBe("trace-name1");
|
||||
expect(traceResponse.body.metadata).toEqual({ key: { foo: "bar" } });
|
||||
expect(traceResponse.body.input).toEqual({
|
||||
args: [
|
||||
{
|
||||
foo: "bar",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/** @jest-environment node */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
|
||||
import type { Session } from "next-auth";
|
||||
import { pruneDatabase } from "@/src/__tests__/test-utils";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { appRouter } from "@/src/server/api/root";
|
||||
import { createInnerTRPCContext } from "@/src/server/api/trpc";
|
||||
import { createTrace, createTracesCh } from "@langfuse/shared/src/server";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
describe("traces trps", () => {
|
||||
const projectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
|
||||
|
||||
beforeEach(async () => await pruneDatabase());
|
||||
|
||||
const session: Session = {
|
||||
expires: "1",
|
||||
user: {
|
||||
id: "user-1",
|
||||
canCreateOrganizations: true,
|
||||
name: "Demo User",
|
||||
organizations: [
|
||||
{
|
||||
id: "seed-org-id",
|
||||
name: "Test Organization",
|
||||
role: "OWNER",
|
||||
plan: "cloud:hobby",
|
||||
cloudConfig: undefined,
|
||||
projects: [
|
||||
{
|
||||
id: projectId,
|
||||
role: "ADMIN",
|
||||
retentionDays: 30,
|
||||
deletedAt: null,
|
||||
name: "Test Project",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
featureFlags: {
|
||||
excludeClickhouseRead: false,
|
||||
templateFlag: true,
|
||||
},
|
||||
admin: true,
|
||||
},
|
||||
environment: {} as any,
|
||||
};
|
||||
|
||||
const ctx = createInnerTRPCContext({ session });
|
||||
const caller = appRouter.createCaller({ ...ctx, prisma });
|
||||
|
||||
describe("traces.byId", () => {
|
||||
it("access private trace", async () => {
|
||||
const trace = createTrace({
|
||||
project_id: projectId,
|
||||
});
|
||||
|
||||
await createTracesCh([trace]);
|
||||
|
||||
const traceRes = await caller.traces.byId({
|
||||
projectId,
|
||||
traceId: trace.id,
|
||||
});
|
||||
|
||||
expect(traceRes?.id).toEqual(trace.id);
|
||||
expect(traceRes?.projectId).toEqual(projectId);
|
||||
expect(traceRes?.name).toEqual(trace.name);
|
||||
expect(traceRes?.timestamp).toEqual(new Date(trace.timestamp));
|
||||
expect(traceRes?.tags).toEqual(trace.tags);
|
||||
expect(traceRes?.input).toEqual(trace.input);
|
||||
expect(traceRes?.output).toEqual(trace.output);
|
||||
expect(traceRes?.userId).toEqual(trace.user_id);
|
||||
expect(traceRes?.sessionId).toEqual(trace.session_id);
|
||||
});
|
||||
|
||||
it("access public trace", async () => {
|
||||
const differentProjectId = randomUUID();
|
||||
const trace = createTrace({
|
||||
project_id: differentProjectId,
|
||||
public: true,
|
||||
});
|
||||
|
||||
await createTracesCh([trace]);
|
||||
|
||||
const traceRes = await caller.traces.byId({
|
||||
projectId: differentProjectId,
|
||||
traceId: trace.id,
|
||||
});
|
||||
|
||||
expect(traceRes?.id).toEqual(trace.id);
|
||||
expect(traceRes?.projectId).toEqual(differentProjectId);
|
||||
expect(traceRes?.name).toEqual(trace.name);
|
||||
expect(traceRes?.timestamp).toEqual(new Date(trace.timestamp));
|
||||
});
|
||||
|
||||
it("access trace without any authentication", async () => {
|
||||
const unAuthedSession = createInnerTRPCContext({ session: null });
|
||||
const unAuthedCaller = appRouter.createCaller({
|
||||
...unAuthedSession,
|
||||
prisma,
|
||||
});
|
||||
|
||||
const trace = createTrace({
|
||||
project_id: projectId,
|
||||
public: true,
|
||||
});
|
||||
|
||||
await createTracesCh([trace]);
|
||||
|
||||
const traceRes = await unAuthedCaller.traces.byId({
|
||||
projectId,
|
||||
traceId: trace.id,
|
||||
});
|
||||
|
||||
expect(traceRes?.id).toEqual(trace.id);
|
||||
expect(traceRes?.projectId).toEqual(projectId);
|
||||
expect(traceRes?.name).toEqual(trace.name);
|
||||
expect(traceRes?.timestamp).toEqual(new Date(trace.timestamp));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,39 @@ describe("Traces table API test", () => {
|
||||
expect(tableRows[0].public).toEqual(trace.public);
|
||||
});
|
||||
|
||||
it("#5274: should get a traces in expected default order", async () => {
|
||||
const project_id = v4();
|
||||
const trace_id = v4();
|
||||
|
||||
// Trace1 happened after Trace2, but Trace2 got updated.
|
||||
const trace1 = createTrace({
|
||||
id: `${trace_id}-1`,
|
||||
project_id,
|
||||
timestamp: new Date().getTime(),
|
||||
event_ts: new Date().getTime(),
|
||||
});
|
||||
const trace2 = createTrace({
|
||||
id: `${trace_id}-2`,
|
||||
project_id,
|
||||
timestamp: new Date().getTime() - 5000,
|
||||
event_ts: new Date().getTime() + 5000,
|
||||
});
|
||||
await createTracesCh([trace1, trace2]);
|
||||
|
||||
const tableRows = await getTracesTable(
|
||||
project_id,
|
||||
[],
|
||||
undefined,
|
||||
{ column: "timestamp", order: "DESC" },
|
||||
2,
|
||||
0,
|
||||
);
|
||||
|
||||
expect(tableRows).toHaveLength(2);
|
||||
expect(tableRows[0].id).toEqual(`${trace_id}-1`);
|
||||
expect(tableRows[1].id).toEqual(`${trace_id}-2`);
|
||||
});
|
||||
|
||||
it("should get a correct trace with observations", async () => {
|
||||
const project_id = v4();
|
||||
const trace_id = v4();
|
||||
@@ -99,6 +132,22 @@ describe("Traces table API test", () => {
|
||||
],
|
||||
expected: [],
|
||||
},
|
||||
{
|
||||
traceInput: {},
|
||||
observationInput: [
|
||||
{ usage_details: { total: 100 } },
|
||||
{ usage_details: { total: 200 } },
|
||||
],
|
||||
filterstate: [
|
||||
{
|
||||
column: "totalTokens",
|
||||
operator: ">" as const,
|
||||
value: 3456789,
|
||||
type: "number" as const,
|
||||
},
|
||||
],
|
||||
expected: [],
|
||||
},
|
||||
{
|
||||
traceInput: {},
|
||||
observationInput: [],
|
||||
|
||||
@@ -423,6 +423,7 @@ describe("/api/public/v2/prompts API Endpoint", () => {
|
||||
prompt: chatMessages,
|
||||
type: "chat",
|
||||
labels: ["production"],
|
||||
commitMessage: "chore: setup initial prompt",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
@@ -442,6 +443,7 @@ describe("/api/public/v2/prompts API Endpoint", () => {
|
||||
expect(validatedPrompt.labels).toEqual(["production", "latest"]);
|
||||
expect(validatedPrompt.createdBy).toBe("API");
|
||||
expect(validatedPrompt.config).toEqual({});
|
||||
expect(validatedPrompt.commitMessage).toBe("chore: setup initial prompt");
|
||||
});
|
||||
|
||||
it("should fail if chat prompt has string prompt", async () => {
|
||||
@@ -1116,7 +1118,7 @@ describe("/api/public/v2/prompts API Endpoint", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH api/public/v2/prompts/[promptName]/version/[version]", () => {
|
||||
describe("PATCH api/public/v2/prompts/[promptName]/versions/[version]", () => {
|
||||
it("should update the labels of a prompt", async () => {
|
||||
const { projectId: newProjectId, auth: newAuth } =
|
||||
await createOrgProjectAndApiKey();
|
||||
@@ -1134,7 +1136,7 @@ describe("PATCH api/public/v2/prompts/[promptName]/version/[version]", () => {
|
||||
|
||||
const response = await makeAPICall(
|
||||
"PATCH",
|
||||
`${baseURI}/prompt-1/version/1`,
|
||||
`${baseURI}/prompt-1/versions/1`,
|
||||
{
|
||||
newLabels: ["new-label"],
|
||||
},
|
||||
@@ -1184,7 +1186,7 @@ describe("PATCH api/public/v2/prompts/[promptName]/version/[version]", () => {
|
||||
// Add "production" label to version 2
|
||||
const response = await makeAPICall(
|
||||
"PATCH",
|
||||
`${baseURI}/prompt-1/version/2`,
|
||||
`${baseURI}/prompt-1/versions/2`,
|
||||
{
|
||||
newLabels: ["production"],
|
||||
},
|
||||
@@ -1234,7 +1236,7 @@ describe("PATCH api/public/v2/prompts/[promptName]/version/[version]", () => {
|
||||
// Try to set "latest" label
|
||||
const response = await makeAPICall(
|
||||
"PATCH",
|
||||
`${baseURI}/prompt-1/version/1`,
|
||||
`${baseURI}/prompt-1/versions/1`,
|
||||
{
|
||||
newLabels: ["latest"],
|
||||
},
|
||||
@@ -1250,7 +1252,7 @@ describe("PATCH api/public/v2/prompts/[promptName]/version/[version]", () => {
|
||||
// Try to update non-existing prompt
|
||||
const response = await makeAPICall(
|
||||
"PATCH",
|
||||
`${baseURI}/non-existing-prompt/version/1`,
|
||||
`${baseURI}/non-existing-prompt/versions/1`,
|
||||
{
|
||||
newLabels: ["production"],
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ describe("RateLimitService", () => {
|
||||
beforeAll(() => {
|
||||
redis = new Redis("redis://:myredissecret@127.0.0.1:6379", {
|
||||
maxRetriesPerRequest: null,
|
||||
enableAutoPipelining: false, // Align with our settings overwrite for rate limit service
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,7 +72,7 @@ describe("RateLimitService", () => {
|
||||
|
||||
expect(redis).toBeDefined();
|
||||
|
||||
const rateLimitService = new RateLimitService(redis!);
|
||||
const rateLimitService = RateLimitService.getInstance(redis);
|
||||
const result = await rateLimitService.rateLimitRequest(scope, "public-api");
|
||||
|
||||
expect(result?.res).toEqual({
|
||||
@@ -102,7 +103,7 @@ describe("RateLimitService", () => {
|
||||
rateLimitOverrides: [],
|
||||
};
|
||||
|
||||
const rateLimitService = new RateLimitService(redis!);
|
||||
const rateLimitService = RateLimitService.getInstance(redis);
|
||||
await rateLimitService.rateLimitRequest(scope, "public-api");
|
||||
|
||||
const result = await rateLimitService.rateLimitRequest(scope, "public-api");
|
||||
@@ -130,7 +131,7 @@ describe("RateLimitService", () => {
|
||||
],
|
||||
};
|
||||
|
||||
const rateLimitService = new RateLimitService(redis!);
|
||||
const rateLimitService = RateLimitService.getInstance(redis);
|
||||
await rateLimitService.rateLimitRequest(scope, "public-api");
|
||||
|
||||
const firstResult = await rateLimitService.rateLimitRequest(
|
||||
@@ -180,7 +181,7 @@ describe("RateLimitService", () => {
|
||||
],
|
||||
};
|
||||
|
||||
const rateLimitService = new RateLimitService(redis!);
|
||||
const rateLimitService = RateLimitService.getInstance(redis);
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await rateLimitService.rateLimitRequest(scope, "public-api");
|
||||
@@ -211,7 +212,7 @@ describe("RateLimitService", () => {
|
||||
],
|
||||
};
|
||||
|
||||
const rateLimitService = new RateLimitService(redis!);
|
||||
const rateLimitService = RateLimitService.getInstance(redis);
|
||||
|
||||
const result = await rateLimitService.rateLimitRequest(scope, "public-api");
|
||||
|
||||
@@ -237,7 +238,7 @@ describe("RateLimitService", () => {
|
||||
],
|
||||
};
|
||||
|
||||
const rateLimitService = new RateLimitService(redis!);
|
||||
const rateLimitService = RateLimitService.getInstance(redis);
|
||||
|
||||
const result = await rateLimitService.rateLimitRequest(scope, "prompts");
|
||||
|
||||
@@ -256,31 +257,32 @@ describe("RateLimitService", () => {
|
||||
],
|
||||
};
|
||||
|
||||
const rateLimitService = new RateLimitService(redis!);
|
||||
const rateLimitService = RateLimitService.getInstance(redis);
|
||||
|
||||
const result = await rateLimitService.rateLimitRequest(scope, "ingestion");
|
||||
|
||||
expect(result?.res).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should not apply rate limits when redis is not defined", async () => {
|
||||
const scope = {
|
||||
orgId: orgId,
|
||||
plan: "cloud:hobby" as const,
|
||||
projectId: "test-project-id",
|
||||
accessLevel: "all" as const,
|
||||
rateLimitOverrides: [
|
||||
{ resource: "public-api" as const, points: 5, durationInSec: 10 },
|
||||
],
|
||||
};
|
||||
|
||||
const rateLimitService = new RateLimitService(null);
|
||||
|
||||
const result = await rateLimitService.rateLimitRequest(scope, "public-api");
|
||||
|
||||
expect(result?.res).toBeUndefined();
|
||||
expect(result?.isRateLimited()).toBe(false);
|
||||
});
|
||||
// Not applicable now that the RateLimitService instantiates its own Redis
|
||||
// it("should not apply rate limits when redis is not defined", async () => {
|
||||
// const scope = {
|
||||
// orgId: orgId,
|
||||
// plan: "cloud:hobby" as const,
|
||||
// projectId: "test-project-id",
|
||||
// accessLevel: "all" as const,
|
||||
// rateLimitOverrides: [
|
||||
// { resource: "public-api" as const, points: 5, durationInSec: 10 },
|
||||
// ],
|
||||
// };
|
||||
//
|
||||
// const rateLimitService = new RateLimitService(null);
|
||||
//
|
||||
// const result = await rateLimitService.rateLimitRequest(scope, "public-api");
|
||||
//
|
||||
// expect(result?.res).toBeUndefined();
|
||||
// expect(result?.isRateLimited()).toBe(false);
|
||||
// });
|
||||
|
||||
it("should not apply rate limits for OSS plan", async () => {
|
||||
const scope = {
|
||||
@@ -291,7 +293,7 @@ describe("RateLimitService", () => {
|
||||
rateLimitOverrides: [],
|
||||
};
|
||||
|
||||
const rateLimitService = new RateLimitService(redis!);
|
||||
const rateLimitService = RateLimitService.getInstance(redis);
|
||||
|
||||
const result = await rateLimitService.rateLimitRequest(scope, "public-api");
|
||||
|
||||
|
||||
@@ -0,0 +1,691 @@
|
||||
import { convertOtelSpanToIngestionEvent } from "@/src/features/otel/server";
|
||||
import { ingestionEvent } from "@langfuse/shared/src/server";
|
||||
|
||||
describe("OTel Resource Span Mapping", () => {
|
||||
describe("Vendor Spans", () => {
|
||||
it("should convert an OpenLit OTel Span to Langfuse Events", () => {
|
||||
// Setup
|
||||
const resourceSpan = {
|
||||
resource: {
|
||||
attributes: [
|
||||
{
|
||||
key: "telemetry.sdk.language",
|
||||
value: { stringValue: "python" },
|
||||
},
|
||||
{ key: "telemetry.sdk.name", value: { stringValue: "openlit" } },
|
||||
{
|
||||
key: "telemetry.sdk.version",
|
||||
value: { stringValue: "1.27.0" },
|
||||
},
|
||||
{ key: "service.name", value: { stringValue: "default" } },
|
||||
{
|
||||
key: "deployment.environment",
|
||||
value: { stringValue: "default" },
|
||||
},
|
||||
],
|
||||
},
|
||||
scopeSpans: [
|
||||
{
|
||||
scope: { name: "openlit.otel.tracing" },
|
||||
spans: [
|
||||
{
|
||||
traceId: {
|
||||
type: "Buffer",
|
||||
data: [
|
||||
234, 103, 55, 8, 68, 28, 41, 132, 165, 74, 62, 57, 98, 211,
|
||||
89, 95,
|
||||
],
|
||||
},
|
||||
spanId: {
|
||||
type: "Buffer",
|
||||
data: [185, 4, 191, 251, 32, 190, 109, 126],
|
||||
},
|
||||
name: "openai.chat.completions",
|
||||
kind: 3,
|
||||
startTimeUnixNano: {
|
||||
low: 153687506,
|
||||
high: 404677085,
|
||||
unsigned: true,
|
||||
},
|
||||
endTimeUnixNano: {
|
||||
low: 1327836088,
|
||||
high: 404677085,
|
||||
unsigned: true,
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
key: "telemetry.sdk.name",
|
||||
value: { stringValue: "openlit" },
|
||||
},
|
||||
{ key: "gen_ai.system", value: { stringValue: "openai" } },
|
||||
{
|
||||
key: "gen_ai.operation.name",
|
||||
value: { stringValue: "chat" },
|
||||
},
|
||||
{
|
||||
key: "gen_ai.endpoint",
|
||||
value: { stringValue: "openai.chat.completions" },
|
||||
},
|
||||
{
|
||||
key: "gen_ai.response.id",
|
||||
value: {
|
||||
stringValue: "chatcmpl-AugxBIoQzz2zFMWFoiyS3Vmm1OuQI",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "gen_ai.environment",
|
||||
value: { stringValue: "default" },
|
||||
},
|
||||
{
|
||||
key: "gen_ai.application_name",
|
||||
value: { stringValue: "default" },
|
||||
},
|
||||
{
|
||||
key: "gen_ai.request.model",
|
||||
value: { stringValue: "gpt-3.5-turbo" },
|
||||
},
|
||||
{
|
||||
key: "gen_ai.request.top_p",
|
||||
value: { doubleValue: 1 },
|
||||
},
|
||||
{
|
||||
key: "gen_ai.request.max_tokens",
|
||||
value: { intValue: { low: -1, high: -1, unsigned: false } },
|
||||
},
|
||||
{
|
||||
key: "gen_ai.request.user",
|
||||
value: { stringValue: "" },
|
||||
},
|
||||
{
|
||||
key: "gen_ai.request.temperature",
|
||||
value: { doubleValue: 1 },
|
||||
},
|
||||
{
|
||||
key: "gen_ai.request.presence_penalty",
|
||||
value: { doubleValue: 0 },
|
||||
},
|
||||
{
|
||||
key: "gen_ai.request.frequency_penalty",
|
||||
value: { doubleValue: 0 },
|
||||
},
|
||||
{ key: "gen_ai.request.seed", value: { stringValue: "" } },
|
||||
{
|
||||
key: "gen_ai.request.is_stream",
|
||||
value: { boolValue: false },
|
||||
},
|
||||
{
|
||||
key: "gen_ai.usage.input_tokens",
|
||||
value: { intValue: { low: 14, high: 0, unsigned: false } },
|
||||
},
|
||||
{
|
||||
key: "gen_ai.usage.output_tokens",
|
||||
value: { intValue: { low: 96, high: 0, unsigned: false } },
|
||||
},
|
||||
{
|
||||
key: "gen_ai.usage.total_tokens",
|
||||
value: { intValue: { low: 110, high: 0, unsigned: false } },
|
||||
},
|
||||
{
|
||||
key: "gen_ai.response.finish_reasons",
|
||||
value: {
|
||||
arrayValue: { values: [{ stringValue: "stop" }] },
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "gen_ai.usage.cost",
|
||||
value: { doubleValue: 0.000151 },
|
||||
},
|
||||
],
|
||||
events: [
|
||||
{
|
||||
timeUnixNano: {
|
||||
low: 1327691067,
|
||||
high: 404677085,
|
||||
unsigned: true,
|
||||
},
|
||||
name: "gen_ai.content.prompt",
|
||||
attributes: [
|
||||
{
|
||||
key: "gen_ai.prompt",
|
||||
value: {
|
||||
stringValue: "user: What is LLM Observability?",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
timeUnixNano: {
|
||||
low: 1327737136,
|
||||
high: 404677085,
|
||||
unsigned: true,
|
||||
},
|
||||
name: "gen_ai.content.completion",
|
||||
attributes: [
|
||||
{
|
||||
key: "gen_ai.completion",
|
||||
value: {
|
||||
stringValue:
|
||||
"LLM Observability stands for logs, metrics, and traces observability. It refers to the practice of monitoring and analyzing logs, metrics, and traces from a software application to gain insight into its performance, behavior, and issues. By collecting and analyzing these data points, developers and operators can better understand how the application is functioning and troubleshoot any problems that may arise. This approach is increasingly important in modern software development, where applications are often complex and distributed across multiple environments.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
status: { code: 1 },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// When
|
||||
const langfuseEvents = convertOtelSpanToIngestionEvent(resourceSpan);
|
||||
|
||||
// Then
|
||||
// Will throw an error if the parsing fails
|
||||
const parsedEvents = langfuseEvents.map((event) =>
|
||||
ingestionEvent.parse(event),
|
||||
);
|
||||
expect(parsedEvents).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should convert a TraceLoop OTel Span to Langfuse Events", () => {
|
||||
// Setup
|
||||
const resourceSpan = {
|
||||
resource: {
|
||||
attributes: [
|
||||
{
|
||||
key: "service.name",
|
||||
value: {
|
||||
stringValue:
|
||||
"/usr/local/lib/python3.11/dist-packages/colab_kernel_launcher.py",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
scopeSpans: [
|
||||
{
|
||||
scope: {
|
||||
name: "opentelemetry.instrumentation.openai.v1",
|
||||
version: "0.33.9",
|
||||
},
|
||||
spans: [
|
||||
{
|
||||
traceId: {
|
||||
type: "Buffer",
|
||||
data: [
|
||||
228, 239, 69, 2, 92, 155, 64, 146, 75, 255, 23, 94, 43, 18,
|
||||
91, 91,
|
||||
],
|
||||
},
|
||||
spanId: {
|
||||
type: "Buffer",
|
||||
data: [170, 191, 22, 228, 22, 174, 73, 82],
|
||||
},
|
||||
name: "openai.chat",
|
||||
kind: 3,
|
||||
startTimeUnixNano: {
|
||||
low: 865964564,
|
||||
high: 404693214,
|
||||
unsigned: true,
|
||||
},
|
||||
endTimeUnixNano: {
|
||||
low: -1984808674,
|
||||
high: 404693214,
|
||||
unsigned: true,
|
||||
},
|
||||
attributes: [
|
||||
{ key: "llm.request.type", value: { stringValue: "chat" } },
|
||||
{ key: "gen_ai.system", value: { stringValue: "OpenAI" } },
|
||||
{
|
||||
key: "gen_ai.request.model",
|
||||
value: { stringValue: "gpt-3.5-turbo" },
|
||||
},
|
||||
{ key: "llm.headers", value: { stringValue: "None" } },
|
||||
{ key: "llm.is_streaming", value: { boolValue: false } },
|
||||
{
|
||||
key: "gen_ai.openai.api_base",
|
||||
value: { stringValue: "https://api.openai.com/v1/" },
|
||||
},
|
||||
{
|
||||
key: "gen_ai.prompt.0.role",
|
||||
value: { stringValue: "user" },
|
||||
},
|
||||
{
|
||||
key: "gen_ai.prompt.0.content",
|
||||
value: { stringValue: "What is LLM Observability?" },
|
||||
},
|
||||
{
|
||||
key: "gen_ai.response.model",
|
||||
value: { stringValue: "gpt-3.5-turbo-0125" },
|
||||
},
|
||||
{
|
||||
key: "llm.usage.total_tokens",
|
||||
value: { intValue: { low: 187, high: 0, unsigned: false } },
|
||||
},
|
||||
{
|
||||
key: "gen_ai.usage.completion_tokens",
|
||||
value: { intValue: { low: 173, high: 0, unsigned: false } },
|
||||
},
|
||||
{
|
||||
key: "gen_ai.usage.prompt_tokens",
|
||||
value: { intValue: { low: 14, high: 0, unsigned: false } },
|
||||
},
|
||||
{
|
||||
key: "gen_ai.completion.0.finish_reason",
|
||||
value: { stringValue: "stop" },
|
||||
},
|
||||
{
|
||||
key: "gen_ai.completion.0.role",
|
||||
value: { stringValue: "assistant" },
|
||||
},
|
||||
{
|
||||
key: "gen_ai.completion.0.content",
|
||||
value: {
|
||||
stringValue:
|
||||
"LLM Observability (Logs, Metrics, and Traces Observability) is a strategy for monitoring and understanding the behavior of applications and systems in real-time. It involves collecting, analyzing, and correlating logs, metrics, and traces from various sources to gain insights into system performance, availability, and overall health.\n\nLogs provide detailed records of events and activities within a system, metrics offer quantitative measurements of system performance, and traces show the flow of requests through a system. By combining these sources of data, organizations can gain a comprehensive view of their systems and applications, identify issues quickly, and optimize performance.\n\nLLM Observability is essential for modern, complex distributed systems as it allows teams to troubleshoot issues, improve system performance, and enhance the overall user experience. It enables organizations to proactively monitor their systems and respond quickly to any issues that may arise.",
|
||||
},
|
||||
},
|
||||
],
|
||||
status: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// When
|
||||
const langfuseEvents = convertOtelSpanToIngestionEvent(resourceSpan);
|
||||
|
||||
// Then
|
||||
// Will throw an error if the parsing fails
|
||||
const parsedEvents = langfuseEvents.map((event) =>
|
||||
ingestionEvent.parse(event),
|
||||
);
|
||||
expect(parsedEvents).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Property Mapping", () => {
|
||||
const defaultSpanProps = {
|
||||
traceId: {
|
||||
type: "Buffer",
|
||||
data: [
|
||||
234, 103, 55, 8, 68, 28, 41, 132, 165, 74, 62, 57, 98, 211, 89, 95,
|
||||
],
|
||||
},
|
||||
spanId: {
|
||||
type: "Buffer",
|
||||
data: [185, 4, 191, 251, 32, 190, 109, 126],
|
||||
},
|
||||
name: "openai.chat.completions",
|
||||
kind: 3,
|
||||
startTimeUnixNano: {
|
||||
low: 153687506,
|
||||
high: 404677085,
|
||||
unsigned: true,
|
||||
},
|
||||
endTimeUnixNano: {
|
||||
low: 1327836088,
|
||||
high: 404677085,
|
||||
unsigned: true,
|
||||
},
|
||||
};
|
||||
|
||||
it.each([
|
||||
[
|
||||
"should extract userId on trace from user.id",
|
||||
{
|
||||
entity: "trace",
|
||||
otelAttributeKey: "user.id",
|
||||
otelAttributeValue: { stringValue: "user-1" },
|
||||
entityAttributeKey: "userId",
|
||||
entityAttributeValue: "user-1",
|
||||
},
|
||||
],
|
||||
[
|
||||
"should extract userId on trace from langfuse.user.id",
|
||||
{
|
||||
entity: "trace",
|
||||
otelAttributeKey: "langfuse.user.id",
|
||||
otelAttributeValue: { stringValue: "user-1" },
|
||||
entityAttributeKey: "userId",
|
||||
entityAttributeValue: "user-1",
|
||||
},
|
||||
],
|
||||
[
|
||||
"should extract sessionId on trace from session.id",
|
||||
{
|
||||
entity: "trace",
|
||||
otelAttributeKey: "session.id",
|
||||
otelAttributeValue: { stringValue: "session-1" },
|
||||
entityAttributeKey: "sessionId",
|
||||
entityAttributeValue: "session-1",
|
||||
},
|
||||
],
|
||||
[
|
||||
"should extract sessionId on trace from langfuse.session.id",
|
||||
{
|
||||
entity: "trace",
|
||||
otelAttributeKey: "langfuse.session.id",
|
||||
otelAttributeValue: { stringValue: "session-1" },
|
||||
entityAttributeKey: "sessionId",
|
||||
entityAttributeValue: "session-1",
|
||||
},
|
||||
],
|
||||
[
|
||||
"should extract providedModelName from gen_ai.request.model",
|
||||
{
|
||||
entity: "observation",
|
||||
otelAttributeKey: "gen_ai.request.model",
|
||||
otelAttributeValue: { stringValue: "gpt-4" },
|
||||
entityAttributeKey: "model",
|
||||
entityAttributeValue: "gpt-4",
|
||||
},
|
||||
],
|
||||
[
|
||||
"should extract modelParameters from gen_ai.request (request.temperature)",
|
||||
{
|
||||
entity: "observation",
|
||||
otelAttributeKey: "gen_ai.request.temperature",
|
||||
otelAttributeValue: { doubleValue: 1 },
|
||||
entityAttributeKey: "modelParameters.temperature",
|
||||
entityAttributeValue: 1,
|
||||
},
|
||||
],
|
||||
[
|
||||
"should extract modelParameters from gen_ai.request (request.max_tokens)",
|
||||
{
|
||||
entity: "observation",
|
||||
otelAttributeKey: "gen_ai.request.max_tokens",
|
||||
otelAttributeValue: {
|
||||
intValue: { low: 100, high: 0, unsigned: false },
|
||||
},
|
||||
entityAttributeKey: "modelParameters.max_tokens",
|
||||
entityAttributeValue: 100,
|
||||
},
|
||||
],
|
||||
[
|
||||
"should extract usage from gen_ai.usage (input_tokens)",
|
||||
{
|
||||
entity: "observation",
|
||||
otelAttributeKey: "gen_ai.usage.input_tokens",
|
||||
otelAttributeValue: {
|
||||
intValue: { low: 100, high: 0, unsigned: false },
|
||||
},
|
||||
entityAttributeKey: "usageDetails.input",
|
||||
entityAttributeValue: 100,
|
||||
},
|
||||
],
|
||||
[
|
||||
"should extract usage from gen_ai.usage (completion_tokens)",
|
||||
{
|
||||
entity: "observation",
|
||||
otelAttributeKey: "gen_ai.usage.completion_tokens",
|
||||
otelAttributeValue: {
|
||||
intValue: { low: 100, high: 0, unsigned: false },
|
||||
},
|
||||
entityAttributeKey: "usageDetails.output",
|
||||
entityAttributeValue: 100,
|
||||
},
|
||||
],
|
||||
[
|
||||
"should extract usage from gen_ai.usage (total_tokens)",
|
||||
{
|
||||
entity: "observation",
|
||||
otelAttributeKey: "gen_ai.usage.total_tokens",
|
||||
otelAttributeValue: {
|
||||
intValue: { low: 100, high: 0, unsigned: false },
|
||||
},
|
||||
entityAttributeKey: "usageDetails.total",
|
||||
entityAttributeValue: 100,
|
||||
},
|
||||
],
|
||||
[
|
||||
"should extract usage from gen_ai.usage (custom_tokens)",
|
||||
{
|
||||
entity: "observation",
|
||||
otelAttributeKey: "gen_ai.usage.custom_tokens",
|
||||
otelAttributeValue: {
|
||||
intValue: { low: 100, high: 0, unsigned: false },
|
||||
},
|
||||
entityAttributeKey: "usageDetails.custom_tokens",
|
||||
entityAttributeValue: 100,
|
||||
},
|
||||
],
|
||||
[
|
||||
"should extract cost from gen_ai.usage.cost",
|
||||
{
|
||||
entity: "observation",
|
||||
otelAttributeKey: "gen_ai.usage.cost",
|
||||
otelAttributeValue: {
|
||||
doubleValue: 0.000151,
|
||||
},
|
||||
entityAttributeKey: "costDetails.total",
|
||||
entityAttributeValue: 0.000151,
|
||||
},
|
||||
],
|
||||
[
|
||||
"should not treat usage.cost as usage",
|
||||
{
|
||||
entity: "observation",
|
||||
otelAttributeKey: "gen_ai.usage.cost",
|
||||
otelAttributeValue: {
|
||||
doubleValue: 0.000151,
|
||||
},
|
||||
entityAttributeKey: "usageDetails.cost",
|
||||
entityAttributeValue: undefined,
|
||||
},
|
||||
],
|
||||
[
|
||||
"should map gen_ai.prompt.0.content to input[0].content",
|
||||
{
|
||||
entity: "observation",
|
||||
otelAttributeKey: "gen_ai.prompt.0.content",
|
||||
otelAttributeValue: {
|
||||
stringValue: "What is LLM Observability?",
|
||||
},
|
||||
entityAttributeKey: "input",
|
||||
entityAttributeValue: [{ content: "What is LLM Observability?" }],
|
||||
},
|
||||
],
|
||||
[
|
||||
"should map gen_ai.completion.0.content to output[0].content",
|
||||
{
|
||||
entity: "observation",
|
||||
otelAttributeKey: "gen_ai.completion.0.content",
|
||||
otelAttributeValue: {
|
||||
stringValue: "Observing LLMs",
|
||||
},
|
||||
entityAttributeKey: "output",
|
||||
entityAttributeValue: [{ content: "Observing LLMs" }],
|
||||
},
|
||||
],
|
||||
[
|
||||
"should map gen_ai.completion.content to output.content",
|
||||
{
|
||||
entity: "observation",
|
||||
otelAttributeKey: "gen_ai.completion.content",
|
||||
otelAttributeValue: {
|
||||
stringValue: "Observing LLMs",
|
||||
},
|
||||
entityAttributeKey: "output",
|
||||
entityAttributeValue: { content: "Observing LLMs" },
|
||||
},
|
||||
],
|
||||
[
|
||||
"should map gen_ai.completion to output",
|
||||
{
|
||||
entity: "observation",
|
||||
otelAttributeKey: "gen_ai.completion",
|
||||
otelAttributeValue: {
|
||||
stringValue: "Observing LLMs",
|
||||
},
|
||||
entityAttributeKey: "output",
|
||||
entityAttributeValue: "Observing LLMs",
|
||||
},
|
||||
],
|
||||
[
|
||||
"#5412: should map input.value to input for smolagents",
|
||||
{
|
||||
entity: "observation",
|
||||
otelAttributeKey: "input.value",
|
||||
otelAttributeValue: {
|
||||
stringValue: JSON.stringify({
|
||||
task: "Play some chess",
|
||||
stream: false,
|
||||
}),
|
||||
},
|
||||
entityAttributeKey: "input",
|
||||
entityAttributeValue: JSON.stringify({
|
||||
task: "Play some chess",
|
||||
stream: false,
|
||||
}),
|
||||
},
|
||||
],
|
||||
[
|
||||
"#5412: should map llm.token_count.completion to provided_usage_details.output",
|
||||
{
|
||||
entity: "observation",
|
||||
otelAttributeKey: "llm.token_count.completion",
|
||||
otelAttributeValue: {
|
||||
intValue: { low: 100, high: 0, unsigned: false },
|
||||
},
|
||||
entityAttributeKey: "usageDetails.output",
|
||||
entityAttributeValue: 100,
|
||||
},
|
||||
],
|
||||
[
|
||||
"#5412: should map llm.token_count.total to provided_usage_details.total",
|
||||
{
|
||||
entity: "observation",
|
||||
otelAttributeKey: "llm.token_count.total",
|
||||
otelAttributeValue: {
|
||||
intValue: { low: 100, high: 0, unsigned: false },
|
||||
},
|
||||
entityAttributeKey: "usageDetails.total",
|
||||
entityAttributeValue: 100,
|
||||
},
|
||||
],
|
||||
[
|
||||
"#5412: should map llm.invocation_parameters to modelParameters",
|
||||
{
|
||||
entity: "observation",
|
||||
otelAttributeKey: "llm.invocation_parameters",
|
||||
otelAttributeValue: {
|
||||
stringValue: '{"max_tokens": 4096}',
|
||||
},
|
||||
entityAttributeKey: "modelParameters.max_tokens",
|
||||
entityAttributeValue: 4096,
|
||||
},
|
||||
],
|
||||
[
|
||||
"#5457: should map traceloop.entity.input to input",
|
||||
{
|
||||
entity: "trace",
|
||||
otelAttributeKey: "traceloop.entity.input",
|
||||
otelAttributeValue: {
|
||||
stringValue: '{"foo": "bar"}',
|
||||
},
|
||||
entityAttributeKey: "input",
|
||||
entityAttributeValue: '{"foo": "bar"}',
|
||||
},
|
||||
],
|
||||
])(
|
||||
"Attributes: %s",
|
||||
(
|
||||
_name: string,
|
||||
spec: {
|
||||
entity: string;
|
||||
otelAttributeKey: string;
|
||||
otelAttributeValue: any;
|
||||
entityAttributeKey: string;
|
||||
entityAttributeValue: any;
|
||||
},
|
||||
) => {
|
||||
// Setup
|
||||
const resourceSpan = {
|
||||
scopeSpans: [
|
||||
{
|
||||
spans: [
|
||||
{
|
||||
...defaultSpanProps,
|
||||
attributes: [
|
||||
{
|
||||
key: spec.otelAttributeKey,
|
||||
value: spec.otelAttributeValue,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// When
|
||||
const langfuseEvents = convertOtelSpanToIngestionEvent(resourceSpan);
|
||||
|
||||
// Then
|
||||
const entity: { body: Record<string, any> } =
|
||||
spec.entity === "trace" ? langfuseEvents[0] : langfuseEvents[1];
|
||||
expect(
|
||||
spec.entityAttributeKey // This logic allows to follow a path in the object, e.g. foo.bar.baz.
|
||||
.split(".")
|
||||
.reduce((acc: any, key: string) => acc && acc[key], entity.body),
|
||||
).toEqual(spec.entityAttributeValue);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[
|
||||
"should extract version on trace from resource attribute service.version",
|
||||
{
|
||||
entity: "trace",
|
||||
otelResourceAttributeKey: "service.version",
|
||||
otelResourceAttributeValue: { stringValue: "1.0.5" },
|
||||
entityAttributeKey: "version",
|
||||
entityAttributeValue: "1.0.5",
|
||||
},
|
||||
],
|
||||
])(
|
||||
"ResourceAttributes: %s",
|
||||
(
|
||||
_name: string,
|
||||
spec: {
|
||||
entity: string;
|
||||
otelResourceAttributeKey: string;
|
||||
otelResourceAttributeValue: any;
|
||||
entityAttributeKey: string;
|
||||
entityAttributeValue: any;
|
||||
},
|
||||
) => {
|
||||
// Setup
|
||||
const resourceSpan = {
|
||||
resource: {
|
||||
attributes: [
|
||||
{
|
||||
key: spec.otelResourceAttributeKey,
|
||||
value: spec.otelResourceAttributeValue,
|
||||
},
|
||||
],
|
||||
},
|
||||
scopeSpans: [
|
||||
{
|
||||
spans: [defaultSpanProps],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// When
|
||||
const langfuseEvents = convertOtelSpanToIngestionEvent(resourceSpan);
|
||||
|
||||
// Then
|
||||
const entity: { body: Record<string, any> } =
|
||||
spec.entity === "trace" ? langfuseEvents[0] : langfuseEvents[1];
|
||||
expect(entity.body[spec.entityAttributeKey]).toEqual(
|
||||
spec.entityAttributeValue,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -20,12 +20,15 @@ export const pruneDatabase = async () => {
|
||||
await prisma.dataset.deleteMany();
|
||||
await prisma.datasetRuns.deleteMany();
|
||||
await prisma.prompt.deleteMany();
|
||||
await prisma.events.deleteMany();
|
||||
await prisma.model.deleteMany();
|
||||
await prisma.llmApiKeys.deleteMany();
|
||||
await prisma.comment.deleteMany();
|
||||
await prisma.media.deleteMany();
|
||||
|
||||
await truncateClickhouseTables();
|
||||
};
|
||||
|
||||
export const truncateClickhouseTables = async () => {
|
||||
if (!env.CLICKHOUSE_URL?.includes("localhost:8123")) {
|
||||
throw new Error("You cannot prune clickhouse unless running on localhost.");
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { capitalize } from "lodash";
|
||||
import { GripVertical, MinusCircleIcon } from "lucide-react";
|
||||
import { type ChangeEvent, useEffect, useState, useRef } from "react";
|
||||
import { useState } from "react";
|
||||
import { ChatMessageRole, type ChatMessageWithId } from "@langfuse/shared";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { Card, CardContent } from "@/src/components/ui/card";
|
||||
import { Textarea } from "@/src/components/ui/textarea";
|
||||
import { CodeMirrorEditor } from "@/src/components/editor";
|
||||
import type { MessagesContext } from "./types";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
@@ -13,16 +13,15 @@ import { CSS } from "@dnd-kit/utilities";
|
||||
type ChatMessageProps = Pick<
|
||||
MessagesContext,
|
||||
"deleteMessage" | "updateMessage" | "availableRoles"
|
||||
> & { message: ChatMessageWithId };
|
||||
> & { message: ChatMessageWithId; index: number };
|
||||
|
||||
export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
|
||||
message,
|
||||
updateMessage,
|
||||
deleteMessage,
|
||||
availableRoles,
|
||||
index,
|
||||
}) => {
|
||||
const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const [textAreaRows, setTextAreaRows] = useState(1);
|
||||
const [roleIndex, setRoleIndex] = useState(1);
|
||||
|
||||
const {
|
||||
@@ -35,8 +34,6 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
|
||||
} = useSortable({ id: message.id });
|
||||
|
||||
const toggleRole = () => {
|
||||
if (message.role === ChatMessageRole.System) return;
|
||||
|
||||
// if user has set custom roles, available roles will be non-empty and we toggle through custom and default roles (assistant, user)
|
||||
if (!!availableRoles && Boolean(availableRoles.length)) {
|
||||
let randomRole = availableRoles[roleIndex % availableRoles.length];
|
||||
@@ -52,26 +49,15 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
|
||||
"role",
|
||||
message.role === ChatMessageRole.User
|
||||
? ChatMessageRole.Assistant
|
||||
: ChatMessageRole.User,
|
||||
: message.role === ChatMessageRole.Assistant && index === 0
|
||||
? ChatMessageRole.System
|
||||
: ChatMessageRole.User,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContentChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
updateMessage(message.id, "content", event.target.value);
|
||||
};
|
||||
|
||||
const placeholder = `Enter ${message.role === ChatMessageRole.User ? "a user" : message.role === ChatMessageRole.System ? "a system" : "an assistant"} message here.`;
|
||||
|
||||
useEffect(() => {
|
||||
const textAreaWidth = textAreaRef.current?.clientWidth ?? 0;
|
||||
const charsPerRow = Math.floor(textAreaWidth / 10);
|
||||
|
||||
setTextAreaRows(
|
||||
countContentRows(message.content, charsPerRow || undefined),
|
||||
);
|
||||
}, [message.content]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
ref={setNodeRef}
|
||||
@@ -94,32 +80,31 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
|
||||
</div>
|
||||
)}
|
||||
<CardContent className="ml-4 flex flex-row space-x-1 p-0">
|
||||
<div className="min-w-[6rem]">
|
||||
<div className="min-w-[5rem]">
|
||||
<Button
|
||||
onClick={toggleRole}
|
||||
type="button" // prevents submitting a form if this button is inside a form
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
className="px-2 text-xs"
|
||||
>
|
||||
{capitalize(message.role)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
ref={textAreaRef}
|
||||
id={message.id}
|
||||
className="height-[auto] min-h-6 w-full font-mono text-xs focus:outline-none"
|
||||
placeholder={placeholder}
|
||||
<CodeMirrorEditor
|
||||
value={message.content}
|
||||
onChange={handleContentChange}
|
||||
rows={textAreaRows}
|
||||
onChange={(value) => updateMessage(message.id, "content", value)}
|
||||
mode="prompt"
|
||||
minHeight={30}
|
||||
className="w-full"
|
||||
editable={true}
|
||||
lineNumbers={false}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button" // prevents submitting a form if this button is inside a form
|
||||
size="icon"
|
||||
onClick={() => deleteMessage(message.id)}
|
||||
disabled={message.role === ChatMessageRole.System}
|
||||
>
|
||||
<MinusCircleIcon size={16} />
|
||||
</Button>
|
||||
@@ -127,15 +112,3 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
function countContentRows(str: string, charsPerRow = 80) {
|
||||
const lines = str.split("\n");
|
||||
|
||||
const totalRows = lines.reduce((acc, line) => {
|
||||
const additionalRows = Math.max(1, Math.ceil(line.length / charsPerRow));
|
||||
|
||||
return acc + additionalRows;
|
||||
}, 0);
|
||||
|
||||
return totalRows;
|
||||
}
|
||||
|
||||
@@ -81,10 +81,10 @@ export const ChatMessages: React.FC<ChatMessagesProps> = (props) => {
|
||||
items={props.messages.map((message) => message.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{props.messages.map((message) => {
|
||||
{props.messages.map((message, index) => {
|
||||
return (
|
||||
<ChatMessageComponent
|
||||
{...{ message, ...props }}
|
||||
{...{ message, ...props, index }}
|
||||
key={message.id}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,8 @@ type DiffViewerProps = {
|
||||
newString: string;
|
||||
oldLabel?: string;
|
||||
newLabel?: string;
|
||||
oldSubLabel?: string;
|
||||
newSubLabel?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
@@ -37,6 +39,8 @@ const DiffViewer: React.FC<DiffViewerProps> = ({
|
||||
newString,
|
||||
oldLabel = "Original Version",
|
||||
newLabel = "New Version",
|
||||
oldSubLabel,
|
||||
newSubLabel,
|
||||
className,
|
||||
}) => {
|
||||
const [diffLines, setDiffLines] = useState<{
|
||||
@@ -168,11 +172,27 @@ const DiffViewer: React.FC<DiffViewerProps> = ({
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="border-b border-r bg-muted px-4 py-2 text-xs font-semibold">
|
||||
<div className="flex flex-row gap-1 border-b border-r bg-muted px-4 py-2 text-xs font-semibold">
|
||||
{oldLabel}
|
||||
{oldSubLabel && (
|
||||
<div
|
||||
className="truncate text-xs text-muted-foreground"
|
||||
title={oldSubLabel}
|
||||
>
|
||||
{oldSubLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-b bg-muted px-4 py-2 text-xs font-semibold">
|
||||
<div className="flex flex-row gap-1 border-b bg-muted px-4 py-2 text-xs font-semibold">
|
||||
{newLabel}
|
||||
{newSubLabel && (
|
||||
<div
|
||||
className="truncate text-xs text-muted-foreground"
|
||||
title={newSubLabel}
|
||||
>
|
||||
{newSubLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
type Accuracy = "minute" | "second" | "millisecond";
|
||||
|
||||
export const LocalIsoDate = ({
|
||||
date,
|
||||
accuracy = "second",
|
||||
}: {
|
||||
date: Date;
|
||||
accuracy?: Accuracy;
|
||||
}) => {
|
||||
if (!(date instanceof Date) || isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const formatDate = (date: Date, useUTC = false, pAccuracy: Accuracy) => {
|
||||
const pad = (num: number) => String(num).padStart(2, "0");
|
||||
|
||||
const year = useUTC ? date.getUTCFullYear() : date.getFullYear();
|
||||
const month = useUTC ? date.getUTCMonth() + 1 : date.getMonth() + 1;
|
||||
const day = useUTC ? date.getUTCDate() : date.getDate();
|
||||
const hours = useUTC ? date.getUTCHours() : date.getHours();
|
||||
const minutes = useUTC ? date.getUTCMinutes() : date.getMinutes();
|
||||
const seconds = useUTC ? date.getUTCSeconds() : date.getSeconds();
|
||||
const ms = useUTC ? date.getUTCMilliseconds() : date.getMilliseconds();
|
||||
|
||||
let formatted = `${year}-${pad(month)}-${pad(day)} ${pad(hours)}:${pad(minutes)}`;
|
||||
|
||||
if (["second", "millisecond"].includes(pAccuracy)) {
|
||||
formatted += `:${pad(seconds)}`;
|
||||
}
|
||||
if (pAccuracy === "millisecond") {
|
||||
formatted += `.${String(ms).padStart(3, "0")}`;
|
||||
}
|
||||
|
||||
return formatted;
|
||||
};
|
||||
|
||||
const localDateString = formatDate(date, false, accuracy);
|
||||
const utcDateString = formatDate(date, true, "millisecond");
|
||||
|
||||
return <span title={`UTC: ${utcDateString}`}>{localDateString}</span>;
|
||||
};
|
||||
@@ -0,0 +1,173 @@
|
||||
import CodeMirror, { EditorView } from "@uiw/react-codemirror";
|
||||
import { json, jsonParseLinter } from "@codemirror/lang-json";
|
||||
import { linter, type Diagnostic } from "@codemirror/lint";
|
||||
import { useTheme } from "next-themes";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { useState } from "react";
|
||||
import { LanguageSupport, StreamLanguage } from "@codemirror/language";
|
||||
import type { StringStream } from "@codemirror/language";
|
||||
import {
|
||||
isValidVariableName,
|
||||
MULTILINE_VARIABLE_REGEX,
|
||||
MUSTACHE_REGEX,
|
||||
UNCLOSED_VARIABLE_REGEX,
|
||||
} from "@langfuse/shared";
|
||||
import { lightTheme } from "@/src/components/editor/light-theme";
|
||||
import { darkTheme } from "@/src/components/editor/dark-theme";
|
||||
|
||||
// Custom language mode for prompts that highlights mustache variables
|
||||
const promptLanguage = StreamLanguage.define({
|
||||
name: "prompt",
|
||||
startState: () => ({}),
|
||||
token: (stream: StringStream) => {
|
||||
if (stream.match("{{")) {
|
||||
const start = stream.pos;
|
||||
stream.skipTo("}}") || stream.skipToEnd();
|
||||
const content = stream.string.slice(start, stream.pos);
|
||||
stream.match("}}");
|
||||
return isValidVariableName(content) ? "variable" : "error";
|
||||
}
|
||||
stream.next();
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
// Linter for prompt variables
|
||||
const promptLinter = linter((view) => {
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
const content = view.state.doc.toString();
|
||||
|
||||
// Check for multiline variables
|
||||
for (const match of content.matchAll(MULTILINE_VARIABLE_REGEX)) {
|
||||
diagnostics.push({
|
||||
from: match.index,
|
||||
to: match.index + match[0].length,
|
||||
severity: "error",
|
||||
message: "Variables cannot span multiple lines",
|
||||
});
|
||||
}
|
||||
|
||||
// Check for unclosed variables
|
||||
for (const match of content.matchAll(UNCLOSED_VARIABLE_REGEX)) {
|
||||
diagnostics.push({
|
||||
from: match.index,
|
||||
to: match.index + 2,
|
||||
severity: "error",
|
||||
message: "Unclosed variable brackets",
|
||||
});
|
||||
}
|
||||
|
||||
// Check variable format
|
||||
for (const match of content.matchAll(MUSTACHE_REGEX)) {
|
||||
const variable = match[1];
|
||||
if (!variable || variable.trim() === "") {
|
||||
diagnostics.push({
|
||||
from: match.index,
|
||||
to: match.index + match[0].length,
|
||||
severity: "error",
|
||||
message: "Empty variable is not allowed",
|
||||
});
|
||||
} else if (!isValidVariableName(variable)) {
|
||||
diagnostics.push({
|
||||
from: match.index,
|
||||
to: match.index + match[0].length,
|
||||
severity: "error",
|
||||
message:
|
||||
"Variable must start with a letter and can only contain letters and underscores",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return diagnostics;
|
||||
});
|
||||
|
||||
// Create a language support instance that combines the language and its configuration
|
||||
const promptSupport = new LanguageSupport(promptLanguage);
|
||||
|
||||
export function CodeMirrorEditor({
|
||||
value,
|
||||
onChange,
|
||||
editable = true,
|
||||
lineWrapping = true,
|
||||
lineNumbers = true,
|
||||
className,
|
||||
onBlur,
|
||||
mode,
|
||||
minHeight,
|
||||
placeholder,
|
||||
}: {
|
||||
value: string;
|
||||
onChange?: (value: string) => void;
|
||||
editable?: boolean;
|
||||
onBlur?: () => void;
|
||||
lineNumbers?: boolean;
|
||||
lineWrapping?: boolean;
|
||||
className?: string;
|
||||
mode: "json" | "text" | "prompt";
|
||||
minHeight: "none" | 30 | 100 | 200;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const codeMirrorTheme = resolvedTheme === "dark" ? darkTheme : lightTheme;
|
||||
|
||||
// used to disable linter when field is empty
|
||||
const [linterEnabled, setLinterEnabled] = useState<boolean>(
|
||||
!!value && value !== "",
|
||||
);
|
||||
|
||||
return (
|
||||
<CodeMirror
|
||||
value={value}
|
||||
theme={codeMirrorTheme}
|
||||
basicSetup={{
|
||||
foldGutter: lineNumbers,
|
||||
highlightActiveLine: false,
|
||||
lineNumbers: lineNumbers,
|
||||
}}
|
||||
lang={mode === "json" ? "json" : undefined}
|
||||
extensions={[
|
||||
// Hide gutter when lineNumbers is false
|
||||
// Fix missing gutter border
|
||||
...(!lineNumbers
|
||||
? [
|
||||
EditorView.theme({
|
||||
".cm-gutters": { display: "none" },
|
||||
}),
|
||||
]
|
||||
: [
|
||||
EditorView.theme({
|
||||
".cm-gutters": { borderRight: "1px solid" },
|
||||
}),
|
||||
]),
|
||||
// Extend gutter to full height when minHeight > content height
|
||||
// This also enlarges the text area to minHeight
|
||||
...(minHeight === "none"
|
||||
? []
|
||||
: [
|
||||
EditorView.theme({
|
||||
".cm-gutter,.cm-content": { minHeight: `${minHeight}px` },
|
||||
".cm-scroller": { overflow: "auto" },
|
||||
}),
|
||||
]),
|
||||
...(mode === "json" ? [json()] : []),
|
||||
...(mode === "json" && linterEnabled
|
||||
? [linter(jsonParseLinter())]
|
||||
: []),
|
||||
...(mode === "prompt" ? [promptSupport, promptLinter] : []),
|
||||
...(lineWrapping ? [EditorView.lineWrapping] : []),
|
||||
]}
|
||||
defaultValue={value}
|
||||
onChange={(c) => {
|
||||
if (onChange) onChange(c);
|
||||
setLinterEnabled(c !== "");
|
||||
}}
|
||||
onBlur={onBlur}
|
||||
className={cn(
|
||||
"overflow-hidden overflow-y-auto rounded-md border text-xs",
|
||||
className,
|
||||
)}
|
||||
editable={editable}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { defaultSettingsBothThemes } from "@/src/components/editor/shared-theme";
|
||||
import { tags as t } from "@lezer/highlight";
|
||||
import { createTheme, type CreateThemeOptions } from "@uiw/codemirror-themes";
|
||||
import { bothThemeStyles } from "@/src/components/editor/shared-theme";
|
||||
|
||||
export const defaultSettingsDarkTheme: CreateThemeOptions["settings"] = {
|
||||
...defaultSettingsBothThemes,
|
||||
};
|
||||
|
||||
export const darkThemeStyle: CreateThemeOptions["styles"] = [
|
||||
...bothThemeStyles,
|
||||
{ tag: t.keyword, color: "#bb9af7" },
|
||||
{
|
||||
tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)],
|
||||
color: "#9ece6a",
|
||||
},
|
||||
{ tag: [t.labelName], color: "#7aa2f7" },
|
||||
{ tag: [t.color, t.constant(t.name), t.standard(t.name)], color: "#bb9af7" },
|
||||
{ tag: [t.definition(t.name), t.separator], color: "#c0caf5" },
|
||||
{ tag: [t.className], color: "#c0caf5" },
|
||||
{
|
||||
tag: [t.number, t.changed, t.annotation, t.modifier, t.self, t.namespace],
|
||||
color: "#ff9e64",
|
||||
},
|
||||
{ tag: [t.typeName], color: "#0db9d7" },
|
||||
{ tag: [t.operator, t.operatorKeyword], color: "#bb9af7" },
|
||||
{ tag: [t.url, t.escape, t.regexp, t.link], color: "#b4f9f8" },
|
||||
{ tag: [t.meta, t.comment], color: "#444b6a" },
|
||||
{ tag: t.heading, fontWeight: "bold", color: "#89ddff" },
|
||||
{ tag: [t.atom, t.bool], color: "#c0caf5" },
|
||||
{ tag: t.strikethrough, textDecoration: "line-through" },
|
||||
];
|
||||
|
||||
export const darkThemeInit = (options?: Partial<CreateThemeOptions>) => {
|
||||
const { theme = "dark", settings = {}, styles = [] } = options || {};
|
||||
return createTheme({
|
||||
theme: theme,
|
||||
settings: {
|
||||
...defaultSettingsDarkTheme,
|
||||
...settings,
|
||||
},
|
||||
styles: [...darkThemeStyle, ...styles],
|
||||
});
|
||||
};
|
||||
|
||||
export const darkTheme = darkThemeInit();
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./CodeMirrorEditor";
|
||||
@@ -0,0 +1,75 @@
|
||||
import { defaultSettingsBothThemes } from "@/src/components/editor/shared-theme";
|
||||
import { tags as t } from "@lezer/highlight";
|
||||
import { createTheme, type CreateThemeOptions } from "@uiw/codemirror-themes";
|
||||
import { bothThemeStyles } from "@/src/components/editor/shared-theme";
|
||||
|
||||
export const defaultSettingsLightTheme: CreateThemeOptions["settings"] = {
|
||||
...defaultSettingsBothThemes,
|
||||
};
|
||||
|
||||
export const lightThemeStyle: CreateThemeOptions["styles"] = [
|
||||
...bothThemeStyles,
|
||||
{
|
||||
tag: [
|
||||
t.keyword,
|
||||
t.operatorKeyword,
|
||||
t.modifier,
|
||||
t.color,
|
||||
t.constant(t.name),
|
||||
t.standard(t.name),
|
||||
t.standard(t.tagName),
|
||||
t.special(t.brace),
|
||||
t.atom,
|
||||
t.bool,
|
||||
t.special(t.variableName),
|
||||
],
|
||||
color: "#0000ff",
|
||||
},
|
||||
{ tag: [t.moduleKeyword, t.controlKeyword], color: "#af00db" },
|
||||
{ tag: t.heading, fontWeight: "bold", color: "#0070c1" },
|
||||
{
|
||||
tag: [
|
||||
t.typeName,
|
||||
t.className,
|
||||
t.tagName,
|
||||
t.number,
|
||||
t.changed,
|
||||
t.annotation,
|
||||
t.self,
|
||||
t.namespace,
|
||||
],
|
||||
color: "#267f99",
|
||||
},
|
||||
{
|
||||
tag: [t.function(t.variableName), t.function(t.propertyName)],
|
||||
color: "#795e26",
|
||||
},
|
||||
{ tag: [t.number], color: "#098658" },
|
||||
{
|
||||
tag: [t.operator, t.punctuation, t.separator, t.url, t.escape, t.regexp],
|
||||
color: "#383a42",
|
||||
},
|
||||
{ tag: [t.regexp], color: "#af00db" },
|
||||
{
|
||||
tag: [t.special(t.string), t.processingInstruction, t.string, t.inserted],
|
||||
color: "#a31515",
|
||||
},
|
||||
{ tag: [t.angleBracket], color: "#383a42" },
|
||||
{ tag: t.strikethrough, textDecoration: "line-through" },
|
||||
{ tag: [t.meta, t.comment], color: "#008000" },
|
||||
{ tag: t.link, color: "#4078f2", textDecoration: "underline" },
|
||||
];
|
||||
|
||||
export function lightThemeInit(options?: Partial<CreateThemeOptions>) {
|
||||
const { theme = "light", settings = {}, styles = [] } = options || {};
|
||||
return createTheme({
|
||||
theme: theme,
|
||||
settings: {
|
||||
...defaultSettingsLightTheme,
|
||||
...settings,
|
||||
},
|
||||
styles: [...lightThemeStyle, ...styles],
|
||||
});
|
||||
}
|
||||
|
||||
export const lightTheme = lightThemeInit();
|
||||
@@ -0,0 +1,35 @@
|
||||
import { type CreateThemeOptions } from "@uiw/codemirror-themes";
|
||||
import { tags as t } from "@lezer/highlight";
|
||||
|
||||
export const defaultSettingsBothThemes: CreateThemeOptions["settings"] = {
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
caret: "hsl(var(--foreground))",
|
||||
gutterBackground: "hsl(var(--sidebar-background))",
|
||||
gutterForeground: "hsl(var(--sidebar-foreground))",
|
||||
gutterBorder: "hsl(var(--sidebar-border))",
|
||||
gutterActiveForeground: "hsl(var(--sidebar-primary))",
|
||||
selection: "hsl(var(--accent))",
|
||||
selectionMatch: "hsl(var(--muted))",
|
||||
lineHighlight: "hsl(var(--muted))",
|
||||
};
|
||||
|
||||
export const bothThemeStyles: CreateThemeOptions["styles"] = [
|
||||
{ tag: t.invalid, color: "hsl(var(--dark-red))" },
|
||||
{
|
||||
tag: [
|
||||
t.name,
|
||||
t.deleted,
|
||||
t.character,
|
||||
t.macroName,
|
||||
t.propertyName,
|
||||
t.variableName,
|
||||
t.labelName,
|
||||
t.definition(t.name),
|
||||
],
|
||||
color: "hsl(var(--primary-accent))",
|
||||
},
|
||||
{ tag: t.strong, fontWeight: "bold" },
|
||||
{ tag: t.emphasis, fontStyle: "italic" },
|
||||
{ tag: t.link, textDecoration: "underline" },
|
||||
];
|
||||
@@ -1,55 +0,0 @@
|
||||
import CodeMirror, { EditorView } from "@uiw/react-codemirror";
|
||||
import { githubLight } from "@uiw/codemirror-theme-github";
|
||||
import { tokyoNight } from "@uiw/codemirror-theme-tokyo-night";
|
||||
import { json, jsonParseLinter } from "@codemirror/lang-json";
|
||||
import { linter } from "@codemirror/lint";
|
||||
import { useTheme } from "next-themes";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { useState } from "react";
|
||||
|
||||
export function JsonEditor({
|
||||
defaultValue,
|
||||
onChange,
|
||||
editable = true,
|
||||
lineWrapping = true,
|
||||
className,
|
||||
onBlur,
|
||||
}: {
|
||||
defaultValue: string;
|
||||
onChange?: (value: string) => void;
|
||||
editable?: boolean;
|
||||
onBlur?: () => void;
|
||||
lineWrapping?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const codeMirrorTheme = resolvedTheme === "dark" ? tokyoNight : githubLight;
|
||||
|
||||
// used to disable linter when field is empty
|
||||
const [linterEnabled, setLinterEnabled] = useState<boolean>(
|
||||
!!defaultValue && defaultValue !== "",
|
||||
);
|
||||
return (
|
||||
<CodeMirror
|
||||
value={defaultValue}
|
||||
theme={codeMirrorTheme}
|
||||
basicSetup={{
|
||||
foldGutter: true,
|
||||
}}
|
||||
lang={"json"}
|
||||
extensions={[
|
||||
json(),
|
||||
...(linterEnabled ? [linter(jsonParseLinter())] : []),
|
||||
...(lineWrapping ? [EditorView.lineWrapping] : []),
|
||||
]}
|
||||
defaultValue={defaultValue}
|
||||
onChange={(c) => {
|
||||
if (onChange) onChange(c);
|
||||
setLinterEnabled(c !== "");
|
||||
}}
|
||||
onBlur={onBlur}
|
||||
className={cn("overflow-hidden rounded-md border text-xs", className)}
|
||||
editable={editable}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { useUiCustomization } from "@/src/ee/features/ui-customization/useUiCust
|
||||
import { hasOrganizationAccess } from "@/src/features/rbac/utils/checkOrganizationAccess";
|
||||
import { SidebarInset, SidebarProvider } from "@/src/components/ui/sidebar";
|
||||
import { AppSidebar } from "@/src/components/nav/app-sidebar";
|
||||
import { CommandMenu } from "@/src/features/command-k-menu/CommandMenu";
|
||||
|
||||
const signOutUser = async () => {
|
||||
localStorage.clear();
|
||||
@@ -68,7 +69,17 @@ function useSessionWithRetryOnUnauthenticated() {
|
||||
useEffect(() => {
|
||||
if (session.status === "unauthenticated" && retryCount < MAX_RETRIES) {
|
||||
const fetchSession = async () => {
|
||||
await getSession({ broadcast: true });
|
||||
try {
|
||||
await getSession({ broadcast: true });
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Error fetching session:",
|
||||
error,
|
||||
"\nError details:",
|
||||
JSON.stringify(error, null, 2),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
setRetryCount((prevCount) => prevCount + 1);
|
||||
};
|
||||
fetchSession();
|
||||
@@ -304,6 +315,7 @@ export default function Layout(props: PropsWithChildren) {
|
||||
<SidebarInset className="h-dvh max-w-full md:peer-data-[state=collapsed]:w-[calc(100vw-var(--sidebar-width-icon))] md:peer-data-[state=expanded]:w-[calc(100vw-var(--sidebar-width))]">
|
||||
<main className="h-full p-3">{props.children}</main>
|
||||
<Toaster visibleToasts={1} />
|
||||
<CommandMenu mainNavigation={navigation} />
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
@@ -315,7 +327,7 @@ export type NavigationItem = NestedNavigationItem & {
|
||||
items?: NestedNavigationItem[];
|
||||
};
|
||||
|
||||
type NestedNavigationItem = Omit<Route, "children"> & {
|
||||
type NestedNavigationItem = Omit<Route, "children" | "items"> & {
|
||||
url: string;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Grid2X2,
|
||||
Sparkle,
|
||||
FileJson,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { type ReactNode } from "react";
|
||||
import { type Entitlement } from "@/src/features/entitlements/constants/entitlements";
|
||||
@@ -20,6 +21,9 @@ import { type UiCustomizationOption } from "@/src/ee/features/ui-customization/u
|
||||
import { type User } from "next-auth";
|
||||
import { type OrganizationScope } from "@/src/features/rbac/constants/organizationAccessRights";
|
||||
import { SupportMenuDropdown } from "@/src/components/nav/support-menu-dropdown";
|
||||
import { SidebarMenuButton } from "@/src/components/ui/sidebar";
|
||||
import { useCommandMenu } from "@/src/features/command-k-menu/CommandMenuProvider";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
|
||||
export type Route = {
|
||||
title: string;
|
||||
@@ -41,6 +45,12 @@ export type Route = {
|
||||
};
|
||||
|
||||
export const ROUTES: Route[] = [
|
||||
{
|
||||
title: "Go to...",
|
||||
pathname: "", // Empty pathname since this is a dropdown
|
||||
icon: Search,
|
||||
menuNode: <CommandMenuTrigger />,
|
||||
},
|
||||
{
|
||||
title: "Organizations",
|
||||
pathname: "/",
|
||||
@@ -161,3 +171,31 @@ export const ROUTES: Route[] = [
|
||||
menuNode: <SupportMenuDropdown />,
|
||||
},
|
||||
];
|
||||
|
||||
function CommandMenuTrigger() {
|
||||
const { setOpen } = useCommandMenu();
|
||||
const capture = usePostHogClientCapture();
|
||||
|
||||
return (
|
||||
<SidebarMenuButton
|
||||
onClick={() => {
|
||||
capture("cmd_k_menu:opened", {
|
||||
source: "main_navigation",
|
||||
});
|
||||
setOpen(true);
|
||||
}}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
Go to...
|
||||
<kbd className="pointer-events-none ml-auto inline-flex h-5 select-none items-center gap-1 rounded-md border px-1.5 font-mono text-[10px]">
|
||||
{navigator.userAgent.includes("Mac") ? (
|
||||
<span className="text-[12px]">⌘</span>
|
||||
) : (
|
||||
<span>Ctrl</span>
|
||||
)}
|
||||
<span>K</span>
|
||||
</kbd>
|
||||
</SidebarMenuButton>
|
||||
);
|
||||
}
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { type MultiSelect } from "@/src/components/table/data-table-toolbar";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
|
||||
export function DataTableSelectAllBanner({
|
||||
selectAll,
|
||||
setSelectAll,
|
||||
setRowSelection,
|
||||
pageSize,
|
||||
totalCount,
|
||||
}: MultiSelect) {
|
||||
return (
|
||||
<div className="my-2 flex flex-wrap items-center justify-center gap-2 rounded-sm bg-input p-2 @container">
|
||||
{selectAll ? (
|
||||
<span className="text-sm">
|
||||
All <span className="font-semibold">{totalCount}</span> items are
|
||||
selected.{" "}
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-auto p-0 font-semibold text-accent-dark-blue hover:text-accent-dark-blue/80"
|
||||
onClick={() => {
|
||||
setSelectAll(false);
|
||||
setRowSelection({});
|
||||
}}
|
||||
>
|
||||
Clear selection
|
||||
</Button>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm">
|
||||
All <span className="font-semibold">{pageSize}</span> items on this
|
||||
page are selected.{" "}
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-auto p-0 font-semibold text-accent-dark-blue hover:text-accent-dark-blue/80"
|
||||
onClick={() => {
|
||||
setSelectAll(true);
|
||||
}}
|
||||
>
|
||||
Select all {totalCount} items
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-260
@@ -1,260 +0,0 @@
|
||||
import { ChevronDown, ClipboardPen, Trash } from "lucide-react";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from "@/src/components/ui/dropdown-menu";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import { api } from "@/src/utils/api";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/src/components/ui/dialog";
|
||||
import { useState } from "react";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectValue,
|
||||
} from "@/src/components/ui/select";
|
||||
import { showSuccessToast } from "@/src/features/notifications/showSuccessToast";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@/src/components/ui/form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useHasEntitlement } from "@/src/features/entitlements/hooks";
|
||||
|
||||
const addToQueueFormSchema = z.object({
|
||||
queueId: z.string(),
|
||||
});
|
||||
|
||||
export function TraceTableMultiSelectAction({
|
||||
selectedTraceIds,
|
||||
projectId,
|
||||
onDeleteSuccess,
|
||||
}: {
|
||||
selectedTraceIds: string[];
|
||||
projectId: string;
|
||||
onDeleteSuccess: () => void;
|
||||
}) {
|
||||
const utils = api.useUtils();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [addToQueueDialogOpen, setAddToQueueDialogOpen] = useState(false);
|
||||
const session = useSession();
|
||||
const capture = usePostHogClientCapture();
|
||||
|
||||
const hasDeleteAccess = useHasProjectAccess({
|
||||
projectId,
|
||||
scope: "traces:delete",
|
||||
});
|
||||
const mutDeleteTraces = api.traces.deleteMany.useMutation({
|
||||
onSuccess: () => {
|
||||
onDeleteSuccess();
|
||||
void utils.traces.all.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const hasAnnotationEntitlement = useHasEntitlement("annotation-queues");
|
||||
const hasTraceDeletionEntitlement = useHasEntitlement("trace-deletion");
|
||||
const hasAddToQueueAccess = useHasProjectAccess({
|
||||
projectId,
|
||||
scope: "annotationQueues:CUD",
|
||||
});
|
||||
const mutAddToQueue = api.annotationQueueItems.createMany.useMutation({
|
||||
onSuccess: (data) => {
|
||||
showSuccessToast({
|
||||
title: "Traces added to queue",
|
||||
description: `${selectedTraceIds.length} traces added to queue "${data.queueName}".`,
|
||||
link: {
|
||||
href: `/project/${projectId}/annotation-queues/${data.queueId}`,
|
||||
text: `View queue "${data.queueName}"`,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof addToQueueFormSchema>>({
|
||||
resolver: zodResolver(addToQueueFormSchema),
|
||||
});
|
||||
|
||||
const queues = api.annotationQueues.allNamesAndIds.useQuery(
|
||||
{
|
||||
projectId,
|
||||
},
|
||||
{ enabled: session.status === "authenticated" && hasAnnotationEntitlement },
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button disabled={selectedTraceIds.length < 1}>
|
||||
Actions ({selectedTraceIds.length} selected)
|
||||
<ChevronDown className="h-5 w-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{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={() => {
|
||||
setAddToQueueDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<ClipboardPen className="mr-2 h-4 w-4" />
|
||||
<span>Add to Annotation Queue</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Dialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
setDeleteDialogOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete traces</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action cannot be undone and removes all the data associated
|
||||
with these traces.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="sm:justify-start">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
loading={mutDeleteTraces.isLoading}
|
||||
disabled={mutDeleteTraces.isLoading}
|
||||
onClick={() => {
|
||||
void mutDeleteTraces
|
||||
.mutateAsync({
|
||||
traceIds: selectedTraceIds,
|
||||
projectId,
|
||||
})
|
||||
.then(() => {
|
||||
setDeleteDialogOpen(false);
|
||||
});
|
||||
capture("trace:delete_form_submit", {
|
||||
count: selectedTraceIds.length,
|
||||
source: "table-multi-select",
|
||||
});
|
||||
}}
|
||||
>
|
||||
Delete {selectedTraceIds.length} trace(s)
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={addToQueueDialogOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
setAddToQueueDialogOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-6"
|
||||
onSubmit={form.handleSubmit((data) => {
|
||||
if (data.queueId) {
|
||||
void mutAddToQueue
|
||||
.mutateAsync({
|
||||
projectId,
|
||||
queueId: data.queueId,
|
||||
objectIds: selectedTraceIds,
|
||||
objectType: "TRACE",
|
||||
})
|
||||
.then(() => {
|
||||
setAddToQueueDialogOpen(false);
|
||||
});
|
||||
}
|
||||
})}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add to Annotation Queue</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select an annotation queue to add the selected traces to.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="queueId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a queue" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{queues?.data?.map((queue) => (
|
||||
<SelectItem key={queue.id} value={queue.id}>
|
||||
{queue.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter className="sm:justify-start">
|
||||
<Button
|
||||
type="submit"
|
||||
loading={mutAddToQueue.isLoading}
|
||||
disabled={mutAddToQueue.isLoading}
|
||||
>
|
||||
Add {selectedTraceIds.length} trace(s) to queue
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { type FilterState } from "@langfuse/shared";
|
||||
import { PopoverFilterBuilder } from "@/src/features/filters/components/filter-builder";
|
||||
import { type ColumnDefinition } from "@langfuse/shared";
|
||||
import {
|
||||
type RowSelectionState,
|
||||
type ColumnOrderState,
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
@@ -21,6 +22,17 @@ import {
|
||||
type TableDateRange,
|
||||
type TableDateRangeOptions,
|
||||
} from "@/src/utils/date-range-utils";
|
||||
import { DataTableSelectAllBanner } from "@/src/components/table/data-table-multi-select-actions/data-table-select-all-banner";
|
||||
|
||||
export interface MultiSelect {
|
||||
selectAll: boolean;
|
||||
setSelectAll: Dispatch<SetStateAction<boolean>>;
|
||||
selectedRowIds: string[];
|
||||
setRowSelection: Dispatch<SetStateAction<RowSelectionState>>;
|
||||
pageSize: number;
|
||||
pageIndex: number;
|
||||
totalCount: number | null;
|
||||
}
|
||||
|
||||
interface SearchConfig {
|
||||
placeholder: string;
|
||||
@@ -49,6 +61,7 @@ interface DataTableToolbarProps<TData, TValue> {
|
||||
option: TableDateRangeOptions,
|
||||
date?: TableDateRange,
|
||||
) => void;
|
||||
multiSelect?: MultiSelect;
|
||||
}
|
||||
|
||||
export function DataTableToolbar<TData, TValue>({
|
||||
@@ -67,6 +80,7 @@ export function DataTableToolbar<TData, TValue>({
|
||||
columnsWithCustomSelect,
|
||||
selectedOption,
|
||||
setDateRangeAndOption,
|
||||
multiSelect,
|
||||
}: DataTableToolbarProps<TData, TValue>) {
|
||||
const [searchString, setSearchString] = useState(
|
||||
searchConfig?.currentQuery ?? "",
|
||||
@@ -74,66 +88,73 @@ export function DataTableToolbar<TData, TValue>({
|
||||
const capture = usePostHogClientCapture();
|
||||
|
||||
return (
|
||||
<div className="my-2 flex flex-wrap items-center gap-2 @container">
|
||||
{searchConfig && (
|
||||
<div className="flex max-w-md items-center">
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder={searchConfig.placeholder}
|
||||
value={searchString}
|
||||
onChange={(event) => setSearchString(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
<>
|
||||
<div className="my-2 flex flex-wrap items-center gap-2 @container">
|
||||
{searchConfig && (
|
||||
<div className="flex max-w-md items-center">
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder={searchConfig.placeholder}
|
||||
value={searchString}
|
||||
onChange={(event) => setSearchString(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
capture("table:search_submit");
|
||||
searchConfig.updateQuery(searchString);
|
||||
}
|
||||
}}
|
||||
className="w-[150px] rounded-r-none @6xl:w-[250px]"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
capture("table:search_submit");
|
||||
searchConfig.updateQuery(searchString);
|
||||
}
|
||||
}}
|
||||
className="w-[150px] rounded-r-none @6xl:w-[250px]"
|
||||
}}
|
||||
className="rounded-l-none border-l-0 p-3"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{!!filterColumnDefinition && !!filterState && !!setFilterState && (
|
||||
<PopoverFilterBuilder
|
||||
columns={filterColumnDefinition}
|
||||
filterState={filterState}
|
||||
onChange={setFilterState}
|
||||
columnsWithCustomSelect={columnsWithCustomSelect}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
capture("table:search_submit");
|
||||
searchConfig.updateQuery(searchString);
|
||||
}}
|
||||
className="rounded-l-none border-l-0 p-3"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{selectedOption && setDateRangeAndOption && (
|
||||
<TableDateRangeDropdown
|
||||
selectedOption={selectedOption}
|
||||
setDateRangeAndOption={setDateRangeAndOption}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-row flex-wrap gap-2 pr-0.5 @6xl:ml-auto">
|
||||
{!!columnVisibility && !!setColumnVisibility && (
|
||||
<DataTableColumnVisibilityFilter
|
||||
columns={columns}
|
||||
columnVisibility={columnVisibility}
|
||||
setColumnVisibility={setColumnVisibility}
|
||||
columnOrder={columnOrder}
|
||||
setColumnOrder={setColumnOrder}
|
||||
/>
|
||||
)}
|
||||
{!!rowHeight && !!setRowHeight && (
|
||||
<DataTableRowHeightSwitch
|
||||
rowHeight={rowHeight}
|
||||
setRowHeight={setRowHeight}
|
||||
/>
|
||||
)}
|
||||
{actionButtons}
|
||||
</div>
|
||||
)}
|
||||
{!!filterColumnDefinition && !!filterState && !!setFilterState && (
|
||||
<PopoverFilterBuilder
|
||||
columns={filterColumnDefinition}
|
||||
filterState={filterState}
|
||||
onChange={setFilterState}
|
||||
columnsWithCustomSelect={columnsWithCustomSelect}
|
||||
/>
|
||||
)}
|
||||
{selectedOption && setDateRangeAndOption && (
|
||||
<TableDateRangeDropdown
|
||||
selectedOption={selectedOption}
|
||||
setDateRangeAndOption={setDateRangeAndOption}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-row flex-wrap gap-2 pr-0.5 @6xl:ml-auto">
|
||||
{!!columnVisibility && !!setColumnVisibility && (
|
||||
<DataTableColumnVisibilityFilter
|
||||
columns={columns}
|
||||
columnVisibility={columnVisibility}
|
||||
setColumnVisibility={setColumnVisibility}
|
||||
columnOrder={columnOrder}
|
||||
setColumnOrder={setColumnOrder}
|
||||
/>
|
||||
)}
|
||||
{!!rowHeight && !!setRowHeight && (
|
||||
<DataTableRowHeightSwitch
|
||||
rowHeight={rowHeight}
|
||||
setRowHeight={setRowHeight}
|
||||
/>
|
||||
)}
|
||||
{actionButtons}
|
||||
</div>
|
||||
</div>
|
||||
{multiSelect &&
|
||||
multiSelect.pageIndex === 0 &&
|
||||
multiSelect.selectedRowIds.length === multiSelect.pageSize && (
|
||||
<DataTableSelectAllBanner {...multiSelect} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export default function TableLink({
|
||||
)}
|
||||
href={path}
|
||||
title={value}
|
||||
prefetch={false}
|
||||
>
|
||||
{icon ? icon : value}
|
||||
</Link>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { DataTable } from "@/src/components/table/data-table";
|
||||
import { type LangfuseColumnDef } from "@/src/components/table/types";
|
||||
import useColumnVisibility from "@/src/features/column-visibility/hooks/useColumnVisibility";
|
||||
@@ -88,13 +86,6 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
scope: "models:CUD",
|
||||
});
|
||||
|
||||
// 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: "modelName",
|
||||
@@ -105,7 +96,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="font-mono text-xs font-semibold">
|
||||
<span className="truncate font-mono text-xs font-semibold">
|
||||
{row.original.modelName}
|
||||
</span>
|
||||
);
|
||||
@@ -152,7 +143,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
const value: string = row.getValue("matchPattern");
|
||||
|
||||
return value ? (
|
||||
<span className="font-mono text-xs">{value}</span>
|
||||
<span className="truncate font-mono text-xs">{value}</span>
|
||||
) : null;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -45,6 +45,7 @@ import { BreakdownTooltip } from "@/src/components/trace/BreakdownToolTip";
|
||||
import { InfoIcon, PlusCircle } from "lucide-react";
|
||||
import { UpsertModelFormDrawer } from "@/src/features/models/components/UpsertModelFormDrawer";
|
||||
import { ColorCodedObservationType } from "@/src/components/trace/ObservationTree";
|
||||
import { LocalIsoDate } from "@/src/components/LocalIsoDate";
|
||||
|
||||
export type ObservationsTableRow = {
|
||||
id: string;
|
||||
@@ -52,7 +53,7 @@ export type ObservationsTableRow = {
|
||||
startTime: Date;
|
||||
level?: ObservationLevel;
|
||||
statusMessage?: string;
|
||||
endTime?: string;
|
||||
endTime?: Date;
|
||||
completionStartTime?: Date;
|
||||
latency?: number;
|
||||
timeToFirstToken?: number;
|
||||
@@ -314,7 +315,7 @@ export default function ObservationsTable({
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const value: Date = row.getValue("startTime");
|
||||
return value.toLocaleString();
|
||||
return <LocalIsoDate date={value} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -324,6 +325,10 @@ export default function ObservationsTable({
|
||||
size: 150,
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const value: Date | undefined = row.getValue("endTime");
|
||||
return value ? <LocalIsoDate date={value} /> : undefined;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "timeToFirstToken",
|
||||
@@ -763,7 +768,7 @@ export default function ObservationsTable({
|
||||
type: generation.type ?? undefined,
|
||||
traceName: generation.traceName ?? "",
|
||||
startTime: generation.startTime,
|
||||
endTime: generation.endTime?.toLocaleString() ?? undefined,
|
||||
endTime: generation.endTime ?? undefined,
|
||||
timeToFirstToken: generation.timeToFirstToken ?? undefined,
|
||||
scores: verifyAndPrefixScoreDataAgainstKeys(
|
||||
scoreKeysAndProps,
|
||||
|
||||
@@ -27,11 +27,12 @@ import { useQueryParams, withDefault, NumberParam } from "use-query-params";
|
||||
import TagList from "@/src/features/tag/components/TagList";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import useColumnOrder from "@/src/features/column-visibility/hooks/useColumnOrder";
|
||||
import { LocalIsoDate } from "@/src/components/LocalIsoDate";
|
||||
|
||||
export type ScoresTableRow = {
|
||||
id: string;
|
||||
traceId: string;
|
||||
timestamp: string;
|
||||
timestamp: Date;
|
||||
source: string;
|
||||
name: string;
|
||||
dataType: ScoreDataType;
|
||||
@@ -254,6 +255,10 @@ export default function ScoresTable({
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
size: 150,
|
||||
cell: ({ row }) => {
|
||||
const value: ScoresTableRow["timestamp"] = row.getValue("timestamp");
|
||||
return value ? <LocalIsoDate date={value} /> : undefined;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "source",
|
||||
@@ -391,7 +396,7 @@ export default function ScoresTable({
|
||||
): ScoresTableRow => {
|
||||
return {
|
||||
id: score.id,
|
||||
timestamp: score.timestamp.toLocaleString(),
|
||||
timestamp: score.timestamp,
|
||||
source: score.source,
|
||||
name: score.name,
|
||||
dataType: score.dataType,
|
||||
|
||||
@@ -29,10 +29,11 @@ import TagList from "@/src/features/tag/components/TagList";
|
||||
import { useRowHeightLocalStorage } from "@/src/components/table/data-table-row-height-switch";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import useColumnOrder from "@/src/features/column-visibility/hooks/useColumnOrder";
|
||||
import { LocalIsoDate } from "@/src/components/LocalIsoDate";
|
||||
|
||||
export type SessionTableRow = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
createdAt: Date;
|
||||
bookmarked: boolean;
|
||||
userIds: string[] | undefined;
|
||||
countTraces: number | undefined;
|
||||
@@ -219,6 +220,10 @@ export default function SessionsTable({
|
||||
size: 150,
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const value: SessionTableRow["createdAt"] = row.getValue("createdAt");
|
||||
return value ? <LocalIsoDate date={value} /> : undefined;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "sessionDuration",
|
||||
@@ -503,7 +508,7 @@ export default function SessionsTable({
|
||||
(session) => ({
|
||||
id: session.id,
|
||||
bookmarked: session.bookmarked,
|
||||
createdAt: session.createdAt.toLocaleString(),
|
||||
createdAt: session.createdAt,
|
||||
userIds: session.userIds,
|
||||
countTraces: session.countTraces,
|
||||
sessionDuration: session.sessionDuration,
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { StarTraceToggle } from "@/src/components/star-toggle";
|
||||
import { DataTable } from "@/src/components/table/data-table";
|
||||
import { TraceTableMultiSelectAction } from "@/src/components/table/data-table-multi-select-actions/trace-table-multi-select-action";
|
||||
import { DataTableToolbar } from "@/src/components/table/data-table-toolbar";
|
||||
import TableLink from "@/src/components/table/table-link";
|
||||
import { type LangfuseColumnDef } from "@/src/components/table/types";
|
||||
import { TagTracePopover } from "@/src/features/tag/components/TagTracePopver";
|
||||
import { TokenUsageBadge } from "@/src/components/token-usage-badge";
|
||||
import { Checkbox } from "@/src/components/ui/checkbox";
|
||||
import useColumnVisibility from "@/src/features/column-visibility/hooks/useColumnVisibility";
|
||||
import { useQueryFilterState } from "@/src/features/filters/hooks/useFilterState";
|
||||
import { api } from "@/src/utils/api";
|
||||
@@ -38,6 +36,8 @@ import {
|
||||
tracesTableColsWithOptions,
|
||||
type ObservationLevel,
|
||||
BatchExportTableName,
|
||||
AnnotationQueueObjectType,
|
||||
BatchActionType,
|
||||
} from "@langfuse/shared";
|
||||
import { useRowHeightLocalStorage } from "@/src/components/table/data-table-row-height-switch";
|
||||
import { IOTableCell } from "@/src/components/ui/CodeJsonViewer";
|
||||
@@ -58,6 +58,12 @@ import { InfoIcon } from "lucide-react";
|
||||
import { useHasEntitlement } from "@/src/features/entitlements/hooks";
|
||||
import { Separator } from "@/src/components/ui/separator";
|
||||
import React from "react";
|
||||
import { TableActionMenu } from "@/src/features/table/components/TableActionMenu";
|
||||
import { useSelectAll } from "@/src/features/table/hooks/useSelectAll";
|
||||
import { LocalIsoDate } from "@/src/components/LocalIsoDate";
|
||||
import { TableSelectionManager } from "@/src/features/table/components/TableSelectionManager";
|
||||
import { showSuccessToast } from "@/src/features/notifications/showSuccessToast";
|
||||
import { type TableAction } from "@/src/features/table/types";
|
||||
|
||||
export type TracesTableRow = {
|
||||
bookmarked: boolean;
|
||||
@@ -155,6 +161,7 @@ export default function TracesTable({
|
||||
pageIndex: withDefault(NumberParam, 0),
|
||||
pageSize: withDefault(NumberParam, 50),
|
||||
});
|
||||
const { selectAll, setSelectAll } = useSelectAll(projectId, "traces");
|
||||
|
||||
const tracesAllCountFilter = {
|
||||
projectId,
|
||||
@@ -251,42 +258,114 @@ export default function TracesTable({
|
||||
|
||||
const hasTraceDeletionEntitlement = useHasEntitlement("trace-deletion");
|
||||
|
||||
const columns: LangfuseColumnDef<TracesTableRow>[] = [
|
||||
{
|
||||
id: "select",
|
||||
accessorKey: "select",
|
||||
size: 30,
|
||||
isPinned: true,
|
||||
header: ({ table }) => (
|
||||
<div className="flex h-full items-center">
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected()
|
||||
? true
|
||||
: table.getIsSomePageRowsSelected()
|
||||
? "indeterminate"
|
||||
: false
|
||||
}
|
||||
onCheckedChange={(value) => {
|
||||
table.toggleAllPageRowsSelected(!!value);
|
||||
if (!value) {
|
||||
setSelectedRows({});
|
||||
}
|
||||
}}
|
||||
aria-label="Select all"
|
||||
className="opacity-60"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
className="opacity-60"
|
||||
/>
|
||||
),
|
||||
const { selectActionColumn } = TableSelectionManager<TracesTableRow>({
|
||||
projectId,
|
||||
tableName: "traces",
|
||||
setSelectedRows,
|
||||
});
|
||||
|
||||
const traceDeleteMutation = api.traces.deleteMany.useMutation({
|
||||
onSuccess: () => {
|
||||
showSuccessToast({
|
||||
title: "Traces deleted",
|
||||
description: "Selected traces will be deleted. This may take a minute.",
|
||||
});
|
||||
},
|
||||
onSettled: () => {
|
||||
void utils.traces.all.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const addToQueueMutation = api.annotationQueueItems.createMany.useMutation({
|
||||
onSuccess: (data) => {
|
||||
showSuccessToast({
|
||||
title: "Traces added to queue",
|
||||
description: `Selected traces will be added to queue "${data.queueName}". This may take a minute.`,
|
||||
link: {
|
||||
href: `/project/${projectId}/annotation-queues/${data.queueId}`,
|
||||
text: `View queue "${data.queueName}"`,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleDeleteTraces = async ({ projectId }: { projectId: string }) => {
|
||||
const selectedTraceIds = Object.keys(selectedRows).filter((traceId) =>
|
||||
traces.data?.traces.map((t) => t.id).includes(traceId),
|
||||
);
|
||||
|
||||
await traceDeleteMutation.mutateAsync({
|
||||
projectId,
|
||||
traceIds: selectedTraceIds,
|
||||
query: {
|
||||
filter: filterState,
|
||||
orderBy: orderByState,
|
||||
},
|
||||
isBatchAction: selectAll,
|
||||
});
|
||||
setSelectedRows({});
|
||||
};
|
||||
|
||||
const handleAddToAnnotationQueue = async ({
|
||||
projectId,
|
||||
targetId,
|
||||
}: {
|
||||
projectId: string;
|
||||
targetId: string;
|
||||
}) => {
|
||||
const selectedTraceIds = Object.keys(selectedRows).filter((traceId) =>
|
||||
traces.data?.traces.map((t) => t.id).includes(traceId),
|
||||
);
|
||||
|
||||
await addToQueueMutation.mutateAsync({
|
||||
projectId,
|
||||
objectIds: selectedTraceIds,
|
||||
objectType: AnnotationQueueObjectType.TRACE,
|
||||
queueId: targetId,
|
||||
isBatchAction: selectAll,
|
||||
query: {
|
||||
filter: filterState,
|
||||
orderBy: orderByState,
|
||||
},
|
||||
});
|
||||
setSelectedRows({});
|
||||
};
|
||||
|
||||
const tableActions: TableAction[] = [
|
||||
// temporary: hide if no entitlement until we support trace deletion on cloud again
|
||||
// https://github.com/orgs/langfuse/discussions/5313
|
||||
...(hasTraceDeletionEntitlement
|
||||
? [
|
||||
{
|
||||
id: "trace-delete",
|
||||
type: BatchActionType.Delete,
|
||||
label: "Delete Traces",
|
||||
description:
|
||||
"This action permanently deletes traces and cannot be undone.",
|
||||
accessCheck: {
|
||||
scope: "traces:delete",
|
||||
entitlement: "trace-deletion",
|
||||
},
|
||||
execute: handleDeleteTraces,
|
||||
} as TableAction,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "trace-add-to-annotation-queue",
|
||||
type: BatchActionType.Create,
|
||||
label: "Add to Annotation Queue",
|
||||
description: "Add selected traces to an annotation queue.",
|
||||
targetLabel: "Annotation Queue",
|
||||
execute: handleAddToAnnotationQueue,
|
||||
accessCheck: {
|
||||
scope: "annotationQueues:CUD",
|
||||
entitlement: "annotation-queues",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const columns: LangfuseColumnDef<TracesTableRow>[] = [
|
||||
selectActionColumn,
|
||||
{
|
||||
accessorKey: "bookmarked",
|
||||
header: undefined,
|
||||
@@ -339,7 +418,7 @@ export default function TracesTable({
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const value: TracesTableRow["timestamp"] = row.getValue("timestamp");
|
||||
return value ? new Date(value).toLocaleString() : undefined;
|
||||
return value ? <LocalIsoDate date={value} /> : undefined;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -838,15 +917,11 @@ export default function TracesTable({
|
||||
Object.keys(selectedRows).filter((traceId) =>
|
||||
traces.data?.traces.map((t) => t.id).includes(traceId),
|
||||
).length > 0 ? (
|
||||
<TraceTableMultiSelectAction
|
||||
// Exclude traces that are not in the current page
|
||||
selectedTraceIds={Object.keys(selectedRows).filter((traceId) =>
|
||||
traces.data?.traces.map((t) => t.id).includes(traceId),
|
||||
)}
|
||||
<TableActionMenu
|
||||
key="traces-multi-select-actions"
|
||||
projectId={projectId}
|
||||
onDeleteSuccess={() => {
|
||||
setSelectedRows({});
|
||||
}}
|
||||
actions={tableActions}
|
||||
tableName={BatchExportTableName.Traces}
|
||||
/>
|
||||
) : null,
|
||||
<BatchExportTableButton
|
||||
@@ -863,6 +938,16 @@ export default function TracesTable({
|
||||
setRowHeight={setRowHeight}
|
||||
selectedOption={selectedOption}
|
||||
setDateRangeAndOption={setDateRangeAndOption}
|
||||
multiSelect={{
|
||||
selectAll,
|
||||
setSelectAll,
|
||||
selectedRowIds: Object.keys(selectedRows).filter((traceId) =>
|
||||
traces.data?.traces.map((t) => t.id).includes(traceId),
|
||||
),
|
||||
setRowSelection: setSelectedRows,
|
||||
totalCount,
|
||||
...paginationState,
|
||||
}}
|
||||
/>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
import { BreakdownTooltip } from "./BreakdownToolTip";
|
||||
import { InfoIcon, PlusCircle } from "lucide-react";
|
||||
import { UpsertModelFormDrawer } from "@/src/features/models/components/UpsertModelFormDrawer";
|
||||
import { LocalIsoDate } from "@/src/components/LocalIsoDate";
|
||||
|
||||
export const ObservationPreview = ({
|
||||
observations,
|
||||
@@ -89,6 +90,7 @@ export const ObservationPreview = ({
|
||||
projectId: projectId,
|
||||
},
|
||||
{
|
||||
enabled: isAuthenticatedAndProjectMember,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
@@ -157,7 +159,10 @@ export const ObservationPreview = ({
|
||||
<span>{preloadedObservation.name}</span>
|
||||
</CardTitle>
|
||||
<CardDescription className="flex gap-2">
|
||||
{preloadedObservation.startTime.toLocaleString()}
|
||||
<LocalIsoDate
|
||||
date={preloadedObservation.startTime}
|
||||
accuracy="millisecond"
|
||||
/>
|
||||
</CardDescription>
|
||||
{viewType === "detailed" && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user