Compare commits

...
103 Commits
Author SHA1 Message Date
Marc Klingen 4677cb10a9 chore: release v2.75.2
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (node20, pg12) (push) Waiting to run
CI/CD / tests-web (node20, pg15) (push) Waiting to run
CI/CD / tests-worker (node20, pg12) (push) Waiting to run
CI/CD / tests-worker (node20, pg15) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / e2e-server-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-08-30 20:38:04 +02:00
Marc KlingenandGitHub 7ed03a0946 fix(playground): allow for creation of non-openai llm api keys (#3162) 2024-08-30 20:36:55 +02:00
Marc Klingen bbe40a0ba8 chore: release v2.75.1
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (node20, pg12) (push) Waiting to run
CI/CD / tests-web (node20, pg15) (push) Waiting to run
CI/CD / tests-worker (node20, pg12) (push) Waiting to run
CI/CD / tests-worker (node20, pg15) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / e2e-server-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-08-30 20:25:38 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2cf9a75fa5 chore(deps): bump @uiw/codemirror-theme-github from 4.21.25 to 4.23.0 (#3151)
Bumps [@uiw/codemirror-theme-github](https://github.com/uiwjs/react-codemirror) from 4.21.25 to 4.23.0.
- [Release notes](https://github.com/uiwjs/react-codemirror/releases)
- [Commits](https://github.com/uiwjs/react-codemirror/compare/v4.21.25...v4.23.0)

---
updated-dependencies:
- dependency-name: "@uiw/codemirror-theme-github"
  dependency-type: direct:production
  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-08-30 17:59:28 +02:00
Max DeichmannandGitHub 38acccbfac fix: make redis upsert queue backwards compatible (#3165) 2024-08-30 14:31:23 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
8327863176 chore(deps): bump the radix-ui group with 18 updates (#3159)
Bumps the radix-ui group with 18 updates:

| Package | From | To |
| --- | --- | --- |
| [@radix-ui/react-accordion](https://github.com/radix-ui/primitives) | `1.1.2` | `1.2.0` |
| [@radix-ui/react-alert-dialog](https://github.com/radix-ui/primitives) | `1.0.5` | `1.1.1` |
| [@radix-ui/react-avatar](https://github.com/radix-ui/primitives) | `1.0.4` | `1.1.0` |
| [@radix-ui/react-checkbox](https://github.com/radix-ui/primitives) | `1.0.4` | `1.1.1` |
| [@radix-ui/react-dialog](https://github.com/radix-ui/primitives) | `1.0.5` | `1.1.1` |
| [@radix-ui/react-dropdown-menu](https://github.com/radix-ui/primitives) | `2.0.6` | `2.1.1` |
| [@radix-ui/react-hover-card](https://github.com/radix-ui/primitives) | `1.0.7` | `1.1.1` |
| [@radix-ui/react-label](https://github.com/radix-ui/primitives) | `2.0.2` | `2.1.0` |
| [@radix-ui/react-popover](https://github.com/radix-ui/primitives) | `1.0.7` | `1.1.1` |
| [@radix-ui/react-scroll-area](https://github.com/radix-ui/primitives) | `1.0.5` | `1.1.0` |
| [@radix-ui/react-select](https://github.com/radix-ui/primitives) | `2.0.0` | `2.1.1` |
| [@radix-ui/react-separator](https://github.com/radix-ui/primitives) | `1.0.3` | `1.1.0` |
| [@radix-ui/react-slider](https://github.com/radix-ui/primitives) | `1.1.2` | `1.2.0` |
| [@radix-ui/react-slot](https://github.com/radix-ui/primitives) | `1.0.2` | `1.1.0` |
| [@radix-ui/react-switch](https://github.com/radix-ui/primitives) | `1.0.3` | `1.1.0` |
| [@radix-ui/react-tabs](https://github.com/radix-ui/primitives) | `1.0.4` | `1.1.0` |
| [@radix-ui/react-toggle](https://github.com/radix-ui/primitives) | `1.0.3` | `1.1.0` |
| [@radix-ui/react-tooltip](https://github.com/radix-ui/primitives) | `1.0.7` | `1.1.2` |


Updates `@radix-ui/react-accordion` from 1.1.2 to 1.2.0
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-alert-dialog` from 1.0.5 to 1.1.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-avatar` from 1.0.4 to 1.1.0
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-checkbox` from 1.0.4 to 1.1.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-dialog` from 1.0.5 to 1.1.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-dropdown-menu` from 2.0.6 to 2.1.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-hover-card` from 1.0.7 to 1.1.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-label` from 2.0.2 to 2.1.0
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-popover` from 1.0.7 to 1.1.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-scroll-area` from 1.0.5 to 1.1.0
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-select` from 2.0.0 to 2.1.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-separator` from 1.0.3 to 1.1.0
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-slider` from 1.1.2 to 1.2.0
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-slot` from 1.0.2 to 1.1.0
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-switch` from 1.0.3 to 1.1.0
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-tabs` from 1.0.4 to 1.1.0
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-toggle` from 1.0.3 to 1.1.0
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-tooltip` from 1.0.7 to 1.1.2
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-accordion"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-alert-dialog"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-avatar"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-checkbox"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-dialog"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-dropdown-menu"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-hover-card"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-label"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-popover"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-scroll-area"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-select"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-separator"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-slider"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-slot"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-switch"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-tabs"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-toggle"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix-ui
- dependency-name: "@radix-ui/react-tooltip"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: radix-ui
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-30 16:04:22 +02:00
Max DeichmannandGitHub 6045324d2b fix: fix rate limits for orgs with stripe details (#3164) 2024-08-30 13:53:47 +00:00
Marc KlingenandGitHub 87923e5d6c fix(ui): add default llm base urls for reference in llm api key form (#3163)
* ui nit

* fix(ui): add default llm base urls for reference in llm api key form
2024-08-30 13:28:07 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
f584de7577 chore(deps): bump zod-to-json-schema from 3.22.5 to 3.23.2 (#3161)
Bumps [zod-to-json-schema](https://github.com/StefanTerdell/zod-to-json-schema) from 3.22.5 to 3.23.2.
- [Release notes](https://github.com/StefanTerdell/zod-to-json-schema/releases)
- [Changelog](https://github.com/StefanTerdell/zod-to-json-schema/blob/master/changelog.md)
- [Commits](https://github.com/StefanTerdell/zod-to-json-schema/commits)

---
updated-dependencies:
- dependency-name: zod-to-json-schema
  dependency-type: direct:production
  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-08-30 13:55:34 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d01c403f62 chore(deps): bump kysely from 0.27.3 to 0.27.4 (#3160)
Bumps [kysely](https://github.com/kysely-org/kysely) from 0.27.3 to 0.27.4.
- [Release notes](https://github.com/kysely-org/kysely/releases)
- [Commits](https://github.com/kysely-org/kysely/compare/0.27.3...0.27.4)

---
updated-dependencies:
- dependency-name: kysely
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-30 13:38:28 +02:00
Max DeichmannandGitHub a001e6072c chore: run snyk on pr pushes only (#3158) 2024-08-30 12:17:12 +02:00
Max DeichmannandGitHub d9a3f977bc chore: radix dependabot group (#3157) 2024-08-30 11:57:58 +02:00
Max DeichmannandGitHub 8fae4f3051 feat: add custom rate limits for organisations (#2976) 2024-08-30 11:57:31 +02:00
Max DeichmannandGitHub c36b1ff51d chore: dependabot gorup for lodash (#3155)
smaller fixes
2024-08-30 11:17:51 +02:00
Max DeichmannandGitHub d3cf604f4f chore: increase dependabot limit (#3148) 2024-08-30 10:51:41 +02:00
dependabot[bot]andGitHub cc886917b6 chore(deps): bump @radix-ui/react-toggle-group from 1.0.4 to 1.1.0 (#3146) 2024-08-30 08:51:05 +02:00
Marc KlingenandGitHub b924bf9e09 feat(auth): improve password reset error messages (#3143) 2024-08-29 22:02:57 +00:00
Marc Klingen 895825dd1c chore: release v2.75.0
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (node20, pg12) (push) Waiting to run
CI/CD / tests-web (node20, pg15) (push) Waiting to run
CI/CD / tests-worker (node20, pg12) (push) Waiting to run
CI/CD / tests-worker (node20, pg15) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / e2e-server-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-08-29 18:52:21 +02:00
Max DeichmannandGitHub 6ceccb649f chore: fix worker tests (#3133) 2024-08-29 16:29:56 +02:00
Max DeichmannandGitHub 911021ac5d chore: ensure clickhouse is healthy before migrating (#3128) 2024-08-29 13:58:02 +00:00
marliessophieandGitHub f39778ef9f chore(ui): render cloudflare errors in more user readable format (#3118) 2024-08-29 10:26:55 +00:00
Marc KlingenandGitHub 4d00e39c6b fix(ui): reset to first table page when number of total pages is reduced (#3122) 2024-08-28 21:58:14 +00:00
Marc Klingen 7af49fd649 chore(ui): adjust padding in settings 2024-08-28 23:30:16 +02:00
Max DeichmannandGitHub 662f1917b6 fix: create fast hashed secret key upon key creation (#3117) 2024-08-28 20:14:12 +02:00
Marc KlingenandGitHub d8f047f2a1 chore(ui): improve trpc error types (#3097) 2024-08-28 19:03:27 +02:00
69815a51b9 feat: add trace tags to generations, sessions and score table (#3109)
* feat: Add query to retrieve tags for a specific project

* chore(api): Add "tags" column to scores table definition

* add nullable to score table config

* chore(api): Parallelize filter options queries

* chore(api): Update datetime filter function name to use raw SQL

* chore(api): Update datetime filter function name to use raw SQL

* chore: Update datetime filter function name to use raw SQL

* chore: Update scores router to include timestamp filter in query

* chore: Update datetime filter function name to use raw SQL

* chore: Update datetime filter function name to use raw SQL

* chore: Update datetime filter function name to use raw SQL

* refactor: Use datetimeFilterToPrisma function for start time filter

* refactor: Use datetimeFilterToPrisma function for start time filter

* refactor: Use datetimeFilterToPrisma function for timestamp filter

* refactor: Update datetime filter function name to use raw SQL

* refactor: Add viewOnly prop to TagList component

* refactor: Add viewOnly prop to TagButton component

* feat: Add trace tags to GenerationsTable

* refactor: Add traceTags column to ScoresTable

* feat: Add traceTags column to SessionsTable

* refactor: Add traceTags column to ScoresTable

* feat: Add traceTags to session metrics

* feat: Add traceTags to getAllGenerationsSqlQuery

* revert rename

* push

* add row height to sessions

* wrap if non-s row height

---------

Co-authored-by: Marc Klingen <git@marcklingen.com>
2024-08-28 19:01:49 +02:00
Marc KlingenandGitHub 2bb419213a feat(ui): reduce font size and white-space to shrink UI to fit more content (#3001) 2024-08-28 19:00:55 +02:00
Richard KrümmelandGitHub a330035c40 feat: add tags filter to scores table (#3071) 2024-08-28 17:11:25 +02:00
Max DeichmannandGitHub dd4434fe25 chore: set keepalive on web docker container (#3113) 2024-08-28 15:00:34 +00:00
Max DeichmannandGitHub f898f57bff chore: upgrade dd-trace (#3114)
fix
2024-08-28 14:15:32 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
eda52c7b77 chore(deps-dev): bump prettier-plugin-tailwindcss from 0.5.14 to 0.6.6 (#3103)
Bumps [prettier-plugin-tailwindcss](https://github.com/tailwindlabs/prettier-plugin-tailwindcss) from 0.5.14 to 0.6.6.
- [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.14...v0.6.6)

---
updated-dependencies:
- dependency-name: prettier-plugin-tailwindcss
  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-08-28 16:01:30 +02:00
marliessophieandGitHub e5c7686bc7 fix(comments): reference object check for comment creation (#3108) 2024-08-28 08:57:48 +00:00
marliessophieandGitHub 251be1a240 fix(comments): reintroduce old procedure name for backwards compatibility (#3107) 2024-08-28 08:15:42 +00:00
marliessophieandGitHub 66c810e4a7 chore(comments): fetch comment counts by type in trace tree (#3098) 2024-08-27 16:30:02 +00:00
marliessophieandGitHub c3c8a741e4 chore(ui): style TRPC error toast to differentiate between errors and warnings (#3095) 2024-08-27 17:28:32 +02:00
Marc KlingenandGitHub 81c898b4ed feat(ui): jump to page x in table via input in pagination (#3090) 2024-08-27 17:04:46 +02:00
834d0bcc7a feat: add tag filter to generations table (#3022)
* feat(api): add tag filtering to generation filter options query

* chore: add Trace Tags column to observations table

* feat(api): optimize filterOptionsQuery by removing count property

* chore(api): optimize filterOptionsQuery for better performance

* feat: add tags filter to sessions table (#3067)



---------

Co-authored-by: Marc Klingen <git@marcklingen.com>

* refactor: use DISTINCT keyword in filterOptionsQuery

---------

Co-authored-by: Marc Klingen <git@marcklingen.com>
2024-08-27 15:19:10 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
40953d7732 chore(deps): bump pino-http from 9.0.0 to 10.2.0 (#3091)
Bumps [pino-http](https://github.com/pinojs/pino-http) from 9.0.0 to 10.2.0.
- [Release notes](https://github.com/pinojs/pino-http/releases)
- [Commits](https://github.com/pinojs/pino-http/compare/v9.0.0...v10.2.0)

---
updated-dependencies:
- dependency-name: pino-http
  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-08-27 15:05:05 +02:00
Max DeichmannandGitHub d471be8783 fix: fix async instrumentation (#3094) 2024-08-27 12:03:47 +00:00
marliessophieandGitHub 0ca38d8070 chore(evals): display eval config sampling rate in UI (#3093) 2024-08-27 11:13:19 +00:00
Max DeichmannandGitHub 12458e3d71 chore: add instrumentation to auth apis (#3069) 2024-08-27 09:44:32 +00:00
Max DeichmannandGitHub 51575b5491 chore: fix dependabot observability group (#3086)
fix
2024-08-27 10:47:30 +02:00
Max DeichmannandGitHub a9c11f3323 chore: create dependabot observability group (#3085) 2024-08-27 10:42:42 +02:00
Max DeichmannandGitHub 066140c903 fix: fix grouping of 3 months dashboard (#3080) 2024-08-27 08:40:32 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
0b7de157ad chore(deps): bump tailwind-merge from 2.2.2 to 2.5.2 (#3060)
Bumps [tailwind-merge](https://github.com/dcastil/tailwind-merge) from 2.2.2 to 2.5.2.
- [Release notes](https://github.com/dcastil/tailwind-merge/releases)
- [Commits](https://github.com/dcastil/tailwind-merge/compare/v2.2.2...v2.5.2)

---
updated-dependencies:
- dependency-name: tailwind-merge
  dependency-type: direct:production
  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-08-27 10:20:55 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
7dcc718c77 chore(deps-dev): bump tsx from 4.7.2 to 4.18.0 (#3066)
Bumps [tsx](https://github.com/privatenumber/tsx) from 4.7.2 to 4.18.0.
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.7.2...v4.18.0)

---
updated-dependencies:
- dependency-name: tsx
  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-08-26 22:08:43 +02:00
marliessophieandGitHub e098618614 chore(filters): improve filter table type safety (#3078) 2024-08-26 14:45:28 +00:00
marliessophieandGitHub 3e8ba54aa0 chore(tables): persist date range in session storage (#3076) 2024-08-26 15:44:05 +02:00
Marc KlingenandGitHub ea36cd58a4 chore(ui): disable jump to last page while count pages is loading (#3077) 2024-08-26 14:38:05 +02:00
Marc KlingenandGitHub c7b726a73a perf(ui): split totalCount from scores.all to reduce loading time of scores table (#3075) 2024-08-26 14:11:10 +02:00
Marc KlingenandGitHub 112e7a2dcf perf(ui): split totalCount from sessions.all to reduce loading time of sessions table (#3073) 2024-08-26 14:03:14 +02:00
Marc Klingen c1d98aba6a fix(ui): remove artificial wait time used in testing 2024-08-26 13:41:31 +02:00
Marc KlingenandGitHub 75fd1cbd3f perf(ui): split totalCount from generations.all to reduce loading time of generations table (#3072) 2024-08-26 13:40:29 +02:00
Marc KlingenandGitHub 75ec734a45 perf(ui): split total count query on trace table to reduce loading time (#3070) 2024-08-26 13:24:54 +02:00
marliessophieandGitHub 98e951423c chore(filters): persist table and dashboard filter state in session storage (#3036) 2024-08-26 11:05:58 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
8e1dc3ee99 chore(deps): bump core-js from 3.36.1 to 3.38.1 (#3058)
Bumps [core-js](https://github.com/zloirock/core-js/tree/HEAD/packages/core-js) from 3.36.1 to 3.38.1.
- [Release notes](https://github.com/zloirock/core-js/releases)
- [Changelog](https://github.com/zloirock/core-js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/zloirock/core-js/commits/v3.38.1/packages/core-js)

---
updated-dependencies:
- dependency-name: core-js
  dependency-type: direct:production
  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-08-26 12:35:47 +02:00
Marc KlingenandGitHub 7668995ec9 perf(ui): increase speed of tables, no nulls first/last modifier when sorting by non-nullable attributes (#3068) 2024-08-26 11:30:31 +02:00
marliessophieandGitHub 06233f878c chore(comments): disable comments count on public traces (#3065) 2024-08-26 09:13:58 +00:00
Max DeichmannandGitHub dae3d4e4e2 fix: remove dd profiling presets (#3063)
fix
2024-08-24 17:36:50 +02:00
Max DeichmannandGitHub 04dca2ad6b perf: use dedicated redis clients for bullmq (#3052) 2024-08-23 16:54:39 +00:00
Marc KlingenandGitHub 48c61ad77f perf(ui): load metrics in trace table independently to reduce initial table load time (#3051) 2024-08-23 17:39:32 +02:00
Marc KlingenandGitHub 34140302a6 perf(ui): load metrics in session table independently to reduce initial table load time (#3048) 2024-08-23 13:28:32 +02:00
Marc KlingenandGitHub 93cc36ecdc fix(cloud): catch crisp chat exception (#3042) 2024-08-23 13:22:36 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ace30782a0 chore(deps): bump @radix-ui/react-collapsible from 1.0.3 to 1.1.0 (#3031)
Bumps [@radix-ui/react-collapsible](https://github.com/radix-ui/primitives) from 1.0.3 to 1.1.0.
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-collapsible"
  dependency-type: direct:production
  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-08-23 13:04:49 +02:00
Marc KlingenandGitHub 3c94a0cda9 perf(ui): parallelize db query for sessions.totalCount (#3044) 2024-08-23 09:58:58 +00:00
Max DeichmannandGitHub b0fea44556 feat: sentry capture console.error in frontend (#3043)
fix
2024-08-23 08:30:42 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ed91330c37 chore(deps): bump lucide-react from 0.364.0 to 0.429.0 (#3037)
Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 0.364.0 to 0.429.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/0.429.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-type: direct:production
  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-08-23 10:16:58 +02:00
Max DeichmannandGitHub b8ed9d681b fix: free up tokenizer at worker shutdown (#3041) 2024-08-23 08:02:57 +00:00
Max DeichmannandGitHub ea2e1b4873 fix: security policy for posthog (#3040)
fix
2024-08-23 07:32:55 +00:00
Max DeichmannandGitHub 3ddef14e75 fix: add sentry frontend support (#3012) 2024-08-23 06:41:27 +00:00
Marc KlingenandGitHub 4658942701 perf(ui): parallelize db queries in traces.all (#3038) 2024-08-23 01:32:33 +02:00
Marc KlingenandGitHub 51be8f8fb3 perf(ui): parallelize users, sessions, models, score config, scores queries in trpc routes (#3039)
* prompts

* models

* score config

* scores

* sessions
2024-08-22 23:26:59 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
6ede3b7690 chore(deps): bump the next group with 2 updates (#3030)
Bumps the next group with 2 updates: [next](https://github.com/vercel/next.js) and [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next).


Updates `next` from 14.2.3 to 14.2.6
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v14.2.3...v14.2.6)

Updates `eslint-config-next` from 14.2.3 to 14.2.6
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v14.2.6/packages/eslint-config-next)

---
updated-dependencies:
- dependency-name: next
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: next
- dependency-name: eslint-config-next
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: next
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-22 22:51:08 +00:00
Max DeichmannandGitHub dee36bbe29 chore: remove instrumentation from prisma dependabot group (#3033) 2024-08-23 00:31:28 +02:00
Max DeichmannandGitHub f71cf2f894 fix: remove dependabot patch group (#3027) 2024-08-22 23:52:35 +02:00
Marc KlingenandGitHub 1a707fe147 fix(ui): layout entitlements (#3026)
* fix(ui): layout entitlements

* push
2024-08-22 21:51:09 +00:00
Marc KlingenandGitHub 8da1640777 perf(ui): parallelize db queries in users.all route (#3018) 2024-08-22 23:24:14 +02:00
Max DeichmannandGitHub 1e56499f00 chore: upgrade bull (#3025)
fix
2024-08-22 21:23:18 +00:00
Max DeichmannandGitHub 2524fb9648 fix: otel error formatting (#3024) 2024-08-22 19:29:31 +00:00
marliessophieandGitHub 91cd86a286 chore(ui): Add free text option to dynamically populated filter options (#3020) 2024-08-22 18:23:42 +02:00
Lucas MagnumandGitHub e6b9d23615 fix(auth): Cannot read properties of undefined, GoogleProfile.hd (#3019) 2024-08-22 17:36:20 +02:00
Marc KlingenandGitHub 8b76feeffa perf(ui): parallelize db queries in filterOptions routes (#3016) 2024-08-22 15:27:35 +00:00
88a53dc72d perf(tables): drop filter option counts to reduce latency (#3013)
* perf(filters): rm counts from prompt filter options

* perf: drop counts from generations filter options

* perf: drop counts from trace filter options

---------

Co-authored-by: Marc Klingen <git@marcklingen.com>
2024-08-22 17:06:15 +02:00
Marc KlingenandGitHub 649a9af91c perf(ui): simplify default traces.byId route to not load observations and scores for table use cases (#3010) 2024-08-22 16:19:02 +02:00
Marc KlingenandGitHub 876917d112 perf(ui): remove unused scores from observations.byId (#3011) 2024-08-22 16:17:51 +02:00
Max DeichmannandGitHub bb6bc79bdc fix: use serverComponentsExternalPackages for dd and otel (#3009) 2024-08-22 13:21:14 +00:00
Marc KlingenandGitHub 7aff2b2e61 perf(ui): parallelize db queries in byId trpc routes (#3006) 2024-08-22 14:45:36 +02:00
Max DeichmannandGitHub 8f742f660a fix: await closure of bull queues (#3007)
fix
2024-08-22 11:15:54 +00:00
Marc Klingen 8245cf8ae7 chore: release v2.74.0
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (node20, pg12) (push) Waiting to run
CI/CD / tests-web (node20, pg15) (push) Waiting to run
CI/CD / tests-worker (node20, pg12) (push) Waiting to run
CI/CD / tests-worker (node20, pg15) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / e2e-server-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-08-22 12:38:18 +02:00
Marc Klingen 05b3021bff fix(ee): remove model-based-evaluations entitlement as it is not available in v2 2024-08-22 11:59:32 +02:00
Marc KlingenandGitHub 44bcebdaec feat(ui): add markdown support to comments (#3002)
* feat(ui): add markdown support to comments

* Apply suggestions from code review
2024-08-21 23:50:35 +00:00
Marc Klingen 2ac15e8714 chore: release v2.73.0
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (node20, pg12) (push) Waiting to run
CI/CD / tests-web (node20, pg15) (push) Waiting to run
CI/CD / tests-worker (node20, pg12) (push) Waiting to run
CI/CD / tests-worker (node20, pg15) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / e2e-server-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-08-21 23:50:09 +02:00
marliessophieandGitHub 13b027cd3c fix(ui): handle JSON assistant output correctly in ChatML Schema (#3000) 2024-08-21 23:49:05 +02:00
Marc KlingenandGitHub 4a89cf4e45 perf(ui): disable trpc batching (#2999) 2024-08-21 23:24:56 +02:00
Marc Klingen e776af544f perf(cloud): report usage to stripe only for orgs with usage in timeframe 2024-08-21 19:28:25 +02:00
Marc KlingenandGitHub 0d781f12f3 chore(ui): add note that prompts from playground can only be saved to chat prompts (#2996) 2024-08-21 15:28:33 +00:00
marliessophieandGitHub 5d81505d84 feat(ui): support OpenAI's image content part format via markdown on trace/observation preview (#2984) 2024-08-21 17:18:04 +02:00
Marc KlingenandGitHub 8471b7f7b3 security: disable x-powered-by header (#2995) 2024-08-21 15:14:23 +02:00
Max DeichmannandGitHub 78a90afdab fix: fix api key link (#2994) 2024-08-21 11:39:45 +00:00
Max DeichmannandGitHub 9e304ad873 infra: improve eval logs (#2993) 2024-08-21 10:10:13 +00:00
Marc Klingen 5457053fdf chore(ui): change prompt label icon 2024-08-21 11:47:13 +02:00
Max DeichmannandGitHub e91716c6a2 infra: improve eval observability (#2992) 2024-08-21 09:45:38 +00:00
Max DeichmannandGitHub 5148cfa690 chore: remove log line (#2991) 2024-08-21 09:24:19 +00:00
227 changed files with 6244 additions and 3554 deletions
+6 -2
View File
@@ -139,12 +139,12 @@ ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000
# NEXT_PUBLIC_POSTHOG_KEY=
# Sentry
# NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE
# NEXT_PUBLIC_SENTRY_DSN=
# NEXT_SENTRY_ORG=
# NEXT_SENTRY_PROJECT=
# SENTRY_AUTH_TOKEN=
# Used to determine the Sentry sample rate
# LANGFUSE_TRACING_SAMPLE_RATE=
# SENTRY_CSP_REPORT_URI=
# LANGFUSE_WORKER_BETTERSTACK_TOKEN=
@@ -167,6 +167,7 @@ ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000
# REDIS_PORT=
# REDIS_AUTH=
# REDIS_CONNECTION_STRING=
# REDIS_ENABLE_AUTO_PIPELINING=
# Cache configuration
# LANGFUSE_CACHE_API_KEY_ENABLED=
@@ -174,6 +175,9 @@ ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000
# LANGFUSE_CACHE_PROMPT_ENABLED=
# LANGFUSE_CACHE_PROMPT_TTL_SECONDS=
# Rate limiting
# LANGFUSE_RATE_LIMITS_ENABLED=
# Stripe
# STRIPE_SECRET_KEY=
# STRIPE_WEBHOOK_SIGNING_SECRET=
+16 -7
View File
@@ -8,6 +8,7 @@ updates:
- package-ecosystem: npm
directory: "/" # Location of package manifests
rebase-strategy: "disabled" # use dependabot-rebase-stale
open-pull-requests-limit: 10
schedule:
interval: "daily"
versioning-strategy: "increase"
@@ -19,17 +20,25 @@ updates:
- dependency-name: "@types/node"
- dependency-name: "@trpc/*"
groups:
sentry:
patterns:
- "@sentry/*"
prisma:
patterns:
- "prisma"
- "@prisma/*"
- "@prisma/client"
next:
patterns:
- "eslint-config-next"
- "next"
patches:
update-types:
- "patch"
lodash:
patterns:
- "lodash"
- "@types/lodash"
observability:
patterns:
- "dd-trace"
- "@opentelemetry/*"
- "@appsignal/opentelemetry-instrumentation-bullmq"
- "@prisma/instrumentation"
- "@sentry/*"
radix-ui:
patterns:
- "@radix-ui/*"
+11 -2
View File
@@ -136,6 +136,7 @@ jobs:
- uses: pnpm/action-setup@v3
with:
version: 9.5.0
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
@@ -165,18 +166,26 @@ jobs:
sleep 5 # Wait for PostgreSQL to accept connections
docker compose ps
- name: Ensure no unhealthy status
run: |
if docker compose ps | grep "(unhealthy)"; then
echo "One or more services are unhealthy"
exit 1
else
echo "All services are healthy"
fi
- name: Seed DB
run: |
pnpm run --filter=shared ch:up
pnpm run db:migrate
pnpm run db:seed
pnpm run --filter=shared ch:up
- name: Build
run: pnpm --filter=worker... run build
- name: run tests
run: pnpm --filter=worker run test
e2e-tests:
runs-on: ubuntu-latest
steps:
+3 -3
View File
@@ -1,11 +1,11 @@
name: Snyk Container
on:
push:
branches-ignore:
- "dependabot/**"
pull_request:
branches:
- "**"
push:
branches:
- main
jobs:
snyk:
+1 -1
View File
@@ -26,7 +26,7 @@
"dependencies": {
"@langfuse/shared": "workspace:*",
"@opentelemetry/api": ">=1.0.0 <1.9.0",
"next": "^14.2.3",
"next": "^14.2.6",
"next-auth": "^4.24.7",
"zod": "^3.23.8"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "2.72.0",
"version": "2.75.2",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
+1 -1
View File
@@ -11,7 +11,7 @@
"@typescript-eslint/eslint-plugin": "^7.1.0",
"@typescript-eslint/parser": "^7.12.0",
"@vercel/style-guide": "^6.0.0",
"eslint-config-next": "^14.2.3",
"eslint-config-next": "^14.2.6",
"eslint-config-prettier": "^9.1.0",
"eslint-config-turbo": "^1.13.4",
"eslint-plugin-only-warn": "^1.1.0",
+1
View File
@@ -18,5 +18,6 @@ if [ "$CLICKHOUSE_MIGRATION_SSL" = true ] ; then
else
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&x-migrations-table-engine=MergeTree"
fi
# Execute the up command
migrate -source file://clickhouse/migrations -database "$DATABASE_URL" up
+4 -4
View File
@@ -63,12 +63,12 @@
"@react-email/render": "^0.0.15",
"@types/bcryptjs": "^2.4.6",
"bcryptjs": "^2.4.3",
"bullmq": "^5.4.2",
"bullmq": "^5.12.10",
"dd-trace": "^5.19.0",
"decimal.js": "^10.4.3",
"exponential-backoff": "^3.1.1",
"dd-trace": "^5.19.0",
"ioredis": "^5.4.1",
"kysely": "^0.27.3",
"kysely": "^0.27.4",
"langchain": "^0.2.6",
"lodash": "^4.17.21",
"next-auth": "^4.24.7",
@@ -76,7 +76,7 @@
"prisma-extension-kysely": "^2.1.0",
"uuid": "^9.0.1",
"zod": "^3.23.8",
"zod-to-json-schema": "^3.22.5"
"zod-to-json-schema": "^3.23.2"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
+64 -29
View File
@@ -832,6 +832,8 @@ function createObjects(
)
];
const { input, output } = getGenerationInputOutput();
const generation = {
type: ObservationType.GENERATION,
id: `generation-${v4()}`,
@@ -843,35 +845,8 @@ function createObjects(
name: `generation-${i}-${j}-${k}`,
projectId: trace.projectId,
promptId: promptId,
input:
Math.random() > 0.5
? [
{
role: "system",
content: "Be a helpful assistant",
},
{
role: "user",
content: "How can i create a *React* component?",
},
]
: {
input: "How can i create a React component?",
retrievedDocuments: [
{
title: "How to create a React component",
url: "https://www.google.com",
description: "A guide to creating React components",
},
{
title: "React component creation",
url: "https://www.google.com",
description: "A guide to creating React components",
},
],
},
output:
"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.\n\n**Image**\n\n![Languse Example Image](https://static.langfuse.com/langfuse-dev/langfuse-example-image.jpeg)\n\n1. **Functional Component**:\n\nA functional component is just a plain JavaScript function that accepts props as an argument, and returns a React element. Here's how you can create one:\n\n```javascript\nimport React from 'react';\nfunction Greeting(props) {\n return <h1>Hello, {props.name}</h1>;\n}\nexport default Greeting;\n```\n\nTo use this component in another file, you can do:\n\n```javascript\nimport Greeting from './Greeting';\nfunction App() {\n return (\n <div>\n <Greeting name=\"John\" />\n </div>\n );\n}\nexport default App;\n```\n\n2. **Class Component**:\n\nYou can also define components as classes in React. These have some additional features compared to functional components:\n\n```javascript\nimport React, { Component } from 'react';\nclass Greeting extends Component {\n render() {\n return <h1>Hello, {this.props.name}</h1>;\n }\n}\nexport default Greeting;\n```\n\nAnd here's how to use this component:\n\n```javascript\nimport Greeting from './Greeting';\nclass App extends Component {\n render() {\n return (\n <div>\n <Greeting name=\"John\" />\n </div>\n );\n }\n}\nexport default App;\n```\n\nWith the advent of hooks in React, functional components can do everything that class components can do and hence, the community has been favoring functional components over class components.\n\nRemember to import React at the top of your file whenever you're creating a component, because JSX transpiles to `React.createElement` calls under the hood.",
input,
output,
model: model,
internalModel: model,
modelParameters: {
@@ -1243,3 +1218,63 @@ async function generateConfigs(project: Project) {
return configNameAndId;
}
function getGenerationInputOutput(): {
input: Prisma.InputJsonValue;
output: Prisma.InputJsonValue;
} {
if (Math.random() > 0.9) {
const input = [
{
role: "user",
content: [
{ text: "Whats depicted in this image?", type: "text" },
{
type: "image_url",
image_url: {
url: "https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg",
},
},
{ text: "Describe the scene in detail.", type: "text" },
],
},
];
const output =
"The image depicts a serene landscape featuring a wooden pathway or boardwalk that winds through a lush green field. The field is filled with tall grass and surrounded by trees and shrubs. Above, the sky is bright with scattered clouds, suggesting a clear and pleasant day. The scene conveys a sense of tranquility and natural beauty.";
return { input, output };
}
const input =
Math.random() > 0.5
? [
{
role: "system",
content: "Be a helpful assistant",
},
{
role: "user",
content: "How can i create a *React* component?",
},
]
: {
input: "How can i create a React component?",
retrievedDocuments: [
{
title: "How to create a React component",
url: "https://www.google.com",
description: "A guide to creating React components",
},
{
title: "React component creation",
url: "https://www.google.com",
description: "A guide to creating React components",
},
],
};
const output =
"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.\n\n**Image**\n\n![Languse Example Image](https://static.langfuse.com/langfuse-dev/langfuse-example-image.jpeg)\n\n1. **Functional Component**:\n\nA functional component is just a plain JavaScript function that accepts props as an argument, and returns a React element. Here's how you can create one:\n\n```javascript\nimport React from 'react';\nfunction Greeting(props) {\n return <h1>Hello, {props.name}</h1>;\n}\nexport default Greeting;\n```\n\nTo use this component in another file, you can do:\n\n```javascript\nimport Greeting from './Greeting';\nfunction App() {\n return (\n <div>\n <Greeting name=\"John\" />\n </div>\n );\n}\nexport default App;\n```\n\n2. **Class Component**:\n\nYou can also define components as classes in React. These have some additional features compared to functional components:\n\n```javascript\nimport React, { Component } from 'react';\nclass Greeting extends Component {\n render() {\n return <h1>Hello, {this.props.name}</h1>;\n }\n}\nexport default Greeting;\n```\n\nAnd here's how to use this component:\n\n```javascript\nimport Greeting from './Greeting';\nclass App extends Component {\n render() {\n return (\n <div>\n <Greeting name=\"John\" />\n </div>\n );\n }\n}\nexport default App;\n```\n\nWith the advent of hooks in React, functional components can do everything that class components can do and hence, the community has been favoring functional components over class components.\n\nRemember to import React at the top of your file whenever you're creating a component, because JSX transpiles to `React.createElement` calls under the hood.";
return { input, output };
}
+1
View File
@@ -16,6 +16,7 @@ const EnvSchema = z.object({
.nullable(),
REDIS_AUTH: z.string().nullish(),
REDIS_CONNECTION_STRING: z.string().nullish(),
REDIS_ENABLE_AUTO_PIPELINING: z.enum(["true", "false"]).default("true"),
ENCRYPTION_KEY: z
.string()
.length(
@@ -12,3 +12,6 @@ export const plans = Object.keys(planLabels) as Plan[];
// This function is kept here to ensure consistency when updating plan names in the future.
export const isCloudPlan = (plan: Plan) => plan.startsWith("cloud");
export const isPlan = (value: string): value is Plan =>
plans.includes(value as Plan);
+18 -1
View File
@@ -1,7 +1,8 @@
import { Prisma } from "@prisma/client";
import { ColumnDefinition, type TableNames } from "./tableDefinitions";
import { FilterState } from "./types";
import { filterOperators } from "./interfaces/filters";
import { filterOperators, timeFilter } from "./interfaces/filters";
import { z } from "zod";
const operatorReplacements = {
"any of": "IN",
@@ -179,3 +180,19 @@ export const datetimeFilterToPrismaSql = (
operator
)} ${value}::timestamp with time zone at time zone 'UTC'`;
};
export const datetimeFilterToPrisma = (
timestampFilter: z.infer<typeof timeFilter>
) => {
const prismaTimestampFilter =
timestampFilter.operator === ">="
? { gte: timestampFilter.value }
: timestampFilter.operator === ">"
? { gt: timestampFilter.value }
: timestampFilter.operator === "<="
? { lte: timestampFilter.value }
: timestampFilter.operator === "<"
? { lt: timestampFilter.value }
: {};
return prismaTimestampFilter;
};
+2
View File
@@ -15,6 +15,8 @@ export * from "./utils/zod";
export * from "./utils/json";
export * from "./utils/objects";
export * from "./utils/typeChecks";
export * from "./features/entitlements/plans";
export * from "./interfaces/rate-limits";
export { env } from "./env";
// llm api
@@ -1,4 +1,5 @@
import { z } from "zod";
import { CloudConfigRateLimit } from "./rate-limits";
export const CloudConfigSchema = z.object({
plan: z.enum(["Hobby", "Pro", "Team", "Enterprise"]).optional(),
@@ -13,5 +14,9 @@ export const CloudConfigSchema = z.object({
activeProductId: z.string().optional(),
})
.optional(),
// custom rate limits for an organization
rateLimitOverrides: CloudConfigRateLimit.optional(),
});
export type CloudConfigSchema = z.infer<typeof CloudConfigSchema>;
@@ -0,0 +1,30 @@
import z from "zod";
import { ApiAccessScope } from "../server";
export const RateLimitResource = z.enum([
"ingestion",
"public-api",
"public-api-metrics",
"prompts",
]);
// result of the rate limit check.
export type RateLimitResult = {
resource: z.infer<typeof RateLimitResource>;
points: number;
scope: ApiAccessScope;
// from rate-limiter-flexible
remainingPoints: number;
msBeforeNext: number;
consumedPoints: number;
isFirstInDuration: boolean;
};
export const RateLimitConfig = z.object({
points: z.number().nullish(),
durationInSec: z.number().nullish(),
resource: RateLimitResource,
});
export const CloudConfigRateLimit = z.array(RateLimitConfig);
+33 -1
View File
@@ -18,6 +18,7 @@ export const observationsTableCols: ColumnDefinition[] = [
type: "stringOptions",
internal: 'o."name"',
options: [], // to be added at runtime
nullable: true,
},
{ name: "Trace ID", id: "traceId", type: "string", internal: 't."id"' },
{
@@ -26,8 +27,15 @@ export const observationsTableCols: ColumnDefinition[] = [
type: "stringOptions",
internal: 't."name"',
options: [], // to be added at runtime
nullable: true,
},
{
name: "User ID",
id: "userId",
type: "string",
internal: 't."user_id"',
nullable: true,
},
{ name: "User ID", id: "userId", type: "string", internal: 't."user_id"' },
{
name: "Start Time",
id: "startTime",
@@ -45,6 +53,7 @@ export const observationsTableCols: ColumnDefinition[] = [
id: "timeToFirstToken",
type: "number",
internal: 'o."completion_start_time" - o."start_time"',
nullable: true,
},
{
name: "Latency (s)",
@@ -57,24 +66,28 @@ export const observationsTableCols: ColumnDefinition[] = [
id: "timePerOutputToken",
type: "number",
internal: '"latency" / o."completion_tokens"',
nullable: true,
},
{
name: "Input Cost ($)",
id: "inputCost",
type: "number",
internal: 'o."calculated_input_cost"',
nullable: true,
},
{
name: "Output Cost ($)",
id: "outputCost",
type: "number",
internal: 'o."calculated_output_cost"',
nullable: true,
},
{
name: "Total Cost ($)",
id: "totalCost",
type: "number",
internal: 'o."calculated_total_cost"',
nullable: true,
},
{
name: "Level",
@@ -88,6 +101,7 @@ export const observationsTableCols: ColumnDefinition[] = [
id: "statusMessage",
type: "string",
internal: 'o."status_message"',
nullable: true,
},
{
name: "Model",
@@ -95,24 +109,28 @@ export const observationsTableCols: ColumnDefinition[] = [
type: "stringOptions",
internal: 'o."model"',
options: [], // to be added at runtime
nullable: true,
},
{
name: "Input Tokens",
id: "inputTokens",
type: "number",
internal: 'o."prompt_tokens"',
nullable: true,
},
{
name: "Output Tokens",
id: "outputTokens",
type: "number",
internal: 'o."completion_tokens"',
nullable: true,
},
{
name: "Total Tokens",
id: "totalTokens",
type: "number",
internal: 'o."total_tokens"',
nullable: true,
},
{
name: "Usage",
@@ -137,6 +155,7 @@ export const observationsTableCols: ColumnDefinition[] = [
id: "version",
type: "string",
internal: 'o."version"',
nullable: true,
},
{
name: "Prompt Name",
@@ -144,12 +163,21 @@ export const observationsTableCols: ColumnDefinition[] = [
type: "stringOptions",
internal: "p.name",
options: [], // to be added at runtime
nullable: true,
},
{
name: "Prompt Version",
id: "promptVersion",
type: "number",
internal: "p.version",
nullable: true,
},
{
name: "Trace Tags",
id: "tags",
type: "arrayOptions",
internal: "t.tags",
options: [], // to be added at runtime
},
];
@@ -161,6 +189,7 @@ export type ObservationOptions = {
traceName: Array<OptionsDefinition>;
scores_avg: Array<string>;
promptName: Array<OptionsDefinition>;
tags: Array<OptionsDefinition>;
};
export function observationsTableColsWithOptions(
@@ -182,6 +211,9 @@ export function observationsTableColsWithOptions(
if (col.id === "promptName") {
return { ...col, options: options?.promptName ?? [] };
}
if (col.id === "tags") {
return { ...col, options: options?.tags ?? [] };
}
return col;
});
}
+2 -2
View File
@@ -16,7 +16,7 @@ export function orderByToPrismaSql(
tableColumns: ColumnDefinition[]
): Prisma.Sql {
if (!orderBy) {
return Prisma.sql`ORDER BY t.timestamp DESC NULLS LAST`;
return Prisma.sql`ORDER BY t.timestamp DESC`;
}
// Get column definition to map column to internal name, e.g. "t.id"
const col = tableColumns.find(
@@ -40,6 +40,6 @@ export function orderByToPrismaSql(
// Both column and order are safe, can use raw SQL
return Prisma.raw(
`ORDER BY ${col.internal} ${order.data} ${orderBy.order === "DESC" ? "NULLS LAST" : "NULLS FIRST"}`
`ORDER BY ${col.internal} ${order.data} ${col.nullable ? (orderBy.order === "DESC" ? "NULLS LAST" : "NULLS FIRST") : ""}`
);
}
@@ -16,8 +16,13 @@ const GetSessionTableSQLParamsSchema = z.object({
});
type GetSessionTableSQLParams = z.infer<typeof GetSessionTableSQLParamsSchema>;
export const getSessionTableSQL = (
params: GetSessionTableSQLParams
export const createSessionsAllQuery = (
select: Prisma.Sql,
params: GetSessionTableSQLParams,
options?: {
ignoreOrderBy?: boolean; // used by session.metrics and session.all.totalCount
sessionIdList?: string[]; // used by session.metrics
}
): Prisma.Sql => {
const { projectId, filter, orderBy, page, limit } =
GetSessionTableSQLParamsSchema.parse(params);
@@ -29,22 +34,13 @@ export const getSessionTableSQL = (
);
const orderByCondition = orderByToPrismaSql(orderBy, sessionsViewCols);
const sessionIdFilter = options?.sessionIdList
? Prisma.sql`AND s.id IN (${Prisma.join(options?.sessionIdList)})`
: Prisma.sql``;
const sql = Prisma.sql`
SELECT
s.id,
s. "created_at" AS "createdAt",
s.bookmarked,
s.public,
t. "userIds",
t. "countTraces",
o. "sessionDuration",
o. "totalCost" AS "totalCost",
o. "inputCost" AS "inputCost",
o. "outputCost" AS "outputCost",
o. "promptTokens" AS "promptTokens",
o. "completionTokens" AS "completionTokens",
o. "totalTokens" AS "totalTokens",
(count(*) OVER ())::int AS "totalCount"
${select}
FROM
trace_sessions AS s
LEFT JOIN LATERAL (
@@ -54,9 +50,12 @@ export const getSessionTableSQL = (
MIN(t. "timestamp") AS "min_timestamp",
array_agg(t.id) AS "traceIds",
array_agg(DISTINCT t.user_id) AS "userIds",
count(t.id)::int AS "countTraces"
count(t.id)::int AS "countTraces",
array_agg(DISTINCT u.tag) AS "tags"
FROM
traces t
LEFT JOIN LATERAL (
SELECT DISTINCT UNNEST(t.tags) AS tag) AS u ON TRUE
WHERE
t.project_id = ${projectId}
AND t.session_id = s.id
@@ -79,7 +78,8 @@ export const getSessionTableSQL = (
WHERE
s. "project_id" = ${projectId}
${filterCondition}
${orderByCondition}
${sessionIdFilter}
${options?.ignoreOrderBy ? Prisma.sql`` : orderByCondition}
LIMIT ${limit}
OFFSET ${page * limit}
`;
+1 -1
View File
@@ -1 +1 @@
export { getSessionTableSQL } from "./getSessionTableSQL";
export { createSessionsAllQuery } from "./createSessionsAllQuery";
+30
View File
@@ -1,3 +1,30 @@
import z from "zod";
import { Plan, plans } from "../../features/entitlements/plans";
import { CloudConfigRateLimit } from "../../interfaces/rate-limits";
export const OrgEnrichedApiKey = z.object({
id: z.string(),
note: z.string().nullable(),
publicKey: z.string(),
displaySecretKey: z.string(),
createdAt: z.string().datetime().nullable(),
lastUsedAt: z.string().datetime().nullable(),
expiresAt: z.string().datetime().nullable(),
projectId: z.string(),
fastHashedSecretKey: z.string(),
hashedSecretKey: z.string(),
orgId: z.string(),
plan: z.enum(plans as unknown as [string, ...string[]]),
rateLimitOverrides: CloudConfigRateLimit.nullish(),
});
export const API_KEY_NON_EXISTENT = "api-key-non-existent";
export const CachedApiKey = z.union([
OrgEnrichedApiKey,
z.literal(API_KEY_NON_EXISTENT),
]);
export type AuthHeaderVerificationResult =
| AuthHeaderValidVerificationResult
| {
@@ -13,4 +40,7 @@ export type AuthHeaderValidVerificationResult = {
export type ApiAccessScope = {
projectId: string;
accessLevel: "all" | "scores";
orgId: string;
plan: Plan;
rateLimitOverrides: z.infer<typeof CloudConfigRateLimit>;
};
@@ -12,18 +12,18 @@ import {
sdkLogEvent,
} from "../types";
import { validateAndInflateScore } from "../validateAndInflateScore";
import { ApiAccessScope } from "../../auth/types";
import { Trace, Observation, Score, Prisma, Model } from "@prisma/client";
import { ForbiddenError, LangfuseNotFoundError } from "../../../errors";
import { mergeJson } from "../../../utils/json";
import { jsonSchema } from "../../../utils/zod";
import { prisma } from "../../../db";
import { LegacyIngestionAccessScope } from ".";
export interface EventProcessor {
auth(apiScope: ApiAccessScope): void;
auth(apiScope: LegacyIngestionAccessScope): void;
process(
apiScope: ApiAccessScope
apiScope: LegacyIngestionAccessScope
): Promise<Trace | Observation | Score> | undefined;
}
@@ -46,7 +46,7 @@ export class ObservationProcessor implements EventProcessor {
}
async convertToObservation(
apiScope: ApiAccessScope,
apiScope: LegacyIngestionAccessScope,
existingObservation: Observation | null
): Promise<{
id: string;
@@ -398,12 +398,12 @@ export class ObservationProcessor implements EventProcessor {
};
}
auth(apiScope: ApiAccessScope): void {
auth(apiScope: LegacyIngestionAccessScope): void {
if (apiScope.accessLevel !== "all")
throw new ForbiddenError("Access denied for observation creation");
}
async process(apiScope: ApiAccessScope): Promise<Observation> {
async process(apiScope: LegacyIngestionAccessScope): Promise<Observation> {
this.auth(apiScope);
const existingObservation = this.event.body.id
@@ -441,13 +441,13 @@ export class TraceProcessor implements EventProcessor {
this.event = event;
}
auth(apiScope: ApiAccessScope): void {
auth(apiScope: LegacyIngestionAccessScope): void {
if (apiScope.accessLevel !== "all")
throw new ForbiddenError("Access denied for trace creation");
}
async process(
apiScope: ApiAccessScope
apiScope: LegacyIngestionAccessScope
): Promise<Trace | Observation | Score> {
const { body } = this.event;
@@ -553,7 +553,7 @@ export class ScoreProcessor implements EventProcessor {
this.event = event;
}
auth(apiScope: ApiAccessScope) {
auth(apiScope: LegacyIngestionAccessScope) {
if (apiScope.accessLevel !== "scores" && apiScope.accessLevel !== "all")
throw new ForbiddenError(
`Access denied for score creation, ${apiScope.accessLevel}`
@@ -561,7 +561,7 @@ export class ScoreProcessor implements EventProcessor {
}
async process(
apiScope: ApiAccessScope
apiScope: LegacyIngestionAccessScope
): Promise<Trace | Observation | Score> {
const { body } = this.event;
@@ -613,7 +613,7 @@ export class SdkLogProcessor implements EventProcessor {
this.event = event;
}
auth(apiScope: ApiAccessScope) {
auth(apiScope: LegacyIngestionAccessScope) {
return;
}
@@ -14,7 +14,7 @@ import {
convertTraceUpsertEventsToRedisEvents,
getTraceUpsertQueue,
} from "../../redis/trace-upsert";
import { ApiAccessScope, AuthHeaderVerificationResult } from "../../auth/types";
import { ApiAccessScope } from "../../auth/types";
import { redis } from "../../redis/redis";
import { backOff } from "exponential-backoff";
import { Model } from "../../..";
@@ -31,9 +31,24 @@ type TokenCountInput = {
text: unknown;
};
export type LegacyIngestionAccessScope = Omit<
ApiAccessScope,
"orgId" | "plan" | "rateLimitOverrides"
>;
type LegacyIngestionAuthHeaderVerificationResult =
| {
validKey: true;
scope: LegacyIngestionAccessScope;
}
| {
validKey: false;
error: string;
};
export const handleBatch = async (
events: z.infer<typeof ingestionApiSchema>["batch"],
authCheck: AuthHeaderVerificationResult,
authCheck: LegacyIngestionAuthHeaderVerificationResult,
calculateTokenDelegate: (p: TokenCountInput) => number | undefined
) => {
console.log(`handling ingestion ${events.length} events`);
@@ -103,7 +118,7 @@ async function retry<T>(request: () => Promise<T>): Promise<T> {
const handleSingleEvent = async (
event: z.infer<typeof ingestionEvent>,
apiScope: ApiAccessScope,
apiScope: LegacyIngestionAccessScope,
calculateTokenDelegate: (p: {
model: Model;
text: unknown;
@@ -10,12 +10,38 @@ export type SpanCtx = {
traceScope?: string;
};
type CallbackFn<T> = () => T | Promise<T>;
type AsyncCallbackFn<T> = () => Promise<T>;
export function instrument<T>(
export async function instrumentAsync<T>(
ctx: SpanCtx,
callback: CallbackFn<T>
): T extends Promise<any> ? Promise<T> : T {
callback: AsyncCallbackFn<T>
): Promise<T> {
return await getTracer(ctx.traceScope ?? callback.name).startActiveSpan(
ctx.name,
{
root: ctx.rootSpan,
kind: ctx.spanKind,
},
async (span) => {
try {
const result = await callback();
span.end();
return result;
} catch (ex) {
traceException(ex as opentelemetry.Exception, span);
span.end();
throw ex;
}
}
);
}
type SyncCallbackFn<T> = () => T;
export function instrumentSync<T>(
ctx: SpanCtx,
callback: SyncCallbackFn<T>
): T {
return getTracer(ctx.traceScope ?? callback.name).startActiveSpan(
ctx.name,
{
@@ -23,30 +49,14 @@ export function instrument<T>(
kind: ctx.spanKind,
},
(span) => {
const handleResult = (result: T) => {
try {
const result = callback();
span.end();
return result;
};
const handleError = (ex: unknown) => {
} catch (ex) {
traceException(ex as opentelemetry.Exception, span);
span.end();
throw ex;
};
try {
const result = callback();
if (result instanceof Promise) {
return result
.then(handleResult)
.catch(handleError) as T extends Promise<any> ? Promise<T> : T;
} else {
return handleResult(result) as T extends Promise<any>
? Promise<T>
: T;
}
} catch (ex) {
return handleError(ex) as T extends Promise<any> ? Promise<T> : T;
}
}
);
@@ -67,9 +77,24 @@ export const traceException = (
const exception = {
code: code,
message: ex instanceof Error ? ex.message : String(ex),
name: ex instanceof Error ? ex.name : "Error",
stack: ex instanceof Error ? ex.stack : undefined,
message:
ex instanceof Error
? ex.message
: typeof ex === "object" && ex !== null && "message" in ex
? JSON.stringify(ex.message)
: JSON.stringify(ex),
name:
ex instanceof Error
? ex.name
: typeof ex === "object" && ex !== null && "name" in ex
? JSON.stringify(ex.name)
: "Error",
stack:
ex instanceof Error
? JSON.stringify(ex.stack)
: typeof ex === "object" && ex !== null && "stack" in ex
? JSON.stringify(ex.stack)
: undefined,
};
// adds an otel event
@@ -1,6 +1,6 @@
import { Queue } from "bullmq";
import { QueueName, TQueueJobTypes } from "../queues";
import { redis } from "./redis";
import { createNewRedisInstance } from "./redis";
let batchExportQueue: Queue<TQueueJobTypes[QueueName.BatchExport]> | null =
null;
@@ -8,9 +8,11 @@ let batchExportQueue: Queue<TQueueJobTypes[QueueName.BatchExport]> | null =
export const getBatchExportQueue = () => {
if (batchExportQueue) return batchExportQueue;
batchExportQueue = redis
const connection = createNewRedisInstance();
batchExportQueue = connection
? new Queue<TQueueJobTypes[QueueName.BatchExport]>(QueueName.BatchExport, {
connection: redis,
connection: connection,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100,
@@ -1,7 +1,7 @@
import { Queue } from "bullmq";
import { env } from "../../env";
import { redis } from "../redis/redis";
import { createNewRedisInstance } from "../redis/redis";
import { QueueName } from "../queues";
export type IngestionFlushQueue = Queue<null>;
@@ -11,12 +11,14 @@ let ingestionFlushQueue: IngestionFlushQueue | null = null;
export const getIngestionFlushQueue = () => {
if (ingestionFlushQueue) return ingestionFlushQueue;
ingestionFlushQueue = redis
const connection = createNewRedisInstance();
ingestionFlushQueue = connection
? new Queue<null>(QueueName.IngestionFlushQueue, {
connection: redis,
connection: connection,
defaultJobOptions: {
removeOnComplete: true, // Important: If not true, new jobs for that ID would be ignored as jobs in the complete set are still considered as part of the queue
removeOnFail: 1000,
removeOnFail: 1_000,
delay: env.LANGFUSE_INGESTION_FLUSH_DELAY_MS,
attempts: env.LANGFUSE_INGESTION_FLUSH_ATTEMPTS,
},
@@ -1,6 +1,6 @@
import { Queue } from "bullmq";
import { QueueName, TQueueJobTypes } from "../queues";
import { redis } from "./redis";
import { createNewRedisInstance } from "./redis";
let legacyIngestionQueue: Queue<
TQueueJobTypes[QueueName.LegacyIngestionQueue]
@@ -9,11 +9,13 @@ let legacyIngestionQueue: Queue<
export const getLegacyIngestionQueue = () => {
if (legacyIngestionQueue) return legacyIngestionQueue;
legacyIngestionQueue = redis
const newRedis = createNewRedisInstance();
legacyIngestionQueue = newRedis
? new Queue<TQueueJobTypes[QueueName.LegacyIngestionQueue]>(
QueueName.LegacyIngestionQueue,
{
connection: redis,
connection: newRedis,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100,
+18 -10
View File
@@ -1,18 +1,26 @@
import Redis from "ioredis";
import { env } from "../../env";
export const createNewRedisInstance = () => {
return env.REDIS_CONNECTION_STRING
? new Redis(env.REDIS_CONNECTION_STRING, {
maxRetriesPerRequest: null,
enableAutoPipelining: env.REDIS_ENABLE_AUTO_PIPELINING === "true",
})
: env.REDIS_HOST
? new Redis({
host: String(env.REDIS_HOST),
port: Number(env.REDIS_PORT),
password: String(env.REDIS_AUTH),
maxRetriesPerRequest: null, // Set to `null` to disable retrying
enableAutoPipelining: env.REDIS_ENABLE_AUTO_PIPELINING === "true",
})
: null;
};
const createRedisClient = () => {
try {
return env.REDIS_CONNECTION_STRING
? new Redis(env.REDIS_CONNECTION_STRING, { maxRetriesPerRequest: null })
: env.REDIS_HOST
? new Redis({
host: String(env.REDIS_HOST),
port: Number(env.REDIS_PORT),
password: String(env.REDIS_AUTH),
maxRetriesPerRequest: null, // Set to `null` to disable retrying
})
: null;
return createNewRedisInstance();
} catch (e) {
console.error(e, "Failed to connect to redis");
return null;
@@ -6,7 +6,7 @@ import {
TraceUpsertEventType,
} from "../queues";
import { Queue } from "bullmq";
import { redis } from "./redis";
import { createNewRedisInstance } from "./redis";
let traceUpsertQueue: Queue<TQueueJobTypes[QueueName.TraceUpsert]> | null =
null;
@@ -14,12 +14,14 @@ let traceUpsertQueue: Queue<TQueueJobTypes[QueueName.TraceUpsert]> | null =
export const getTraceUpsertQueue = () => {
if (traceUpsertQueue) return traceUpsertQueue;
traceUpsertQueue = redis
const connection = createNewRedisInstance();
traceUpsertQueue = connection
? new Queue<TQueueJobTypes[QueueName.TraceUpsert]>(QueueName.TraceUpsert, {
connection: redis,
connection: connection,
defaultJobOptions: {
removeOnComplete: 100, // Important: If not true, new jobs for that ID would be ignored as jobs in the complete set are still considered as part of the queue
removeOnFail: 1000,
removeOnFail: 1_000,
},
})
: null;
@@ -54,7 +56,7 @@ export function convertTraceUpsertEventsToRedisEvents(
name: QueueJobs.TraceUpsert as const,
},
opts: {
removeOnFail: 10000,
removeOnFail: 1_000,
removeOnComplete: true,
attempts: 5,
backoff: {
@@ -17,6 +17,7 @@ export const sessionsViewCols: ColumnDefinition[] = [
type: "arrayOptions",
internal: 't."userIds"',
options: [], // to be filled in at runtime
nullable: true,
},
{
name: "Session Duration (s)",
@@ -78,10 +79,18 @@ export const sessionsViewCols: ColumnDefinition[] = [
type: "number",
internal: 'o."totalTokens"',
},
{
name: "Trace Tags",
id: "tags",
type: "arrayOptions",
internal: 't."tags"',
options: [], // to be filled in at runtime
},
];
export type SessionOptions = {
userIds: Array<OptionsDefinition>;
tags: Array<OptionsDefinition>;
};
export function sessionsTableColsWithOptions(
@@ -91,6 +100,9 @@ export function sessionsTableColsWithOptions(
if (col.id === "userIds") {
return { ...col, options: options?.userIds ?? [] };
}
if (col.id === "tags") {
return { ...col, options: options?.tags ?? [] };
}
return col;
});
}
@@ -9,6 +9,7 @@ export type ColumnDefinition =
id: string;
type: "number" | "string" | "datetime" | "boolean";
internal: string;
nullable?: boolean;
}
| {
name: string;
@@ -16,6 +17,7 @@ export type ColumnDefinition =
type: "stringOptions";
options: Array<OptionsDefinition>;
internal: string;
nullable?: boolean;
}
| {
name: string;
@@ -23,6 +25,7 @@ export type ColumnDefinition =
type: "arrayOptions";
options: Array<OptionsDefinition>;
internal: string;
nullable?: boolean;
}
| {
name: string;
@@ -30,6 +33,7 @@ export type ColumnDefinition =
type: "stringObject" | "numberObject";
internal: string;
keyOptions?: Array<string>;
nullable?: boolean;
};
export const tableNames = [
+18 -1
View File
@@ -15,6 +15,7 @@ const tracesOnlyCols: ColumnDefinition[] = [
type: "stringOptions",
internal: 't."name"',
options: [], // to be filled in at runtime
nullable: true,
},
{
name: "Timestamp",
@@ -22,12 +23,19 @@ const tracesOnlyCols: ColumnDefinition[] = [
type: "datetime",
internal: 't."timestamp"',
},
{ name: "User ID", id: "userId", type: "string", internal: 't."user_id"' },
{
name: "User ID",
id: "userId",
type: "string",
internal: 't."user_id"',
nullable: true,
},
{
name: "Session ID",
id: "sessionId",
type: "string",
internal: 't."session_id"',
nullable: true,
},
{
name: "Metadata",
@@ -40,12 +48,14 @@ const tracesOnlyCols: ColumnDefinition[] = [
id: "version",
type: "string",
internal: 't."version"',
nullable: true,
},
{
name: "Release",
id: "release",
type: "string",
internal: 't."release"',
nullable: true,
},
{
name: "Level",
@@ -69,24 +79,28 @@ export const tracesTableCols: ColumnDefinition[] = [
id: "inputTokens",
type: "number",
internal: 'tm."promptTokens"',
nullable: true,
},
{
name: "Output Tokens",
id: "outputTokens",
type: "number",
internal: 'tm."completionTokens"',
nullable: true,
},
{
name: "Total Tokens",
id: "totalTokens",
type: "number",
internal: 'tm."totalTokens"',
nullable: true,
},
{
name: "Usage",
id: "usage",
type: "number",
internal: 'tm."totalTokens"',
nullable: true,
},
{
@@ -106,18 +120,21 @@ export const tracesTableCols: ColumnDefinition[] = [
id: "inputCost",
type: "number",
internal: '"calculatedInputCost"',
nullable: true,
},
{
name: "Output Cost ($)",
id: "outputCost",
type: "number",
internal: '"calculatedOutputCost"',
nullable: true,
},
{
name: "Total Cost ($)",
id: "totalCost",
type: "number",
internal: '"calculatedTotalCost"',
nullable: true,
},
];
+1531 -1198
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -41,6 +41,8 @@ ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY
ARG NEXT_PUBLIC_POSTHOG_KEY
ARG NEXT_PUBLIC_POSTHOG_HOST
ARG NEXT_PUBLIC_CRISP_WEBSITE_ID
ARG NEXT_PUBLIC_SENTRY_DSN
ARG NEXT_LANGFUSE_TRACING_SAMPLE_RATE
# Copy source code of isolated subworkspace
COPY --from=pruner /app/out/full/ .
@@ -100,4 +102,4 @@ ENV PORT 3000
ENTRYPOINT ["dumb-init", "--", "./web/entrypoint.sh"]
# startup command
CMD ["node", "./web/server.js"]
CMD ["node", "./web/server.js", "--keepAliveTimeout", "110000"]
+32 -9
View File
@@ -12,26 +12,49 @@ import { env } from "./src/env.mjs";
*/
const cspHeader = `
default-src 'self' https://*.langfuse.com https://*.posthog.com https://*.sentry.io wss://*.crisp.chat https://*.crisp.chat;
script-src 'self' 'unsafe-eval' https://*.langfuse.com https://*.crisp.chat https://challenges.cloudflare.com https://*.sentry.io https://ph.langfuse.com https://static.cloudflareinsights.com https://*.stripe.com;
style-src 'self' 'unsafe-inline' https://*.crisp.chat;
img-src 'self' https: blob: data:;
font-src 'self' https://*.crisp.chat;
frame-src 'self' https://challenges.cloudflare.com https://*.stripe.com;
script-src 'self' 'unsafe-eval' 'unsafe-inline' https://*.langfuse.com https://client.crisp.chat https://settings.crisp.chat https://challenges.cloudflare.com https://*.sentry.io https://ph.langfuse.com https://static.cloudflareinsights.com https://*.stripe.com;
style-src 'self' 'unsafe-inline' https://client.crisp.chat;
img-src 'self' https: blob: data: https://client.crisp.chat https://image.crisp.chat https://storage.crisp.chat;
font-src 'self' https://client.crisp.chat;
frame-src 'self' https://challenges.cloudflare.com https://*.stripe.com https://game.crisp.chat;
worker-src 'self' blob:;
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
connect-src 'self' https://client.crisp.chat https://storage.crisp.chat wss://client.relay.crisp.chat wss://stream.relay.crisp.chat https://*.ingest.us.sentry.io https://ph.langfuse.com;
media-src 'self' https://client.crisp.chat;
${env.LANGFUSE_CSP_ENFORCE_HTTPS === "true" ? "upgrade-insecure-requests; block-all-mixed-content;" : ""}
${env.SENTRY_CSP_REPORT_URI ? `report-uri ${env.SENTRY_CSP_REPORT_URI}; report-to csp-endpoint;` : ""}
`;
const reportToHeader = {
key: "Report-To",
value: JSON.stringify({
group: "csp-endpoint",
max_age: 10886400,
endpoints: [
{
url: env.SENTRY_CSP_REPORT_URI,
},
],
include_subdomains: true,
}),
};
/** @type {import("next").NextConfig} */
const nextConfig = {
transpilePackages: ["@langfuse/shared"],
reactStrictMode: true,
experimental: {
instrumentationHook: true,
serverComponentsExternalPackages: [
"dd-trace",
"@opentelemetry/auto-instrumentations-node",
"@opentelemetry/api",
],
},
poweredByHeader: false,
/**
* If you have `experimental: { appDir: true }` set, then you must comment the below `i18n` config
@@ -66,6 +89,7 @@ const nextConfig = {
key: "Permissions-Policy",
value: "autoplay=*, fullscreen=*, microphone=*",
},
...(env.SENTRY_CSP_REPORT_URI ? [reportToHeader] : []),
],
},
{
@@ -116,10 +140,6 @@ const nextConfig = {
// webassembly support for @dqbd/tiktoken
webpack(config, { isServer }) {
if (isServer) {
// https://github.com/open-telemetry/opentelemetry-js/issues/4173
config.ignoreWarnings = [{ module: /opentelemetry/ }];
}
config.experiments = {
asyncWebAssembly: true,
layers: true,
@@ -158,6 +178,9 @@ const sentryOptions = {
// - excludeServerRoutes
// 'Configure Tunneling':
// - tunnelRoute
// An auth token is required for uploading source maps.
authToken: env.SENTRY_AUTH_TOKEN,
tunnelRoute: "/api/monitoring-tunnel",
};
+34 -33
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "2.72.0",
"version": "2.75.2",
"private": true,
"license": "MIT",
"engines": {
@@ -54,32 +54,31 @@
"@opentelemetry/sdk-trace-node": "^1.25.1",
"@opentelemetry/semantic-conventions": "^1.25.1",
"@prisma/instrumentation": "^5.13.0",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-hover-card": "^1.0.7",
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-alert-dialog": "^1.1.1",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.1.1",
"@radix-ui/react-collapsible": "^1.1.0",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-hover-card": "^1.1.1",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toggle": "^1.0.3",
"@radix-ui/react-toggle-group": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slider": "^1.2.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.2",
"@remixicon/react": "^4.2.0",
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@sentry/nextjs": "8.13.0",
"@sentry/node": "8.13.0",
"@sentry/nextjs": "8.26.0",
"@t3-oss/env-nextjs": "^0.10.1",
"@tailwindcss/container-queries": "^0.1.1",
"@tanstack/react-query": "^4.36.1",
@@ -89,16 +88,16 @@
"@trpc/next": "^10.45.0",
"@trpc/react-query": "^10.45.0",
"@trpc/server": "^10.45.0",
"@uiw/codemirror-theme-github": "^4.21.25",
"@uiw/codemirror-theme-github": "^4.23.0",
"@uiw/codemirror-theme-tokyo-night": "^4.22.2",
"@uiw/react-codemirror": "^4.21.25",
"ai": "^3.0.23",
"bcryptjs": "^2.4.3",
"bullmq": "^5.4.2",
"bullmq": "^5.12.10",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"core-js": "^3.36.1",
"core-js": "^3.38.1",
"cors": "^2.8.5",
"date-fns": "^3.3.1",
"dd-trace": "^5.21.0",
@@ -108,11 +107,11 @@
"ioredis": "^5.4.1",
"ip-address": "^9.0.5",
"js-tiktoken": "^1.0.12",
"kysely": "^0.27.3",
"kysely": "^0.27.4",
"langchain": "^0.2.6",
"lodash": "^4.17.21",
"lucide-react": "^0.364.0",
"next": "^14.2.3",
"lucide-react": "^0.429.0",
"next": "^14.2.6",
"next-auth": "^4.24.7",
"next-query-params": "^5.0.0",
"next-themes": "^0.3.0",
@@ -120,12 +119,14 @@
"posthog-node": "^3.6.3",
"prexit": "^2.2.0",
"prisma": "^5.13.0",
"rate-limiter-flexible": "^5.0.3",
"react": "18.2.0",
"react-day-picker": "^8.10.1",
"react-dom": "18.2.0",
"react-hook-form": "^7.51.5",
"react-icons": "^5.2.1",
"react-markdown": "^9.0.1",
"react-resizable-panels": "^2.1.1",
"react-responsive": "^10.0.0",
"react-syntax-highlighter": "^15.5.0",
"react18-json-view": "^0.2.8-canary.6",
@@ -134,7 +135,7 @@
"sonner": "^1.4.41",
"stripe": "^16.8.0",
"superjson": "2.2.1",
"tailwind-merge": "^2.2.1",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"use-query-params": "^2.2.1",
"uuid": "^9.0.1",
@@ -164,17 +165,17 @@
"autoprefixer": "^10.4.19",
"dotenv-cli": "^7.4.2",
"eslint": "^8.56.0",
"eslint-config-next": "^14.2.3",
"eslint-config-next": "^14.2.6",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"node-mocks-http": "^1.14.1",
"postcss": "^8.4.38",
"prettier": "^3.2.5",
"prettier-plugin-tailwindcss": "^0.5.14",
"prettier-plugin-tailwindcss": "^0.6.6",
"tailwindcss": "^3.4.4",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.7.2",
"tsx": "^4.18.0",
"typescript": "^5.4.5",
"wait-for-expect": "^3.0.2"
},
@@ -3,19 +3,28 @@ import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
// Replay may only be enabled for the client-side
integrations: [Sentry.replayIntegration()],
integrations: [
Sentry.replayIntegration(),
// Sentry.debugIntegration(),
Sentry.captureConsoleIntegration({
levels: ["error"],
}),
],
// Set tracesSampleRate to 1.0 to capture 100%
// of transactions for performance monitoring.
// We recommend adjusting this value in production
tracesSampleRate: process.env.NEXT_LANGFUSE_TRACING_SAMPLE_RATE
? Number(process.env.NEXT_LANGFUSE_TRACING_SAMPLE_RATE)
: 0.1,
tracesSampleRate: process.env.NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE
? Number(process.env.NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE)
: 0.5,
// Capture Replay for 100% of all sessions,
// plus for 100% of sessions with an error
replaysSessionSampleRate: 0,
replaysSessionSampleRate: process.env.NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE
? Number(process.env.NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE)
: 0.5,
replaysOnErrorSampleRate: 1.0,
debug: false,
beforeSend(event, _hint) {
// Check if it is an exception, and if so, show the report dialog
+96 -4
View File
@@ -1,8 +1,7 @@
import { v4 } from "uuid";
import { JobExecutionStatus, prisma } from "@langfuse/shared/src/db";
import { redis } from "@langfuse/shared/src/server";
import { JobExecutionStatus, Prisma, prisma } from "@langfuse/shared/src/db";
import { OrgEnrichedApiKey, redis } from "@langfuse/shared/src/server";
import waitForExpect from "wait-for-expect";
import { ApiKeyZod } from "@/src/features/public-api/server/apiAuth";
const generateAuth = (username: string, password: string) => {
const auth = Buffer.from(`${username}:${password}`).toString("base64");
@@ -38,6 +37,14 @@ describe("Health endpoints", () => {
});
describe("Ingestion Pipeline", () => {
beforeEach(async () => {
// clear the redis cache
const keys = await redis?.keys("*");
if (keys && keys.length > 0) {
await redis?.del(keys);
}
});
it("ingest a trace", async () => {
const traceId = v4();
const spanId = v4();
@@ -118,7 +125,7 @@ describe("Ingestion Pipeline", () => {
expect(redisKeys?.length).toBe(1);
const redisValue = await redis?.get(redisKeys![0]);
const llmApiKey = ApiKeyZod.parse(JSON.parse(redisValue!));
const llmApiKey = OrgEnrichedApiKey.parse(JSON.parse(redisValue!));
expect(llmApiKey.projectId).toBe("7a88fb47-b4e2-43b8-a06c-a5ce950dc53a");
});
@@ -143,6 +150,91 @@ describe("Ingestion Pipeline", () => {
expect(response.status).toBe(207);
}, 25000);
it("rate limit ingestion", async () => {
// update the org in the database and set the rate limit to 1 for ingestion
const org = await prisma.organization.findUnique({
where: {
id: "seed-org-id",
},
});
await prisma.organization.update({
where: {
id: "seed-org-id",
},
data: {
cloudConfig: {
...(typeof org?.cloudConfig === "object" ? org.cloudConfig : {}),
rateLimitOverrides: [
{
resource: "ingestion",
points: 1,
durationInSec: 60,
},
],
},
},
});
const traceId = v4();
const spanId = v4();
const event = {
batch: [
{
id: v4(),
type: "trace-create",
timestamp: new Date().toISOString(),
body: {
name: "test trace",
id: traceId,
userId: "user-1", // triggers the eval
},
},
{
id: v4(),
type: "span-create",
timestamp: new Date().toISOString(),
body: {
id: spanId,
traceId: traceId,
name: "test span",
},
},
],
};
// Arrange
const url = "http://localhost:3000/api/public/ingestion";
// Act
let responses = [];
for (let i = 0; i < 10; i++) {
responses.push(
await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: userApiKeyAuth,
},
body: JSON.stringify(event),
}),
);
}
// check that at least one of the responses is a 429
const rateLimitedResponse = responses.find((r) => r.status === 429);
expect(rateLimitedResponse).not.toBeNull();
// revert the rate limit on the org
await prisma.organization.update({
where: {
id: "seed-org-id",
},
data: {
cloudConfig: org?.cloudConfig ?? Prisma.JsonNull,
},
});
});
});
describe("Prompts endpoint", () => {
+160 -8
View File
@@ -1,14 +1,12 @@
import {
getDisplaySecretKey,
hashSecretKey,
OrgEnrichedApiKey,
} from "@langfuse/shared/src/server";
import {
ApiAuthService,
ApiKeyZod,
} from "@/src/features/public-api/server/apiAuth";
import { type PrismaClient, prisma } from "@langfuse/shared/src/db";
import { Prisma, type PrismaClient, prisma } from "@langfuse/shared/src/db";
import { Redis } from "ioredis";
import { env } from "@/src/env.mjs";
import { ApiAuthService } from "@/src/features/public-api/server/apiAuth";
describe("Authenticate API calls", () => {
beforeEach(async () => {
@@ -68,6 +66,118 @@ describe("Authenticate API calls", () => {
expect(auth2.validKey).toBe(true);
});
it("should create new api key with stripe data and succeed with new key", async () => {
await createAPIKey();
await prisma.organization.update({
where: {
id: "seed-org-id",
},
data: {
cloudConfig: {
stripe: {
customerId: "cus_test123",
activeSubscriptionId: "sub_test123",
activeProductId: "prod_test123",
},
},
},
});
await new ApiAuthService(prisma, null).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
const auth = await new ApiAuthService(
prisma,
null,
).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
expect(auth.validKey).toBe(true);
if (auth.validKey) {
expect(auth.scope.orgId).toBe("seed-org-id");
expect(auth.scope.plan).toBe("cloud:hobby");
expect(auth.scope.rateLimitOverrides).toEqual([]);
}
const apiKey = await prisma.apiKey.findUnique({
where: { publicKey: "pk-lf-1234567890" },
});
expect(apiKey).not.toBeNull();
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
await prisma.organization.update({
where: {
id: "seed-org-id",
},
data: {
cloudConfig: Prisma.JsonNull,
},
});
});
it("should create new api key with custom rate limits and succeed with new key", async () => {
await createAPIKey();
await prisma.organization.update({
where: {
id: "seed-org-id",
},
data: {
cloudConfig: {
rateLimitOverrides: [
{
resource: "ingestion",
points: 100,
durationInSec: 60,
},
],
},
},
});
await new ApiAuthService(prisma, null).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
const auth = await new ApiAuthService(
prisma,
null,
).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
expect(auth.validKey).toBe(true);
if (auth.validKey) {
expect(auth.scope.orgId).toBe("seed-org-id");
expect(auth.scope.plan).toBe("cloud:hobby");
expect(auth.scope.rateLimitOverrides).toEqual([
{
resource: "ingestion",
points: 100,
durationInSec: 60,
},
]);
}
const apiKey = await prisma.apiKey.findUnique({
where: { publicKey: "pk-lf-1234567890" },
});
expect(apiKey).not.toBeNull();
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
await prisma.organization.update({
where: {
id: "seed-org-id",
},
data: {
cloudConfig: Prisma.JsonNull,
},
});
});
it("should fail on wrong api key with new key", async () => {
await createAPIKey();
const auth = await new ApiAuthService(
@@ -149,6 +259,25 @@ describe("Authenticate API calls", () => {
it("should create new api key and read from cache", async () => {
await createAPIKey();
// update the organization with a cloud config
await prisma.organization.update({
where: { id: "seed-org-id" },
data: {
cloudConfig: {
rateLimitOverrides: [
{
resource: "public-api",
points: 1000,
durationInSec: 60,
},
{
resource: "ingestion",
},
],
},
},
});
// first auth will generate the fast hashed api key
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
@@ -177,12 +306,31 @@ describe("Authenticate API calls", () => {
expect(cachedKey2).not.toBeNull();
const parsed = ApiKeyZod.parse(JSON.parse(cachedKey2!));
const parsed = OrgEnrichedApiKey.parse(JSON.parse(cachedKey2!));
expect(parsed).toEqual({
...apiKey,
orgId: "seed-org-id",
plan: "cloud:hobby",
rateLimitOverrides: [
{
resource: "public-api",
points: 1000,
durationInSec: 60,
},
{
resource: "ingestion",
},
],
createdAt: apiKey?.createdAt.toISOString(),
});
await prisma.organization.update({
where: { id: "seed-org-id" },
data: {
cloudConfig: Prisma.JsonNull,
},
});
});
it("searching for non-existing key stores flag in redis and fails auth", async () => {
@@ -275,7 +423,7 @@ describe("Authenticate API calls", () => {
);
expect(cachedKey).not.toBeNull();
const parsed = ApiKeyZod.parse(JSON.parse(cachedKey!));
const parsed = OrgEnrichedApiKey.parse(JSON.parse(cachedKey!));
expect(parsed).toEqual({
id: expect.any(String),
@@ -288,6 +436,8 @@ describe("Authenticate API calls", () => {
lastUsedAt: null,
expiresAt: null,
projectId: expect.any(String),
orgId: "seed-org-id",
plan: "cloud:hobby",
});
});
@@ -372,10 +522,12 @@ describe("Authenticate API calls", () => {
);
expect(cachedKey).not.toBeNull();
const parsed = ApiKeyZod.parse(JSON.parse(cachedKey!));
const parsed = OrgEnrichedApiKey.parse(JSON.parse(cachedKey!));
expect(parsed).toEqual({
...apiKey,
orgId: "seed-org-id",
plan: "cloud:hobby",
createdAt: apiKey?.createdAt.toISOString(),
});
+269
View File
@@ -0,0 +1,269 @@
import { RateLimitService } from "@/src/features/public-api/server/RateLimitService";
import { Redis } from "ioredis";
describe("RateLimitService", () => {
const orgId = "seed-org-id";
let redis: Redis;
beforeAll(() => {
redis = new Redis("redis://:myredissecret@127.0.0.1:6379", {
maxRetriesPerRequest: null,
});
});
afterAll(async () => {
redis.disconnect();
});
beforeEach(async () => {
expect(redis).toBeDefined();
const keys = await redis?.keys("rate-limit*");
if (keys && keys?.length > 0) {
await redis?.del(keys);
}
});
afterEach(async () => {
const keys = await redis?.keys("rate-limit*");
if (keys && keys.length > 0) {
await redis?.del(keys);
}
});
it("should rate limit", async () => {
const scope = {
orgId: orgId,
plan: "cloud:hobby" as const,
projectId: "test-project-id",
accessLevel: "all" as const,
rateLimitOverrides: [],
};
const rateLimitService = new RateLimitService(redis!);
const result = await rateLimitService.rateLimitRequest(scope, "public-api");
expect(result?.res).toEqual({
scope: scope,
resource: "public-api",
points: 1000,
remainingPoints: 999,
msBeforeNext: expect.any(Number),
consumedPoints: 1,
isFirstInDuration: true,
});
expect(result?.isRateLimited()).toBe(false);
// check redis for the rate limit key
const value = await redis.get("rate-limit:public-api:seed-org-id");
expect(value).toBeDefined();
expect(parseInt(value ?? "0")).toBeGreaterThan(0);
});
it("should increment the rate limit count", async () => {
const scope = {
orgId: orgId,
plan: "cloud:hobby" as const,
projectId: "test-project-id",
accessLevel: "all" as const,
rateLimitOverrides: [],
};
const rateLimitService = new RateLimitService(redis!);
await rateLimitService.rateLimitRequest(scope, "public-api");
const result = await rateLimitService.rateLimitRequest(scope, "public-api");
expect(result?.res).toEqual({
scope: scope,
resource: "public-api",
points: 1000,
remainingPoints: 998,
msBeforeNext: expect.any(Number),
consumedPoints: 2,
isFirstInDuration: false,
});
expect(result?.isRateLimited()).toBe(false);
});
it("should reset the rate limit count after the window expires", async () => {
const scope = {
orgId: orgId,
plan: "cloud:hobby" as const,
projectId: "test-project-id",
accessLevel: "all" as const,
rateLimitOverrides: [
{ resource: "public-api" as const, points: 100, durationInSec: 2 },
],
};
const rateLimitService = new RateLimitService(redis!);
await rateLimitService.rateLimitRequest(scope, "public-api");
const firstResult = await rateLimitService.rateLimitRequest(
scope,
"public-api",
);
expect(firstResult?.isRateLimited()).toBe(false);
expect(firstResult?.res).toEqual({
scope: scope,
resource: "public-api",
points: 100,
remainingPoints: 98,
msBeforeNext: expect.any(Number),
consumedPoints: 2,
isFirstInDuration: false,
});
await new Promise((resolve) => setTimeout(resolve, 3000));
const secondResult = await rateLimitService.rateLimitRequest(
scope,
"public-api",
);
expect(secondResult?.res).toEqual({
scope: scope,
resource: "public-api",
points: 100,
remainingPoints: 99,
msBeforeNext: expect.any(Number),
consumedPoints: 1,
isFirstInDuration: true,
});
expect(secondResult?.isRateLimited()).toBe(false);
});
it("should return false when rate limit is exceeded", async () => {
const scope = {
orgId: orgId,
plan: "cloud:hobby" as const,
projectId: "test-project-id",
accessLevel: "all" as const,
rateLimitOverrides: [
{ resource: "public-api" as const, points: 5, durationInSec: 60 },
],
};
const rateLimitService = new RateLimitService(redis!);
for (let i = 0; i < 5; i++) {
await rateLimitService.rateLimitRequest(scope, "public-api");
}
const result = await rateLimitService.rateLimitRequest(scope, "public-api");
expect(result?.res).toEqual({
scope: scope,
resource: "public-api",
points: 5,
remainingPoints: 0,
msBeforeNext: expect.any(Number),
consumedPoints: 6,
isFirstInDuration: false,
});
expect(result?.isRateLimited()).toBe(true);
});
it("should apply rate limits with override for specific resource", async () => {
const scope = {
orgId: orgId,
plan: "cloud:hobby" as const,
projectId: "test-project-id",
accessLevel: "all" as const,
rateLimitOverrides: [
{ resource: "public-api" as const, points: 5, durationInSec: 10 },
],
};
const rateLimitService = new RateLimitService(redis!);
const result = await rateLimitService.rateLimitRequest(scope, "public-api");
expect(result?.res).toEqual({
scope: scope,
resource: "public-api",
points: 5,
remainingPoints: 4,
msBeforeNext: expect.any(Number),
consumedPoints: 1,
isFirstInDuration: true,
});
});
it("should not apply rate limits for resource prompts", async () => {
const scope = {
orgId: orgId,
plan: "cloud:hobby" as const,
projectId: "test-project-id",
accessLevel: "all" as const,
rateLimitOverrides: [
{ resource: "public-api" as const, points: 5, durationInSec: 10 },
],
};
const rateLimitService = new RateLimitService(redis!);
const result = await rateLimitService.rateLimitRequest(scope, "prompts");
expect(result?.res).toBeUndefined();
expect(result?.isRateLimited()).toBe(false);
});
it("should not apply rate limits for ingestion when overridden to null in API key", async () => {
const scope = {
orgId: orgId,
plan: "cloud:hobby" as const,
projectId: "test-project-id",
accessLevel: "all" as const,
rateLimitOverrides: [
{ resource: "ingestion" as const, points: null, durationInSec: null },
],
};
const rateLimitService = new RateLimitService(redis!);
const result = await rateLimitService.rateLimitRequest(scope, "ingestion");
expect(result?.res).toBeUndefined();
});
it("should not apply rate limits when redis is not defined", async () => {
const scope = {
orgId: orgId,
plan: "cloud:hobby" as const,
projectId: "test-project-id",
accessLevel: "all" as const,
rateLimitOverrides: [
{ resource: "public-api" as const, points: 5, durationInSec: 10 },
],
};
const rateLimitService = new RateLimitService(null);
const result = await rateLimitService.rateLimitRequest(scope, "public-api");
expect(result?.res).toBeUndefined();
expect(result?.isRateLimited()).toBe(false);
});
it("should not apply rate limits for OSS plan", async () => {
const scope = {
orgId: orgId,
plan: "oss" as const,
projectId: "test-project-id",
accessLevel: "all" as const,
rateLimitOverrides: [],
};
const rateLimitService = new RateLimitService(redis!);
const result = await rateLimitService.rateLimitRequest(scope, "public-api");
expect(result?.res).toBeUndefined();
expect(result?.isRateLimited()).toBe(false);
});
});
@@ -64,7 +64,7 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
<Textarea
ref={textAreaRef}
id={message.id}
className="height-[auto] min-h-8 w-full pt-3 font-mono text-xs focus:outline-none"
className="height-[auto] min-h-6 w-full font-mono text-xs focus:outline-none"
placeholder={placeholder}
value={message.content}
onChange={handleContentChange}
+17 -9
View File
@@ -24,13 +24,21 @@ export const ChatMessages: React.FC<ChatMessagesProps> = (props) => {
}, [scrollAreaRef, messages.length]);
return (
<div className="h-full overflow-auto scroll-smooth" ref={scrollAreaRef}>
<div className="mb-4 flex-1 space-y-4">
{props.messages.map((message) => {
return (
<ChatMessageComponent {...{ message, ...props }} key={message.id} />
);
})}
<div className="flex h-full flex-col">
<div className="mb-2 font-semibold">Messages</div>
<div className="flex-1 overflow-auto scroll-smooth" ref={scrollAreaRef}>
<div className="mb-4 flex-1 space-y-3">
{props.messages.map((message) => {
return (
<ChatMessageComponent
{...{ message, ...props }}
key={message.id}
/>
);
})}
</div>
</div>
<div className="py-3">
<AddMessageButton {...props} />
</div>
</div>
@@ -52,10 +60,10 @@ const AddMessageButton: React.FC<AddMessageButtonProps> = ({
<Button
type="button" // prevents submitting a form if this button is inside a form
variant="outline"
className="w-full space-x-2 py-6"
className="w-full"
onClick={() => addMessage(nextMessageRole)}
>
<PlusCircleIcon size={16} />
<PlusCircleIcon size={14} className="mr-2" />
<p>Add message</p>
</Button>
);
@@ -47,7 +47,7 @@ export const LLMApiKeyComponent = (p: {
<Label className="text-xs font-semibold">API key</Label>
<div>
{apiKey ? (
<Link href={`/project/${p.projectId}/settings#llm-api-keys`}>
<Link href={`/project/${p.projectId}/settings/api-keys`}>
<span className="mr-2 rounded-sm bg-input p-1 text-xs">
{apiKey.displaySecretKey}
</span>
@@ -60,7 +60,7 @@ export const LLMApiKeyComponent = (p: {
{`No LLM API key found for provider ${modelProvider}.`}
<Link
href={`/project/${p.projectId}/settings`}
href={`/project/${p.projectId}/settings/api-keys`}
className="flex flex-row"
>
Create a new LLM API key here. <ArrowTopRightIcon />
+8 -5
View File
@@ -31,12 +31,15 @@ export type ModelParamsContext = {
formDisabled?: boolean;
};
export const ModelParameters: React.FC<ModelParamsContext> = ({
export const ModelParameters: React.FC<
ModelParamsContext & { evalModelsOnly: boolean }
> = ({
modelParams,
availableProviders,
availableModels,
updateModelParamValue,
setModelParamEnabled,
evalModelsOnly,
formDisabled = false,
}) => {
const projectId = useProjectIdFromURL();
@@ -48,11 +51,11 @@ export const ModelParameters: React.FC<ModelParamsContext> = ({
<p className="font-semibold">Model</p>
{availableProviders.length === 0 ? (
<>
<p className="text-sm">
No LLM API key set in project. For evals, we only support OpenAI so
far.
<p className="text-xs">
No LLM API key set in project.{" "}
{evalModelsOnly && "For evals, only OpenAI models are supported."}
</p>
<CreateLLMApiKeyDialog evalModelsOnly={true} />
<CreateLLMApiKeyDialog evalModelsOnly={evalModelsOnly} />
</>
) : (
<div className="space-y-4">
@@ -49,7 +49,7 @@ export const PagedSettingsContainer = ({
return (
<main className="flex flex-1 flex-col gap-4 py-4 md:gap-8">
<div className="grid w-full items-start gap-4 md:grid-cols-[180px_1fr] lg:grid-cols-[220px_1fr]">
<div className="grid w-full items-start gap-4 md:grid-cols-[150px_1fr] lg:grid-cols-[220px_1fr]">
<nav className="block md:hidden">
<Select
onValueChange={(slug) => {
+2 -2
View File
@@ -11,11 +11,11 @@ export const SettingsDangerZone: React.FC<{
return (
<div className="space-y-3">
<Header title="Danger Zone" level="h3" />
<div className="rounded border">
<div className="rounded-lg border">
{items.map((item, index) => (
<div
key={index}
className="flex items-center justify-between gap-4 border-b p-4 last:border-b-0"
className="flex items-center justify-between gap-4 border-b p-3 last:border-b-0"
>
<div>
<h4 className="font-semibold">{item.title}</h4>
+12 -15
View File
@@ -9,15 +9,14 @@ import {
} from "@/src/components/ui/select";
import {
DEFAULT_AGGREGATION_SELECTION,
tableDateRangeAggregationSettings,
dashboardDateRangeAggregationSettings,
DASHBOARD_AGGREGATION_PLACEHOLDER,
type DashboardDateRangeOptions,
type TableDateRangeOptions,
DASHBOARD_AGGREGATION_OPTIONS,
type DashboardDateRange,
TABLE_RANGE_DROPDOWN_OPTIONS,
TABLE_AGGREGATION_OPTIONS,
getDateFromOption,
} from "@/src/utils/date-range-utils";
import { Clock } from "lucide-react";
@@ -101,24 +100,22 @@ export const TableDateRangeDropdown: React.FC<TableDateRangeDropdownProps> = ({
setDateRangeAndOption,
}) => {
const onDropDownSelection = (value: TableDateRangeOptions) => {
if (value === DEFAULT_AGGREGATION_SELECTION) {
setDateRangeAndOption(DEFAULT_AGGREGATION_SELECTION, undefined);
return;
}
const setting =
tableDateRangeAggregationSettings[
value as keyof typeof tableDateRangeAggregationSettings
];
setDateRangeAndOption(value, {
from: addMinutes(new Date(), -setting),
to: new Date(),
const dateFromOption = getDateFromOption({
filterSource: "TABLE",
option: value,
});
const initialDateRange = !!dateFromOption
? { from: dateFromOption, to: new Date() }
: undefined;
setDateRangeAndOption(value, initialDateRange);
};
return (
<BaseDateRangeDropdown
selectedOption={selectedOption}
options={TABLE_RANGE_DROPDOWN_OPTIONS}
options={TABLE_AGGREGATION_OPTIONS}
onSelectionChange={onDropDownSelection}
/>
);
+1 -1
View File
@@ -45,7 +45,7 @@ export function JsonEditor({
if (onChange) onChange(c);
setLinterEnabled(c !== "");
}}
className={cn("overflow-hidden rounded-md border", className)}
className={cn("overflow-hidden rounded-md border text-xs", className)}
editable={editable}
/>
);
@@ -1,8 +1,19 @@
export const FullScreenPage: React.FC<React.PropsWithChildren<{}>> = ({
children,
}) => {
import { cn } from "@/src/utils/tailwind";
export const FullScreenPage: React.FC<
React.PropsWithChildren<{
mobile?: boolean;
className?: string;
}>
> = ({ children, mobile = true, className }) => {
return (
<div className="flex h-[calc(100dvh-6rem)] flex-col overflow-hidden lg:h-[calc(100dvh-2rem)]">
<div
className={cn(
"flex flex-col overflow-hidden lg:h-[calc(100dvh-1.5rem)]",
mobile && "h-[calc(100dvh-6rem)]",
className,
)}
>
{children}
</div>
);
+2 -2
View File
@@ -212,7 +212,7 @@ const BreadcrumbComponent = ({
asChild
variant="ghost"
size="xs"
className="-my-1 ml-4 mr-1 hover:bg-background"
className="-my-1 ml-4 hover:bg-background"
>
<div
onClick={(e) => {
@@ -299,7 +299,7 @@ const BreadcrumbComponent = ({
asChild
variant="ghost"
size="xs"
className="-my-1 ml-4 mr-1 hover:bg-background"
className="-my-1 ml-4 hover:bg-background"
>
<div
onClick={(e) => {
+15 -11
View File
@@ -118,6 +118,10 @@ export default function Layout(props: PropsWithChildren) {
const uiCustomization = useUiCustomization();
const cloudAdmin =
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== undefined &&
session.data?.user?.admin === true;
// project info based on projectId in the URL
const { project, organization } = useQueryProjectOrOrganization();
@@ -134,7 +138,7 @@ export default function Layout(props: PropsWithChildren) {
if (
route.featureFlag !== undefined &&
!enableExperimentalFeatures &&
session.data?.user?.admin !== true &&
!cloudAdmin &&
session.data?.user?.featureFlags[route.featureFlag] !== true
)
return null;
@@ -143,14 +147,14 @@ export default function Layout(props: PropsWithChildren) {
if (
route.entitlement !== undefined &&
!entitlements.includes(route.entitlement) &&
session.data?.user?.admin !== true
!cloudAdmin
)
return null;
// RBAC
if (
route.projectRbacScope !== undefined &&
session.data?.user?.admin !== true &&
!cloudAdmin &&
(!project ||
!organization ||
!hasProjectAccess({
@@ -346,7 +350,7 @@ export default function Layout(props: PropsWithChildren) {
</Transition.Root>
{/* Static sidebar for desktop */}
<div className="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-56 lg:flex-col">
<div className="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-52 lg:flex-col">
{/* Sidebar component, swap this element with another sidebar if you like */}
<div className="flex h-screen grow flex-col border-r border-border bg-background">
<nav className="flex h-full flex-1 flex-col overflow-y-auto px-4 py-3">
@@ -418,7 +422,7 @@ export default function Layout(props: PropsWithChildren) {
>
<Menu.Items className="absolute -top-full bottom-1 right-0 z-10 overflow-hidden rounded-md bg-background py-2 shadow-lg ring-1 ring-border focus:outline-none">
<span
className="block max-w-56 overflow-hidden truncate border-b px-3 pb-2 text-sm leading-6 text-muted-foreground"
className="block max-w-52 overflow-hidden truncate border-b px-3 pb-2 text-sm leading-6 text-muted-foreground"
title={session.data?.user?.email ?? undefined}
>
{session.data?.user?.email}
@@ -486,7 +490,7 @@ export default function Layout(props: PropsWithChildren) {
>
<Menu.Items className="absolute right-0 z-10 mt-2.5 rounded-md bg-background py-2 pb-1 shadow-lg ring-1 ring-border focus:outline-none">
<span
className="mb-1 block max-w-56 overflow-hidden truncate border-b px-3 pb-2 text-sm leading-6 text-muted-foreground"
className="mb-1 block max-w-52 overflow-hidden truncate border-b px-3 pb-2 text-sm leading-6 text-muted-foreground"
title={session.data?.user?.email ?? undefined}
>
{session.data?.user?.email}
@@ -511,7 +515,7 @@ export default function Layout(props: PropsWithChildren) {
</Transition>
</Menu>
</div>
<div className="lg:pl-56">
<div className="lg:pl-52">
{env.NEXT_PUBLIC_DEMO_ORG_ID &&
env.NEXT_PUBLIC_DEMO_PROJECT_ID &&
routerProjectId === env.NEXT_PUBLIC_DEMO_PROJECT_ID &&
@@ -532,7 +536,7 @@ export default function Layout(props: PropsWithChildren) {
</Button>
</div>
) : null}
<main className="p-4">{props.children}</main>
<main className="p-3">{props.children}</main>
<Toaster visibleToasts={1} />
</div>
</div>
@@ -634,7 +638,7 @@ const MainNavigation: React.FC<{
{item.label && (
<span
className={cn(
"-my-0.5 self-center whitespace-nowrap break-keep rounded-sm border px-1 py-0.5 text-xs",
"-my-0.5 self-center whitespace-nowrap break-keep rounded-sm border px-1 text-xs",
item.current
? "border-primary-accent text-primary-accent"
: "border-border text-muted-foreground group-hover:border-primary-accent group-hover:text-primary-accent",
@@ -648,7 +652,7 @@ const MainNavigation: React.FC<{
open
? "rotate-90 text-muted-foreground"
: "text-muted-foreground",
"ml-auto h-5 w-5 shrink-0",
"ml-auto h-4 w-4 shrink-0",
)}
aria-hidden="true"
/>
@@ -663,7 +667,7 @@ const MainNavigation: React.FC<{
subItem.current
? "bg-primary-foreground text-primary-accent"
: "text-primary hover:bg-primary-foreground hover:text-primary-accent",
"ml-0.5 flex w-full items-center gap-x-3 rounded-md p-1.5 pl-7 pr-2 text-sm",
"ml-0.5 flex w-full items-center gap-x-3 rounded-md p-1 pl-7 pr-2 text-sm",
)}
target={subItem.newTab ? "_blank" : undefined}
>
+35 -1
View File
@@ -1,11 +1,43 @@
import { z } from "zod";
// OpenAI API Content Schema defined as per https://platform.openai.com/docs/api-reference/chat/create#chat-create-messages, 20.08.2024
const OpenAITextContentPart = z.object({
type: z.literal("text"),
text: z.string(),
});
export const OpenAIUrlImageUrl = z.string().regex(/^https?:/);
const OpenAIBase64ImageUrl = z
.string()
.regex(/^data:image\/(png|jpeg|jpg|gif|webp);base64,/);
const OpenAIImageContentPart = z.object({
type: z.literal("image_url"),
image_url: z.object({
url: z.union([OpenAIUrlImageUrl, OpenAIBase64ImageUrl]),
detail: z.enum(["low", "high", "auto"]).optional(), // Controls how the model processes the image. Defaults to "auto". [https://platform.openai.com/docs/guides/vision/low-or-high-fidelity-image-understanding]
}),
});
export const OpenAIContentParts = z.array(
z.union([OpenAITextContentPart, OpenAIImageContentPart]),
);
export const OpenAIContentSchema = z.union([z.string(), OpenAIContentParts]);
export type OpenAIContentSchema = z.infer<typeof OpenAIContentSchema>;
export const ChatMlMessageSchema = z
.object({
role: z.string().optional(),
name: z.string().optional(),
content: z
.union([z.record(z.any()), z.string(), z.array(z.any())])
.union([
z.record(z.any()),
z.string(),
z.array(z.any()),
OpenAIContentSchema,
])
.nullish(),
additional_kwargs: z.record(z.any()).optional(),
})
@@ -21,5 +53,7 @@ export const ChatMlMessageSchema = z
content,
json: Object.keys(other).length === 0 ? undefined : other,
}));
export type ChatMlMessageSchema = z.infer<typeof ChatMlMessageSchema>;
export const ChatMlArraySchema = z.array(ChatMlMessageSchema).min(1);
export type ChatMlArraySchema = z.infer<typeof ChatMlArraySchema>;
+2 -2
View File
@@ -53,10 +53,10 @@ export const SessionPage: React.FC<{
string[]
>("emptySelectedConfigIds", []);
const commentCounts = api.comments.getCountsByObjectIds.useQuery(
const commentCounts = api.comments.getCountByObjectId.useQuery(
{
projectId,
objectIds: [sessionId],
objectId: sessionId,
objectType: "SESSION",
},
{ enabled: session.isSuccess },
+26 -15
View File
@@ -13,13 +13,13 @@ export function StarToggle({
value,
disabled = false,
onClick,
size = "sm",
size = "icon",
isLoading,
}: {
value: boolean;
disabled?: boolean;
onClick: (value: boolean) => Promise<unknown>;
size?: "sm" | "xs";
size?: "icon" | "icon-xs";
isLoading: boolean;
}) {
return (
@@ -40,18 +40,19 @@ export function StarToggle({
);
}
// use by the trace table
export function StarTraceToggle({
tracesFilter,
projectId,
traceId,
value,
size = "sm",
size = "icon",
}: {
tracesFilter: RouterInput["traces"]["all"];
projectId: string;
traceId: string;
value: boolean;
size?: "sm" | "xs";
size?: "icon" | "icon-xs";
}) {
const utils = api.useUtils();
const hasAccess = useHasProjectAccess({
@@ -89,7 +90,6 @@ export function StarTraceToggle({
tracesFilter,
(oldQueryData: RouterOutput["traces"]["all"] | undefined) => {
return {
totalCount: oldQueryData?.totalCount,
traces: oldQueryData?.traces
? oldQueryData.traces.map((trace) => {
return {
@@ -130,16 +130,17 @@ export function StarTraceToggle({
);
}
// use by the single trace view
export function StarTraceDetailsToggle({
projectId,
traceId,
value,
size = "sm",
size = "icon",
}: {
projectId: string;
traceId: string;
value: boolean;
size?: "sm" | "xs";
size?: "icon" | "icon-xs";
}) {
const utils = api.useUtils();
const hasAccess = useHasProjectAccess({
@@ -153,12 +154,15 @@ export function StarTraceDetailsToggle({
onMutate: async () => {
// Cancel any outgoing refetches
// (so they don't overwrite our optimistic update)
await utils.traces.byId.cancel();
await utils.traces.byIdWithObservationsAndScores.cancel();
setIsLoading(true);
// Snapshot the previous value
const prevData = utils.traces.byId.getData({ traceId, projectId });
const prevData = utils.traces.byIdWithObservationsAndScores.getData({
traceId,
projectId,
});
return { prevData };
},
@@ -166,14 +170,21 @@ export function StarTraceDetailsToggle({
setIsLoading(false);
trpcErrorToast(err);
// Rollback to the previous value if mutation fails
utils.traces.byId.setData({ traceId, projectId }, context?.prevData);
utils.traces.byIdWithObservationsAndScores.setData(
{ traceId, projectId },
context?.prevData,
);
},
onSettled: () => {
setIsLoading(false);
utils.traces.byId.setData(
utils.traces.byIdWithObservationsAndScores.setData(
{ traceId, projectId },
(oldQueryData: RouterOutput["traces"]["byId"] | undefined) => {
(
oldQueryData:
| RouterOutput["traces"]["byIdWithObservationsAndScores"]
| undefined,
) => {
return oldQueryData
? {
...oldQueryData,
@@ -182,7 +193,7 @@ export function StarTraceDetailsToggle({
: undefined;
},
);
void utils.traces.byId.invalidate();
void utils.traces.byIdWithObservationsAndScores.invalidate();
void utils.traces.all.invalidate();
},
});
@@ -212,12 +223,12 @@ export function StarSessionToggle({
projectId,
sessionId,
value,
size = "sm",
size = "icon",
}: {
projectId: string;
sessionId: string;
value: boolean;
size?: "sm" | "xs";
size?: "icon" | "icon-xs";
}) {
const utils = api.useUtils();
const hasAccess = useHasProjectAccess({
@@ -15,6 +15,9 @@ import {
SelectValue,
} from "@/src/components/ui/select";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { LoaderCircle } from "lucide-react";
import { Input } from "@/src/components/ui/input";
import { useEffect, useState } from "react";
interface DataTablePaginationProps<TData> {
table: Table<TData>;
@@ -26,6 +29,22 @@ export function DataTablePagination<TData>({
paginationOptions = [10, 20, 30, 40, 50],
}: DataTablePaginationProps<TData>) {
const capture = usePostHogClientCapture();
const currentPage = table.getState().pagination.pageIndex + 1;
const [inputState, setInputState] = useState<number | string>(currentPage);
useEffect(() => {
setInputState(currentPage);
}, [currentPage]);
const pageCount = table.getPageCount();
const setPageIndex = table.setPageIndex;
useEffect(() => {
if (currentPage > pageCount && pageCount > 0) {
setPageIndex(0);
}
}, [currentPage, pageCount, setPageIndex]);
return (
<div className="flex items-center justify-between">
<div className="flex-1 text-sm text-muted-foreground">
@@ -61,10 +80,60 @@ export function DataTablePagination<TData>({
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-center whitespace-nowrap text-sm font-medium">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{Math.max(table.getPageCount(), 1)}
<div className="flex items-center justify-center gap-1 whitespace-nowrap text-sm font-medium">
{table.getPageCount() !== -1 ? (
<>
Page
<Input
type="number"
min={1}
max={pageCount}
value={inputState} // Ensure the value is within bounds
onChange={(e) => {
setInputState(e.target.value);
}}
onBlur={(e) => {
const newValue = e.target.value;
if (newValue === "") {
table.setPageIndex(0);
setInputState(1);
return;
}
// if nan, reset to current page
if (isNaN(Number(newValue))) {
setInputState(currentPage);
return;
}
const newPageIndex = Number(newValue) - 1;
if (newPageIndex < 0 || newPageIndex >= pageCount) {
setInputState(currentPage);
return;
}
table.setPageIndex(newPageIndex);
setInputState(newPageIndex + 1);
}}
className="h-8 appearance-none"
style={{
width: `${3 + Math.max(1, pageCount.toString().length)}ch`,
}}
/>
</>
) : (
`Page ${currentPage}`
)}
{pageCount !== -1 ? (
<span>of {pageCount}</span>
) : (
<span>
of{" "}
<LoaderCircle className="ml-1 inline-block h-3 w-3 animate-spin text-muted-foreground" />
</span>
)}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
@@ -112,12 +181,12 @@ export function DataTablePagination<TData>({
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => {
table.setPageIndex(table.getPageCount() - 1);
table.setPageIndex(pageCount - 1);
capture("table:pagination_button_click", {
type: "lastPage",
});
}}
disabled={!table.getCanNextPage()}
disabled={!table.getCanNextPage() || pageCount === -1}
>
<span className="sr-only">Go to last page</span>
<DoubleArrowRightIcon className="h-4 w-4" />
@@ -31,7 +31,9 @@ interface DataTableToolbarProps<TData, TValue> {
searchConfig?: SearchConfig;
actionButtons?: React.ReactNode;
filterState?: FilterState;
setFilterState?: Dispatch<SetStateAction<FilterState>>;
setFilterState?:
| Dispatch<SetStateAction<FilterState>>
| ((newState: FilterState) => void);
columnVisibility?: VisibilityState;
setColumnVisibility?: Dispatch<SetStateAction<VisibilityState>>;
rowHeight?: RowHeight;
@@ -79,7 +81,7 @@ export function DataTableToolbar<TData, TValue>({
searchConfig.updateQuery(searchString);
}
}}
className="h-10 w-[150px] rounded-r-none @6xl:w-[250px]"
className="w-[150px] rounded-r-none @6xl:w-[250px]"
/>
<Button
variant="outline"
+8 -2
View File
@@ -36,7 +36,7 @@ interface DataTableProps<TData, TValue> {
columns: LangfuseColumnDef<TData, TValue>[];
data: AsyncTableData<TData[]>;
pagination?: {
pageCount: number;
totalCount: number | null; // null if loading
onChange: OnChangeFn<PaginationState>;
state: PaginationState;
options?: number[];
@@ -90,7 +90,13 @@ export function DataTable<TData extends object, TValue>({
getFilteredRowModel: getFilteredRowModel(),
getCoreRowModel: getCoreRowModel(),
manualPagination: pagination !== undefined,
pageCount: pagination?.pageCount ?? 0,
pageCount:
pagination?.totalCount === null ||
pagination?.state.pageSize === undefined
? -1
: Math.ceil(
Number(pagination?.totalCount) / pagination?.state.pageSize,
),
onPaginationChange: pagination?.onChange,
onRowSelectionChange: setRowSelection,
onColumnVisibilityChange: onColumnVisibilityChange,
+1 -1
View File
@@ -17,7 +17,7 @@ export default function TableLink({
return (
<Link
className={cn(
"inline-block max-w-full overflow-hidden text-ellipsis text-nowrap rounded bg-primary-accent/20 px-2 py-1 text-xs font-semibold text-accent-dark-blue shadow-sm hover:bg-accent-light-blue/45",
"inline-block max-w-full overflow-hidden text-ellipsis text-nowrap rounded bg-primary-accent/20 px-2 py-0.5 text-xs font-semibold text-accent-dark-blue shadow-sm hover:bg-accent-light-blue/45",
className,
)}
href={path}
@@ -49,6 +49,7 @@ import { useTableDateRange } from "@/src/hooks/useTableDateRange";
import { useDebounce } from "@/src/hooks/useDebounce";
import { type ScoreAggregate } from "@/src/features/scores/lib/types";
import { useIndividualScoreColumns } from "@/src/features/scores/hooks/useIndividualScoreColumns";
import TagList from "@/src/features/tag/components/TagList";
export type GenerationsTableRow = {
id: string;
@@ -80,6 +81,7 @@ export type GenerationsTableRow = {
promptId?: string;
promptName?: string;
promptVersion?: string;
traceTags?: string[];
};
export type GenerationsTableProps = {
@@ -113,11 +115,12 @@ export default function GenerationsTable({
);
const { selectedOption, dateRange, setDateRangeAndOption } =
useTableDateRange();
useTableDateRange(projectId);
const [inputFilterState, setInputFilterState] = useQueryFilterState(
[],
"generations",
projectId,
);
const [orderByState, setOrderByState] = useOrderByState({
@@ -164,16 +167,26 @@ export default function GenerationsTable({
...promptVersionFilter,
]);
const generations = api.generations.all.useQuery({
page: paginationState.pageIndex,
limit: paginationState.pageSize,
const getCountPayload = {
projectId,
filter: filterState,
orderBy: orderByState,
searchQuery,
});
page: 0,
limit: 0,
orderBy: null,
};
const totalCount = generations.data?.totalCount ?? 0;
const getAllPayload = {
...getCountPayload,
page: paginationState.pageIndex,
limit: paginationState.pageSize,
orderBy: orderByState,
};
const generations = api.generations.all.useQuery(getAllPayload);
const totalCountQuery = api.generations.countAll.useQuery(getCountPayload);
const totalCount = totalCountQuery.data?.totalCount ?? null;
const startTimeFilter = filterState.find((f) => f.column === "Start Time");
const filterOptions = api.generations.filterOptions.useQuery(
@@ -653,6 +666,30 @@ export default function GenerationsTable({
);
},
},
{
accessorKey: "traceTags",
id: "traceTags",
header: "Trace Tags",
size: 250,
enableHiding: true,
defaultHidden: true,
cell: ({ row }) => {
const traceTags: string[] | undefined = row.getValue("traceTags");
console.log(traceTags);
return (
traceTags && (
<div
className={cn(
"flex gap-x-2 gap-y-1",
rowHeight !== "s" && "flex-wrap",
)}
>
<TagList selectedTags={traceTags} isLoading={false} viewOnly />
</div>
)
);
},
},
];
const [columnVisibility, setColumnVisibilityState] =
@@ -692,6 +729,7 @@ export default function GenerationsTable({
promptId: generation.promptId ?? undefined,
promptName: generation.promptName ?? undefined,
promptVersion: generation.promptVersion ?? undefined,
traceTags: generation.traceTags ?? undefined,
};
})
: [];
@@ -709,6 +747,7 @@ export default function GenerationsTable({
updateQuery: setSearchQuery,
currentQuery: searchQuery ?? undefined,
}}
columnsWithCustomSelect={["model", "name", "traceName", "promptName"]}
columnVisibility={columnVisibility}
setColumnVisibility={setColumnVisibilityState}
rowHeight={rowHeight}
@@ -766,7 +805,7 @@ export default function GenerationsTable({
}
}
pagination={{
pageCount: Math.ceil(totalCount / paginationState.pageSize),
totalCount,
onChange: setPaginationState,
state: paginationState,
}}
@@ -63,7 +63,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
limit: paginationState.pageSize,
projectId,
});
const totalCount = models.data?.totalCount ?? 0;
const totalCount = models.data?.totalCount ?? null;
const [rowHeight, setRowHeight] = useRowHeightLocalStorage("models", "s");
@@ -303,7 +303,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
}
}
pagination={{
pageCount: Math.ceil(totalCount / paginationState.pageSize),
totalCount,
onChange: setPaginationState,
state: paginationState,
}}
@@ -97,7 +97,7 @@ export function ScoreConfigsTable({ projectId }: { projectId: string }) {
limit: paginationState.pageSize,
});
const totalCount = configs.data?.totalCount ?? 0;
const totalCount = configs.data?.totalCount ?? null;
const columns: LangfuseColumnDef<ScoreConfigTableRow>[] = [
{
@@ -272,7 +272,7 @@ export function ScoreConfigsTable({ projectId }: { projectId: string }) {
}
}
pagination={{
pageCount: Math.ceil(totalCount / paginationState.pageSize),
totalCount,
onChange: setPaginationState,
state: paginationState,
}}
+49 -7
View File
@@ -24,6 +24,8 @@ import {
type ScoreDataType,
} from "@langfuse/shared";
import { useQueryParams, withDefault, NumberParam } from "use-query-params";
import TagList from "@/src/features/tag/components/TagList";
import { cn } from "@/src/utils/tailwind";
export type ScoresTableRow = {
id: string;
@@ -43,6 +45,7 @@ export type ScoresTableRow = {
traceName?: string;
userId?: string;
jobConfigurationId?: string;
traceTags?: string[];
};
export type ScoreFilterInput = Omit<
@@ -90,11 +93,12 @@ export default function ScoresTable({
const [rowHeight, setRowHeight] = useRowHeightLocalStorage("scores", "s");
const { selectedOption, dateRange, setDateRangeAndOption } =
useTableDateRange();
useTableDateRange(projectId);
const [userFilterState, setUserFilterState] = useQueryFilterState(
[],
"scores",
projectId,
);
const dateRangeFilter: FilterState = dateRange
@@ -120,18 +124,32 @@ export default function ScoresTable({
order: "DESC",
});
const scores = api.scores.all.useQuery({
page: paginationState.pageIndex,
limit: paginationState.pageSize,
const getCountPayload = {
projectId,
filter: filterState,
page: 0,
limit: 1,
orderBy: null,
};
const getAllPayload = {
...getCountPayload,
page: paginationState.pageIndex,
limit: paginationState.pageSize,
orderBy: orderByState,
});
const totalCount = scores.data?.totalCount ?? 0;
};
const scores = api.scores.all.useQuery(getAllPayload);
const totalScoreCountQuery = api.scores.countAll.useQuery(getCountPayload);
const totalCount = totalScoreCountQuery.data?.totalCount ?? null;
const filterOptions = api.scores.filterOptions.useQuery(
{
projectId,
timestampFilter:
dateRangeFilter[0]?.type === "datetime"
? dateRangeFilter[0]
: undefined,
},
{
trpc: {
@@ -323,6 +341,29 @@ export default function ScoresTable({
) : undefined;
},
},
{
accessorKey: "traceTags",
id: "traceTags",
header: "Trace Tags",
size: 250,
enableHiding: true,
defaultHidden: true,
cell: ({ row }) => {
const traceTags: string[] | undefined = row.getValue("traceTags");
return (
traceTags && (
<div
className={cn(
"flex gap-x-2 gap-y-1",
rowHeight !== "s" && "flex-wrap",
)}
>
<TagList selectedTags={traceTags} isLoading={false} viewOnly />
</div>
)
);
},
},
];
const columns = rawColumns.filter(
@@ -358,6 +399,7 @@ export default function ScoresTable({
traceName: score.traceName ?? undefined,
userId: score.traceUserId ?? undefined,
jobConfigurationId: score.jobConfigurationId ?? undefined,
traceTags: score.traceTags ?? undefined,
};
};
@@ -401,7 +443,7 @@ export default function ScoresTable({
}
}
pagination={{
pageCount: Math.ceil(totalCount / paginationState.pageSize),
totalCount,
onChange: setPaginationState,
state: paginationState,
}}
+160 -56
View File
@@ -23,20 +23,26 @@ import { NumberParam, useQueryParams, withDefault } from "use-query-params";
import { BatchExportTableButton } from "@/src/components/BatchExportTableButton";
import { useTableDateRange } from "@/src/hooks/useTableDateRange";
import { useDebounce } from "@/src/hooks/useDebounce";
import { joinTableCoreAndMetrics } from "@/src/components/table/utils/joinTableCoreAndMetrics";
import { Skeleton } from "@/src/components/ui/skeleton";
import TagList from "@/src/features/tag/components/TagList";
import { useRowHeightLocalStorage } from "@/src/components/table/data-table-row-height-switch";
import { cn } from "@/src/utils/tailwind";
export type SessionTableRow = {
id: string;
createdAt: string;
userIds: string[];
countTraces: number;
bookmarked: boolean;
sessionDuration: number | null;
inputCost: Decimal;
outputCost: Decimal;
totalCost: Decimal;
inputTokens: number;
outputTokens: number;
totalTokens: number;
userIds: string[] | undefined;
countTraces: number | undefined;
sessionDuration: number | null | undefined;
inputCost: Decimal | undefined;
outputCost: Decimal | undefined;
totalCost: Decimal | undefined;
inputTokens: number | undefined;
outputTokens: number | undefined;
totalTokens: number | undefined;
traceTags: string[] | undefined;
};
export type SessionTableProps = {
@@ -52,11 +58,12 @@ export default function SessionsTable({
}: SessionTableProps) {
const { setDetailPageList } = useDetailPageLists();
const { selectedOption, dateRange, setDateRangeAndOption } =
useTableDateRange();
useTableDateRange(projectId);
const [userFilterState, setUserFilterState] = useQueryFilterState(
[],
"sessions",
projectId,
);
const userIdFilter: FilterState = userId
@@ -88,22 +95,56 @@ export default function SessionsTable({
pageSize: withDefault(NumberParam, 50),
});
const [rowHeight, setRowHeight] = useRowHeightLocalStorage("sessions", "s");
const [orderByState, setOrderByState] = useOrderByState({
column: "createdAt",
order: "DESC",
});
const sessions = api.sessions.all.useQuery({
page: paginationState.pageIndex,
limit: paginationState.pageSize,
const payloadCount = {
projectId,
filter: filterState,
orderBy: null,
page: 0,
limit: 1,
};
const payloadGetAll = {
...payloadCount,
orderBy: orderByState,
});
page: paginationState.pageIndex,
limit: paginationState.pageSize,
};
const sessions = api.sessions.all.useQuery(payloadGetAll);
const sessionCountQuery = api.sessions.countAll.useQuery(payloadCount);
const sessionMetrics = api.sessions.metrics.useQuery(
{
projectId,
sessionIds: sessions.data?.sessions.map((s) => s.id) ?? [],
},
{
enabled: sessions.data !== undefined,
},
);
type SessionCoreOutput = RouterOutput["sessions"]["all"]["sessions"][number];
type SessionMetricOutput = RouterOutput["sessions"]["metrics"][number];
const sessionRowData = joinTableCoreAndMetrics<
SessionCoreOutput,
SessionMetricOutput
>(sessions.data?.sessions, sessionMetrics.data);
const filterOptions = api.sessions.filterOptions.useQuery(
{
projectId,
timestampFilter:
dateRangeFilter[0]?.type === "datetime"
? dateRangeFilter[0]
: undefined,
},
{
trpc: {
@@ -114,36 +155,17 @@ export default function SessionsTable({
},
);
const totalCount = sessions.data?.slice(1)[0]?.totalCount ?? 0;
const totalCount = sessionCountQuery.data?.totalCount ?? null;
useEffect(() => {
if (sessions.isSuccess) {
setDetailPageList(
"sessions",
sessions.data.map((t) => t.id),
sessions.data.sessions.map((t) => t.id),
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessions.isSuccess, sessions.data]);
const convertToTableRow = (
session: RouterOutput["sessions"]["all"][0],
): SessionTableRow => {
return {
id: session.id,
createdAt: session.createdAt.toLocaleString(),
userIds: session.userIds,
countTraces: session.countTraces,
bookmarked: session.bookmarked,
sessionDuration: session.sessionDuration,
inputCost: session.inputCost,
outputCost: session.outputCost,
totalCost: session.totalCost,
inputTokens: session.promptTokens,
outputTokens: session.completionTokens,
totalTokens: session.totalTokens,
};
};
const columns: LangfuseColumnDef<SessionTableRow>[] = [
{
accessorKey: "bookmarked",
@@ -151,8 +173,9 @@ export default function SessionsTable({
header: undefined,
size: 50,
cell: ({ row }) => {
const bookmarked = row.getValue("bookmarked");
const sessionId = row.getValue("id");
const bookmarked: SessionTableRow["bookmarked"] =
row.getValue("bookmarked");
const sessionId: SessionTableRow["id"] = row.getValue("id");
return typeof sessionId === "string" &&
typeof bookmarked === "boolean" ? (
@@ -160,7 +183,7 @@ export default function SessionsTable({
sessionId={sessionId}
projectId={projectId}
value={bookmarked}
size="xs"
size="icon-xs"
/>
) : undefined;
},
@@ -172,7 +195,7 @@ export default function SessionsTable({
header: "ID",
size: 200,
cell: ({ row }) => {
const value = row.getValue("id");
const value: SessionTableRow["id"] = row.getValue("id");
return value && typeof value === "string" ? (
<TableLink
path={`/project/${projectId}/sessions/${encodeURIComponent(value)}`}
@@ -197,7 +220,11 @@ export default function SessionsTable({
size: 130,
enableHiding: true,
cell: ({ row }) => {
const value = row.getValue("sessionDuration");
const value: SessionTableRow["sessionDuration"] =
row.getValue("sessionDuration");
if (!sessionMetrics.isSuccess) {
return <Skeleton className="h-3 w-1/2" />;
}
return value && typeof value === "number"
? formatIntervalSeconds(value)
: undefined;
@@ -212,7 +239,10 @@ export default function SessionsTable({
size: 200,
enableHiding: true,
cell: ({ row }) => {
const value = row.getValue("userIds");
const value: SessionTableRow["userIds"] = row.getValue("userIds");
if (!sessionMetrics.isSuccess) {
return <Skeleton className="h-3 w-1/2" />;
}
return value && Array.isArray(value) ? (
<div className="flex gap-1">
{(value as string[]).map((user) => (
@@ -236,6 +266,14 @@ export default function SessionsTable({
},
enableHiding: true,
enableSorting: true,
cell: ({ row }) => {
const value: SessionTableRow["countTraces"] =
row.getValue("countTraces");
if (!sessionMetrics.isSuccess) {
return <Skeleton className="h-3 w-1/2" />;
}
return value ? <span>{numberFormatter(value, 0)}</span> : undefined;
},
},
{
accessorKey: "inputCost",
@@ -246,7 +284,10 @@ export default function SessionsTable({
defaultHidden: true,
enableSorting: true,
cell: ({ row }) => {
const value: Decimal | null | undefined = row.getValue("inputCost");
const value: SessionTableRow["inputCost"] = row.getValue("inputCost");
if (!sessionMetrics.isSuccess) {
return <Skeleton className="h-3 w-1/2" />;
}
return value ? (
<span>{usdFormatter(value.toNumber())}</span>
) : undefined;
@@ -261,8 +302,10 @@ export default function SessionsTable({
enableSorting: true,
defaultHidden: true,
cell: ({ row }) => {
const value: Decimal | null | undefined = row.getValue("outputCost");
const value: SessionTableRow["outputCost"] = row.getValue("outputCost");
if (!sessionMetrics.isSuccess) {
return <Skeleton className="h-3 w-1/2" />;
}
return value ? (
<span>{usdFormatter(value.toNumber())}</span>
) : undefined;
@@ -276,8 +319,10 @@ export default function SessionsTable({
enableHiding: true,
enableSorting: true,
cell: ({ row }) => {
const value: Decimal | null | undefined = row.getValue("totalCost");
const value: SessionTableRow["totalCost"] = row.getValue("totalCost");
if (!sessionMetrics.isSuccess) {
return <Skeleton className="h-3 w-1/2" />;
}
return value ? (
<span>{usdFormatter(value.toNumber())}</span>
) : undefined;
@@ -292,8 +337,11 @@ export default function SessionsTable({
defaultHidden: true,
enableSorting: true,
cell: ({ row }) => {
const value: number | undefined = row.getValue("inputTokens");
const value: SessionTableRow["inputTokens"] =
row.getValue("inputTokens");
if (!sessionMetrics.isSuccess) {
return <Skeleton className="h-3 w-1/2" />;
}
return value ? (
<span>{numberFormatter(Number(value), 0)}</span>
) : undefined;
@@ -308,8 +356,11 @@ export default function SessionsTable({
defaultHidden: true,
enableSorting: true,
cell: ({ row }) => {
const value = row.getValue("outputTokens");
const value: SessionTableRow["outputTokens"] =
row.getValue("outputTokens");
if (!sessionMetrics.isSuccess) {
return <Skeleton className="h-3 w-1/2" />;
}
return value ? (
<span>{numberFormatter(Number(value), 0)}</span>
) : undefined;
@@ -324,7 +375,11 @@ export default function SessionsTable({
defaultHidden: true,
enableSorting: true,
cell: ({ row }) => {
const value = row.getValue("totalTokens");
const value: SessionTableRow["totalTokens"] =
row.getValue("totalTokens");
if (!sessionMetrics.isSuccess) {
return <Skeleton className="h-3 w-1/2" />;
}
return value ? (
<span>{numberFormatter(Number(value), 0)}</span>
) : undefined;
@@ -338,9 +393,15 @@ export default function SessionsTable({
enableHiding: true,
enableSorting: true,
cell: ({ row }) => {
const promptTokens = row.getValue("inputTokens");
const completionTokens = row.getValue("outputTokens");
const totalTokens = row.getValue("totalTokens");
const promptTokens: SessionTableRow["inputTokens"] =
row.getValue("inputTokens");
const completionTokens: SessionTableRow["outputTokens"] =
row.getValue("outputTokens");
const totalTokens: SessionTableRow["totalTokens"] =
row.getValue("totalTokens");
if (!sessionMetrics.isSuccess) {
return <Skeleton className="h-3 w-1/2" />;
}
return (
<TokenUsageBadge
promptTokens={Number(promptTokens)}
@@ -351,6 +412,32 @@ export default function SessionsTable({
);
},
},
{
accessorKey: "traceTags",
id: "traceTags",
header: "Trace Tags",
size: 250,
enableHiding: true,
defaultHidden: true,
cell: ({ row }) => {
const value: SessionTableRow["traceTags"] = row.getValue("traceTags");
if (!sessionMetrics.isSuccess) {
return <Skeleton className="h-3 w-1/2" />;
}
return (
value && (
<div
className={cn(
"flex gap-x-2 gap-y-1",
rowHeight !== "s" && "flex-wrap",
)}
>
<TagList selectedTags={value} isLoading={false} viewOnly />
</div>
)
);
},
},
];
const transformFilterOptions = () => {
@@ -381,6 +468,8 @@ export default function SessionsTable({
selectedOption={selectedOption}
setDateRangeAndOption={setDateRangeAndOption}
columnsWithCustomSelect={["userIds"]}
rowHeight={rowHeight}
setRowHeight={setRowHeight}
/>
<DataTable
columns={columns}
@@ -396,11 +485,25 @@ export default function SessionsTable({
: {
isLoading: false,
isError: false,
data: sessions.data.map((t) => convertToTableRow(t)),
data: sessionRowData.rows?.map((session) => ({
id: session.id,
createdAt: session.createdAt.toLocaleString(),
userIds: session.userIds,
countTraces: session.countTraces,
bookmarked: session.bookmarked,
sessionDuration: session.sessionDuration,
inputCost: session.inputCost,
outputCost: session.outputCost,
totalCost: session.totalCost,
inputTokens: session.promptTokens,
outputTokens: session.completionTokens,
totalTokens: session.totalTokens,
traceTags: session.traceTags,
})),
}
}
pagination={{
pageCount: Math.ceil(totalCount / paginationState.pageSize),
totalCount,
onChange: setPaginationState,
state: paginationState,
}}
@@ -413,6 +516,7 @@ export default function SessionsTable({
"A session is a collection of related traces, such as a conversation or thread. To begin, add a sessionId to the trace.",
href: "https://langfuse.com/docs/tracing-features/sessions",
}}
rowHeight={rowHeight}
/>
</>
);
+80 -26
View File
@@ -11,7 +11,7 @@ import useColumnVisibility from "@/src/features/column-visibility/hooks/useColum
import { useQueryFilterState } from "@/src/features/filters/hooks/useFilterState";
import { api } from "@/src/utils/api";
import { formatIntervalSeconds } from "@/src/utils/dates";
import { type RouterInput } from "@/src/utils/types";
import { type RouterOutput, type RouterInput } from "@/src/utils/types";
import { type RowSelectionState } from "@tanstack/react-table";
import { useEffect, useMemo, useState } from "react";
import {
@@ -44,6 +44,8 @@ import { useTableDateRange } from "@/src/hooks/useTableDateRange";
import { useDebounce } from "@/src/hooks/useDebounce";
import { type ScoreAggregate } from "@/src/features/scores/lib/types";
import { useIndividualScoreColumns } from "@/src/features/scores/hooks/useIndividualScoreColumns";
import { joinTableCoreAndMetrics } from "@/src/components/table/utils/joinTableCoreAndMetrics";
import { Skeleton } from "@/src/components/ui/skeleton";
export type TracesTableRow = {
bookmarked: boolean;
@@ -51,10 +53,10 @@ export type TracesTableRow = {
timestamp: string;
name: string;
userId: string;
level: ObservationLevel;
observationCount: number;
level?: ObservationLevel;
observationCount?: number;
// scores holds grouped column with individual scores
scores: ScoreAggregate;
scores?: ScoreAggregate;
latency?: number;
release?: string;
version?: string;
@@ -65,9 +67,9 @@ export type TracesTableRow = {
metadata?: unknown;
tags: string[];
usage: {
promptTokens: bigint;
completionTokens: bigint;
totalTokens: bigint;
promptTokens?: bigint;
completionTokens?: bigint;
totalTokens?: bigint;
};
inputCost?: Decimal;
outputCost?: Decimal;
@@ -96,10 +98,11 @@ export default function TracesTable({
);
const { selectedOption, dateRange, setDateRangeAndOption } =
useTableDateRange();
useTableDateRange(projectId);
const [userFilterState, setUserFilterState] = useQueryFilterState(
[],
"traces",
projectId,
);
const [orderByState, setOrderByState] = useOrderByState({
column: "timestamp",
@@ -133,17 +136,44 @@ export default function TracesTable({
pageSize: withDefault(NumberParam, 50),
});
const tracesAllQueryFilter = {
page: paginationState.pageIndex,
limit: paginationState.pageSize,
const tracesAllCountFilter = {
projectId,
filter: filterState,
searchQuery,
// "empty" values as they do not matter for total count
page: 0,
limit: 0,
orderBy: null,
};
const tracesAllQueryFilter = {
...tracesAllCountFilter,
page: paginationState.pageIndex,
limit: paginationState.pageSize,
orderBy: orderByState,
};
const traces = api.traces.all.useQuery(tracesAllQueryFilter);
const totalCountQuery = api.traces.countAll.useQuery(tracesAllCountFilter);
const traceMetrics = api.traces.metrics.useQuery(
{
projectId,
traceIds: traces.data?.traces.map((t) => t.id) ?? [],
},
{
enabled: traces.data !== undefined,
},
);
type TracesCoreOutput = RouterOutput["traces"]["all"]["traces"][number];
type TraceMetricOutput = RouterOutput["traces"]["metrics"][number];
const traceRowData = joinTableCoreAndMetrics<
TracesCoreOutput,
TraceMetricOutput
>(traces.data?.traces, traceMetrics.data);
const totalCount = totalCountQuery.data?.totalCount ?? null;
const totalCount = traces.data?.totalCount ?? 0;
useEffect(() => {
if (traces.isSuccess) {
setDetailPageList(
@@ -188,6 +218,7 @@ export default function TracesTable({
projectId,
scoreColumnKey: "scores",
selectedFilterOption: selectedOption,
cellsLoading: !traceMetrics.data,
});
const columns: LangfuseColumnDef<TracesTableRow>[] = [
@@ -239,7 +270,7 @@ export default function TracesTable({
traceId={traceId}
projectId={projectId}
value={bookmarked}
size="xs"
size="icon-xs"
/>
) : undefined;
},
@@ -328,6 +359,7 @@ export default function TracesTable({
// add seconds to the end of the latency
cell: ({ row }) => {
const value: TracesTableRow["latency"] = row.getValue("latency");
if (!traceMetrics.data) return <Skeleton className="h-3 w-1/2" />;
return value !== undefined ? formatIntervalSeconds(value) : undefined;
},
enableHiding: true,
@@ -340,6 +372,7 @@ export default function TracesTable({
size: 110,
cell: ({ row }) => {
const value: TracesTableRow["usage"] = row.getValue("usage");
if (!traceMetrics.data) return <Skeleton className="h-3 w-1/2" />;
return <span>{numberFormatter(value.promptTokens, 0)}</span>;
},
enableHiding: true,
@@ -353,6 +386,7 @@ export default function TracesTable({
size: 110,
cell: ({ row }) => {
const value: TracesTableRow["usage"] = row.getValue("usage");
if (!traceMetrics.data) return <Skeleton className="h-3 w-1/2" />;
return <span>{numberFormatter(value.completionTokens, 0)}</span>;
},
enableHiding: true,
@@ -366,6 +400,7 @@ export default function TracesTable({
size: 110,
cell: ({ row }) => {
const value: TracesTableRow["usage"] = row.getValue("usage");
if (!traceMetrics.data) return <Skeleton className="h-3 w-1/2" />;
return <span>{numberFormatter(value.totalTokens, 0)}</span>;
},
enableHiding: true,
@@ -379,11 +414,12 @@ export default function TracesTable({
size: 220,
cell: ({ row }) => {
const value: TracesTableRow["usage"] = row.getValue("usage");
if (!traceMetrics.data) return <Skeleton className="h-3 w-1/2" />;
return (
<TokenUsageBadge
promptTokens={value.promptTokens}
completionTokens={value.completionTokens}
totalTokens={value.totalTokens}
promptTokens={value.promptTokens ?? 0}
completionTokens={value.completionTokens ?? 0}
totalTokens={value.totalTokens ?? 0}
inline
/>
);
@@ -391,7 +427,10 @@ export default function TracesTable({
enableSorting: true,
enableHiding: true,
},
{ ...getScoreGroupColumnProps(isColumnLoading), columns: scoreColumns },
{
...getScoreGroupColumnProps(isColumnLoading || !traceMetrics.data),
columns: scoreColumns,
},
{
accessorKey: "inputCost",
id: "inputCost",
@@ -399,6 +438,7 @@ export default function TracesTable({
size: 100,
cell: ({ row }) => {
const cost: TracesTableRow["inputCost"] = row.getValue("inputCost");
if (!traceMetrics.data) return <Skeleton className="h-3 w-1/2" />;
return (
<div>
{cost ? (
@@ -420,6 +460,7 @@ export default function TracesTable({
size: 100,
cell: ({ row }) => {
const cost: TracesTableRow["outputCost"] = row.getValue("outputCost");
if (!traceMetrics.data) return <Skeleton className="h-3 w-1/2" />;
return (
<div>
{cost ? (
@@ -441,6 +482,7 @@ export default function TracesTable({
size: 100,
cell: ({ row }) => {
const cost: TracesTableRow["totalCost"] = row.getValue("totalCost");
if (!traceMetrics.data) return <Skeleton className="h-3 w-1/2" />;
return (
<div>
{cost ? (
@@ -521,7 +563,8 @@ export default function TracesTable({
size: 75,
cell: ({ row }) => {
const value: TracesTableRow["level"] = row.getValue("level");
return (
if (!traceMetrics.data) return <Skeleton className="h-3 w-1/2" />;
return value ? (
<span
className={cn(
"rounded-sm p-0.5 text-xs",
@@ -531,6 +574,8 @@ export default function TracesTable({
>
{value}
</span>
) : (
<span>-</span>
);
},
enableHiding: true,
@@ -547,6 +592,12 @@ export default function TracesTable({
},
enableHiding: true,
defaultHidden: true,
cell: ({ row }) => {
const value: TracesTableRow["observationCount"] =
row.getValue("observationCount");
if (!traceMetrics.data) return <Skeleton className="h-3 w-1/2" />;
return <span>{value}</span>;
},
},
{
accessorKey: "version",
@@ -629,7 +680,7 @@ export default function TracesTable({
const rows = useMemo(() => {
return traces.isSuccess
? traces.data.traces.map((trace) => {
? traceRowData?.rows?.map((trace) => {
return {
bookmarked: trace.bookmarked,
id: trace.id,
@@ -648,17 +699,19 @@ export default function TracesTable({
completionTokens: trace.completionTokens,
totalTokens: trace.totalTokens,
},
scores: verifyAndPrefixScoreDataAgainstKeys(
scoreKeysAndProps,
trace.scores,
),
scores: trace.scores
? verifyAndPrefixScoreDataAgainstKeys(
scoreKeysAndProps,
trace.scores,
)
: undefined,
inputCost: trace.calculatedInputCost ?? undefined,
outputCost: trace.calculatedOutputCost ?? undefined,
totalCost: trace.calculatedTotalCost ?? undefined,
};
})
}) ?? []
: [];
}, [traces, scoreKeysAndProps]);
}, [traces, traceRowData, scoreKeysAndProps]);
return (
<>
@@ -672,6 +725,7 @@ export default function TracesTable({
}}
filterState={userFilterState}
setFilterState={useDebounce(setUserFilterState)}
columnsWithCustomSelect={["name", "tags"]}
actionButtons={
Object.keys(selectedRows).filter((traceId) =>
traces.data?.traces.map((t) => t.id).includes(traceId),
@@ -713,7 +767,7 @@ export default function TracesTable({
}
}
pagination={{
pageCount: Math.ceil(Number(totalCount) / paginationState.pageSize),
totalCount,
onChange: setPaginationState,
state: paginationState,
}}
+26 -7
View File
@@ -11,10 +11,19 @@ import { MarkdownView } from "@/src/components/ui/MarkdownViewer";
import { MarkdownSchema } from "@/src/components/schemas/MarkdownSchema";
import {
ChatMlArraySchema,
ChatMlMessageSchema,
type ChatMlMessageSchema,
OpenAIContentSchema,
} from "@/src/components/schemas/ChatMlSchema";
import { useMarkdownContext } from "@/src/features/theming/useMarkdownContext";
const isSupportedMarkdownFormat = (
content: unknown,
contentValidation: z.SafeParseReturnType<
string,
z.infer<typeof OpenAIContentSchema>
>,
): content is z.infer<typeof OpenAIContentSchema> => contentValidation.success;
// MarkdownOrJsonView will render markdown if `isMarkdownEnabled` (global context) is true and the content is valid markdown
// otherwise, if content is valid markdown will render JSON with switch to enable markdown globally
function MarkdownOrJsonView({
@@ -32,12 +41,20 @@ function MarkdownOrJsonView({
() => MarkdownSchema.safeParse(content),
[content],
);
const validatedOpenAIContent = useMemo(
() => OpenAIContentSchema.safeParse(content),
[content],
);
const { isMarkdownEnabled } = useMarkdownContext();
const canEnableMarkdown = isSupportedMarkdownFormat(
content,
validatedOpenAIContent,
);
return validatedMarkdown.success && isMarkdownEnabled ? (
return isMarkdownEnabled && canEnableMarkdown ? (
<MarkdownView
markdown={validatedMarkdown.data}
markdown={validatedMarkdown.data ?? content}
title={title}
className={className}
customCodeHeaderClassName={customCodeHeaderClassName}
@@ -45,7 +62,7 @@ function MarkdownOrJsonView({
) : (
<JSONView
json={content}
canEnableMarkdown={validatedMarkdown.success}
canEnableMarkdown={canEnableMarkdown}
title={title}
className={className}
/>
@@ -140,10 +157,12 @@ export const IOPreview: React.FC<{
role: m.role ?? "assistant",
}))
: [
ChatMlMessageSchema.parse({
{
role: "assistant",
content: outputClean,
}),
...(typeof outputClean === "string"
? { content: outputClean }
: { json: outputClean }),
} as ChatMlMessageSchema,
]),
]}
shouldRenderMarkdown
+5 -3
View File
@@ -84,7 +84,9 @@ const ObservationTreeTraceNode = (props: {
onClick={() => props.setCurrentObservationId(undefined)}
>
<div className="flex gap-2">
<span className={cn("rounded-sm bg-input p-1 text-xs")}>TRACE</span>
<span className={cn("rounded-sm bg-input px-1 py-0.5 text-xs")}>
TRACE
</span>
<span className="break-all text-sm">{props.trace.name}</span>
{props.comments ? (
<CommentCountIcon count={props.comments.get(props.trace.id)} />
@@ -191,7 +193,7 @@ const ObservationTreeNode = (props: {
variant="default"
pressed={collapsed}
size="xs"
className="w-7"
className="-m-1 h-6 w-6"
title={
collapsed ? "Expand children" : "Collapse children"
}
@@ -284,7 +286,7 @@ const ColorCodedObservationType = (props: {
return (
<span
className={cn(
"self-start rounded-sm p-1 text-xs",
"self-start rounded-sm px-1 py-0.5 text-xs",
treeItemColors.get(props.observationType),
)}
>
+17 -12
View File
@@ -30,6 +30,7 @@ import { TracePreview } from "@/src/components/trace/TracePreview";
import { ObservationPreview } from "@/src/components/trace/ObservationPreview";
import useSessionStorage from "@/src/components/useSessionStorage";
import { api } from "@/src/utils/api";
import { useSession } from "next-auth/react";
// Fixed widths for styling for v1
const SCALE_WIDTH = 800;
@@ -45,14 +46,12 @@ const PREDEFINED_STEP_SIZES = [
const getNestedObservationKeys = (
observations: NestedObservation[],
): { keys: string[]; ids: string[] } => {
): string[] => {
const keys: string[] = [];
const ids: string[] = [];
const collectKeys = (obs: NestedObservation[]) => {
obs.forEach((observation) => {
keys.push(`observation-${observation.id}`);
ids.push(observation.id);
if (observation.children) {
collectKeys(observation.children);
}
@@ -60,7 +59,7 @@ const getNestedObservationKeys = (
};
collectKeys(observations);
return { keys, ids };
return keys;
};
const calculateStepSize = (latency: number, scaleWidth: number) => {
@@ -97,7 +96,7 @@ function TreeItemInner({
const customLabelWidth = cardWidth - SCALE_WIDTH - CARD_PADDING;
return (
<div className="group my-1 grid w-full min-w-fit grid-cols-[1fr,auto] items-center">
<div className="group my-0.5 grid w-full min-w-fit grid-cols-[1fr,auto] items-center">
<div
className="flex flex-row items-center gap-2"
style={{
@@ -106,7 +105,10 @@ function TreeItemInner({
}}
>
<span
className={cn("rounded-sm p-1 text-xs", treeItemColors.get(type))}
className={cn(
"rounded-sm px-1 py-0.5 text-xs",
treeItemColors.get(type),
)}
>
{type}
</span>
@@ -305,15 +307,16 @@ export function TraceTimelineView({
() => nestObservations(observations),
[observations],
);
const { keys: nestedObservationKeys, ids: nestedObservationIds } = useMemo(
const nestedObservationKeys = useMemo(
() => getNestedObservationKeys(nestedObservations),
[nestedObservations],
);
const observationCommentCounts = api.comments.getCountsByObjectIds.useQuery(
const session = useSession();
const observationCommentCounts = api.comments.getCountByObjectType.useQuery(
{
projectId: trace.projectId,
objectIds: nestedObservationIds,
objectType: "OBSERVATION",
},
{
@@ -323,13 +326,14 @@ export function TraceTimelineView({
},
},
refetchOnMount: false, // prevents refetching loops
enabled: session.status === "authenticated",
},
);
const traceCommentCounts = api.comments.getCountsByObjectIds.useQuery(
const traceCommentCounts = api.comments.getCountByObjectId.useQuery(
{
projectId: trace.projectId,
objectIds: [trace.id],
objectId: trace.id,
objectType: "TRACE",
},
{
@@ -339,6 +343,7 @@ export function TraceTimelineView({
},
},
refetchOnMount: false, // prevents refetching loops
enabled: session.status === "authenticated",
},
);
@@ -360,7 +365,7 @@ export function TraceTimelineView({
minWidth: `${MIN_LABEL_WIDTH}px`,
}}
>
<h3 className="text-2xl font-semibold tracking-tight">
<h3 className="text-xl font-semibold tracking-tight">
Trace Timeline
</h3>
<div className="flex h-full items-center">
+13 -13
View File
@@ -27,13 +27,14 @@ import {
} from "lucide-react";
import { usdFormatter } from "@/src/utils/numbers";
import Decimal from "decimal.js";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useState } from "react";
import { DeleteButton } from "@/src/components/deleteButton";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { Tabs, TabsList, TabsTrigger } from "@/src/components/ui/tabs";
import { TraceTimelineView } from "@/src/components/trace/TraceTimelineView";
import { type APIScore } from "@langfuse/shared";
import { useSession } from "next-auth/react";
import { FullScreenPage } from "@/src/components/layouts/full-screen-page";
export function Trace(props: {
observations: Array<ObservationReturnType>;
@@ -57,14 +58,11 @@ export function Trace(props: {
[],
);
const observationObjectIds: string[] = useMemo(() => {
return props.observations.map(({ id }) => id);
}, [props.observations]);
const session = useSession();
const observationCommentCounts = api.comments.getCountsByObjectIds.useQuery(
const observationCommentCounts = api.comments.getCountByObjectType.useQuery(
{
projectId: props.trace.projectId,
objectIds: observationObjectIds,
objectType: "OBSERVATION",
},
{
@@ -74,13 +72,14 @@ export function Trace(props: {
},
},
refetchOnMount: false, // prevents refetching loops
enabled: session.status === "authenticated",
},
);
const traceCommentCounts = api.comments.getCountsByObjectIds.useQuery(
const traceCommentCounts = api.comments.getCountByObjectId.useQuery(
{
projectId: props.trace.projectId,
objectIds: [props.trace.id],
objectId: props.trace.id,
objectType: "TRACE",
},
{
@@ -90,6 +89,7 @@ export function Trace(props: {
},
},
refetchOnMount: false, // prevents refetching loops
enabled: session.status === "authenticated",
},
);
@@ -173,7 +173,7 @@ export function Trace(props: {
});
setScoresOnObservationTree(e);
}}
size="sm"
size="xs"
title="Show scores"
>
<Award className="h-4 w-4" />
@@ -186,7 +186,7 @@ export function Trace(props: {
});
setMetricsOnObservationTree(e);
}}
size="sm"
size="xs"
title="Show metrics"
>
{metricsOnObservationTree ? (
@@ -223,7 +223,7 @@ export function TracePage({ traceId }: { traceId: string }) {
const router = useRouter();
const utils = api.useUtils();
const session = useSession();
const trace = api.traces.byId.useQuery(
const trace = api.traces.byIdWithObservationsAndScores.useQuery(
{ traceId, projectId: router.query.projectId as string },
{
retry(failureCount, error) {
@@ -264,7 +264,7 @@ export function TracePage({ traceId }: { traceId: string }) {
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)]">
<FullScreenPage mobile={false} className="2xl:container">
<Header
title="Trace Detail"
breadcrumb={[
@@ -397,7 +397,7 @@ export function TracePage({ traceId }: { traceId: string }) {
/>
</div>
)}
</div>
</FullScreenPage>
);
}
+9 -4
View File
@@ -56,7 +56,7 @@ export function JSONView(props: {
title="Enable Markdown"
variant="ghost"
type="button"
size="xs"
size="icon-xs"
onClick={() => {
setIsMarkdownEnabled(true);
capture("trace_detail:io_pretty_format_toggle_group", {
@@ -71,10 +71,10 @@ export function JSONView(props: {
<Button
title="Copy to clipboard"
variant="ghost"
size="xs"
size="icon-xs"
type="button"
onClick={handleCopy}
className="hover:bg-border"
className="-mr-2 hover:bg-border"
>
{isCopied ? (
<Check className="h-3 w-3" />
@@ -164,7 +164,12 @@ export function CodeView(props: {
)}
</Button>
) : undefined}
<Button variant="secondary" size="xs" onClick={handleCopy}>
<Button
variant="secondary"
size="icon-xs"
onClick={handleCopy}
className=""
>
{isCopied ? (
<Check className="h-3 w-3" />
) : (
+201 -282
View File
@@ -9,31 +9,25 @@ import {
Children,
createElement,
} from "react";
import ReactMarkdown, { type Components, type Options } from "react-markdown";
import ReactMarkdown, { type Options } from "react-markdown";
import Link from "next/link";
import Image from "next/image";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { CodeBlock } from "@/src/components/ui/Codeblock";
import { useTheme } from "next-themes";
import { Button } from "@/src/components/ui/button";
import {
Check,
Copy,
Image as ImageIcon,
ImageOff,
Maximize2,
Minimize2,
} from "lucide-react";
import { api } from "@/src/utils/api";
import { isPresent } from "@langfuse/shared";
import { Check, Copy, ImageOff } from "lucide-react";
import { BsMarkdown } from "react-icons/bs";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { Skeleton } from "@/src/components/ui/skeleton";
import { useMarkdownContext } from "@/src/features/theming/useMarkdownContext";
import { captureException } from "@sentry/nextjs";
import { useSession } from "next-auth/react";
import { type ExtraProps as ReactMarkdownExtraProps } from "react-markdown";
import {
OpenAIUrlImageUrl,
type OpenAIContentParts,
type OpenAIContentSchema,
} from "@/src/components/schemas/ChatMlSchema";
import { type z } from "zod";
import { ResizableImage } from "@/src/components/ui/resizable-image";
type ReactMarkdownNode = ReactMarkdownExtraProps["node"];
type ReactMarkdownNodeChildren = Exclude<
@@ -50,147 +44,6 @@ const MemoizedReactMarkdown: FC<Options> = memo(
prevProps.className === nextProps.className,
);
/**
* Implemented customLoader as we cannot whitelist user provided image domains
* Security risks are taken care of by a validation in api.utilities.validateImgUrl
* Fetching image will fail if SSL/TLS certificate is invalid or expired, will be handled by onError
* Do not use this customLoader in production if you are not using the above mentioned security measures */
const customLoader = ({
src,
width,
quality,
}: {
src: string;
width: number;
quality?: number;
}) => {
return `${src}?w=${width}&q=${quality || 75}`;
};
const ImageErrorDisplay = ({
src,
displayError,
}: {
src: string;
displayError: string;
}) => (
<div className="flex flex-row items-center gap-2">
<span title={displayError} className="h-4 w-4">
<ImageOff className="h-4 w-4" />
</span>
<Link href={src} className="underline" target="_blank">
{src}
</Link>
</div>
);
const MarkdownImage: Components["img"] = ({ src, alt }) => {
const [isZoomedIn, setIsZoomedIn] = useState(true);
const [hasFetchError, setHasFetchError] = useState(false);
const [isImageVisible, setIsImageVisible] = useState(false);
const session = useSession();
if (!isPresent(src)) return null;
const isValidImage = api.utilities.validateImgUrl.useQuery(src, {
enabled: session.status === "authenticated",
});
if (session.status !== "authenticated") {
return (
<ImageErrorDisplay
src={src}
displayError="Images not rendered on public traces and observations"
/>
);
}
if (isValidImage.isLoading) {
return (
<Skeleton className="h-8 w-1/2 items-center p-2 text-xs">
<span className="opacity-80">Loading image...</span>
</Skeleton>
);
}
const displayError = `Cannot load image. ${src.includes("http") ? "Http images are not rendered in Langfuse for security reasons" : "Invalid image URL"}`;
if (isValidImage.data?.isValid) {
return (
<div>
{hasFetchError ? (
<ImageErrorDisplay src={src} displayError={displayError} />
) : (
<div
className={cn(
"group relative w-full overflow-hidden",
isZoomedIn ? "h-1/2 w-1/2" : "h-full w-full",
)}
>
{isImageVisible ? (
<Image
loader={customLoader}
src={src}
alt={alt ?? `Markdown Image-${Math.random()}`}
loading="lazy"
width={0}
height={0}
title={src}
className="h-full w-full rounded border object-contain"
onError={(error) => {
setHasFetchError(true);
captureException(error);
}}
/>
) : (
<div className="grid h-14 w-full grid-cols-[auto,1fr] items-center gap-2 rounded border border-dashed bg-muted/30 p-2 text-xs text-muted-foreground/60">
<Button
title="Render image"
type="button"
size="icon"
variant="ghost"
onClick={() => setIsImageVisible(!isImageVisible)}
>
<ImageIcon className="h-4 w-4" />
</Button>
<div className="flex items-center overflow-hidden">
<Link
href={src}
title={src}
className="overflow-hidden underline"
target="_blank"
>
<div className="h-8 overflow-hidden overflow-ellipsis">
{src}
</div>
</Link>
</div>
</div>
)}
{isImageVisible && (
<Button
type="button"
className="absolute right-0 top-0 mr-1 mt-1 h-8 w-8 opacity-0 group-hover:!bg-accent/30 group-hover:opacity-100"
variant="ghost"
size="icon"
onClick={() => setIsZoomedIn(!isZoomedIn)}
>
{isZoomedIn ? (
<Maximize2 className="h-4 w-4"></Maximize2>
) : (
<Minimize2 className="h-4 w-4"></Minimize2>
)}
</Button>
)}
</div>
)}
</div>
);
}
return <ImageErrorDisplay src={src} displayError={displayError} />;
};
const isTextElement = (child: ReactNode): child is ReactElement =>
isValidElement(child) &&
typeof child.type !== "string" &&
@@ -219,25 +72,179 @@ const isImageNode = (node?: ReactMarkdownNode): boolean =>
"tagName" in child && child.tagName === "img",
);
function MarkdownRenderer({
markdown,
theme,
className,
customCodeHeaderClassName,
}: {
markdown: string;
theme?: string;
className?: string;
customCodeHeaderClassName?: string;
}) {
return (
<MemoizedReactMarkdown
className={cn("space-y-2 overflow-x-auto break-words text-sm", className)}
remarkPlugins={[remarkGfm, remarkMath]}
components={{
p({ children, node }) {
if (isImageNode(node)) {
return <>{children}</>;
}
return (
<p className="mb-2 whitespace-pre-wrap last:mb-0">{children}</p>
);
},
a({ children, href }) {
if (href)
return (
<Link href={href} className="underline" target="_blank">
{children}
</Link>
);
},
ul({ children }) {
if (isChecklist(children))
return <ul className="list-none">{children}</ul>;
return <ul className="list-outside list-disc pl-4">{children}</ul>;
},
ol({ children }) {
return <ol className="list-outside list-decimal pl-4">{children}</ol>;
},
li({ children }) {
return (
<li className="mb-1 list-item">
{transformListItemChildren(children)}
</li>
);
},
pre({ children }) {
return <pre className="rounded p-2">{children}</pre>;
},
h1({ children }) {
return <h1 className="text-2xl font-bold">{children}</h1>;
},
h2({ children }) {
return <h2 className="text-xl font-bold">{children}</h2>;
},
h3({ children }) {
return <h3 className="text-lg font-bold">{children}</h3>;
},
h4({ children }) {
return <h4 className="text-base font-bold">{children}</h4>;
},
h5({ children }) {
return <h5 className="text-sm font-bold">{children}</h5>;
},
h6({ children }) {
return <h6 className="text-xs font-bold">{children}</h6>;
},
code({ children, className }) {
const languageMatch = /language-(\w+)/.exec(className || "");
const language = languageMatch ? languageMatch[1] : "";
const codeContent = String(children).replace(/\n$/, "");
const isMultiLine = codeContent.includes("\n");
return language || isMultiLine ? (
// code block
<CodeBlock
key={Math.random()}
language={language}
value={codeContent}
theme={theme}
className={customCodeHeaderClassName}
/>
) : (
// inline code
<code className="rounded border bg-secondary px-0.5">
{codeContent}
</code>
);
},
blockquote({ children }) {
return (
<blockquote className="border-l-4 pl-4 italic">
{children}
</blockquote>
);
},
img({ src, alt }) {
return <ResizableImage src={src} alt={alt} />;
},
hr() {
return <hr className="my-4" />;
},
table({ children }) {
return (
<div className="overflow-x-auto rounded border text-xs">
<table className="min-w-full divide-y">{children}</table>
</div>
);
},
thead({ children }) {
return <thead>{children}</thead>;
},
tbody({ children }) {
return <tbody className="divide-y divide-border">{children}</tbody>;
},
tr({ children }) {
return <tr>{children}</tr>;
},
th({ children }) {
return (
<th className="px-4 py-2 text-left text-xs font-medium uppercase tracking-wider">
{children}
</th>
);
},
td({ children }) {
return <td className="whitespace-nowrap px-4 py-2">{children}</td>;
},
}}
>
{markdown}
</MemoizedReactMarkdown>
);
}
const parseOpenAIContentParts = (
content: z.infer<typeof OpenAIContentParts>,
): string => {
return content
.map((item) => {
if (item.type === "text") {
return item.text;
} else {
return `![image](${item.image_url.url})`;
}
})
.join("\n");
};
export function MarkdownView({
markdown,
title,
className,
customCodeHeaderClassName,
}: {
markdown: string;
markdown: string | z.infer<typeof OpenAIContentSchema>;
title?: string;
className?: string;
customCodeHeaderClassName?: string;
}) {
const [isCopied, setIsCopied] = useState(false);
const { resolvedTheme: theme } = useTheme();
const capture = usePostHogClientCapture();
const { resolvedTheme: theme } = useTheme();
const { setIsMarkdownEnabled } = useMarkdownContext();
const handleCopy = () => {
setIsCopied(true);
void navigator.clipboard.writeText(markdown);
const rawText =
typeof markdown === "string"
? markdown
: parseOpenAIContentParts(markdown);
void navigator.clipboard.writeText(rawText);
setTimeout(() => setIsCopied(false), 1000);
};
@@ -288,132 +295,44 @@ export function MarkdownView({
</Button>
</div>
</div>
) : undefined}
<MemoizedReactMarkdown
className={cn(
"space-y-4 overflow-x-auto break-words p-3 text-sm",
className,
)}
remarkPlugins={[remarkGfm, remarkMath]}
components={{
p({ children, node }) {
if (isImageNode(node)) {
return <>{children}</>;
}
return (
<p className="mb-2 whitespace-pre-wrap last:mb-0">{children}</p>
);
},
a({ children, href }) {
if (href)
return (
<Link href={href} className="underline" target="_blank">
{children}
</Link>
);
},
ul({ children }) {
if (isChecklist(children))
return <ul className="list-none">{children}</ul>;
return <ul className="list-outside list-disc pl-4">{children}</ul>;
},
ol({ children }) {
return (
<ol className="list-outside list-decimal pl-4">{children}</ol>
);
},
li({ children }) {
return (
<li className="mb-1 list-item">
{transformListItemChildren(children)}
</li>
);
},
pre({ children }) {
return <pre className="rounded p-2">{children}</pre>;
},
h1({ children }) {
return <h1 className="text-2xl font-bold">{children}</h1>;
},
h2({ children }) {
return <h2 className="text-xl font-bold">{children}</h2>;
},
h3({ children }) {
return <h3 className="text-lg font-bold">{children}</h3>;
},
h4({ children }) {
return <h4 className="text-base font-bold">{children}</h4>;
},
h5({ children }) {
return <h5 className="text-sm font-bold">{children}</h5>;
},
h6({ children }) {
return <h6 className="text-xs font-bold">{children}</h6>;
},
code({ children, className }) {
const languageMatch = /language-(\w+)/.exec(className || "");
const language = languageMatch ? languageMatch[1] : "";
const codeContent = String(children).replace(/\n$/, "");
const isMultiLine = codeContent.includes("\n");
return language || isMultiLine ? (
// code block
<CodeBlock
key={Math.random()}
language={language}
value={codeContent}
) : null}
<div className="grid grid-flow-row gap-2 p-3">
{typeof markdown === "string" ? (
// plain string
<MarkdownRenderer
markdown={markdown}
theme={theme}
className={className}
customCodeHeaderClassName={customCodeHeaderClassName}
/>
) : (
// content parts (multi-modal)
markdown.map((content, index) =>
content.type === "text" ? (
<MarkdownRenderer
key={index}
markdown={content.text}
theme={theme}
className={customCodeHeaderClassName}
className={className}
customCodeHeaderClassName={customCodeHeaderClassName}
/>
) : (
// inline code
<code className="rounded border bg-secondary px-0.5">
{codeContent}
</code>
);
},
blockquote({ children }) {
return (
<blockquote className="border-l-4 pl-4 italic">
{children}
</blockquote>
);
},
img: MarkdownImage,
hr() {
return <hr className="my-4" />;
},
table({ children }) {
return (
<div className="overflow-x-auto rounded border text-xs">
<table className="min-w-full divide-y">{children}</table>
) : OpenAIUrlImageUrl.safeParse(content.image_url.url).success ? (
<div key={index}>
<ResizableImage src={content.image_url.url} />
</div>
);
},
thead({ children }) {
return <thead>{children}</thead>;
},
tbody({ children }) {
return <tbody className="divide-y divide-border">{children}</tbody>;
},
tr({ children }) {
return <tr>{children}</tr>;
},
th({ children }) {
return (
<th className="px-4 py-2 text-left text-xs font-medium uppercase tracking-wider">
{children}
</th>
);
},
td({ children }) {
return <td className="whitespace-nowrap px-4 py-2">{children}</td>;
},
}}
>
{markdown}
</MemoizedReactMarkdown>
) : (
<div className="grid grid-cols-[auto,1fr] items-center gap-2">
<span title="No Base64 image support yet" className="h-4 w-4">
<ImageOff className="h-4 w-4" />
</span>
<span className="truncate text-sm">
{content.image_url.url}
</span>
</div>
),
)
)}
</div>
</div>
);
}
+1 -1
View File
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/src/utils/tailwind";
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
"relative w-full rounded-lg border p-3 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
+1 -1
View File
@@ -12,7 +12,7 @@ const Avatar = React.forwardRef<
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
"relative flex h-8 w-8 shrink-0 overflow-hidden rounded-full",
className,
)}
{...props}
+6 -5
View File
@@ -25,11 +25,12 @@ const buttonVariants = cva(
"bg-destructive-foreground/90 text-destructive hover:bg-destructive-foreground/80",
},
size: {
default: "h-10 px-4 py-2",
xs: "h-6 px-1 rounded-sm",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
default: "h-8 px-3 py-1",
xs: "h-4 px-1 rounded-sm",
sm: "h-6 rounded-md px-3",
lg: "h-9 rounded-md px-8",
icon: "h-8 w-8",
"icon-xs": "h-6 w-6",
},
},
defaultVariants: {
+3 -3
View File
@@ -23,7 +23,7 @@ const CardHeader = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
className={cn("flex flex-col space-y-1 p-4", className)}
{...props}
/>
));
@@ -60,7 +60,7 @@ const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
<div ref={ref} className={cn("p-4 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
@@ -70,7 +70,7 @@ const CardFooter = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
className={cn("flex items-center p-4 pt-0", className)}
{...props}
/>
));
+2 -5
View File
@@ -41,15 +41,12 @@ const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div
className="flex items-center rounded-md border px-3"
cmdk-input-wrapper=""
>
<div className="flex items-center rounded border px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md border-transparent bg-transparent py-3 text-sm outline-none placeholder:text-slate-500 focus:border-0 focus:border-none focus:border-transparent focus:ring-0 disabled:cursor-not-allowed disabled:opacity-50 dark:placeholder:text-slate-400 ",
"flex h-8 w-full rounded border-transparent bg-transparent py-3 text-sm outline-none placeholder:text-slate-500 focus:border-0 focus:border-none focus:border-transparent focus:ring-0 disabled:cursor-not-allowed disabled:opacity-50 dark:placeholder:text-slate-400 ",
className,
)}
{...props}
+1 -1
View File
@@ -88,7 +88,7 @@ const DialogTitle = React.forwardRef<
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
"text-xl font-semibold leading-none tracking-tight",
className,
)}
{...props}
+1 -1
View File
@@ -18,7 +18,7 @@ const HoverCardContent = React.forwardRef<
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"z-50 w-64 rounded-md border bg-popover p-3 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
+1 -1
View File
@@ -11,7 +11,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50",
"flex h-8 w-full rounded-md border border-input bg-background px-2 py-1 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
+1 -1
View File
@@ -14,7 +14,7 @@ const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(
<input
type={showPassword ? "text" : "password"}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 pr-10 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
"flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 pr-10 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
+1 -1
View File
@@ -19,7 +19,7 @@ const PopoverContent = React.forwardRef<
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"z-50 w-72 rounded-md border bg-popover p-3 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
+159
View File
@@ -0,0 +1,159 @@
import { cn } from "@/src/utils/tailwind";
import { useState } from "react";
import Link from "next/link";
import Image from "next/image";
import { Button } from "@/src/components/ui/button";
import {
Image as ImageIcon,
ImageOff,
Maximize2,
Minimize2,
} from "lucide-react";
import { api } from "@/src/utils/api";
import { isPresent } from "@langfuse/shared";
import { Skeleton } from "@/src/components/ui/skeleton";
import { captureException } from "@sentry/nextjs";
import { useSession } from "next-auth/react";
/**
* Implemented customLoader as we cannot whitelist user provided image domains
* Security risks are taken care of by a validation in api.utilities.validateImgUrl
* Fetching image will fail if SSL/TLS certificate is invalid or expired, will be handled by onError
* Do not use this customLoader in production if you are not using the above mentioned security measures */
const customLoader = ({
src,
width,
quality,
}: {
src: string;
width: number;
quality?: number;
}) => {
return `${src}?w=${width}&q=${quality || 75}`;
};
const ImageErrorDisplay = ({
src,
displayError,
}: {
src: string;
displayError: string;
}) => (
<div className="grid grid-cols-[auto,1fr] items-center gap-2">
<span title={displayError} className="h-4 w-4">
<ImageOff className="h-4 w-4" />
</span>
<Link href={src} className="truncate text-sm underline" target="_blank">
{src}
</Link>
</div>
);
export const ResizableImage = ({
src,
alt,
}: {
src?: string;
alt?: string;
}) => {
const [isZoomedIn, setIsZoomedIn] = useState(true);
const [hasFetchError, setHasFetchError] = useState(false);
const [isImageVisible, setIsImageVisible] = useState(false);
const session = useSession();
if (!isPresent(src)) return null;
const isValidImage = api.utilities.validateImgUrl.useQuery(src, {
enabled: session.status === "authenticated" && isImageVisible,
});
if (session.status !== "authenticated") {
return (
<ImageErrorDisplay
src={src}
displayError="Images not rendered on public traces and observations"
/>
);
}
if (isValidImage.isLoading && isImageVisible) {
return (
<Skeleton className="h-8 w-1/2 items-center p-2 text-xs">
<span className="opacity-80">Loading image...</span>
</Skeleton>
);
}
const displayError = `Cannot load image. ${src.includes("http") ? "Http images are not rendered in Langfuse for security reasons" : "Invalid image URL"}`;
return (
<div>
{hasFetchError ? (
<ImageErrorDisplay src={src} displayError={displayError} />
) : (
<div
className={cn(
"group relative w-full overflow-hidden",
isZoomedIn ? "h-1/2 w-1/2" : "h-full w-full",
)}
>
{isImageVisible && isValidImage.data?.isValid ? (
<>
<Image
loader={customLoader}
src={src}
alt={alt ?? `Markdown Image-${Math.random()}`}
loading="lazy"
width={0}
height={0}
title={src}
className="h-full w-full rounded border object-contain"
onError={(error) => {
setHasFetchError(true);
captureException(error);
}}
/>
<Button
type="button"
className="absolute right-0 top-0 mr-1 mt-1 h-8 w-8 opacity-0 group-hover:!bg-accent/30 group-hover:opacity-100"
variant="ghost"
size="icon"
onClick={() => setIsZoomedIn(!isZoomedIn)}
>
{isZoomedIn ? (
<Maximize2 className="h-4 w-4"></Maximize2>
) : (
<Minimize2 className="h-4 w-4"></Minimize2>
)}
</Button>
</>
) : (
<div className="grid h-14 w-full grid-cols-[auto,1fr] items-center gap-2 rounded border border-dashed bg-muted/30 p-2 text-xs text-muted-foreground/60">
<Button
title="Render image"
type="button"
size="icon"
variant="ghost"
onClick={() => setIsImageVisible(!isImageVisible)}
>
<ImageIcon className="h-4 w-4" />
</Button>
<div className="flex items-center overflow-hidden">
<Link
href={src}
title={src}
className="overflow-hidden underline"
target="_blank"
>
<div className="h-8 overflow-hidden overflow-ellipsis">
{src}
</div>
</Link>
</div>
</div>
)}
</div>
)}
</div>
);
};
+45
View File
@@ -0,0 +1,45 @@
"use client"
import { GripVertical } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/src/utils/tailwind"
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
const ResizablePanel = ResizablePrimitive.Panel
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
+1 -1
View File
@@ -19,7 +19,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
"flex h-8 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
+2 -2
View File
@@ -11,7 +11,7 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-muted-foreground",
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-muted-foreground",
className,
)}
{...props}
@@ -19,7 +19,7 @@ const Switch = React.forwardRef<
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitives.Root>
+1 -1
View File
@@ -31,7 +31,7 @@ const TableBody = React.forwardRef<
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
className={cn("text-xs [&_tr:last-child]:border-0", className)}
{...props}
/>
));
+2 -2
View File
@@ -14,7 +14,7 @@ const TabsList = React.forwardRef<
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
"inline-flex h-8 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className,
)}
{...props}
@@ -29,7 +29,7 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
"inline-flex h-6 items-center justify-center whitespace-nowrap rounded-sm px-2 py-0.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className,
)}
{...props}
+1 -1
View File
@@ -16,7 +16,7 @@ const toggleVariants = cva(
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-10 px-3",
default: "h-8 px-3",
xs: "h-6 px-1.5",
sm: "h-9 px-2.5",
lg: "h-11 px-5",
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v2.72.0";
export const VERSION = "v2.75.2";
-1
View File
@@ -16,7 +16,6 @@ if (!process.env.VERCEL && process.env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION) {
opentelemetry.context.setGlobalContextManager(contextManager);
const tracer = dd.init({
profiling: false,
runtimeMetrics: true,
});
@@ -16,10 +16,7 @@ import { useQueryOrganization } from "@/src/features/organizations/hooks";
import { Card } from "@/src/components/ui/card";
import { numberFormatter, compactNumberFormatter } from "@/src/utils/numbers";
import { useHasOrgEntitlement } from "@/src/features/entitlements/hooks";
import {
type Plan,
planLabels,
} from "@/src/features/entitlements/constants/plans";
import { type Plan, planLabels } from "@langfuse/shared";
import { stripeProducts } from "@/src/ee/features/billing/utils/stripeProducts";
import { useRouter } from "next/router";
import {
@@ -52,7 +49,7 @@ export const BillingSettings = () => {
</Alert>
);
return (
<div className="p-4">
<div>
<Header title="Usage & Billing" level="h3" />
<OrganizationUsageChart />
</div>
@@ -81,7 +78,7 @@ const OrganizationUsageChart = () => {
return (
<div>
<Card className="p-4">
<Card className="p-3">
{usage.data !== undefined ? (
<>
<Text>
@@ -107,7 +104,9 @@ const OrganizationUsageChart = () => {
)}
</>
) : (
"Loading (might take a moment) ..."
<span className="text-sm text-muted-foreground">
Loading (might take a moment) ...
</span>
)}
</Card>
<div className="mt-2 flex flex-col gap-1 text-sm text-muted-foreground">
@@ -40,7 +40,7 @@ export const cloudBillingRouter = createTRPCRouter({
});
if (!org) {
throw new TRPCError({
code: "NOT_FOUND",
code: "INTERNAL_SERVER_ERROR",
message: "Organization not found",
});
}
@@ -48,7 +48,7 @@ export const cloudBillingRouter = createTRPCRouter({
const parsedOrg = parseDbOrg(org);
if (parsedOrg.cloudConfig?.plan)
throw new TRPCError({
code: "FORBIDDEN",
code: "INTERNAL_SERVER_ERROR",
message:
"Cannot initialize stripe checkout for orgs that have a manual/legacy plan",
});
@@ -65,7 +65,7 @@ export const cloudBillingRouter = createTRPCRouter({
if (stripeActiveSubscriptionId) {
// If the org has a customer ID, do not return checkout options, should use the billing portal instead
throw new TRPCError({
code: "BAD_REQUEST",
code: "INTERNAL_SERVER_ERROR",
message: "Organization already has an active subscription",
});
}
@@ -78,7 +78,7 @@ export const cloudBillingRouter = createTRPCRouter({
)
)
throw new TRPCError({
code: "BAD_REQUEST",
code: "INTERNAL_SERVER_ERROR",
message: "Invalid stripe product id",
});
@@ -87,7 +87,7 @@ export const cloudBillingRouter = createTRPCRouter({
);
if (!product.default_price) {
throw new TRPCError({
code: "BAD_REQUEST",
code: "INTERNAL_SERVER_ERROR",
message: "Product does not have a default price in Stripe",
});
}
@@ -168,7 +168,7 @@ export const cloudBillingRouter = createTRPCRouter({
});
if (!org) {
throw new TRPCError({
code: "NOT_FOUND",
code: "INTERNAL_SERVER_ERROR",
message: "Organization not found",
});
}
@@ -233,7 +233,7 @@ export const cloudBillingRouter = createTRPCRouter({
});
if (!organization) {
throw new TRPCError({
code: "NOT_FOUND",
code: "INTERNAL_SERVER_ERROR",
message: "Organization not found",
});
}
@@ -8,7 +8,8 @@ import { prisma } from "@langfuse/shared/src/db";
import { stripeClient } from "@/src/ee/features/billing/utils/stripe";
import type Stripe from "stripe";
import { CloudConfigSchema, parseDbOrg } from "@langfuse/shared";
import { traceException } from "@langfuse/shared/src/server";
import { traceException, redis } from "@langfuse/shared/src/server";
import { ApiAuthService } from "@/src/features/public-api/server/apiAuth";
/*
* Sign-up endpoint (email/password users), creates user in database.
@@ -227,5 +228,8 @@ async function handleSubscriptionChanged(
});
}
// need to update the plan in the api keys
await new ApiAuthService(prisma, redis).invalidateOrgApiKeys(parsedOrg.id);
return;
}
@@ -1,5 +1,5 @@
import { env } from "@/src/env.mjs";
import { type Plan } from "@/src/features/entitlements/constants/plans";
import { type Plan } from "@langfuse/shared";
type StripeProduct = {
stripeProductId: string;
@@ -514,8 +514,11 @@ export const InnerEvalConfigForm = (props: {
<span>0%</span>
<span>100%</span>
</FormDescription>
<FormDescription>
Percentage of traces to evaluate.
<FormDescription className="mt-1 flex flex-row gap-1">
<span>Percentage of traces to evaluate.</span>
<span>
Currently set to {(field.value * 100).toFixed(0)}%.
</span>
</FormDescription>
</div>
<FormMessage />
@@ -30,7 +30,7 @@ export default function EvalConfigTable({ projectId }: { projectId: string }) {
limit: paginationState.pageSize,
projectId,
});
const totalCount = templates.data?.totalCount ?? 0;
const totalCount = templates.data?.totalCount ?? null;
const columnHelper = createColumnHelper<EvalConfigRow>();
const columns = [
@@ -120,7 +120,7 @@ export default function EvalConfigTable({ projectId }: { projectId: string }) {
}
}
pagination={{
pageCount: Math.ceil(totalCount / paginationState.pageSize),
totalCount,
onChange: setPaginationState,
state: paginationState,
}}
@@ -41,7 +41,7 @@ export default function EvalLogTable({
jobConfigurationId,
projectId,
});
const totalCount = logs.data?.totalCount ?? 0;
const totalCount = logs.data?.totalCount ?? null;
const columnHelper = createColumnHelper<JobExecutionRow>();
const columns = [
@@ -199,7 +199,7 @@ export default function EvalLogTable({
}
}
pagination={{
pageCount: Math.ceil(totalCount / paginationState.pageSize),
totalCount,
onChange: setPaginationState,
state: paginationState,
}}
@@ -28,7 +28,7 @@ export default function EvalsTemplateTable({
page: paginationState.pageIndex,
limit: paginationState.pageSize,
});
const totalCount = templates.data?.totalCount ?? 0;
const totalCount = templates.data?.totalCount ?? null;
const columnHelper = createColumnHelper<EvalsTemplateRow>();
@@ -104,7 +104,7 @@ export default function EvalsTemplateTable({
}
}
pagination={{
pageCount: Math.ceil(totalCount / paginationState.pageSize),
totalCount,
onChange: setPaginationState,
state: paginationState,
}}
@@ -427,6 +427,7 @@ export const InnerEvalTemplateForm = (props: {
updateModelParamValue: updateModelParamValue,
setModelParamEnabled,
}}
evalModelsOnly
formDisabled={!props.isEditing}
/>
</div>
@@ -74,9 +74,7 @@ export const GenerationOutput = () => {
<p className="flex-1 text-xs font-semibold">Output</p>
{copyButton}
</div>
<pre
className={`whitespace-break-spaces break-words ${isJson ? "text-xs" : ""}`}
>
<pre className="whitespace-break-spaces break-words text-xs">
{isJson ? outputJson : output}
</pre>
</div>
@@ -68,7 +68,7 @@ export const JumpToPlaygroundButton: React.FC<JumpToPlaygroundButtonProps> = (
asChild
>
<Link href={`/project/${projectId}/playground`}>
<Terminal className="h-5 w-5" />
<Terminal className="h-4 w-4" />
{props.fullWidth ? (
<span className="ml-2">Test in playground</span>
) : null}

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