Compare commits

...
46 Commits
Author SHA1 Message Date
Marc Klingen 8584f93307 chore: release v2.30.2
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (20) (push) Waiting to run
CI/CD / tests-worker (20) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2024-04-19 18:05:17 +02:00
Marc KlingenandGitHub 08e576f815 feat(cloud): add settings for posthog integration (#1778) 2024-04-19 17:38:55 +02:00
Marc KlingenandGitHub f64da44b20 chore: update docs links in settings (#1786) 2024-04-19 10:49:26 +00:00
Marc KlingenandGitHub c720a4d896 perf: reduce memory usage of users.all trpc route (#1784) 2024-04-19 10:03:06 +00:00
Marc Klingen b59b61a5cf chore: release v2.30.1
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (20) (push) Waiting to run
CI/CD / tests-worker (20) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2024-04-19 01:07:09 +02:00
Marc KlingenandGitHub 3f18e42fef feat(api): add countObservations to metrics endpoint, model nullable (#1779) 2024-04-18 23:04:29 +00:00
Marc KlingenandGitHub 31a9be6f1d fix(api): support special characters in dataset name and run name (#1762) 2024-04-18 22:49:10 +00:00
Marc Klingen fe6d8a38ec chore: release v2.30.0
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (20) (push) Waiting to run
CI/CD / tests-worker (20) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2024-04-18 18:25:26 +02:00
Marc KlingenandGitHub d4fecbb2a6 fix: support special characters for prompt names (#1763) 2024-04-18 14:20:20 +00:00
Marc KlingenandGitHub d92ba1d26b feat(ui): concise IOTableCell for small rowHeight across tables (#1766) 2024-04-18 13:42:48 +00:00
Marc KlingenandGitHub 61c2312f12 chore: add dev env flag (#1765) 2024-04-18 15:29:41 +02:00
Marc KlingenandGitHub 862a1395d5 feat(ui): render errors on sign in page and log unknown to sentry (#1767) 2024-04-18 14:56:14 +02:00
Marc KlingenandGitHub 21e9e17e93 docs: add comment on too large page sizes to openapi reference (#1772) 2024-04-18 11:17:11 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
5b32d4b9a2 chore(deps): bump react-responsive from 9.0.2 to 10.0.0 (#1749)
Bumps [react-responsive](https://github.com/yocontra/react-responsive) from 9.0.2 to 10.0.0.
- [Changelog](https://github.com/yocontra/react-responsive/blob/master/CHANGELOG.md)
- [Commits](https://github.com/yocontra/react-responsive/compare/v9.0.2...v10.0.0)

---
updated-dependencies:
- dependency-name: react-responsive
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-17 23:17:07 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ea5617d621 chore(deps-dev): bump @typescript-eslint/parser from 7.6.0 to 7.7.0 (#1750)
Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 7.6.0 to 7.7.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v7.7.0/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-17 23:10:35 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
9c72fddc20 chore(deps-dev): bump vitest from 1.4.0 to 1.5.0 (#1751)
Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 1.4.0 to 1.5.0.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v1.5.0/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-17 18:44:22 +00:00
Marc Klingen 548669e546 chore: release v2.29.2
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (20) (push) Waiting to run
CI/CD / tests-worker (20) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2024-04-17 17:08:09 +02:00
Marc Klingen a7a5870508 fix: do not load NODE_ENV for posthog in frontend 2024-04-17 16:58:55 +02:00
Marc Klingen 0385a52e2c chore: release v2.29.1
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (20) (push) Waiting to run
CI/CD / tests-worker (20) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2024-04-17 15:09:29 +02:00
Marc Klingen dd3f4824e3 fix: import types from correct package to fix runtime issue 2024-04-17 15:09:15 +02:00
Marc Klingen aa6145e341 chore: release v2.29.0
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (20) (push) Waiting to run
CI/CD / tests-worker (20) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2024-04-17 14:24:13 +02:00
Marc KlingenandGitHub a70f1d58a5 feat(datasets): add metadata to dataset and datasetItems (#1756) 2024-04-17 14:22:20 +02:00
Max DeichmannandGitHub 16870fb489 fix: use trash icon for eval config (#1757) 2024-04-17 11:15:19 +00:00
Max DeichmannandGitHub 24b8722ad5 fix: disable eval config after confirmation only (#1746) 2024-04-16 22:08:14 +00:00
d0cf2f56a0 feat(scores): Add a score value filter to api (#1745)
---------

Co-authored-by: DHacky <dhgacky@gmail.com>
2024-04-16 21:46:52 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
20e3c25fd6 chore(deps): bump the patches group with 6 updates (#1741)
Bumps the patches group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [@headlessui/react](https://github.com/tailwindlabs/headlessui/tree/HEAD/packages/@headlessui-react) | `1.7.18` | `1.7.19` |
| [@langchain/core](https://github.com/langchain-ai/langchainjs) | `0.1.57` | `0.1.58` |
| [ai](https://github.com/vercel/ai) | `3.0.22` | `3.0.23` |
| [react-day-picker](https://github.com/gpbl/react-day-picker) | `8.10.0` | `8.10.1` |
| [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) | `18.2.78` | `18.2.79` |
| [prettier-plugin-tailwindcss](https://github.com/tailwindlabs/prettier-plugin-tailwindcss) | `0.5.13` | `0.5.14` |


Updates `@headlessui/react` from 1.7.18 to 1.7.19
- [Release notes](https://github.com/tailwindlabs/headlessui/releases)
- [Changelog](https://github.com/tailwindlabs/headlessui/blob/main/packages/@headlessui-react/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/headlessui/commits/@headlessui/react@v1.7.19/packages/@headlessui-react)

Updates `@langchain/core` from 0.1.57 to 0.1.58
- [Release notes](https://github.com/langchain-ai/langchainjs/releases)
- [Changelog](https://github.com/langchain-ai/langchainjs/blob/main/release_workspace.js)
- [Commits](https://github.com/langchain-ai/langchainjs/commits)

Updates `ai` from 3.0.22 to 3.0.23
- [Release notes](https://github.com/vercel/ai/releases)
- [Commits](https://github.com/vercel/ai/compare/ai@3.0.22...ai@3.0.23)

Updates `react-day-picker` from 8.10.0 to 8.10.1
- [Release notes](https://github.com/gpbl/react-day-picker/releases)
- [Commits](https://github.com/gpbl/react-day-picker/compare/v8.10.0...v8.10.1)

Updates `@types/react` from 18.2.78 to 18.2.79
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

Updates `prettier-plugin-tailwindcss` from 0.5.13 to 0.5.14
- [Release notes](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.5.13...v0.5.14)

---
updated-dependencies:
- dependency-name: "@headlessui/react"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: patches
- dependency-name: "@langchain/core"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: patches
- dependency-name: ai
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: patches
- dependency-name: react-day-picker
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: patches
- dependency-name: "@types/react"
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: patches
- dependency-name: prettier-plugin-tailwindcss
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: patches
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-16 19:58:15 +00:00
b2be82b436 feat: add posthog to chatCompletion endpoint (#1742)
---------

Co-authored-by: Marc Klingen <git@marcklingen.com>
2024-04-16 19:42:14 +00:00
Marc Klingen 779771950b chore: add crisp env to env.mjs 2024-04-16 21:36:38 +02:00
Max DeichmannandGitHub fdfc64b853 perf: add internal model index (#1743) 2024-04-16 19:54:31 +02:00
Marc Klingen ca43da7562 chore: release v2.28.0
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (20) (push) Waiting to run
CI/CD / tests-worker (20) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2024-04-16 18:07:41 +02:00
Marc KlingenandGitHub 12a2459a1d feat(api): add fromTimestamp and toTimestamp to GET metrics/daily (#1740) 2024-04-16 16:06:27 +00:00
Marc KlingenandGitHub 5c1a7535ee feat: sentry exception tracking for authentication error page (#1739) 2024-04-16 17:26:54 +02:00
Marc Klingen 32f4e5c64f fix(ui): "update promt" on submit of prompt form 2024-04-16 17:21:43 +02:00
Max DeichmannandGitHub 06b8331a07 fix: rename template select (#1738) 2024-04-16 15:17:37 +00:00
Max DeichmannandGitHub 817554f1c9 feat: move evals to own main menu (#1734) 2024-04-16 15:11:20 +00:00
Marc KlingenandGitHub 37d982a32f perf: add additional db index on models.name (#1729) 2024-04-16 17:02:21 +02:00
Max DeichmannandGitHub 1f75563d55 chore: fix vite version (#1737) 2024-04-16 14:59:39 +00:00
Max DeichmannandGitHub f5775500c8 fix: pin pnpm in docker image (#1735) 2024-04-16 14:50:42 +00:00
Marc KlingenandGitHub 97e26dc8f3 chore: add web/src/ee (#1736) 2024-04-16 16:40:26 +02:00
Marc Klingen 1d733e39ff fix: add help tooltip to single prompt page 2024-04-16 15:11:58 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
12abeac07a chore(deps): bump the sentry group with 4 updates (#1724)
Bumps the sentry group with 4 updates: [@sentry/nextjs](https://github.com/getsentry/sentry-javascript), [@sentry/node](https://github.com/getsentry/sentry-javascript), [@sentry/profiling-node](https://github.com/getsentry/sentry-javascript) and [@sentry/types](https://github.com/getsentry/sentry-javascript).


Updates `@sentry/nextjs` from 7.110.0 to 7.110.1
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/7.110.1/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/7.110.0...7.110.1)

Updates `@sentry/node` from 7.110.0 to 7.110.1
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/7.110.1/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/7.110.0...7.110.1)

Updates `@sentry/profiling-node` from 7.110.0 to 7.110.1
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/7.110.1/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/7.110.0...7.110.1)

Updates `@sentry/types` from 7.110.0 to 7.110.1
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/7.110.1/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/7.110.0...7.110.1)

---
updated-dependencies:
- dependency-name: "@sentry/nextjs"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: sentry
- dependency-name: "@sentry/node"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: sentry
- dependency-name: "@sentry/profiling-node"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: sentry
- dependency-name: "@sentry/types"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: sentry
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-16 11:04:05 +00:00
Hassieb PakzadandGitHub 6478456e6c fix: adds error handling to chat completion calls (#1733) 2024-04-16 10:54:39 +00:00
Marc Klingen 5e27d36d74 chore: release v2.27.0
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (20) (push) Waiting to run
CI/CD / tests-worker (20) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2024-04-16 12:19:28 +02:00
Marc Klingen 8461a7a305 chore: add ADMIN_API_KEY to env example 2024-04-16 12:18:11 +02:00
Marc Klingen 5fd3cd218a chore: remove console log 2024-04-16 12:16:28 +02:00
Marc KlingenandGitHub 38cc982924 feat(ee): domain-specific sso providers (#1713) 2024-04-16 10:08:41 +00:00
125 changed files with 3544 additions and 1140 deletions
+6
View File
@@ -114,4 +114,10 @@ LANGFUSE_CSP_ENFORCE_HTTPS="true"
# Crisp chat
# NEXT_PUBLIC_CRISP_WEBSITE_ID=
# Encryption
# ENCRYPTION_KEY=
# Admin API
# ADMIN_API_KEY=
### END Langfuse Cloud Config
+5 -5
View File
@@ -19,7 +19,7 @@ jobs:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v3
with:
version: 8
version: 8.15.5
- uses: actions/setup-node@v3
with:
node-version: 20
@@ -68,7 +68,7 @@ jobs:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v3
with:
version: 8
version: 8.15.5
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
@@ -120,7 +120,7 @@ jobs:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v3
with:
version: 8
version: 8.15.5
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
@@ -159,7 +159,7 @@ jobs:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v3
with:
version: 8
version: 8.15.5
- uses: actions/setup-node@v3
with:
node-version: 20
@@ -221,7 +221,7 @@ jobs:
steps:
- uses: pnpm/action-setup@v3
with:
version: 8
version: 8.15.5
- name: Setup node
uses: actions/setup-node@v3
with:
+5 -3
View File
@@ -120,9 +120,11 @@ We built a monorepo using [pnpm](https://pnpm.io/motivation) and [turbo](https:/
- `web`: is the main application package providing Frontend and Backend APIs for Langfuse.
- `worker` (no production yet): contains an application for asynchronous processing of tasks. This package is not yet used in production.
- `shared`: contains shared code between the above packages.
- `config-eslint`: contains eslint configurations which are shared between the above packages.
- `config-typescript`: contains typescript configurations which are shared between the above packages.
- `packages`:
- `shared`: contains shared code between the above packages.
- `config-eslint`: contains eslint configurations which are shared between the above packages.
- `config-typescript`: contains typescript configurations which are shared between the above packages.
- `ee`: contains all enterprise features. See [EE README](ee/README.md) for more details.
## Development Setup
+4 -4
View File
@@ -2,9 +2,9 @@ Copyright (c) 2023 Finto Technologies GmbH
Portions of this software are licensed as follows:
* All content that resides under the "ee/" directory of this repository, if that directory exists, is licensed under the license defined in "ee/LICENSE".
* All third party components incorporated into the Finto Technologies Software are licensed under the original license provided by the owner of the applicable component.
* Content outside of the above mentioned directories or restrictions above is available under the "MIT Expat" license as defined below.
- All content that resides under the "ee/" and/or "web/src/ee" directories of this repository, if these directories exist, is licensed under the license defined in "ee/LICENSE".
- All third party components incorporated into the Finto Technologies Software are licensed under the original license provided by the owner of the applicable component.
- Content outside of the above mentioned directories or restrictions above is available under the "MIT Expat" license as defined below.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -22,4 +22,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
SOFTWARE.
+8 -8
View File
@@ -101,13 +101,13 @@ See the [→ Quickstart](https://langfuse.com/docs/get-started) to integrate Lan
### Integrations
| Integration | Supports | Description |
| ---------------------------------------------------------------- | ------------- | ---------------------------------------------------------------- |
| [**SDK** - _recommended_](https://langfuse.com/docs/sdk) | Python, JS/TS | Manual instrumentation using the SDKs for full flexibility. |
| [OpenAI SDK](https://langfuse.com/docs/integrations/openai) | Python, JS/TS | Automated instrumentation of OpenAI SDK. |
| [Langchain](https://langfuse.com/docs/integrations/langchain) | Python, JS/TS | Instrumentation via Langchain callbacks. |
| [LlamaIndex](https://langfuse.com/docs/integrations/llama-index) | Python | Automated instrumentation via LlamaIndex callback system. |
| [API](https://langfuse.com/docs/api) | | Directly call the public API. OpenAPI spec available. |
| Integration | Supports | Description |
| ---------------------------------------------------------------- | ------------- | ----------------------------------------------------------- |
| [**SDK** - _recommended_](https://langfuse.com/docs/sdk) | Python, JS/TS | Manual instrumentation using the SDKs for full flexibility. |
| [OpenAI SDK](https://langfuse.com/docs/integrations/openai) | Python, JS/TS | Automated instrumentation of OpenAI SDK. |
| [Langchain](https://langfuse.com/docs/integrations/langchain) | Python, JS/TS | Instrumentation via Langchain callbacks. |
| [LlamaIndex](https://langfuse.com/docs/integrations/llama-index) | Python | Automated instrumentation via LlamaIndex callback system. |
| [API](https://langfuse.com/docs/api) | | Directly call the public API. OpenAPI spec available. |
External projects/packages that integrate with Langfuse:
@@ -142,7 +142,7 @@ In order of preference the best way to communicate with us:
## License
This repository is MIT licensed, except for the `ee/` folder. See [LICENSE](LICENSE) and [docs](https://langfuse.com/docs/open-source) for more details.
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
+8
View File
@@ -0,0 +1,8 @@
/** @type {import("eslint").Linter.Config} */
module.exports = {
extends: ["@repo/eslint-config/library.js"],
parser: "@typescript-eslint/parser",
parserOptions: {
project: true,
},
};
+3 -1
View File
@@ -1,3 +1,5 @@
# Enterprise Edition
While we are currently working 100% on the open source version, we consider financing the project by developing some premium features as part of an enterprise version under a commercial license. Please reach out to us if you have specific requirements: enterprise@langfuse.com
This folder includes features that are only available in the Enterprise Edition of Langfuse and on Langfuse Cloud.
See [LICENSE](../LICENSE) and [docs](https://langfuse.com/docs/open-source) for more details.
+46
View File
@@ -0,0 +1,46 @@
{
"name": "@langfuse/ee",
"version": "1.0.0",
"private": true,
"main": "./dist/src/index.js",
"types": "./dist/src/index.d.ts",
"exports": {
".": {
"import": "./dist/src/index.js",
"require": "./dist/src/index.js"
},
"./sso": {
"import": "./dist/src/sso/index.js",
"require": "./dist/src/sso/index.js"
}
},
"engines": {
"node": ">=20"
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
"lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix"
},
"dependencies": {
"@langfuse/shared": "workspace:*",
"next": "^14.2.1",
"next-auth": "^4.24.7",
"zod": "^3.22.4"
},
"devDependencies": {
"@repo/eslint-config": "*",
"@repo/typescript-config": "*",
"@types/node": "^20.11.29",
"@typescript-eslint/parser": "^7.7.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-config-standard": "^17.1.0",
"eslint-plugin-prettier": "^5.1.3",
"prettier": "^3.2.5",
"ts-node": "^10.9.2",
"tsc-watch": "^6.2.0",
"typescript": "^5.4.5"
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"trailingComma": "es5",
"printWidth": 120
}
+4
View File
@@ -0,0 +1,4 @@
import { env } from "../env";
export const isEeAvailable: boolean =
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== undefined;
+9
View File
@@ -0,0 +1,9 @@
import { z } from "zod";
import { env as sharedEnv } from "@langfuse/shared";
const EnvSchema = z.object({
NEXT_PUBLIC_LANGFUSE_CLOUD_REGION: z.string().optional(),
ADMIN_API_KEY: z.string().optional(),
});
export const env = { ...sharedEnv, ...EnvSchema.parse(process.env) };
+1
View File
@@ -0,0 +1 @@
export { isEeAvailable } from "./ee-license-check";
+236
View File
@@ -0,0 +1,236 @@
import { type Provider } from "next-auth/providers/index";
import GoogleProvider from "next-auth/providers/google";
import GitHubProvider from "next-auth/providers/github";
import OktaProvider from "next-auth/providers/okta";
import Auth0Provider from "next-auth/providers/auth0";
import AzureADProvider from "next-auth/providers/azure-ad";
import { isEeAvailable } from "..";
import { prisma } from "@langfuse/shared/src/db";
import { encrypt, decrypt } from "@langfuse/shared/encryption";
import { SsoProviderSchema } from "./types";
import { type NextApiRequest, type NextApiResponse } from "next";
import { env } from "../env";
// Local cache for SSO configurations
let cachedSsoConfigs: {
data: SsoProviderSchema[];
timestamp: number;
} | null = null;
/**
* Get all SSO configurations from the database or from local cache and parse them into SsoProviderSchema objects.
*
* @returns {Promise<SsoProviderSchema[]>} - A list of all SSO configurations. Empty array if none are configured or EE is not available.
*/
async function getSsoConfigs(): Promise<SsoProviderSchema[]> {
if (!isEeAvailable) return [];
const CACHE_TTL = 60 * 1000; // 1 minute
// Set/refresh the cache if it's empty or expired
if (
cachedSsoConfigs === null ||
Date.now() - cachedSsoConfigs.timestamp > CACHE_TTL
) {
// transform into zod object
const dbConfigs = await prisma.ssoConfig.findMany();
const parsedSsoConfigs = dbConfigs
.map((v) => {
try {
const parsedValue = SsoProviderSchema.parse(v);
return parsedValue;
} catch (e) {
console.error(
`Failed to parse SSO provider config for domain ${v.domain}`,
e
);
return null;
}
})
.filter((parsed) => parsed !== null) as SsoProviderSchema[];
cachedSsoConfigs = {
data: parsedSsoConfigs,
timestamp: Date.now(),
};
}
return cachedSsoConfigs.data;
}
/**
* Load all custom SSO providers from the database. To be used within `providers` in NextAuth backend configuration.
*
* @returns {Promise<Provider[]>} - A list of all custom SSO providers.
*/
export async function loadSsoProviders(): Promise<Provider[]> {
if (!isEeAvailable) return [];
const ssoConfigs = await getSsoConfigs();
const providers: Provider[] = [];
for (const dbSsoConfig of ssoConfigs) {
const provider = dbToNextAuthProvider(dbSsoConfig);
if (provider !== null) providers.push(provider);
}
return providers;
}
/**
* @returns `true` if any custom SSO provider is configured in the database.
*/
export async function isAnySsoConfigured(): Promise<boolean> {
if (!isEeAvailable) return false;
const ssoConfigs = await getSsoConfigs();
return ssoConfigs.length > 0;
}
/**
* Get the custom SSO providerId for a domain. To be used with NextAuth's `signIn(providerId)`.
*
* @param domain - The domain to check for a custom SSO provider, e.g. "example.com".
* @returns `providerId` or null if none is configured or EE is not available.
*/
export async function getSsoAuthProviderIdForDomain(
domain: string
): Promise<string | null> {
if (!isEeAvailable) return null;
const ssoConfig = (await getSsoConfigs()).find(
(ssoConfig) => ssoConfig.domain === domain.toLowerCase()
);
if (!ssoConfig) return null;
return getAuthProviderIdForSsoConfig(ssoConfig);
}
/**
* Converts a SsoProviderConfig to a NextAuth Provider instance.
*
* @param {SsoProviderSchema} provider - The SSO configuration from the database.
* @returns {Provider | null} - A NextAuth Provider instance or null if parsing fails or no custom credentials are used for this SSO config.
*/
const dbToNextAuthProvider = (provider: SsoProviderSchema): Provider | null => {
// If the SsoConfig does not use custom credentials, return null as no additional provider needs to be added to NextAuth
if (!provider.authConfig) return null;
if (provider.authProvider === "google")
return GoogleProvider({
id: getAuthProviderIdForSsoConfig(provider), // use the domain as the provider id as we use domain-specific credentials
...provider.authConfig,
clientSecret: decrypt(provider.authConfig.clientSecret),
});
else if (provider.authProvider === "github")
return GitHubProvider({
id: getAuthProviderIdForSsoConfig(provider), // use the domain as the provider id as we use domain-specific credentials
...provider.authConfig,
clientSecret: decrypt(provider.authConfig.clientSecret),
});
else if (provider.authProvider === "auth0")
return Auth0Provider({
id: getAuthProviderIdForSsoConfig(provider), // use the domain as the provider id as we use domain-specific credentials
...provider.authConfig,
clientSecret: decrypt(provider.authConfig.clientSecret),
});
else if (provider.authProvider === "okta")
return OktaProvider({
id: getAuthProviderIdForSsoConfig(provider), // use the domain as the provider id as we use domain-specific credentials
...provider.authConfig,
clientSecret: decrypt(provider.authConfig.clientSecret),
});
else if (provider.authProvider === "azure-ad")
return AzureADProvider({
id: getAuthProviderIdForSsoConfig(provider), // use the domain as the provider id as we use domain-specific credentials
...provider.authConfig,
clientSecret: decrypt(provider.authConfig.clientSecret),
});
else {
// Type check to ensure we handle all providers
// eslint-disable-next-line no-unused-vars
const _: never = provider;
throw new Error(
`Unrecognized SSO provider for domain ${(provider as any).domain}`
);
}
};
/**
* Get the custom SSO providerId for a database SSO configuration. To be used with NextAuth's `signIn(providerId)`.
*
* @param {DbSsoConfig} dbSsoConfig - The SSO configuration from the database.
* @returns {string} - The providerId used in NextAuth.
*/
const getAuthProviderIdForSsoConfig = (
dbSsoConfig: SsoProviderSchema
): string => {
if (!dbSsoConfig.authConfig) return dbSsoConfig.authProvider;
return `${dbSsoConfig.domain}.${dbSsoConfig.authProvider}`;
};
export async function createNewSsoConfigHandler(
req: NextApiRequest,
res: NextApiResponse
) {
try {
if (!isEeAvailable) {
res.status(403).json({ error: "EE is not available" });
return;
}
// allow only POST requests
if (req.method !== "POST") {
res.status(405).json({ error: "Method Not Allowed" });
return;
}
// check if ADMIN_API_KEY is set
if (!env.ADMIN_API_KEY) {
res.status(500).json({ error: "ADMIN_API_KEY is not set" });
return;
}
if (!env.ENCRYPTION_KEY) {
res.status(500).json({ error: "ENCRYPTION_KEY is not set" });
return;
}
// check bearer token
const { authorization } = req.headers;
if (!authorization) {
res
.status(401)
.json({ error: "Unauthorized: No authorization header provided" });
return;
}
const [scheme, token] = authorization.split(" ");
if (scheme !== "Bearer" || !token || token !== env.ADMIN_API_KEY) {
res.status(401).json({ error: "Unauthorized: Invalid token" });
return;
}
const body = SsoProviderSchema.safeParse(req.body);
if (!body.success) {
res.status(400).json({ error: body.error });
return;
}
const { domain, authProvider, authConfig } = body.data;
const encryptedClientSecret = authConfig
? {
...authConfig,
clientSecret: encrypt(authConfig.clientSecret),
}
: undefined;
await prisma.ssoConfig.create({
data: {
domain,
authProvider,
authConfig: encryptedClientSecret,
},
});
res.status(201).json({
message: "SSO configuration created successfully",
});
} catch (e) {
console.log(e);
res.status(500).json({ error: "Internal Server Error" });
}
}
+81
View File
@@ -0,0 +1,81 @@
import { z } from "zod";
const base = z.object({
domain: z.string().refine((v) => v === v.toLowerCase(), {
message: "Domain must be lowercase",
}),
});
export const GoogleProviderSchema = base.extend({
authProvider: z.literal("google"),
authConfig: z
.object({
clientId: z.string(),
clientSecret: z.string(),
allowDangerousEmailAccountLinking: z.boolean().optional().default(false),
})
.nullish(),
});
export const GithubProviderSchema = base.extend({
authProvider: z.literal("github"),
authConfig: z
.object({
clientId: z.string(),
clientSecret: z.string(),
allowDangerousEmailAccountLinking: z.boolean().optional().default(false),
})
.nullish(),
});
export const Auth0ProviderSchema = base.extend({
authProvider: z.literal("auth0"),
authConfig: z
.object({
clientId: z.string(),
clientSecret: z.string(),
issuer: z.string(),
allowDangerousEmailAccountLinking: z.boolean().optional().default(false),
})
.nullish(),
});
export const OktaProviderSchema = base.extend({
authProvider: z.literal("okta"),
authConfig: z
.object({
clientId: z.string(),
clientSecret: z.string(),
issuer: z.string(),
allowDangerousEmailAccountLinking: z.boolean().optional().default(false),
})
.nullish(),
});
export const AzureAdProviderSchema = base.extend({
authProvider: z.literal("azure-ad"),
authConfig: z
.object({
clientId: z.string(),
clientSecret: z.string(),
tenantId: z.string(),
allowDangerousEmailAccountLinking: z.boolean().optional().default(false),
})
.nullish(),
});
export type GoogleProviderSchema = z.infer<typeof GoogleProviderSchema>;
export type GithubProviderSchema = z.infer<typeof GithubProviderSchema>;
export type Auth0ProviderSchema = z.infer<typeof Auth0ProviderSchema>;
export type OktaProviderSchema = z.infer<typeof OktaProviderSchema>;
export type AzureAdProviderSchema = z.infer<typeof AzureAdProviderSchema>;
export const SsoProviderSchema = z.discriminatedUnion("authProvider", [
GoogleProviderSchema,
GithubProviderSchema,
Auth0ProviderSchema,
OktaProviderSchema,
AzureAdProviderSchema,
]);
export type SsoProviderSchema = z.infer<typeof SsoProviderSchema>;
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "@repo/typescript-config/base.json",
"compilerOptions": {
"moduleResolution": "NodeNext",
"module": "NodeNext",
"lib": ["ES2020"],
"outDir": "./dist",
"types": ["node"],
"target": "ES2020",
"rootDir": "."
},
"include": ["."],
"exclude": ["node_modules", "dist"]
}
+3
View File
@@ -127,6 +127,7 @@ types:
id: string
name: string
description: optional<string>
metadata: optional<unknown>
projectId: string
createdAt: datetime
updatedAt: datetime
@@ -151,6 +152,7 @@ types:
status: DatasetStatus
input: optional<unknown>
expectedOutput: optional<unknown>
metadata: optional<unknown>
sourceTraceId: optional<string>
sourceObservationId: optional<string>
datasetId: string
@@ -209,6 +211,7 @@ types:
enum:
- API
- REVIEW
- EVAL
errors:
Error:
@@ -26,6 +26,9 @@ types:
datasetName: string
input: optional<unknown>
expectedOutput: optional<unknown>
metadata: optional<unknown>
sourceTraceId: optional<string>
sourceObservationId: optional<string>
id:
type: optional<string>
docs: Dataset items are upserted on their id
+1
View File
@@ -47,3 +47,4 @@ types:
properties:
name: string
description: optional<string>
metadata: optional<unknown>
+8 -1
View File
@@ -29,6 +29,12 @@ service:
type: optional<string>
allow-multiple: true
docs: Optional filter for metrics where traces include all of these tags
fromTimestamp:
type: optional<datetime>
docs: Optional filter to only include traces on or after a certain timestamp
toTimestamp:
type: optional<datetime>
docs: Optional filter to only include traces before a certain timestamp
response: DailyMetrics
types:
DailyMetrics:
@@ -41,12 +47,13 @@ types:
properties:
date: date
countTraces: integer
countObservations: integer
totalCost: double
usage: list<UsageByModel>
UsageByModel:
docs: Daily usage of a given model. Usage corresponds to the unit set for the specific model (e.g. tokens).
properties:
model: string
model: optional<string>
inputUsage: integer
outputUsage: integer
totalUsage: integer
+2 -2
View File
@@ -24,10 +24,10 @@ service:
query-parameters:
page:
type: optional<integer>
docs: page number, starts at 1
docs: Page number, starts at 1.
limit:
type: optional<integer>
docs: limit of items per page
docs: Limit of items per page. If you encounter api issues due to too large page sizes, try to reduce the limit.
name: optional<string>
userId: optional<string>
type: optional<string>
+11 -2
View File
@@ -21,15 +21,24 @@ service:
query-parameters:
page:
type: optional<integer>
docs: page number, starts at 1
docs: Page number, starts at 1.
limit:
type: optional<integer>
docs: limit of items per page
docs: Limit of items per page. If you encounter api issues due to too large page sizes, try to reduce the limit.
userId: optional<string>
name: optional<string>
fromTimestamp:
type: optional<datetime>
docs: Retrieve only scores newer than this timestamp.
source:
type: commons.ScoreSource
docs: Retrieve only scores from a specific source.
operator:
type: optional<string>
docs: Retrieve only scores with <operator> value.
value:
type: optional<double>
docs: Retrieve only scores with <operator> value.
response: Scores
get-by-id:
docs: Get a score
+3 -3
View File
@@ -16,7 +16,7 @@ service:
docs: The unique langfuse identifier of a trace
response: commons.TraceWithFullDetails
list:
docs: Get list of traces
docs: Get list of traces.
method: GET
path: /traces
request:
@@ -24,10 +24,10 @@ service:
query-parameters:
page:
type: optional<integer>
docs: page number, starts at 1
docs: Page number, starts at 1
limit:
type: optional<integer>
docs: limit of items per page
docs: Limit of items per page. If you encounter api issues due to too large page sizes, try to reduce the limit.
userId: optional<string>
name: optional<string>
fromTimestamp:
+1 -1
View File
@@ -1,4 +1,4 @@
{
"organization": "finto",
"version": "0.19.30"
"version": "0.21.0"
}
+36 -11
View File
@@ -65,7 +65,7 @@
"auth": null,
"body": {
"mode": "raw",
"raw": "{\n \"datasetName\": \"example\",\n \"input\": \"UNKNOWN\",\n \"expectedOutput\": \"UNKNOWN\",\n \"id\": \"example\"\n}",
"raw": "{\n \"datasetName\": \"example\",\n \"input\": \"UNKNOWN\",\n \"expectedOutput\": \"UNKNOWN\",\n \"metadata\": \"UNKNOWN\",\n \"sourceTraceId\": \"example\",\n \"sourceObservationId\": \"example\",\n \"id\": \"example\"\n}",
"options": {
"raw": {
"language": "json"
@@ -245,7 +245,7 @@
"auth": null,
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"example\",\n \"description\": \"example\"\n}",
"raw": "{\n \"name\": \"example\",\n \"description\": \"example\",\n \"metadata\": \"UNKNOWN\"\n}",
"options": {
"raw": {
"language": "json"
@@ -379,7 +379,7 @@
"request": {
"description": "Get daily metrics of the Langfuse project",
"url": {
"raw": "{{baseUrl}}/api/public/metrics/daily?page=&limit=&traceName=&userId=&tags=",
"raw": "{{baseUrl}}/api/public/metrics/daily?page=&limit=&traceName=&userId=&tags=&fromTimestamp=&toTimestamp=",
"host": [
"{{baseUrl}}"
],
@@ -414,6 +414,16 @@
"key": "tags",
"value": "",
"description": "Optional filter for metrics where traces include all of these tags"
},
{
"key": "fromTimestamp",
"value": "",
"description": "Optional filter to only include traces on or after a certain timestamp"
},
{
"key": "toTimestamp",
"value": "",
"description": "Optional filter to only include traces before a certain timestamp"
}
],
"variable": []
@@ -483,12 +493,12 @@
{
"key": "page",
"value": "",
"description": "page number, starts at 1"
"description": "Page number, starts at 1."
},
{
"key": "limit",
"value": "",
"description": "limit of items per page"
"description": "Limit of items per page. If you encounter api issues due to too large page sizes, try to reduce the limit."
},
{
"key": "name",
@@ -684,7 +694,7 @@
"request": {
"description": "Get a list of scores",
"url": {
"raw": "{{baseUrl}}/api/public/scores?page=&limit=&userId=&name=&fromTimestamp=",
"raw": "{{baseUrl}}/api/public/scores?page=&limit=&userId=&name=&fromTimestamp=&source=&operator=&value=",
"host": [
"{{baseUrl}}"
],
@@ -697,12 +707,12 @@
{
"key": "page",
"value": "",
"description": "page number, starts at 1"
"description": "Page number, starts at 1."
},
{
"key": "limit",
"value": "",
"description": "limit of items per page"
"description": "Limit of items per page. If you encounter api issues due to too large page sizes, try to reduce the limit."
},
{
"key": "userId",
@@ -718,6 +728,21 @@
"key": "fromTimestamp",
"value": "",
"description": "Retrieve only scores newer than this timestamp."
},
{
"key": "source",
"value": "",
"description": "Retrieve only scores from a specific source."
},
{
"key": "operator",
"value": "",
"description": "Retrieve only scores with <operator> value."
},
{
"key": "value",
"value": "",
"description": "Retrieve only scores with <operator> value."
}
],
"variable": []
@@ -875,7 +900,7 @@
"_type": "endpoint",
"name": "List",
"request": {
"description": "Get list of traces",
"description": "Get list of traces.",
"url": {
"raw": "{{baseUrl}}/api/public/traces?page=&limit=&userId=&name=&fromTimestamp=&orderBy=&tags=",
"host": [
@@ -890,12 +915,12 @@
{
"key": "page",
"value": "",
"description": "page number, starts at 1"
"description": "Page number, starts at 1"
},
{
"key": "limit",
"value": "",
"description": "limit of items per page"
"description": "Limit of items per page. If you encounter api issues due to too large page sizes, try to reduce the limit."
},
{
"key": "userId",
+3 -2
View File
@@ -1,11 +1,12 @@
{
"name": "langfuse",
"version": "2.26.1",
"version": "2.30.2",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
"engines": {
"node": ">=20"
"node": ">=20",
"pnpm": "<9"
},
"scripts": {
"preinstall": "npx only-allow pnpm",
+1 -1
View File
@@ -9,7 +9,7 @@
],
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^7.1.0",
"@typescript-eslint/parser": "^7.6.0",
"@typescript-eslint/parser": "^7.7.0",
"@vercel/style-guide": "^6.0.0",
"eslint-config-next": "^14.2.1",
"eslint-config-prettier": "^9.1.0",
+8 -3
View File
@@ -20,6 +20,10 @@
"./src/server/auth": {
"import": "./dist/src/server/auth.js",
"require": "./dist/src/server/auth.js"
},
"./encryption": {
"import": "./dist/src/encryption/index.js",
"require": "./dist/src/encryption/index.js"
}
},
"scripts": {
@@ -28,6 +32,7 @@
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
"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",
"db:reset": "dotenv -e ../../.env npx -- prisma migrate reset",
"db:deploy": "dotenv -e ../../.env npx -- prisma migrate deploy",
"db:seed": "dotenv -e ../../.env -- npx prisma db seed",
@@ -40,7 +45,7 @@
},
"dependencies": {
"@langchain/anthropic": "^0.1.14",
"@langchain/core": "^0.1.57",
"@langchain/core": "^0.1.58",
"@langchain/openai": "^0.0.28",
"@prisma/client": "^5.12.1",
"@types/bcryptjs": "^2.4.6",
@@ -58,7 +63,7 @@
"@types/node": "^20.11.29",
"@types/pg": "^8.11.5",
"@types/uuid": "^9.0.8",
"@typescript-eslint/parser": "^7.6.0",
"@typescript-eslint/parser": "^7.7.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-config-standard": "^17.1.0",
@@ -72,6 +77,6 @@
"ts-node": "^10.9.2",
"tsc-watch": "^6.2.0",
"typescript": "^5.4.5",
"vitest": "^1.3.1"
"vitest": "^1.5.0"
}
}
+18
View File
@@ -113,6 +113,7 @@ export type Dataset = {
id: string;
name: string;
description: string | null;
metadata: unknown | null;
project_id: string;
created_at: Generated<Timestamp>;
updated_at: Generated<Timestamp>;
@@ -122,6 +123,7 @@ export type DatasetItem = {
status: Generated<DatasetStatus>;
input: unknown | null;
expected_output: unknown | null;
metadata: unknown | null;
source_trace_id: string | null;
source_observation_id: string | null;
dataset_id: string;
@@ -290,6 +292,13 @@ export type ObservationView = {
calculated_total_cost: string | null;
latency: number | null;
};
export type PosthogIntegration = {
project_id: string;
encrypted_posthog_api_key: string;
posthog_host_name: string;
last_sync_at: Timestamp | null;
enabled: boolean;
};
export type Pricing = {
id: string;
model_name: string;
@@ -335,6 +344,13 @@ export type Session = {
user_id: string;
expires: Timestamp;
};
export type SsoConfig = {
domain: string;
created_at: Generated<Timestamp>;
updated_at: Generated<Timestamp>;
auth_provider: string;
auth_config: unknown | null;
};
export type Trace = {
id: string;
external_id: string | null;
@@ -413,11 +429,13 @@ export type DB = {
models: Model;
observations: Observation;
observations_view: ObservationView;
posthog_integrations: PosthogIntegration;
pricings: Pricing;
projects: Project;
prompts: Prompt;
scores: Score;
Session: Session;
sso_configs: SsoConfig;
trace_sessions: TraceSession;
traces: Trace;
traces_view: TraceView;
@@ -0,0 +1,10 @@
-- CreateTable
CREATE TABLE "sso_configs" (
"domain" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"auth_provider" TEXT NOT NULL,
"auth_config" JSONB,
CONSTRAINT "sso_configs_pkey" PRIMARY KEY ("domain")
);
@@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX CONCURRENTLY "models_model_name_idx" ON "models"("model_name");
@@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX CONCURRENTLY "observations_internal_model_idx" ON "observations"("internal_model");
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "dataset_items" ADD COLUMN "metadata" JSONB;
-- AlterTable
ALTER TABLE "datasets" ADD COLUMN "metadata" JSONB;
@@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "posthog_integrations" (
"project_id" TEXT NOT NULL,
"encrypted_posthog_api_key" TEXT NOT NULL,
"posthog_host_name" TEXT NOT NULL,
"last_sync_at" TIMESTAMP(3),
"enabled" BOOLEAN NOT NULL,
CONSTRAINT "posthog_integrations_pkey" PRIMARY KEY ("project_id")
);
-- CreateIndex
CREATE INDEX "posthog_integrations_project_id_idx" ON "posthog_integrations"("project_id");
-- AddForeignKey
ALTER TABLE "posthog_integrations" ADD CONSTRAINT "posthog_integrations_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+51 -19
View File
@@ -92,25 +92,26 @@ model VerificationToken {
}
model Project {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
name String
cloudConfig Json? @map("cloud_config") // Langfuse Cloud, for zod schema see projectsRouter.ts
members Membership[]
traces Trace[]
observations Observation[]
apiKeys ApiKey[]
dataset Dataset[]
RawEvents Events[]
invitations MembershipInvitation[]
sessions TraceSession[]
Prompt Prompt[]
Model Model[]
AuditLog AuditLog[]
EvalTemplate EvalTemplate[]
JobConfiguration JobConfiguration[]
JobExecution JobExecution[]
id String @id @default(cuid())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
name String
cloudConfig Json? @map("cloud_config") // Langfuse Cloud, for zod schema see projectsRouter.ts
members Membership[]
traces Trace[]
observations Observation[]
apiKeys ApiKey[]
dataset Dataset[]
RawEvents Events[]
invitations MembershipInvitation[]
sessions TraceSession[]
Prompt Prompt[]
Model Model[]
AuditLog AuditLog[]
EvalTemplate EvalTemplate[]
JobConfiguration JobConfiguration[]
JobExecution JobExecution[]
PosthogIntegration PosthogIntegration[]
@@map("projects")
}
@@ -302,6 +303,7 @@ model Observation {
@@index(projectId)
@@index(parentObservationId)
@@index(model)
@@index(internalModel)
@@index(promptId)
@@index([projectId, startTime, type])
@@map("observations")
@@ -429,6 +431,7 @@ model Dataset {
id String @id @default(cuid())
name String
description String?
metadata Json?
projectId String @map("project_id")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) @map("created_at")
@@ -446,6 +449,7 @@ model DatasetItem {
status DatasetStatus @default(ACTIVE)
input Json?
expectedOutput Json? @map("expected_output")
metadata Json?
sourceTraceId String? @map("source_trace_id")
sourceTrace Trace? @relation(fields: [sourceTraceId], references: [id], onDelete: SetNull)
sourceObservationId String? @map("source_observation_id")
@@ -566,6 +570,7 @@ model Model {
@@unique([projectId, modelName, startDate, unit])
@@index([projectId, modelName, startDate, unit])
@@index([projectId, modelName])
@@index(modelName)
@@map("models")
}
@@ -680,3 +685,30 @@ model JobExecution {
@@index([projectId])
@@map("job_executions")
}
// Single Sign-On configuration for a domain
// This feature is part of the Enterprise Edition
model SsoConfig {
domain String @id @default(cuid()) // e.g. "google.com"
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
authProvider String @map("auth_provider") // e.g. "okta", ee/sso/types.ts
authConfig Json? @map("auth_config")
// e.g. { "clientId": "1234", "clientSecret": "5678" }, null if credentials from env should be used
// secrets like clientSecret are encrypted on the application level
@@map("sso_configs")
}
model PosthogIntegration {
projectId String @id @map("project_id")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
encryptedPosthogApiKey String @map("encrypted_posthog_api_key")
posthogHostName String @map("posthog_host_name")
lastSyncAt DateTime? @map("last_sync_at")
enabled Boolean
@@index([projectId])
@@map("posthog_integrations")
}
+2
View File
@@ -240,6 +240,7 @@ async function main() {
description:
datasetNumber === 0 ? "Dataset test description" : undefined,
projectId: project2.id,
metadata: datasetNumber === 0 ? { key: "value" } : undefined,
},
});
@@ -268,6 +269,7 @@ async function main() {
Math.random() > 0.3
? "Creating a React component can be done in two ways: as a functional component or as a class component. Let's start with a basic example of both."
: undefined,
metadata: Math.random() > 0.5 ? { key: "value" } : undefined,
},
});
datasetItemIds.push(datasetItem.id);
+58
View File
@@ -0,0 +1,58 @@
import crypto from "crypto";
import { env } from "../env";
const ENCRYPTION_KEY: string = env.ENCRYPTION_KEY || ""; // Must be 256 bits (32 bytes, 64 hex characters)
const IV_LENGTH: number = 16; // For AES, this is always 16
// Alternatively: openssl rand -hex 32
export function keyGen() {
return crypto.randomBytes(32).toString("hex");
}
/**
* Encrypts the given plain text using AES-256-GCM algorithm.
*
* @param {string} plainText - The text to encrypt.
* @param {string} [keyHex=ENCRYPTION_KEY] - The encryption key in hexadecimal format.
* @returns {string} The encrypted data in hex format, including IV and authentication tag.
*/
export function encrypt(
plainText: string,
keyHex: string = ENCRYPTION_KEY
): string {
const iv = crypto.randomBytes(IV_LENGTH); // Directly use Buffer returned by randomBytes
const cipher = crypto.createCipheriv(
"aes-256-gcm",
Buffer.from(keyHex, "hex"),
iv
);
let encrypted = cipher.update(plainText, "utf8", "hex");
encrypted += cipher.final("hex");
const authTag = cipher.getAuthTag();
// Return iv, encrypted data, and authTag as hex, combined in one line
return iv.toString("hex") + ":" + encrypted + ":" + authTag.toString("hex");
}
export function decrypt(text: string, keyHex: string = ENCRYPTION_KEY): string {
const [ivHex, encryptedHex, authTagHex] = text.split(":");
if (!ivHex || !encryptedHex || !authTagHex) {
throw new Error("Invalid or corrupted cipher format");
}
const iv = Buffer.from(ivHex, "hex");
const encryptedText = Buffer.from(encryptedHex, "hex");
const authTag = Buffer.from(authTagHex, "hex");
const decipher = crypto.createDecipheriv(
"aes-256-gcm",
Buffer.from(keyHex, "hex"),
iv
);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encryptedText, undefined, "utf8");
decrypted += decipher.final("utf8");
return decrypted.toString();
}
+13
View File
@@ -0,0 +1,13 @@
import { z } from "zod";
const EnvSchema = z.object({
ENCRYPTION_KEY: z
.string()
.length(
64,
"ENCRYPTION_KEY must be 256 bits, 64 string characters in hex format, generate via: openssl rand -hex 32"
)
.optional(),
});
export const env = EnvSchema.parse(process.env);
+1
View File
@@ -9,6 +9,7 @@ export * from "./filterToPrisma";
export * from "./tracesTable";
export * from "./server/auth";
export * from "./observationsTable";
export { env } from "./env";
// llm api
export * from "./server/llm/types";
+1
View File
@@ -15,6 +15,7 @@ export function getDisplaySecretKey(secretKey: string) {
}
export async function hashSecretKey(key: string) {
// legacy, uses bcrypt, transformed into hashed key upon first use
const hashedKey = await hash(key, 11);
return hashedKey;
}
@@ -4,6 +4,7 @@ import {
HumanMessage,
SystemMessage,
} from "@langchain/core/messages";
import type { BaseCallbackHandler } from "@langchain/core/callbacks/base";
import {
BytesOutputParser,
StringOutputParser,
@@ -25,6 +26,7 @@ type LLMCompletionParams = {
messages: ChatMessage[];
modelParams: ModelParams;
functionCall?: LLMFunctionCall;
callbacks?: BaseCallbackHandler[];
};
type FetchLLMCompletionParams = LLMCompletionParams & {
@@ -34,14 +36,12 @@ type FetchLLMCompletionParams = LLMCompletionParams & {
export async function fetchLLMCompletion(
params: LLMCompletionParams & {
streaming: true;
functionCall: undefined;
}
): Promise<IterableReadableStream<Uint8Array>>;
export async function fetchLLMCompletion(
params: LLMCompletionParams & {
streaming: false;
functionCall: undefined;
}
): Promise<string>;
@@ -55,7 +55,7 @@ export async function fetchLLMCompletion(
export async function fetchLLMCompletion(
params: FetchLLMCompletionParams
): Promise<string | IterableReadableStream<Uint8Array> | unknown> {
const { messages, modelParams, streaming } = params;
const { messages, modelParams, streaming, callbacks } = params;
const finalMessages = messages.map((message) => {
if (message.role === ChatMessageRole.User)
return new HumanMessage(message.content);
@@ -73,6 +73,7 @@ export async function fetchLLMCompletion(
temperature: modelParams.temperature,
maxTokens: modelParams.max_tokens,
topP: modelParams.top_p,
callbacks,
});
} else {
chatModel = new ChatOpenAI({
@@ -81,6 +82,7 @@ export async function fetchLLMCompletion(
temperature: modelParams.temperature,
maxTokens: modelParams.max_tokens,
topP: modelParams.top_p,
callbacks,
});
}
+890 -630
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -3,3 +3,4 @@ packages:
- "web"
- "worker"
- "packages/**"
- "ee"
+2 -1
View File
@@ -5,10 +5,11 @@ FROM node:20-alpine AS alpine
RUN apk update && apk upgrade --no-cache libcrypto3 libssl3 libc6-compat
FROM alpine AS base
RUN npm install pnpm turbo --global
RUN npm install turbo --global
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN corepack prepare pnpm@8.15.5 --activate
FROM base AS pruner
+71 -8
View File
@@ -482,6 +482,24 @@ paths:
items:
type: string
nullable: true
- name: fromTimestamp
in: query
description: >-
Optional filter to only include traces on or after a certain
timestamp
required: false
schema:
type: string
format: date-time
nullable: true
- name: toTimestamp
in: query
description: Optional filter to only include traces before a certain timestamp
required: false
schema:
type: string
format: date-time
nullable: true
responses:
'200':
description: ''
@@ -572,14 +590,16 @@ paths:
parameters:
- name: page
in: query
description: page number, starts at 1
description: Page number, starts at 1.
required: false
schema:
type: integer
nullable: true
- name: limit
in: query
description: limit of items per page
description: >-
Limit of items per page. If you encounter api issues due to too
large page sizes, try to reduce the limit.
required: false
schema:
type: integer
@@ -847,14 +867,16 @@ paths:
parameters:
- name: page
in: query
description: page number, starts at 1
description: Page number, starts at 1.
required: false
schema:
type: integer
nullable: true
- name: limit
in: query
description: limit of items per page
description: >-
Limit of items per page. If you encounter api issues due to too
large page sizes, try to reduce the limit.
required: false
schema:
type: integer
@@ -879,6 +901,27 @@ paths:
type: string
format: date-time
nullable: true
- name: source
in: query
description: Retrieve only scores from a specific source.
required: true
schema:
$ref: '#/components/schemas/ScoreSource'
- name: operator
in: query
description: Retrieve only scores with <operator> value.
required: false
schema:
type: string
nullable: true
- name: value
in: query
description: Retrieve only scores with <operator> value.
required: false
schema:
type: number
format: double
nullable: true
responses:
'200':
description: ''
@@ -1093,21 +1136,23 @@ paths:
security: *ref_0
/api/public/traces:
get:
description: Get list of traces
description: Get list of traces.
operationId: trace_list
tags:
- Trace
parameters:
- name: page
in: query
description: page number, starts at 1
description: Page number, starts at 1
required: false
schema:
type: integer
nullable: true
- name: limit
in: query
description: limit of items per page
description: >-
Limit of items per page. If you encounter api issues due to too
large page sizes, try to reduce the limit.
required: false
schema:
type: integer
@@ -1490,6 +1535,8 @@ components:
description:
type: string
nullable: true
metadata:
nullable: true
projectId:
type: string
createdAt:
@@ -1553,6 +1600,8 @@ components:
nullable: true
expectedOutput:
nullable: true
metadata:
nullable: true
sourceTraceId:
type: string
nullable: true
@@ -1685,6 +1734,7 @@ components:
enum:
- API
- REVIEW
- EVAL
CreateDatasetItemRequest:
title: CreateDatasetItemRequest
type: object
@@ -1695,6 +1745,14 @@ components:
nullable: true
expectedOutput:
nullable: true
metadata:
nullable: true
sourceTraceId:
type: string
nullable: true
sourceObservationId:
type: string
nullable: true
id:
type: string
nullable: true
@@ -1750,6 +1808,8 @@ components:
description:
type: string
nullable: true
metadata:
nullable: true
required:
- name
HealthResponse:
@@ -2337,6 +2397,8 @@ components:
type: string
countTraces:
type: integer
countObservations:
type: integer
totalCost:
type: number
format: double
@@ -2347,6 +2409,7 @@ components:
required:
- date
- countTraces
- countObservations
- totalCost
- usage
UsageByModel:
@@ -2358,6 +2421,7 @@ components:
properties:
model:
type: string
nullable: true
inputUsage:
type: integer
outputUsage:
@@ -2365,7 +2429,6 @@ components:
totalUsage:
type: integer
required:
- model
- inputUsage
- outputUsage
- totalUsage
+14 -13
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "2.26.1",
"version": "2.30.2",
"private": true,
"license": "MIT",
"engines": {
@@ -26,13 +26,14 @@
"@aws-sdk/lib-storage": "^3.550.0",
"@aws-sdk/s3-request-presigner": "^3.554.0",
"@codemirror/lang-json": "^6.0.1",
"@headlessui/react": "^1.7.18",
"@headlessui/react": "^1.7.19",
"@heroicons/react": "^2.1.3",
"@hookform/resolvers": "^3.3.4",
"@langchain/anthropic": "^0.1.14",
"@langchain/core": "^0.1.57",
"@langchain/core": "^0.1.58",
"@langchain/openai": "^0.0.28",
"@langfuse/shared": "workspace:*",
"@langfuse/ee": "workspace:*",
"@marsidev/react-turnstile": "^0.5.4",
"@next-auth/prisma-adapter": "^1.0.7",
"@radix-ui/react-accordion": "^1.1.2",
@@ -59,10 +60,10 @@
"@react-email/render": "^0.0.12",
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@sentry/nextjs": "^7.110.0",
"@sentry/node": "^7.110.0",
"@sentry/profiling-node": "^7.110.0",
"@sentry/types": "^7.110.0",
"@sentry/nextjs": "^7.110.1",
"@sentry/node": "^7.110.1",
"@sentry/profiling-node": "^7.110.1",
"@sentry/types": "^7.110.1",
"@t3-oss/env-nextjs": "^0.8.0",
"@tailwindcss/forms": "^0.5.7",
"@tanstack/react-query": "^4.36.1",
@@ -74,7 +75,7 @@
"@trpc/server": "^10.45.0",
"@uiw/codemirror-theme-github": "^4.21.25",
"@uiw/react-codemirror": "^4.21.25",
"ai": "^3.0.22",
"ai": "^3.0.23",
"bcryptjs": "^2.4.3",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
@@ -97,11 +98,11 @@
"posthog-node": "^3.6.3",
"prisma": "^5.12.1",
"react": "18.2.0",
"react-day-picker": "^8.10.0",
"react-day-picker": "^8.10.1",
"react-dom": "18.2.0",
"react-hook-form": "^7.51.3",
"react-icons": "^5.0.1",
"react-responsive": "^9.0.2",
"react-responsive": "^10.0.0",
"react18-json-view": "^0.2.8-canary.6",
"sonner": "^1.4.41",
"superjson": "2.2.1",
@@ -124,11 +125,11 @@
"@types/lodash": "^4.14.202",
"@types/node": "20.10.5",
"@types/nodemailer": "^6.4.14",
"@types/react": "^18.2.78",
"@types/react": "^18.2.79",
"@types/react-dom": "^18.2.25",
"@types/uuid": "^9.0.8",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^7.6.0",
"@typescript-eslint/parser": "^7.7.0",
"autoprefixer": "^10.4.19",
"dotenv-cli": "^7.3.0",
"eslint": "^8.56.0",
@@ -138,7 +139,7 @@
"node-mocks-http": "^1.14.1",
"postcss": "^8.4.38",
"prettier": "^3.2.5",
"prettier-plugin-tailwindcss": "^0.5.13",
"prettier-plugin-tailwindcss": "^0.5.14",
"tailwindcss": "^3.4.3",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
+5 -5
View File
@@ -10,7 +10,7 @@ test("should redirect to home if signed in", async ({ page }) => {
await page.goto("/auth/sign-in");
await page.fill('input[name="email"]', "demo@langfuse.com");
await page.fill('input[type="password"]', "password");
await page.click('button[type="submit"]');
await page.click('button[data-testid="submit-email-password-sign-in-form"]');
// wait 2 seconds
await page.waitForTimeout(2000);
@@ -32,7 +32,7 @@ test("Successfully sign up & able to go to homepage", async ({ page }) => {
await page.fill('input[name="name"]', "demo lang");
await page.fill('input[name="email"]', randomEmailAddress());
await page.fill('input[type="password"]', "password2");
await page.click('button[type="submit"]');
await page.click('button[data-testid="submit-email-password-sign-up-form"]');
await page.waitForTimeout(2000);
// see get started page
await expect(page).toHaveURL("/?getStarted=1");
@@ -45,7 +45,7 @@ test("Successfully sign up & able to go to homepage with uppercase email", async
await page.fill('input[name="name"]', "demo lang");
await page.fill('input[name="email"]', "A" + randomEmailAddress());
await page.fill('input[type="password"]', "password3");
await page.click('button[type="submit"]');
await page.click('button[data-testid="submit-email-password-sign-up-form"]');
await page.waitForTimeout(2000);
// see get started page
await expect(page).toHaveURL("/?getStarted=1");
@@ -55,13 +55,13 @@ test("Signup input validation", async ({ page }) => {
await page.goto("auth/sign-up");
await page.fill('input[name="email"]', "notanemail");
await page.fill('input[type="password"]', "shortPw");
await page.click('button[type="submit"]');
await page.click('button[data-testid="submit-email-password-sign-up-form"]');
await page.waitForTimeout(2000);
await expect(page.getByText("Invalid email")).toBeVisible();
await expect(
page.getByText("Password must be at least 8 characters long"),
).toBeVisible();
await page.click('button[type="submit"]');
await page.click('button[data-testid="submit-email-password-sign-up-form"]');
await page.waitForTimeout(2000);
// don't see get started page
await expect(page).not.toHaveURL("/?getStarted=1");
+2 -2
View File
@@ -7,7 +7,7 @@ test("should see new projects dialog open after clicking new project btn", async
await page.fill('input[name="name"]', "demo user");
await page.fill('input[name="email"]', randomEmailAddress());
await page.fill('input[type="password"]', "password");
await page.click('button[type="submit"]');
await page.click('button[data-testid="submit-email-password-sign-up-form"]');
await page.waitForTimeout(2000);
await page.waitForTimeout(2000);
expect(await page.innerHTML("data-testid=create-new-project-title")).toBe(
@@ -24,7 +24,7 @@ test("Create a project with provided name", async ({ page }) => {
await page.goto("/auth/sign-in");
await page.fill('input[name="email"]', "demo@langfuse.com");
await page.fill('input[type="password"]', "password");
await page.click('button[type="submit"]');
await page.click('button[data-testid="submit-email-password-sign-in-form"]');
await page.waitForTimeout(2000);
await page.isVisible('[data-testid="new-project-form"]');
await page.click('[data-testid="create-project-btn"]');
+82 -66
View File
@@ -5,63 +5,12 @@ import { makeAPICall, pruneDatabase } from "@/src/__tests__/test-utils";
import { v4 } from "uuid";
describe("/api/public/datasets and /api/public/dataset-items API Endpoints", () => {
beforeEach(async () => await pruneDatabase());
afterEach(async () => await pruneDatabase());
it("should create and get a dataset", async () => {
await makeAPICall("POST", "/api/public/datasets", {
name: "dataset-name",
description: "dataset-description",
});
const dbDataset = await prisma.dataset.findMany({
where: {
name: "dataset-name",
},
});
expect(dbDataset.length).toBeGreaterThan(0);
const getDataset = await makeAPICall(
"GET",
`/api/public/datasets/dataset-name`,
);
expect(getDataset.status).toBe(200);
expect(getDataset.body).toMatchObject({
name: "dataset-name",
description: "dataset-description",
});
});
it("GET datasets", async () => {
await makeAPICall("POST", "/api/public/datasets", {
name: "dataset-name-1",
});
await makeAPICall("POST", "/api/public/datasets", {
name: "dataset-name-2",
});
const datasetItemId = v4();
const createItemRes = await makeAPICall<{
datasetName: string; // field that can break if the API changes as it is not a db column
}>("POST", "/api/public/dataset-items", {
datasetName: "dataset-name-2",
input: { key: "value" },
expectedOutput: { key: "value" },
id: datasetItemId,
});
expect(createItemRes.status).toBe(200);
expect(createItemRes.body).toMatchObject({
datasetName: "dataset-name-2", // not included in db table
});
const traceId = v4();
const observationId = v4();
const traceId = v4();
const observationId = v4();
beforeEach(async () => {
await pruneDatabase();
// create sample trace and observation
const response = await makeAPICall("POST", "/api/public/ingestion", {
batch: [
{
@@ -97,6 +46,62 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
],
});
expect(response.status).toBe(207);
});
afterEach(async () => await pruneDatabase());
it("should create and get a dataset, include special characters", async () => {
await makeAPICall("POST", "/api/public/datasets", {
name: "dataset + name",
description: "dataset-description",
metadata: { foo: "bar" },
});
const dbDataset = await prisma.dataset.findMany({
where: {
name: "dataset + name",
},
});
expect(dbDataset.length).toBeGreaterThan(0);
const getDataset = await makeAPICall(
"GET",
`/api/public/datasets/${encodeURIComponent("dataset + name")}`,
);
expect(getDataset.status).toBe(200);
expect(getDataset.body).toMatchObject({
name: "dataset + name",
description: "dataset-description",
metadata: { foo: "bar" },
});
});
it("GET datasets", async () => {
await makeAPICall("POST", "/api/public/datasets", {
name: "dataset-name-1",
});
await makeAPICall("POST", "/api/public/datasets", {
name: "dataset-name-2",
});
const datasetItemId = v4();
const createItemRes = await makeAPICall<{
datasetName: string; // field that can break if the API changes as it is not a db column
}>("POST", "/api/public/dataset-items", {
datasetName: "dataset-name-2",
input: { key: "value" },
expectedOutput: { key: "value" },
metadata: { key: "value-dataset-item" },
id: datasetItemId,
});
expect(createItemRes.status).toBe(200);
expect(createItemRes.body).toMatchObject({
datasetName: "dataset-name-2", // not included in db table
});
await makeAPICall("POST", "/api/public/dataset-run-items", {
datasetItemId: datasetItemId,
@@ -136,6 +141,9 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
datasetName: "dataset-name",
input: { key: "value" },
expectedOutput: { key: "value" },
metadata: { key: "value-dataset-item" },
sourceTraceId: traceId,
sourceObservationId: observationId,
});
const dbDatasetItem = await prisma.datasetItem.findFirst({
where: {
@@ -159,6 +167,7 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
id: dbDatasetItem!.id,
input: { key: "value" },
expectedOutput: { key: "value" },
metadata: { key: "value-dataset-item" },
datasetName: "dataset-name", // not included in db table
},
],
@@ -173,7 +182,10 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
id: dbDatasetItem!.id,
input: { key: "value" },
expectedOutput: { key: "value" },
metadata: { key: "value-dataset-item" },
datasetName: "dataset-name", // not included in db table
sourceTraceId: traceId,
sourceObservationId: observationId,
});
});
@@ -186,6 +198,7 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
id: "dataset-item-id",
datasetName: "dataset-name",
input: { key: "value" },
metadata: { key: "value-dataset-item" },
});
expect(item1.status).toBe(200);
expect(item1.body).toMatchObject({
@@ -196,6 +209,7 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
id: "dataset-item-id",
datasetName: "dataset-name",
input: { key: "value2" },
metadata: ["hello-world"],
});
expect(item2.status).toBe(200);
expect(item2.body).toMatchObject({
@@ -208,22 +222,23 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
});
expect(dbDatasetItem).not.toBeNull();
expect(dbDatasetItem?.input).toMatchObject({ key: "value2" });
expect(dbDatasetItem?.metadata).toMatchObject(["hello-world"]);
});
it("should create and get a dataset run", async () => {
it("should create and get a dataset run, include special characters", async () => {
const dataset = await makeAPICall<{ id: string }>(
"POST",
"/api/public/datasets",
{
name: "dataset-name",
name: "dataset name",
},
);
expect(dataset.status).toBe(200);
expect(dataset.body).toMatchObject({
name: "dataset-name",
name: "dataset name",
});
await makeAPICall("POST", "/api/public/dataset-items", {
datasetName: "dataset-name",
datasetName: "dataset name",
id: "dataset-item-id",
input: { key: "value" },
expectedOutput: { key: "value" },
@@ -272,14 +287,14 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
{
datasetItemId: "dataset-item-id",
observationId: observationId,
runName: "run-only-observation",
runName: "run + only + observation",
runDescription: "run-description",
metadata: { key: "value" },
},
);
const dbRunObservation = await prisma.datasetRuns.findFirst({
where: {
name: "run-only-observation",
name: "run + only + observation",
},
include: {
datasetRunItems: true,
@@ -298,21 +313,22 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
const getRunAPI = await makeAPICall(
"GET",
`/api/public/datasets/dataset-name/runs/run-only-observation`,
`/api/public/datasets/${encodeURIComponent("dataset name")}/runs/${encodeURIComponent("run + only + observation")}`,
);
expect(getRunAPI.status).toBe(200);
expect(getRunAPI.body).toMatchObject({
name: "run-only-observation",
name: "run + only + observation",
description: "run-description",
metadata: { key: "value" },
datasetId: dataset.body.id,
datasetName: "dataset-name",
datasetName: "dataset name",
datasetRunItems: expect.arrayContaining([
expect.objectContaining({
datasetItemId: "dataset-item-id",
observationId: observationId,
traceId: traceId,
datasetRunName: "run-only-observation",
datasetRunName: "run + only + observation",
}),
]),
});
+43
View File
@@ -56,6 +56,49 @@ describe("/api/public/prompts API Endpoint", () => {
expect(fetchedObservations.body.tags).toEqual([]);
});
it("should fetch a prompt with special character", async () => {
const promptId = uuidv4();
await prisma.prompt.create({
data: {
id: promptId,
name: "prompt + name",
prompt: "prompt",
isActive: true,
version: 1,
config: {
temperature: 0.1,
},
project: {
connect: { id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a" },
},
createdBy: "user-1",
},
});
const fetchedObservations = await makeAPICall(
"GET",
`/api/public/prompts?name=${encodeURIComponent("prompt + name")}&version=1`,
undefined,
);
expect(fetchedObservations.status).toBe(200);
if (!isPrompt(fetchedObservations.body)) {
throw new Error("Expected body to be a prompt");
}
expect(fetchedObservations.body.id).toBe(promptId);
expect(fetchedObservations.body.name).toBe("prompt + name");
expect(fetchedObservations.body.prompt).toBe("prompt");
expect(fetchedObservations.body.type).toBe("text");
expect(fetchedObservations.body.version).toBe(1);
expect(fetchedObservations.body.isActive).toBe(true);
expect(fetchedObservations.body.createdBy).toBe("user-1");
expect(fetchedObservations.body.config).toEqual({ temperature: 0.1 });
expect(fetchedObservations.body.tags).toEqual([]);
});
it("should fetch active prompt only if no prompt version is given", async () => {
const promptId = uuidv4();
+277 -2
View File
@@ -5,8 +5,13 @@ import { makeAPICall, pruneDatabase } from "@/src/__tests__/test-utils";
import { v4 as uuidv4 } from "uuid";
describe("/api/public/scores API Endpoint", () => {
beforeEach(async () => await pruneDatabase());
afterEach(async () => await pruneDatabase());
let should_prune_db = true;
beforeEach(async () => {
if (should_prune_db) await pruneDatabase();
});
afterEach(async () => {
if (should_prune_db) await pruneDatabase();
});
it("should create score for a trace", async () => {
await pruneDatabase();
@@ -316,4 +321,274 @@ describe("/api/public/scores API Endpoint", () => {
observationId: generationId,
});
});
describe("should Filter scores", () => {
const userId = "user-name";
const scoreName = "score-name";
const queryUserName = `userId=${userId}&name=${scoreName}`;
const traceId = uuidv4();
const generationId = uuidv4();
const scoreId_1 = uuidv4();
const scoreId_2 = uuidv4();
const scoreId_3 = uuidv4();
interface GetScoresAPIResponse {
data: [
{
id: string;
name: string;
value: number;
},
];
meta: object;
}
beforeAll(async () => {
should_prune_db = false;
await pruneDatabase();
await makeAPICall("POST", "/api/public/traces", {
id: traceId,
userId: userId,
});
await makeAPICall("POST", "/api/public/generations", {
id: generationId,
});
await makeAPICall("POST", "/api/public/scores", {
id: scoreId_1,
observationId: generationId,
name: scoreName,
value: 10.5,
traceId: traceId,
comment: "comment",
});
await makeAPICall("POST", "/api/public/scores", {
id: scoreId_2,
observationId: generationId,
name: scoreName,
value: 50.5,
traceId: traceId,
comment: "comment",
});
await makeAPICall("POST", "/api/public/scores", {
id: scoreId_3,
observationId: generationId,
name: scoreName,
value: 100.8,
traceId: traceId,
comment: "comment",
});
});
afterAll(async () => {
await pruneDatabase();
});
it("get all scores", async () => {
const getAllScore = await makeAPICall<{
data: [
{
traceId: string;
observationId: string;
},
];
meta: object;
}>("GET", `/api/public/scores?${queryUserName}`);
expect(getAllScore.status).toBe(200);
expect(getAllScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 3,
totalPages: 1,
});
for (const val of getAllScore.body.data) {
expect(val).toMatchObject({
traceId: traceId,
observationId: generationId,
});
}
});
it("test only operator", async () => {
const getScore = await makeAPICall<GetScoresAPIResponse>(
"GET",
`/api/public/scores?${queryUserName}&operator=<`,
);
expect(getScore.status).toBe(200);
expect(getScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 3,
totalPages: 1,
});
});
it("test only value", async () => {
const getScore = await makeAPICall<GetScoresAPIResponse>(
"GET",
`/api/public/scores?${queryUserName}&value=0.8`,
);
expect(getScore.status).toBe(200);
expect(getScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 3,
totalPages: 1,
});
});
it("test operator <", async () => {
const getScore = await makeAPICall<GetScoresAPIResponse>(
"GET",
`/api/public/scores?${queryUserName}&operator=<&value=50`,
);
expect(getScore.status).toBe(200);
expect(getScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 1,
totalPages: 1,
});
expect(getScore.body.data).toMatchObject([
{
id: scoreId_1,
name: scoreName,
value: 10.5,
},
]);
});
it("test operator >", async () => {
const getScore = await makeAPICall<GetScoresAPIResponse>(
"GET",
`/api/public/scores?${queryUserName}&operator=>&value=100`,
);
expect(getScore.status).toBe(200);
expect(getScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 1,
totalPages: 1,
});
expect(getScore.body.data).toMatchObject([
{
id: scoreId_3,
name: scoreName,
value: 100.8,
},
]);
});
it("test operator <=", async () => {
const getScore = await makeAPICall<GetScoresAPIResponse>(
"GET",
`/api/public/scores?${queryUserName}&operator=<=&value=50.5`,
);
expect(getScore.status).toBe(200);
expect(getScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 2,
totalPages: 1,
});
expect(getScore.body.data).toMatchObject([
{
id: scoreId_1,
name: scoreName,
value: 10.5,
},
{
id: scoreId_2,
name: scoreName,
value: 50.5,
},
]);
});
it("test operator >=", async () => {
const getScore = await makeAPICall<GetScoresAPIResponse>(
"GET",
`/api/public/scores?${queryUserName}&operator=>=&value=50.5`,
);
expect(getScore.status).toBe(200);
expect(getScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 2,
totalPages: 1,
});
expect(getScore.body.data).toMatchObject([
{
id: scoreId_2,
name: scoreName,
value: 50.5,
},
{
id: scoreId_3,
name: scoreName,
value: 100.8,
},
]);
});
it("test operator !=", async () => {
const getScore = await makeAPICall<GetScoresAPIResponse>(
"GET",
`/api/public/scores?${queryUserName}&operator=!=&value=50.5`,
);
expect(getScore.status).toBe(200);
expect(getScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 2,
totalPages: 1,
});
expect(getScore.body.data).toMatchObject([
{
id: scoreId_1,
name: scoreName,
value: 10.5,
},
{
id: scoreId_3,
name: scoreName,
value: 100.8,
},
]);
});
it("test operator =", async () => {
const getScore = await makeAPICall<GetScoresAPIResponse>(
"GET",
`/api/public/scores?${queryUserName}&operator==&value=50.5`,
);
expect(getScore.status).toBe(200);
expect(getScore.body.meta).toMatchObject({
page: 1,
limit: 50,
totalItems: 1,
totalPages: 1,
});
expect(getScore.body.data).toMatchObject([
{
id: scoreId_2,
name: scoreName,
value: 50.5,
},
]);
});
it("test invalid operator", async () => {
const getScore = await makeAPICall(
"GET",
`/api/public/scores?${queryUserName}&operator=op&value=50.5`,
);
expect(getScore.status).toBe(400);
expect(getScore.body).toMatchObject({
message: "Invalid request data",
});
});
it("test invalid value", async () => {
const getScore = await makeAPICall(
"GET",
`/api/public/scores?${queryUserName}&operator=<&value=myvalue`,
);
expect(getScore.status).toBe(400);
expect(getScore.body).toMatchObject({
message: "Invalid request data",
});
});
});
});
+9 -3
View File
@@ -1,4 +1,4 @@
import { AlertTriangle } from "lucide-react";
import { AlertTriangle, Check } from "lucide-react";
import { VERSION } from "@/src/constants";
import { env } from "@/src/env.mjs";
@@ -42,10 +42,16 @@ export const LangfuseLogo = ({
"flex items-center gap-2 self-stretch rounded-md px-3 py-2 text-xs ring-1 lg:-mx-2",
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "STAGING"
? "bg-blue-100 text-blue-500 ring-blue-500"
: "bg-red-100 text-red-500 ring-red-500",
: env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "DEV"
? "bg-green-100 text-green-500 ring-green-500"
: "bg-red-100 text-red-500 ring-red-500",
)}
>
<AlertTriangle size={16} />
{env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "DEV" ? (
<Check size={16} />
) : (
<AlertTriangle size={16} />
)}
<span className="whitespace-nowrap">
{["EU", "US"].includes(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION)
? `PROD-${env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION}`
+33
View File
@@ -0,0 +1,33 @@
import * as React from "react";
import { Switch } from "@headlessui/react";
import { cn } from "@/src/utils/tailwind";
export const Slider = (props: {
disabled: boolean; // whether the slider is clickable in the UI
loading?: boolean;
onChecked?: (checked: boolean) => void;
isChecked?: boolean; // whether the slider is active
}) => {
console.log("props", props);
return (
<Switch
checked={props.isChecked}
disabled={props.loading || props.disabled}
onChange={props.onChecked}
className={cn(
props.isChecked ? "bg-black" : "bg-gray-200",
"relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2",
)}
>
<span className="sr-only">Use setting</span>
<span
aria-hidden="true"
className={cn(
props.isChecked ? "translate-x-5" : "translate-x-0",
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out",
)}
/>
</Switch>
);
};
+46
View File
@@ -0,0 +1,46 @@
import { Button } from "@/src/components/ui/button";
import { AlertCircle } from "lucide-react";
import { useSession } from "next-auth/react";
import { useRouter } from "next/router";
import { captureException } from "@sentry/nextjs";
import { useEffect } from "react";
export const ErrorPage = ({
title = "Error",
message,
}: {
title?: string;
message: string;
}) => {
const session = useSession();
const router = useRouter();
return (
<div className="flex h-screen flex-col items-center justify-center">
<AlertCircle className="mb-4 h-12 w-12 text-red-500" />
<h1 className="mb-4 text-xl font-bold">{title}</h1>
<p className="mb-8 text-center">{message}</p>
{session.status === "unauthenticated" ? (
<Button onClick={() => void router.push("/")}>Sign In</Button>
) : null}
</div>
);
};
export const ErrorPageWithSentry = ({
title = "Error",
message,
}: {
title?: string;
message: string;
}) => {
useEffect(() => {
// Capture the error with Sentry
if (window !== undefined)
captureException(
new Error(`ErrorPageWithSentry rendered: ${title}, ${message}`),
);
}, [title, message]); // Empty dependency array means this effect runs once on mount
return <ErrorPage title={title} message={message} />;
};
+21 -3
View File
@@ -5,6 +5,7 @@ import { useSession } from "next-auth/react";
import DocPopup from "@/src/components/layouts/doc-popup";
import { type Status, StatusBadge } from "./status-badge";
import { cn } from "@/src/utils/tailwind";
import { Badge } from "@/src/components/ui/badge";
export default function Header({
level = "h2",
@@ -14,8 +15,10 @@ export default function Header({
breadcrumb?: { name: string; href?: string }[];
status?: Status;
help?: { description: string; href?: string };
featureBetaURL?: string;
actionButtons?: React.ReactNode;
level?: "h2" | "h3";
className?: string;
}) {
const router = useRouter();
const session = useSession();
@@ -40,7 +43,7 @@ export default function Header({
[...props.breadcrumb.map((i) => i.href).filter(Boolean)].pop();
return (
<div className={cn(level === "h2" ? "mb-4" : "mb-2")}>
<div className={cn(level === "h2" ? "mb-4" : "mb-2", props.className)}>
<div>
{backHref ? (
<nav className="sm:hidden" aria-label="Back">
@@ -89,7 +92,7 @@ export default function Header({
</div>
<div className="mt-2 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-3 md:gap-5">
<div className="flex min-w-0 flex-row">
<div className="flex min-w-0 flex-row justify-center align-middle">
{level === "h2" ? (
<h2 className="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight">
{props.title}
@@ -106,10 +109,25 @@ export default function Header({
size="sm"
/>
) : null}
{props.featureBetaURL ? (
<Link
href={props.featureBetaURL}
rel="noopener noreferrer"
target="_blank"
className="flex items-center"
>
<Badge
title="Feature is currently in beta. Click to learn more."
className="ml-2"
>
Beta
</Badge>
</Link>
) : null}
</div>
{props.status && <StatusBadge type={props.status} />}
</div>
<div className="flex items-center gap-3">
<div className="flex flex-wrap items-center gap-3">
{props.actionButtons ?? null}
</div>
</div>
+11 -5
View File
@@ -43,7 +43,11 @@ const userNavigation = [
];
const pathsWithoutNavigation: string[] = ["/onboarding"];
const unauthenticatedPaths: string[] = ["/auth/sign-in", "/auth/sign-up"];
const unauthenticatedPaths: string[] = [
"/auth/sign-in",
"/auth/sign-up",
"/auth/error",
];
const publishablePaths: string[] = [
"/project/[projectId]/sessions/[sessionId]",
"/project/[projectId]/traces/[traceId]",
@@ -457,13 +461,15 @@ export default function Layout(props: PropsWithChildren) {
href={
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "EU"
? "https://langfuse.com/docs/demo"
: "https://docs-staging.langfuse.com/docs/demo"
: "https://docs-staging.langfuse.com/docs/demo" // staging
}
target="_blank"
>
{env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "EU"
? "Use Chat ↗"
: "Use Chat (staging) ↗"}
{
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "EU"
? "Use Chat ↗"
: "Use Chat (staging) ↗" // staging
}
</Link>
</Button>
</div>
+11
View File
@@ -11,6 +11,7 @@ import {
PenSquareIcon,
LibraryBig,
TerminalIcon,
Lightbulb,
} from "lucide-react";
export type Route = {
@@ -56,6 +57,14 @@ export const ROUTES: Route[] = [
name: "Models",
pathname: `/project/[projectId]/models`,
},
],
},
{
name: "Evaluation",
icon: Lightbulb,
cloudOnly: true,
featureFlag: "evals",
children: [
{
name: "Templates",
pathname: `/project/[projectId]/evals/templates`,
@@ -86,6 +95,8 @@ export const ROUTES: Route[] = [
pathname: "/project/[projectId]/playground",
icon: TerminalIcon,
featureFlag: "playground",
cloudOnly: true,
label: "Beta",
},
{
name: "Datasets",
-22
View File
@@ -1,22 +0,0 @@
import { Button } from "@/src/components/ui/button";
import { AlertCircle } from "lucide-react";
import { useSession } from "next-auth/react";
import { useRouter } from "next/router";
export const NoAccessError = () => {
const session = useSession();
const router = useRouter();
return (
<div className="flex h-screen flex-col items-center justify-center">
<AlertCircle className="mb-4 h-12 w-12 text-red-500" />
<h1 className="mb-4 text-xl font-bold">Error</h1>
<p className="mb-8 text-center">
You do not have access to this resource.
</p>
{session.status === "unauthenticated" ? (
<Button onClick={() => void router.push("/")}>Sign In</Button>
) : null}
</div>
);
};
+3 -2
View File
@@ -1,6 +1,6 @@
import { GroupedScoreBadges } from "@/src/components/grouped-score-badge";
import Header from "@/src/components/layouts/header";
import { NoAccessError } from "@/src/components/no-access";
import { ErrorPage } from "@/src/components/error-page";
import { PublishSessionSwitch } from "@/src/components/publish-object-switch";
import { StarSessionToggle } from "@/src/components/star-toggle";
import { IOPreview } from "@/src/components/trace/IOPreview";
@@ -42,7 +42,8 @@ export const SessionPage: React.FC<{
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session.isSuccess, session.data]);
if (session.error?.data?.code === "UNAUTHORIZED") return <NoAccessError />;
if (session.error?.data?.code === "UNAUTHORIZED")
return <ErrorPage message="You do not have access to this session." />;
return (
<div className="flex flex-col overflow-hidden xl:container">
@@ -487,6 +487,7 @@ export default function GenerationsTable({ projectId }: GenerationsTableProps) {
observationId={observationId}
traceId={traceId}
io="input"
singleLine={rowHeight === "s"}
/>
);
},
@@ -505,6 +506,7 @@ export default function GenerationsTable({ projectId }: GenerationsTableProps) {
observationId={observationId}
traceId={traceId}
io="output"
singleLine={rowHeight === "s"}
/>
);
},
@@ -518,7 +520,9 @@ export default function GenerationsTable({ projectId }: GenerationsTableProps) {
const values = row.getValue(
"metadata",
) as GenerationsTableRow["metadata"];
return !!values ? <IOTableCell data={values} /> : null;
return !!values ? (
<IOTableCell data={values} singleLine={rowHeight === "s"} />
) : null;
},
enableHiding: true,
defaultHidden: true,
@@ -673,10 +677,12 @@ const GenerationsIOCell = ({
traceId,
observationId,
io,
singleLine = false,
}: {
traceId: string;
observationId: string;
io: "input" | "output";
singleLine: boolean;
}) => {
const observation = api.observations.byId.useQuery(
{
@@ -700,6 +706,7 @@ const GenerationsIOCell = ({
io === "output" ? observation.data?.output : observation.data?.input
}
className={cn(io === "output" && "bg-green-50")}
singleLine={singleLine}
/>
);
};
@@ -193,7 +193,11 @@ export default function ScoresTable({
enableHiding: true,
cell: ({ row }) => {
const value = row.getValue("comment") as ScoresTableRow["comment"];
return value !== undefined && <IOTableCell data={value} />;
return (
value !== undefined && (
<IOTableCell data={value} singleLine={rowHeight === "s"} />
)
);
},
},
];
+20 -5
View File
@@ -5,7 +5,7 @@ import { TraceTableMultiSelectAction } from "@/src/components/table/data-table-m
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 { TagTracePopver } from "@/src/features/tag/components/TagTracePopver";
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";
@@ -469,7 +469,13 @@ export default function TracesTable({
id: "input",
cell: ({ row }) => {
const traceId: string = row.getValue("id");
return <TracesIOCell traceId={traceId} io="input" />;
return (
<TracesIOCell
traceId={traceId}
io="input"
singleLine={rowHeight === "s"}
/>
);
},
enableHiding: true,
defaultHidden: true,
@@ -480,7 +486,13 @@ export default function TracesTable({
id: "output",
cell: ({ row }) => {
const traceId: string = row.getValue("id");
return <TracesIOCell traceId={traceId} io="output" />;
return (
<TracesIOCell
traceId={traceId}
io="output"
singleLine={rowHeight === "s"}
/>
);
},
enableHiding: true,
defaultHidden: true,
@@ -490,7 +502,7 @@ export default function TracesTable({
header: "Metadata",
cell: ({ row }) => {
const values: string = row.getValue("metadata");
return <IOTableCell data={values} />;
return <IOTableCell data={values} singleLine={rowHeight === "s"} />;
},
enableHiding: true,
},
@@ -540,7 +552,7 @@ export default function TracesTable({
const filterOptionTags = traceFilterOptions.data?.tags ?? [];
const allTags = filterOptionTags.map((t) => t.value);
return (
<TagTracePopver
<TagTracePopover
tags={tags}
availableTags={allTags}
projectId={projectId}
@@ -643,9 +655,11 @@ export default function TracesTable({
const TracesIOCell = ({
traceId,
io,
singleLine = false,
}: {
traceId: string;
io: "input" | "output";
singleLine?: boolean;
}) => {
const trace = api.traces.byId.useQuery(
{ traceId: traceId },
@@ -664,6 +678,7 @@ const TracesIOCell = ({
isLoading={trace.isLoading}
data={io === "output" ? trace.data?.output : trace.data?.input}
className={cn(io === "output" && "bg-green-50")}
singleLine={singleLine}
/>
);
};
@@ -134,6 +134,7 @@ export const ObservationPreview = (props: {
projectId={props.projectId}
input={observationWithInputAndOutput.data.input}
output={observationWithInputAndOutput.data.output}
metadata={preloadedObservation.metadata}
key={preloadedObservation.id}
/>
) : null}
@@ -70,6 +70,7 @@ export const TracePreview = ({
projectId={trace.projectId}
input={trace.input}
output={trace.output}
metadata={trace.metadata}
key={trace.id}
/>
</div>
+3 -2
View File
@@ -14,7 +14,7 @@ import { type ObservationReturnType } from "@/src/server/api/routers/traces";
import { api } from "@/src/utils/api";
import { StarTraceDetailsToggle } from "@/src/components/star-toggle";
import Link from "next/link";
import { NoAccessError } from "@/src/components/no-access";
import { ErrorPage } from "@/src/components/error-page";
import { TagTraceDetailsPopover } from "@/src/features/tag/components/TagTraceDetailsPopover";
import useLocalStorage from "@/src/components/useLocalStorage";
import { Toggle } from "@/src/components/ui/toggle";
@@ -189,7 +189,8 @@ export function TracePage({ traceId }: { traceId: string }) {
const totalCost = calculateDisplayTotalCost(trace.data?.observations ?? []);
if (trace.error?.data?.code === "UNAUTHORIZED") return <NoAccessError />;
if (trace.error?.data?.code === "UNAUTHORIZED")
return <ErrorPage message="You do not have access to this trace." />;
if (!trace.data) return <div>loading...</div>;
return (
<div className="flex flex-col overflow-hidden 2xl:container md:h-[calc(100vh-2rem)]">
+12 -1
View File
@@ -110,18 +110,29 @@ export const IOTableCell = ({
data,
isLoading = false,
className,
singleLine = false,
}: {
data: unknown;
isLoading?: boolean;
className?: string;
singleLine?: boolean;
}) => {
return (
<>
{isLoading ? (
<JsonSkeleton className="h-full w-[400px] overflow-hidden px-2 py-1" />
) : singleLine ? (
<div
className={cn(
"h-full w-[400px] self-stretch overflow-hidden overflow-y-auto truncate rounded-sm border px-2 py-0.5",
className,
)}
>
{stringifyJsonNode(data)}
</div>
) : (
<JSONView
json={data}
json={stringifyJsonNode(data)}
className={cn(
"h-full w-[400px] self-stretch overflow-y-auto rounded-sm ",
className,
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v2.26.1";
export const VERSION = "v2.30.2";
+5
View File
@@ -0,0 +1,5 @@
# Enterprise Edition
This folder includes features that are only available in the Enterprise Edition of Langfuse and on Langfuse Cloud.
See [LICENSE](../../../LICENSE) and [docs](https://langfuse.com/docs/open-source) for more details.
+7
View File
@@ -97,6 +97,9 @@ export const env = createEnv({
NEXT_PUBLIC_DEMO_PROJECT_ID: z.string().optional(),
NEXT_PUBLIC_SIGN_UP_DISABLED: z.enum(["true", "false"]).optional(),
NEXT_PUBLIC_TURNSTILE_SITE_KEY: z.string().optional(),
NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(),
NEXT_PUBLIC_POSTHOG_HOST: z.string().optional(),
NEXT_PUBLIC_CRISP_WEBSITE_ID: z.string().optional(),
},
/**
@@ -173,6 +176,10 @@ export const env = createEnv({
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
TURNSTILE_SECRET_KEY: process.env.TURNSTILE_SECRET_KEY,
NEXT_PUBLIC_TURNSTILE_SITE_KEY: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY,
NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY,
NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST,
// Other
NEXT_PUBLIC_CRISP_WEBSITE_ID: process.env.NEXT_PUBLIC_CRISP_WEBSITE_ID,
},
// Skip validation in Docker builds
// DOCKER_BUILD is set in Dockerfile
+2 -1
View File
@@ -15,7 +15,8 @@ export type AuditableResource =
| "session"
| "apiKey"
| "evalTemplate"
| "job";
| "job"
| "posthogIntegration";
type AuditLog = {
resourceType: AuditableResource;
@@ -12,18 +12,25 @@ const regions =
flag: "🇪🇺",
},
]
: [
{
name: "US",
hostname: "us.cloud.langfuse.com",
flag: "🇺🇸",
},
{
name: "EU",
hostname: "cloud.langfuse.com",
flag: "🇪🇺",
},
];
: env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "DEV"
? [
{
name: "DEV",
flag: "🚧",
},
]
: [
{
name: "US",
hostname: "us.cloud.langfuse.com",
flag: "🇺🇸",
},
{
name: "EU",
hostname: "cloud.langfuse.com",
flag: "🇪🇺",
},
];
export function CloudRegionSwitch({
isSignUpPage,
@@ -66,7 +73,8 @@ export function CloudRegionSwitch({
send_instantly: true,
},
);
window.location.hostname = region.hostname;
if ("hostname" in region)
window.location.hostname = region.hostname;
}}
>
<span className="mr-2 text-xl leading-none">{region.flag}</span>
@@ -11,6 +11,7 @@ import { useState } from "react";
import { DialogTrigger } from "@radix-ui/react-dialog";
import { DatasetForm } from "@/src/features/datasets/components/DatasetForm";
import { useHasAccess } from "@/src/features/rbac/utils/checkAccess";
import { type Prisma } from "@langfuse/shared";
interface BaseDatasetButtonProps {
mode: "create" | "update" | "delete";
@@ -33,6 +34,7 @@ interface UpdateDatasetButtonProps extends BaseDatasetButtonProps {
datasetId: string;
datasetName: string;
datasetDescription?: string;
datasetMetadata?: Prisma.JsonValue;
icon?: boolean;
}
@@ -130,6 +132,7 @@ export const DatasetActionButton = (props: DatasetActionButtonProps) => {
datasetId={props.datasetId}
datasetName={props.datasetName}
datasetDescription={props.datasetDescription}
datasetMetadata={props.datasetMetadata}
/>
)}
</DialogContent>
@@ -14,6 +14,8 @@ import { api } from "@/src/utils/api";
import { useState } from "react";
import { usePostHog } from "posthog-js/react";
import { Input } from "@/src/components/ui/input";
import { JsonEditor } from "@/src/components/json-editor";
import { type Prisma } from "@langfuse/shared";
interface BaseDatasetFormProps {
mode: "create" | "update" | "delete";
@@ -36,6 +38,7 @@ interface UpdateDatasetFormProps extends BaseDatasetFormProps {
datasetId: string;
datasetName: string;
datasetDescription?: string;
datasetMetadata?: Prisma.JsonValue;
}
type DatasetFormProps =
@@ -51,6 +54,21 @@ const formSchema = z.object({
message: "Input should not be only whitespace",
}),
description: z.string(),
metadata: z.string().refine(
(value) => {
if (value === "") return true;
try {
JSON.parse(value);
return true;
} catch (error) {
return false;
}
},
{
message:
"Invalid input. Please provide a JSON object or double-quoted string.",
},
),
});
export const DatasetForm = (props: DatasetFormProps) => {
@@ -63,10 +81,14 @@ export const DatasetForm = (props: DatasetFormProps) => {
? {
name: props.datasetName,
description: props.datasetDescription ?? "",
metadata: props.datasetMetadata
? JSON.stringify(props.datasetMetadata, null, 2)
: "",
}
: {
name: "",
description: "",
metadata: "",
},
});
@@ -173,6 +195,24 @@ export const DatasetForm = (props: DatasetFormProps) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="metadata"
render={({ field }) => (
<FormItem>
<FormLabel>Metadata (optional)</FormLabel>
<FormControl>
<JsonEditor
defaultValue={field.value}
onChange={(v) => {
field.onChange(v);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
<Button
@@ -33,14 +33,17 @@ type RowData = {
createdAt: string;
input: Prisma.JsonValue;
expectedOutput: Prisma.JsonValue;
metadata: Prisma.JsonValue;
};
export function DatasetItemsTable({
projectId,
datasetId,
menuItems,
}: {
projectId: string;
datasetId: string;
menuItems?: React.ReactNode;
}) {
const { setDetailPageList } = useDetailPageLists();
const utils = api.useUtils();
@@ -152,7 +155,9 @@ export function DatasetItemsTable({
enableHiding: true,
cell: ({ row }) => {
const input = row.getValue("input") as RowData["input"];
return !!input ? <IOTableCell data={input} /> : null;
return !!input ? (
<IOTableCell data={input} singleLine={rowHeight === "s"} />
) : null;
},
},
{
@@ -165,7 +170,23 @@ export function DatasetItemsTable({
"expectedOutput",
) as RowData["expectedOutput"];
return !!expectedOutput ? (
<IOTableCell data={expectedOutput} className="bg-green-50" />
<IOTableCell
data={expectedOutput}
className="bg-green-50"
singleLine={rowHeight === "s"}
/>
) : null;
},
},
{
accessorKey: "metadata",
header: "Metadata",
id: "metadata",
enableHiding: true,
cell: ({ row }) => {
const metadata = row.getValue("metadata") as RowData["metadata"];
return !!metadata ? (
<IOTableCell data={metadata} singleLine={rowHeight === "s"} />
) : null;
},
},
@@ -224,6 +245,7 @@ export function DatasetItemsTable({
createdAt: item.createdAt.toLocaleString(),
input: item.input,
expectedOutput: item.expectedOutput,
metadata: item.metadata,
};
};
@@ -240,6 +262,7 @@ export function DatasetItemsTable({
setColumnVisibility={setColumnVisibility}
rowHeight={rowHeight}
setRowHeight={setRowHeight}
actionButtons={menuItems}
/>
<DataTable
columns={columns}
@@ -161,6 +161,7 @@ export function DatasetRunItemsTable(
traceId={trace.traceId}
observationId={trace.observationId}
io="input"
singleLine={rowHeight === "s"}
/>
) : null;
},
@@ -177,6 +178,7 @@ export function DatasetRunItemsTable(
traceId={trace.traceId}
observationId={trace.observationId}
io="output"
singleLine={rowHeight === "s"}
/>
) : null;
},
@@ -194,6 +196,7 @@ export function DatasetRunItemsTable(
datasetId={props.datasetId}
datasetItemId={datasetItemId}
io="expectedOutput"
singleLine={rowHeight === "s"}
/>
) : null;
},
@@ -271,10 +274,12 @@ const TraceObservationIOCell = ({
traceId,
observationId,
io,
singleLine = false,
}: {
traceId: string;
observationId?: string;
io: "input" | "output";
singleLine?: boolean;
}) => {
// conditionally fetch the trace or observation depending on the presence of observationId
const trace = api.traces.byId.useQuery(
@@ -312,6 +317,7 @@ const TraceObservationIOCell = ({
isLoading={!!!observationId ? trace.isLoading : observation.isLoading}
data={io === "output" ? data?.output : data?.input}
className={cn(io === "output" && "bg-green-50")}
singleLine={singleLine}
/>
);
};
@@ -321,11 +327,13 @@ const DatasetItemIOCell = ({
datasetId,
datasetItemId,
io,
singleLine = false,
}: {
projectId: string;
datasetId: string;
datasetItemId: string;
io: "expectedOutput" | "input";
singleLine?: boolean;
}) => {
const datasetItem = api.datasets.itemById.useQuery(
{
@@ -351,6 +359,7 @@ const DatasetItemIOCell = ({
? datasetItem.data?.expectedOutput
: datasetItem.data?.input
}
singleLine={singleLine}
/>
);
};
@@ -33,6 +33,7 @@ type RowData = {
export function DatasetRunsTable(props: {
projectId: string;
datasetId: string;
menuItems?: React.ReactNode;
}) {
const [paginationState, setPaginationState] = useQueryParams({
pageIndex: withDefault(NumberParam, 0),
@@ -138,7 +139,9 @@ export function DatasetRunsTable(props: {
enableHiding: true,
cell: ({ row }) => {
const metadata: RowData["metadata"] = row.getValue("metadata");
return !!metadata ? <IOTableCell data={metadata} /> : null;
return !!metadata ? (
<IOTableCell data={metadata} singleLine={rowHeight === "s"} />
) : null;
},
},
];
@@ -171,6 +174,7 @@ export function DatasetRunsTable(props: {
setColumnVisibility={setColumnVisibility}
rowHeight={rowHeight}
setRowHeight={setRowHeight}
actionButtons={props.menuItems}
/>
<DataTable
columns={columns}
@@ -17,6 +17,9 @@ import { MoreVertical } from "lucide-react";
import { useEffect } from "react";
import useColumnVisibility from "@/src/features/column-visibility/hooks/useColumnVisibility";
import { DataTableToolbar } from "@/src/components/table/data-table-toolbar";
import { type Prisma } from "@langfuse/shared";
import { IOTableCell } from "@/src/components/ui/CodeJsonViewer";
import { useRowHeightLocalStorage } from "@/src/components/table/data-table-row-height-switch";
type RowData = {
key: {
@@ -28,11 +31,14 @@ type RowData = {
lastRunAt?: string;
countItems: number;
countRuns: number;
metadata: Prisma.JsonValue;
};
export function DatasetsTable(props: { projectId: string }) {
const { setDetailPageList } = useDetailPageLists();
const [rowHeight, setRowHeight] = useRowHeightLocalStorage("datasets", "s");
const [paginationState, setPaginationState] = useQueryParams({
pageIndex: withDefault(NumberParam, 0),
pageSize: withDefault(NumberParam, 50),
@@ -100,6 +106,18 @@ export function DatasetsTable(props: { projectId: string }) {
id: "lastRunAt",
enableHiding: true,
},
{
accessorKey: "metadata",
header: "Metadata",
id: "metadata",
enableHiding: true,
cell: ({ row }) => {
const metadata: RowData["metadata"] = row.getValue("metadata");
return !!metadata ? (
<IOTableCell data={metadata} singleLine={rowHeight === "s"} />
) : null;
},
},
{
id: "actions",
accessorKey: "actions",
@@ -145,6 +163,7 @@ export function DatasetsTable(props: { projectId: string }) {
lastRunAt: item.lastRunAt?.toLocaleString() ?? "",
countItems: item.countDatasetItems,
countRuns: item.countDatasetRuns,
metadata: item.metadata,
};
};
@@ -162,6 +181,8 @@ export function DatasetsTable(props: { projectId: string }) {
actionButtons={
<DatasetActionButton projectId={props.projectId} mode="create" />
}
rowHeight={rowHeight}
setRowHeight={setRowHeight}
/>
<DataTable
columns={columns}
@@ -189,6 +210,7 @@ export function DatasetsTable(props: { projectId: string }) {
}}
columnVisibility={columnVisibility}
onColumnVisibilityChange={setColumnVisibility}
rowHeight={rowHeight}
/>
</div>
);
@@ -47,6 +47,21 @@ const formSchema = z.object({
"Invalid input. Please provide a JSON object or double-quoted string.",
},
),
metadata: z.string().refine(
(value) => {
if (value === "") return true;
try {
JSON.parse(value);
return true;
} catch (error) {
return false;
}
},
{
message:
"Invalid input. Please provide a JSON object or double-quoted string.",
},
),
});
export const EditDatasetItem = ({
@@ -75,6 +90,12 @@ export const EditDatasetItem = ({
? JSON.stringify(datasetItem.expectedOutput, null, 2)
: "",
);
form.setValue(
"metadata",
datasetItem?.metadata
? JSON.stringify(datasetItem.metadata, null, 2)
: "",
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [datasetItem]);
@@ -83,6 +104,7 @@ export const EditDatasetItem = ({
defaultValues: {
input: "",
expectedOutput: "",
metadata: "",
},
});
@@ -99,6 +121,7 @@ export const EditDatasetItem = ({
datasetItemId: datasetItem.id,
input: values.input,
expectedOutput: values.expectedOutput,
metadata: values.metadata,
});
setHasChanges(false);
}
@@ -154,6 +177,26 @@ export const EditDatasetItem = ({
)}
/>
</div>
<FormField
control={form.control}
name="metadata"
render={({ field }) => (
<FormItem>
<FormLabel>Metadata</FormLabel>
<FormControl>
<JsonEditor
defaultValue={field.value}
onChange={(v) => {
setHasChanges(true);
field.onChange(v);
}}
editable={hasAccess}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button
type="submit"
@@ -37,10 +37,8 @@ export const NewDatasetItemButton = (props: {
New item
</Button>
</DialogTrigger>
<DialogContent className="h-[calc(100vh-5rem)] max-h-none w-[calc(100vw-5rem)] max-w-none">
<DialogHeader>
<DialogTitle>Create new dataset item</DialogTitle>
</DialogHeader>
<DialogContent className="h-[calc(100vh-5rem)] max-h-none w-[calc(100vw-5rem)] max-w-none items-start">
<DialogHeader>Create new dataset item</DialogHeader>
<NewDatasetItemForm
projectId={props.projectId}
datasetId={props.datasetId}
@@ -21,7 +21,7 @@ import { api } from "@/src/utils/api";
import { useState } from "react";
import { usePostHog } from "posthog-js/react";
import { JsonEditor } from "@/src/components/json-editor";
import { type Prisma } from "@langfuse/shared/src/db";
import { type Prisma } from "@langfuse/shared";
import { cn } from "@/src/utils/tailwind";
const formSchema = z.object({
@@ -56,6 +56,21 @@ const formSchema = z.object({
"Invalid input. Please provide a JSON object or double-quoted string.",
},
),
metadata: z.string().refine(
(value) => {
if (value === "") return true;
try {
JSON.parse(value);
return true;
} catch (error) {
return false;
}
},
{
message:
"Invalid input. Please provide a JSON object or double-quoted string.",
},
),
});
export const NewDatasetItemForm = (props: {
@@ -64,6 +79,7 @@ export const NewDatasetItemForm = (props: {
observationId?: string;
input?: Prisma.JsonValue;
output?: Prisma.JsonValue;
metadata?: Prisma.JsonValue;
datasetId?: string;
className?: string;
onFormSuccess?: () => void;
@@ -76,6 +92,7 @@ export const NewDatasetItemForm = (props: {
datasetId: props.datasetId ?? "",
input: props.input ? JSON.stringify(props.input, null, 2) : "",
expectedOutput: props.output ? JSON.stringify(props.output, null, 2) : "",
metadata: props.metadata ? JSON.stringify(props.metadata, null, 2) : "",
},
});
@@ -141,7 +158,7 @@ export const NewDatasetItemForm = (props: {
</FormItem>
)}
/>
<div className="grid flex-1 content-stretch gap-4 md:grid-cols-2">
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="input"
@@ -175,10 +192,26 @@ export const NewDatasetItemForm = (props: {
)}
/>
</div>
<FormField
control={form.control}
name="metadata"
render={({ field }) => (
<FormItem className="flex flex-col gap-2">
<FormLabel>Metadata</FormLabel>
<FormControl>
<JsonEditor
defaultValue={field.value}
onChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
loading={createDatasetItemMutation.isLoading}
className="w-full"
className="mt-auto w-full"
>
Add to dataset
</Button>
@@ -18,7 +18,7 @@ import {
} from "@/src/components/ui/dropdown-menu";
import Link from "next/link";
import { NewDatasetItemForm } from "@/src/features/datasets/components/NewDatasetItemForm";
import { type Prisma } from "@langfuse/shared/src/db";
import { type Prisma } from "@langfuse/shared";
import { useHasAccess } from "@/src/features/rbac/utils/checkAccess";
import { useSession } from "next-auth/react";
@@ -28,6 +28,7 @@ export const NewDatasetItemFromTrace = (props: {
observationId?: string;
input: Prisma.JsonValue;
output: Prisma.JsonValue;
metadata: Prisma.JsonValue;
}) => {
const [open, setOpen] = useState(false);
const session = useSession();
@@ -111,6 +112,7 @@ export const NewDatasetItemFromTrace = (props: {
projectId={props.projectId}
input={props.input}
output={props.output}
metadata={props.metadata}
onFormSuccess={() => setOpen(false)}
className="h-full overflow-y-auto"
/>
@@ -43,6 +43,7 @@ export const datasetRouter = createTRPCRouter({
"datasets.id",
"datasets.name",
"datasets.description",
"datasets.metadata",
"datasets.created_at as createdAt",
"datasets.updated_at as updatedAt",
eb.fn.count("dataset_items.id").distinct().as("countDatasetItems"),
@@ -295,6 +296,7 @@ export const datasetRouter = createTRPCRouter({
datasetItemId: z.string(),
input: z.string().optional(),
expectedOutput: z.string().optional(),
metadata: z.string().optional(),
sourceTraceId: z.string().optional(),
sourceObservationId: z.string().optional(),
status: z.enum(["ACTIVE", "ARCHIVED"]).optional(),
@@ -327,6 +329,12 @@ export const datasetRouter = createTRPCRouter({
: input.expectedOutput !== undefined
? (JSON.parse(input.expectedOutput) as Prisma.InputJsonObject)
: undefined,
metadata:
input.metadata === ""
? Prisma.DbNull
: input.metadata !== undefined
? (JSON.parse(input.metadata) as Prisma.InputJsonObject)
: undefined,
sourceTraceId: input.sourceTraceId,
sourceObservationId: input.sourceObservationId,
status: input.status,
@@ -348,6 +356,7 @@ export const datasetRouter = createTRPCRouter({
projectId: z.string(),
name: z.string(),
description: z.string().nullish(),
metadata: z.string().nullish(),
}),
)
.mutation(async ({ input, ctx }) => {
@@ -361,6 +370,12 @@ export const datasetRouter = createTRPCRouter({
name: input.name,
description: input.description ?? undefined,
projectId: input.projectId,
metadata:
input.metadata === ""
? Prisma.DbNull
: !!input.metadata
? (JSON.parse(input.metadata) as Prisma.InputJsonObject)
: undefined,
},
});
@@ -382,6 +397,7 @@ export const datasetRouter = createTRPCRouter({
datasetId: z.string(),
name: z.string().nullish(),
description: z.string().nullish(),
metadata: z.string().nullish(),
}),
)
.mutation(async ({ input, ctx }) => {
@@ -398,6 +414,12 @@ export const datasetRouter = createTRPCRouter({
data: {
name: input.name ?? undefined,
description: input.description,
metadata:
input.metadata === ""
? Prisma.DbNull
: !!input.metadata
? (JSON.parse(input.metadata) as Prisma.InputJsonObject)
: undefined,
},
});
await auditLog({
@@ -442,6 +464,7 @@ export const datasetRouter = createTRPCRouter({
datasetId: z.string(),
input: z.string().nullish(),
expectedOutput: z.string().nullish(),
metadata: z.string().nullish(),
sourceTraceId: z.string().optional(),
sourceObservationId: z.string().optional(),
}),
@@ -476,6 +499,12 @@ export const datasetRouter = createTRPCRouter({
: !!input.expectedOutput
? (JSON.parse(input.expectedOutput) as Prisma.InputJsonObject)
: undefined,
metadata:
input.metadata === ""
? Prisma.DbNull
: !!input.metadata
? (JSON.parse(input.metadata) as Prisma.InputJsonObject)
: undefined,
datasetId: input.datasetId,
sourceTraceId: input.sourceTraceId,
sourceObservationId: input.sourceObservationId,
@@ -1,17 +1,17 @@
import * as React from "react";
import Header from "@/src/components/layouts/header";
import { RouterOutputs, api } from "@/src/utils/api";
import { type RouterOutputs, api } from "@/src/utils/api";
import { useRouter } from "next/router";
import { EvalConfigForm } from "@/src/features/evals/components/eval-config-form";
import { useHasAccess } from "@/src/features/rbac/utils/checkAccess";
import { Button } from "@/src/components/ui/button";
import { Pencil } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/src/components/ui/popover";
import { useState } from "react";
import { Trash2 } from "lucide-react";
export const EvalConfigDetail = () => {
const router = useRouter();
@@ -95,11 +95,16 @@ export function DeactivateConfig({
});
const onClick = () => {
if (!projectId) {
console.error("Project ID is missing");
return;
}
mutEvalConfig.mutateAsync({
projectId,
evalConfigId: config?.id ?? "",
updatedStatus: "INACTIVE",
});
setIsOpen(false);
};
return (
@@ -108,11 +113,10 @@ export function DeactivateConfig({
<Button
variant="ghost"
size={"sm"}
onClick={() => void onClick()}
disabled={!hasAccess || config?.status !== "ACTIVE"}
loading={isLoading}
>
<Pencil className="h-5 w-5" />
<Trash2 className="h-5 w-5" />
</Button>
</PopoverTrigger>
<PopoverContent>
@@ -126,14 +130,7 @@ export function DeactivateConfig({
type="button"
variant="destructive"
loading={mutEvalConfig.isLoading}
onClick={() => {
if (!projectId) {
console.error("Project ID is missing");
return;
}
void onClick();
setIsOpen(false);
}}
onClick={onClick}
>
Deactivate Eval Job
</Button>
@@ -210,7 +210,7 @@ export const EvalConfigForm = (props: {
: undefined
}
>
<SelectValue placeholder="Select a model to run this eval template" />
<SelectValue placeholder="Select a template to run this eval config" />
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -52,7 +52,7 @@ export const DetailPageNav = (props: {
if (ids.length > 1)
return (
<div>
<div className="flex flex-row gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -69,7 +69,6 @@ export const DetailPageNav = (props: {
);
}
}}
className="mr-2"
>
<ChevronUp className="h-4 w-4" />
</Button>
@@ -268,7 +268,9 @@ async function* getChatCompletionStream(
});
if (!result.ok) {
throw new Error("Failed to fetch data: " + result.statusText);
const errorData = await result.json();
throw new Error(`Completion failed: ${errorData.message}`);
}
const reader = result.body?.getReader();
@@ -0,0 +1,67 @@
import type { ValidatedChatCompletionBody } from "@/src/features/playground/server/validateChatCompletionBody";
import { ServerPosthog } from "@/src/server/services/posthog";
import type { LLMResult } from "@langchain/core/outputs";
import { BaseCallbackHandler } from "@langchain/core/callbacks/base";
import type { ChatMessage, ModelParams } from "@langfuse/shared";
export class PosthogCallbackHandler extends BaseCallbackHandler {
public name = "PosthogCallbackHandler";
private messages: ChatMessage[];
private modelParams: ModelParams;
private posthog: ServerPosthog;
constructor(
public eventPrefix: string,
public body: ValidatedChatCompletionBody,
private userId: string,
) {
super();
this.posthog = new ServerPosthog();
this.messages = body.messages;
this.modelParams = body.modelParams;
}
async handleLLMEnd(output: LLMResult) {
const outputString = output.generations[0][0].text;
const properties = this.getEventProperties(outputString);
this.captureEvent(properties);
await this.posthog.flushAsync();
}
private getInputLength() {
return this.messages.reduce(
(acc, message) => acc + message.content.length,
0,
);
}
private getEventProperties(output: string): ChatCompletionEventProperties {
return {
outputLength: output.length,
inputLength: this.getInputLength(),
modelProvider: this.modelParams.provider,
modelName: this.modelParams.model,
};
}
private captureEvent(properties: ChatCompletionEventProperties) {
this.posthog.capture({
event: this.eventPrefix + "_chat_completion",
distinctId: this.userId,
properties,
});
}
public async flushAsync() {
await this.posthog.flushAsync();
}
}
type ChatCompletionEventProperties = {
outputLength: number;
inputLength: number;
modelProvider: string;
modelName: string;
};
@@ -4,19 +4,23 @@ import { type NextRequest, NextResponse } from "next/server";
import { fetchLLMCompletion } from "@langfuse/shared";
import { PosthogCallbackHandler } from "./analytics/posthogCallback";
import {
validateChatCompletionBody,
type ValidatedChatCompletionBody,
} from "./validateChatCompletionBody";
import { getCookieName } from "@/src/server/utils/cookies";
import { env } from "@/src/env.mjs";
export default async function chatCompletionHandler(req: NextRequest) {
const token = await getToken({
req,
cookieName: getCookieName("next-auth.session-token"),
secret: env.NEXTAUTH_SECRET,
});
if (!token)
if (!token || !token.sub)
// sub is the user id
return NextResponse.json({ message: "Unauthenticated" }, { status: 401 });
if (req.method !== "POST")
@@ -30,6 +34,8 @@ export default async function chatCompletionHandler(req: NextRequest) {
try {
body = validateChatCompletionBody(await req.json());
} catch (err) {
console.error(err);
return NextResponse.json(
{
message: "Invalid request body",
@@ -39,13 +45,29 @@ export default async function chatCompletionHandler(req: NextRequest) {
);
}
const { messages, modelParams } = body;
const stream = await fetchLLMCompletion({
messages,
modelParams,
streaming: true,
functionCall: undefined,
});
try {
const { messages, modelParams } = body;
const stream = await fetchLLMCompletion({
messages,
modelParams,
streaming: true,
callbacks: [new PosthogCallbackHandler("playground", body, token.sub)],
});
return new StreamingTextResponse(stream);
return new StreamingTextResponse(stream);
} catch (err) {
console.error(err);
if (err instanceof Error) {
return NextResponse.json(
{
message: err.message,
error: err,
},
{ status: 500 },
);
}
throw err;
}
}
@@ -0,0 +1,117 @@
import { z } from "zod";
import { auditLog } from "@/src/features/audit-logs/auditLog";
import { throwIfNoAccess } from "@/src/features/rbac/utils/checkAccess";
import {
createTRPCRouter,
protectedProjectProcedure,
} from "@/src/server/api/trpc";
import { decrypt, encrypt } from "@langfuse/shared/encryption";
import { posthogIntegrationFormSchema } from "@/src/features/posthog-integration/types";
import { TRPCError } from "@trpc/server";
export const posthogIntegrationRouter = createTRPCRouter({
get: protectedProjectProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ input, ctx }) => {
throwIfNoAccess({
session: ctx.session,
projectId: input.projectId,
scope: "integrations:CRUD",
});
try {
const dbConfig = await ctx.prisma.posthogIntegration.findFirst({
where: {
projectId: input.projectId,
},
});
if (!dbConfig) {
return null;
}
const { encryptedPosthogApiKey, ...config } = dbConfig;
return {
...config,
posthogApiKey: decrypt(encryptedPosthogApiKey),
};
} catch (e) {
console.error("posthog integration get", e);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
});
}
}),
update: protectedProjectProcedure
.input(posthogIntegrationFormSchema.extend({ projectId: z.string() }))
.mutation(async ({ input, ctx }) => {
try {
throwIfNoAccess({
session: ctx.session,
projectId: input.projectId,
scope: "integrations:CRUD",
});
await auditLog({
session: ctx.session,
action: "update",
resourceType: "posthogIntegration",
resourceId: input.projectId,
});
const { posthogProjectApiKey, ...config } = input;
const encryptedPosthogApiKey = encrypt(posthogProjectApiKey);
await ctx.prisma.posthogIntegration.upsert({
where: {
projectId: input.projectId,
},
create: {
projectId: input.projectId,
posthogHostName: config.posthogHostname,
encryptedPosthogApiKey,
enabled: config.enabled,
},
update: {
encryptedPosthogApiKey,
posthogHostName: config.posthogHostname,
enabled: config.enabled,
},
});
} catch (e) {
console.log("posthog integration update", e);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
});
}
}),
delete: protectedProjectProcedure
.input(z.object({ projectId: z.string() }))
.mutation(async ({ input, ctx }) => {
try {
throwIfNoAccess({
session: ctx.session,
projectId: input.projectId,
scope: "integrations:CRUD",
});
await auditLog({
session: ctx.session,
action: "delete",
resourceType: "posthogIntegration",
resourceId: input.projectId,
});
await ctx.prisma.posthogIntegration.delete({
where: {
projectId: input.projectId,
},
});
} catch (e) {
console.log("posthog integration delete", e);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
});
}
}),
});
@@ -0,0 +1,9 @@
import { z } from "zod";
export const posthogIntegrationFormSchema = z.object({
posthogHostname: z.string().url(),
posthogProjectApiKey: z.string().refine((v) => v.startsWith("phc_"), {
message: "PostHog Project API Key must start with 'phc_'",
}),
enabled: z.boolean(),
});
@@ -327,7 +327,7 @@ export const NewPromptForm: React.FC<NewPromptFormProps> = (props) => {
!initialPrompt && form.formState.errors.name?.message,
)} // Disable button if prompt name already exists. Check is dynamic and not part of zod schema
>
Create prompt
{!initialPrompt ? "Create prompt" : "Update prompt"}
</Button>
</form>
{formError && (
@@ -85,6 +85,11 @@ export const PromptDetail = () => {
<div className="col-span-3">
<Header
title={prompt.name}
help={{
description:
"You can use this prompt within your application through the Langfuse SDKs and integrations. Refer to the documentation for more information.",
href: "https://langfuse.com/docs/prompts",
}}
breadcrumb={[
{
name: "Prompts",
@@ -50,7 +50,7 @@ export const CreatePromptTRPCSchema = z.union([
export type CreatePromptTRPCType = z.infer<typeof CreatePromptTRPCSchema>;
export const GetPromptSchema = z.object({
name: z.string(),
name: z.string().transform((v) => decodeURIComponent(v)),
version: z.coerce.number().int().nullish(),
});
@@ -20,6 +20,7 @@ const scopes = [
"project:delete",
"project:update",
"project:transfer",
"integrations:CRUD",
"datasets:CUD",
@@ -45,6 +46,7 @@ export const roleAccessRights: Record<MembershipRole, Scope[]> = {
"apiKeys:read",
"apiKeys:create",
"apiKeys:delete",
"integrations:CRUD",
"objects:publish",
"objects:bookmark",
"objects:tag",
@@ -70,6 +72,7 @@ export const roleAccessRights: Record<MembershipRole, Scope[]> = {
"apiKeys:read",
"apiKeys:create",
"apiKeys:delete",
"integrations:CRUD",
"objects:publish",
"objects:bookmark",
"objects:tag",
+3 -2
View File
@@ -2,11 +2,12 @@
import { useEffect } from "react";
import { Crisp } from "crisp-sdk-web";
import { env } from "@/src/env.mjs";
const CrispChat = () => {
useEffect(() => {
if (process.env.NEXT_PUBLIC_CRISP_WEBSITE_ID)
Crisp.configure(process.env.NEXT_PUBLIC_CRISP_WEBSITE_ID);
if (env.NEXT_PUBLIC_CRISP_WEBSITE_ID)
Crisp.configure(env.NEXT_PUBLIC_CRISP_WEBSITE_ID);
});
return null;
@@ -4,7 +4,7 @@ import { useHasAccess } from "@/src/features/rbac/utils/checkAccess";
import { type RouterOutput, type RouterInput } from "@/src/utils/types";
import TagManager from "@/src/features/tag/components/TagMananger";
type TagTracePopverProps = {
type TagTracePopoverProps = {
tags: string[];
availableTags: string[];
projectId: string;
@@ -12,13 +12,13 @@ type TagTracePopverProps = {
tracesFilter: RouterInput["traces"]["all"];
};
export function TagTracePopver({
export function TagTracePopover({
tags,
availableTags,
projectId,
traceId,
tracesFilter,
}: TagTracePopverProps) {
}: TagTracePopoverProps) {
const [isLoading, setIsLoading] = useState(false);
const hasAccess = useHasAccess({ projectId, scope: "objects:tag" });
+2 -9
View File
@@ -1,11 +1,8 @@
import { VERSION } from "@/src/constants";
import { ServerPosthog } from "@/src/server/services/posthog";
import { Prisma, prisma } from "@langfuse/shared/src/db";
import { PostHog } from "posthog-node";
import { v4 as uuidv4 } from "uuid";
// Safe as it is intended to be public
const POSTHOG_API_KEY = "phc_zkMwFajk8ehObUlMth0D7DtPItFnxETi3lmSvyQDrwB";
// Interval between jobs in milliseconds
const JOB_INTERVAL_MINUTES = Prisma.raw("60");
@@ -147,11 +144,7 @@ async function posthogTelemetry({
clientId: string;
}) {
try {
const posthog = new PostHog(POSTHOG_API_KEY, {
host: "https://eu.posthog.com",
});
if (process.env.NODE_ENV === "development") posthog.debug();
const posthog = new ServerPosthog();
// Count projects
const totalProjects = await prisma.project.count();
+5 -13
View File
@@ -29,6 +29,7 @@ import "core-js/features/array/to-sorted";
// Other CSS
import "react18-json-view/src/style.css";
import { DetailPageListsProvider } from "@/src/features/navigate-detail-pages/context";
import { env } from "@/src/env.mjs";
const setProjectInPosthog = () => {
// project
@@ -66,10 +67,7 @@ const MyApp: AppType<{ session: Session | null }> = ({
useEffect(() => {
// PostHog (cloud.langfuse.com)
if (
process.env.NEXT_PUBLIC_POSTHOG_KEY &&
process.env.NEXT_PUBLIC_POSTHOG_HOST
) {
if (env.NEXT_PUBLIC_POSTHOG_KEY && env.NEXT_PUBLIC_POSTHOG_HOST) {
const handleRouteChange = () => {
setProjectInPosthog();
posthog.capture("$pageview");
@@ -110,17 +108,14 @@ function UserTracking() {
useEffect(() => {
if (session.status === "authenticated") {
// PostHog
if (
process.env.NEXT_PUBLIC_POSTHOG_KEY &&
process.env.NEXT_PUBLIC_POSTHOG_HOST
)
if (env.NEXT_PUBLIC_POSTHOG_KEY && env.NEXT_PUBLIC_POSTHOG_HOST)
posthog.identify(session.data.user?.id ?? undefined, {
environment: process.env.NODE_ENV,
email: session.data.user?.email ?? undefined,
name: session.data.user?.name ?? undefined,
featureFlags: session.data.user?.featureFlags ?? undefined,
projects: session.data.user?.projects ?? undefined,
LANGFUSE_CLOUD_REGION: process.env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION,
LANGFUSE_CLOUD_REGION: env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION,
});
const emailDomain = session.data.user?.email?.split("@")[1];
if (emailDomain)
@@ -149,10 +144,7 @@ function UserTracking() {
});
} else {
// PostHog
if (
process.env.NEXT_PUBLIC_POSTHOG_KEY &&
process.env.NEXT_PUBLIC_POSTHOG_HOST
) {
if (env.NEXT_PUBLIC_POSTHOG_KEY && env.NEXT_PUBLIC_POSTHOG_HOST) {
posthog.reset();
posthog.resetGroups();
}
+7 -2
View File
@@ -1,4 +1,9 @@
import { getAuthOptions } from "@/src/server/auth";
import type { NextApiRequest, NextApiResponse } from "next";
import NextAuth from "next-auth";
import { authOptions } from "@/src/server/auth";
export default NextAuth(authOptions);
export default async function auth(req: NextApiRequest, res: NextApiResponse) {
// Do whatever you want here, before the request is passed down to `NextAuth`
const authOptions = await getAuthOptions();
return await NextAuth(req, res, authOptions);
}
+9
View File
@@ -0,0 +1,9 @@
/**
* This endpoint is used to add a new SSO configuration to the database.
*
* This is an EE feature and will return a 404 response if EE is not available.
*/
import { createNewSsoConfigHandler } from "@langfuse/ee/sso";
export default createNewSsoConfigHandler;
+34
View File
@@ -0,0 +1,34 @@
/**
* This API endpoint checks if a custom SSO provider is configured for a given domain.
*
* If no custom SSO provider is configured or EE is not available, this API will return a 404 response.
*/
import { getSsoAuthProviderIdForDomain } from "@langfuse/ee/sso";
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
const requestSchema = z.object({
domain: z.string().min(1),
});
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method !== "POST")
return res.status(405).json({ message: "Method not allowed" });
const validBody = requestSchema.safeParse(req.body);
if (!validBody.success) {
return res.status(400).json({ message: "Invalid request body" });
}
const providerId = await getSsoAuthProviderIdForDomain(validBody.data.domain);
if (!providerId) {
return res.status(404).json({ message: "No SSO provider configured" });
}
return res.status(200).json({ providerId });
}

Some files were not shown because too many files have changed in this diff Show More