Compare commits

...
139 Commits
Author SHA1 Message Date
Max Deichmann 9b7f046e71 chore: release v2.76.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-09-03 10:48:10 +02:00
Steffen SchmitzandGitHub fe63704508 feat: fallback to synchronous ingestion if queue unavailable (#3196) 2024-09-03 10:42:59 +02:00
marliessophieandGitHub c39279f677 style(prompts): actions on hover (#3135) 2024-09-03 08:12:39 +00:00
Marc KlingenandGitHub 4206f5025d perf(cloud): pull usage data shown in settings from stripe if available (#3206) 2024-09-02 23:51:13 +00:00
marliessophieandGitHub 0260a4e861 style(dataset_items): make table vs item detail resizeable (#3139) 2024-09-02 08:01:43 +00:00
Max DeichmannandGitHub 244adf0dc3 feat: disable legacy ingestion queue if required (#3183) 2024-09-01 14:24:25 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
dc6b98d009 chore(deps-dev): bump tsx from 4.18.0 to 4.19.0 (#3172)
Bumps [tsx](https://github.com/privatenumber/tsx) from 4.18.0 to 4.19.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.18.0...v4.19.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-09-01 10:07:44 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b20fc5a20b chore(deps-dev): bump tailwindcss from 3.4.4 to 3.4.10 (#3173)
Bumps [tailwindcss](https://github.com/tailwindlabs/tailwindcss) from 3.4.4 to 3.4.10.
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/v3.4.10/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/compare/v3.4.4...v3.4.10)

---
updated-dependencies:
- dependency-name: tailwindcss
  dependency-type: direct:development
  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-31 22:36:42 +02:00
Max DeichmannandGitHub f59ad5fd60 chore: upload sentry sourcemaps (#3179) 2024-08-31 18:50:48 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
06ef41e372 chore(deps): bump pino-pretty from 10.3.1 to 11.2.2 (#3170)
Bumps [pino-pretty](https://github.com/pinojs/pino-pretty) from 10.3.1 to 11.2.2.
- [Release notes](https://github.com/pinojs/pino-pretty/releases)
- [Commits](https://github.com/pinojs/pino-pretty/compare/v10.3.1...v11.2.2)

---
updated-dependencies:
- dependency-name: pino-pretty
  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-31 17:04:49 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e215a2db99 chore(deps-dev): bump @types/lodash from 4.17.5 to 4.17.7 in the lodash group (#3169)
chore(deps-dev): bump @types/lodash in the lodash group

Bumps the lodash group with 1 update: [@types/lodash](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/lodash).


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

---
updated-dependencies:
- dependency-name: "@types/lodash"
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: lodash
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-31 17:00:36 +02:00
Max DeichmannandGitHub 5e247e88c9 fix: do nothing if invalidating org or project without api keys (#3167)
* also run on main branch

* also run on main branch

* also run on main branch

* also run on main branch

* also run on main branch
2024-08-30 22:57:41 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
8e1e27c868 chore(deps-dev): bump msw from 2.3.1 to 2.4.1 (#3150)
Bumps [msw](https://github.com/mswjs/msw) from 2.3.1 to 2.4.1.
- [Release notes](https://github.com/mswjs/msw/releases)
- [Changelog](https://github.com/mswjs/msw/blob/main/CHANGELOG.md)
- [Commits](https://github.com/mswjs/msw/compare/v2.3.1...v2.4.1)

---
updated-dependencies:
- dependency-name: msw
  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-30 21:52:41 +02:00
dependabot[bot]andGitHub 4648a5be8b chore(deps): bump lucide-react from 0.429.0 to 0.436.0 (#3154) 2024-08-30 21:02:28 +02:00
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
Marlies Mayerhofer 86816e569d chore: release v2.72.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 09:54:34 +02:00
marliessophieandGitHub 0fe2176930 style(ui): align styling of comment button on sessions page and adjust dashboard and session overflow for mobile (#2985)
* style(ui): align styling of comment button on sessions page

* style(dashboard): score analytics charts full width for mobile

* style(markdown): fix overflow behavior on sessions page for mobile
2024-08-21 09:29:17 +02:00
Max DeichmannandGitHub 43f7c524dc fix: drop failed eval jobs (#2986) 2024-08-20 23:07:51 +00:00
Marc Klingen 2bcfab130d chore(cloud): update stripe product ids 2024-08-20 19:10:56 +02:00
Marc Klingen 51bb1c3374 chore(cloud): add more details to upgrade page 2024-08-20 16:58:10 +02:00
Marc KlingenandGitHub 302bb065b7 feat(cloud): add billing period and next invoiced amount to billing settings (#2982) 2024-08-20 16:35:13 +02:00
Max DeichmannandGitHub e6a8fb84f0 fix: remove last backend sentry logic (#2981) 2024-08-20 14:07:43 +00:00
Marc KlingenandGitHub b12f3df933 fix: admin role (#2980) 2024-08-20 15:40:32 +02:00
marliessophieandGitHub 159b5f87fe feat(comments): add comments on objects (#2971) 2024-08-20 15:39:23 +02:00
Marc Klingen 73fe2dc1a7 fix: use test mode stripe products also in staging 2024-08-19 22:52:03 +02:00
Marc KlingenandGitHub 9daf348d61 feat(cloud): add stripe checkout, customer portal, and usage metering (#2962) 2024-08-19 20:35:46 +00:00
Marc KlingenandGitHub 8a9d0b01af chore(cloud): reduce posthog event count (#2973)
optimize how often we use identify with posthog
2024-08-19 16:49:01 +02:00
Marc Klingen 315d8c7619 chore(ui): include NEXT_PUBLIC_DEMO_ORG_ID in demo banner check 2024-08-19 12:18:04 +02:00
Max DeichmannandGitHub b23eaf3cd4 infra: record exceptions of health endpoint (#2970) 2024-08-19 11:54:43 +02:00
Max DeichmannandGitHub eea1e8d054 fix: only openai adapter for evals (#2960) 2024-08-19 08:00:54 +00:00
Max DeichmannandGitHub 06a7a4bfb2 infra: add ingestion api attributes to span (#2968) 2024-08-19 07:46:43 +00:00
Max DeichmannandGitHub 7c8a30d48f infra: add ingestion pipeline processing time (#2965) 2024-08-17 21:13:41 +00:00
Max DeichmannandGitHub 1971adda18 feat: improve dd ressource names for http requests (#2964) 2024-08-17 21:57:33 +02:00
Max DeichmannandGitHub d9b4e3a7dd feat: add dd instrumentation to web container (#2727) 2024-08-17 20:21:37 +02:00
ClemoandGitHub f1aa884cbc docs: update readme 2024-08-17 14:33:46 +02:00
ClemoandGitHub 0ed9acddb6 docs: update careers 2024-08-17 14:28:58 +02:00
Marc KlingenandGitHub 61e3470a93 feat(datasets): add duplicate dataset button (#2957)
* feat(datasets): add duplicate dataset button

* named export
2024-08-17 01:12:32 +02:00
301 changed files with 11857 additions and 4943 deletions
-14
View File
@@ -1,14 +0,0 @@
{
"name": "langfuse dev",
"image": "mcr.microsoft.com/devcontainers/javascript-node:20-bookworm",
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {
"version": "latest",
"moby": true
}
},
"forwardPorts": [3000],
"onCreateCommand": "npm install -g pnpm@9.5.0",
"postCreateCommand": "cp .env.dev.example .env && pnpm run dx-f"
}
+11 -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,13 @@ 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=
### END Langfuse Cloud Config
@@ -196,5 +204,6 @@ ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000
# LANGFUSE_LOG_LEVEL=
# LANGFUSE_LEGACY_INGESTION_WORKER_CONCURRENCY=
# LANGFUSE_ASYNC_INGESTION_PROCESSING="true"
# QUEUE_CONSUMER_LEGACY_INGESTION_QUEUE_IS_ENABLED="true"
## END Langfuse V3 Ingestion
+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/*"
+15 -2
View File
@@ -111,6 +111,10 @@ jobs:
- name: Build
run: pnpm run build
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
- name: Start Langfuse
run: (pnpm run start&)
@@ -136,6 +140,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 +170,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
@@ -1,6 +1,6 @@
![langfuse_logo_1024](https://github.com/user-attachments/assets/f17d8fc1-8206-4afa-a5ec-774826be88a8)
<div align="center"><h1> Langfuse: Open Source LLM Engineering Platform</h4></div>
<div align="center"><h1> Langfuse: Open Source LLM Observability & Engineering Platform</h4></div>
<div align="center"><h2> Debug and improve your LLM app</h4></div>
<div align="center"><h3> LLM Observability, Prompt Management, LLM Evaluations, Datasets, LLM Metrics and Prompt Playground</h4></div>
-1
View File
@@ -3,7 +3,6 @@ Join us in scaling Langfuse in Berlin, Germany. We are an open source company, w
_Open Roles_
- Backend Engineer, 70-130k EUR, 0.25-0.75% Equity, https://www.ycombinator.com/companies/langfuse/jobs/mnrdwla-backend-engineer
- Product Engineer, 70-130k EUR, 0.25-0.75% Equity, https://www.ycombinator.com/companies/langfuse/jobs/aAvmoFB-product-engineer
- Developer Advocate, 60-110k EUR, 0.25-0.5% Equity, https://www.ycombinator.com/companies/langfuse/jobs/uHysbKH-developer-advocate-devrel
+2 -1
View File
@@ -25,7 +25,8 @@
},
"dependencies": {
"@langfuse/shared": "workspace:*",
"next": "^14.2.3",
"@opentelemetry/api": ">=1.0.0 <1.9.0",
"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.71.0",
"version": "2.76.0",
"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
+8 -6
View File
@@ -57,16 +57,18 @@
"@langchain/anthropic": "^0.1.21",
"@langchain/core": "^0.1.61",
"@langchain/openai": "^0.0.33",
"@prisma/client": "^5.13.0",
"@opentelemetry/api": ">=1.0.0 <1.9.0",
"@prisma/client": "^5.17.0",
"@react-email/components": "^0.0.19",
"@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",
"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",
@@ -74,12 +76,12 @@
"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:*",
"@repo/typescript-config": "workspace:*",
"@types/lodash": "^4.17.5",
"@types/lodash": "^4.17.7",
"@types/node": "^20.11.29",
"@types/nodemailer": "^6.4.15",
"@types/pg": "^8.11.6",
@@ -92,7 +94,7 @@
"kysely-codegen": "^0.11.0",
"nodemon": "^3.1.3",
"prettier": "^3.2.5",
"prisma": "^5.13.0",
"prisma": "^5.17.0",
"prisma-erd-generator": "^1.11.2",
"prisma-kysely": "^1.8.0",
"ts-node": "^10.9.2",
+18
View File
@@ -42,6 +42,13 @@ export const DatasetStatus = {
ARCHIVED: "ARCHIVED"
} as const;
export type DatasetStatus = (typeof DatasetStatus)[keyof typeof DatasetStatus];
export const CommentObjectType = {
TRACE: "TRACE",
OBSERVATION: "OBSERVATION",
SESSION: "SESSION",
PROMPT: "PROMPT"
} as const;
export type CommentObjectType = (typeof CommentObjectType)[keyof typeof CommentObjectType];
export const JobType = {
EVAL: "EVAL"
} as const;
@@ -116,6 +123,16 @@ export type BatchExport = {
url: string | null;
log: string | null;
};
export type Comment = {
id: string;
project_id: string;
object_type: CommentObjectType;
object_id: string;
created_at: Generated<Timestamp>;
updated_at: Generated<Timestamp>;
content: string;
author_user_id: string | null;
};
export type CronJobs = {
name: string;
last_run: Timestamp | null;
@@ -491,6 +508,7 @@ export type DB = {
api_keys: ApiKey;
audit_logs: AuditLog;
batch_exports: BatchExport;
comments: Comment;
cron_jobs: CronJobs;
dataset_items: DatasetItem;
dataset_run_items: DatasetRunItems;
@@ -0,0 +1,22 @@
-- CreateEnum
CREATE TYPE "CommentObjectType" AS ENUM ('TRACE', 'OBSERVATION', 'SESSION', 'PROMPT');
-- CreateTable
CREATE TABLE "comments" (
"id" TEXT NOT NULL,
"project_id" TEXT NOT NULL,
"object_type" "CommentObjectType" NOT NULL,
"object_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"content" TEXT NOT NULL,
"author_user_id" TEXT,
CONSTRAINT "comments_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "comments_project_id_object_type_object_id_idx" ON "comments"("project_id", "object_type", "object_id");
-- AddForeignKey
ALTER TABLE "comments" ADD CONSTRAINT "comments_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+23
View File
@@ -129,6 +129,7 @@ model Project {
Score Score[]
scoreConfig ScoreConfig[]
BatchExport BatchExport[]
comment Comment[]
@@index([orgId])
@@map("projects")
@@ -635,6 +636,28 @@ model Events {
@@map("events")
}
model Comment {
id String @id @default(cuid())
projectId String @map("project_id")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
objectType CommentObjectType @map("object_type")
objectId String @map("object_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
content String
authorUserId String? @map("author_user_id") // no fk constraint, user can be deleted
@@index([projectId, objectType, objectId])
@@map("comments")
}
enum CommentObjectType {
TRACE
OBSERVATION
SESSION
PROMPT
}
model Prompt {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map("created_at")
+128 -40
View File
@@ -257,21 +257,29 @@ async function main() {
const traceVolume = environment === "load" ? LOAD_TRACE_VOLUME : 100;
const { traces, observations, scores, sessions, events } = createObjects(
traceVolume,
envTags,
colorTags,
project1,
project2,
promptIds,
configIdsAndNames
);
const { traces, observations, scores, sessions, events, comments } =
createObjects(
traceVolume,
envTags,
colorTags,
project1,
project2,
promptIds,
configIdsAndNames
);
console.log(
`Seeding ${traces.length} traces, ${observations.length} observations, and ${scores.length} scores`
);
await uploadObjects(traces, observations, scores, sessions, events);
await uploadObjects(
traces,
observations,
scores,
sessions,
events,
comments
);
// If openai key is in environment, add it to the projects LLM API keys
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
@@ -464,7 +472,8 @@ async function uploadObjects(
observations: Prisma.ObservationCreateManyInput[],
scores: Prisma.ScoreCreateManyInput[],
sessions: Prisma.TraceSessionCreateManyInput[],
events: Prisma.ObservationCreateManyInput[]
events: Prisma.ObservationCreateManyInput[],
comments: Prisma.CommentCreateManyInput[]
) {
let promises: Prisma.PrismaPromise<unknown>[] = [];
@@ -556,6 +565,22 @@ async function uploadObjects(
);
await promises[i];
}
promises = [];
chunk(comments, chunkSize).forEach((chunk) => {
promises.push(
prisma.comment.createMany({
data: chunk,
})
);
});
for (let i = 0; i < promises.length; i++) {
if (i + 1 >= promises.length || i % Math.ceil(promises.length / 10) === 0)
console.log(
`Seeding of Comments ${((i + 1) / promises.length) * 100}% complete`
);
await promises[i];
}
}
function createObjects(
@@ -581,6 +606,7 @@ function createObjects(
const sessions: Prisma.TraceSessionCreateManyInput[] = [];
const events: Prisma.ObservationCreateManyInput[] = [];
const configs: Prisma.ScoreConfigCreateManyInput[] = [];
const comments: Prisma.CommentCreateManyInput[] = [];
for (let i = 0; i < traceVolume; i++) {
// print progress to console with a progress bar that refreshes every 10 iterations
@@ -611,6 +637,7 @@ function createObjects(
const trace = {
id: `trace-${v4()}`,
timestamp: traceTs,
createdAt: traceTs,
projectId: projectId,
name: ["generate-outreach", "label-inbound", "draft-response"][
i % 3
@@ -669,6 +696,7 @@ function createObjects(
traceId: trace.id,
name: annotationScoreName,
timestamp: traceTs,
createdAt: traceTs,
source: ScoreSource.ANNOTATION,
projectId,
authorUserId: `user-${i}`,
@@ -685,6 +713,7 @@ function createObjects(
name: "sentiment",
value: Math.floor(Math.random() * 10) - 5,
timestamp: traceTs,
createdAt: traceTs,
source: ScoreSource.API,
projectId,
dataType: ScoreDataType.NUMERIC,
@@ -697,6 +726,7 @@ function createObjects(
traceId: trace.id,
name: "Completeness",
timestamp: traceTs,
createdAt: traceTs,
source: ScoreSource.API,
projectId,
dataType: ScoreDataType.CATEGORICAL,
@@ -707,6 +737,15 @@ function createObjects(
: []),
];
if (Math.random() > 0.9)
comments.push({
projectId: trace.projectId,
objectId: trace.id,
objectType: "TRACE",
content: "Trace comment content",
...(Math.random() > 0.5 ? { authorUserId: `user-${i}` } : {}),
});
scores.push(...traceScores);
const existingSpanIds: string[] = [];
@@ -725,6 +764,7 @@ function createObjects(
type: ObservationType.SPAN,
id: `span-${v4()}`,
startTime: spanTsStart,
createdAt: spanTsStart,
endTime: spanTsEnd,
name: `span-${i}-${j}`,
metadata: {
@@ -792,45 +832,21 @@ function createObjects(
)
];
const { input, output } = getGenerationInputOutput();
const generation = {
type: ObservationType.GENERATION,
id: `generation-${v4()}`,
startTime: generationTsStart,
createdAt: generationTsStart,
endTime: generationTsEnd,
completionStartTime:
Math.random() > 0.5 ? generationTsCompletionStart : undefined,
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: {
@@ -867,6 +883,7 @@ function createObjects(
source: ScoreSource.API,
projectId: trace.projectId,
timestamp: generationTsEnd,
createdAt: traceTs,
});
if (Math.random() > 0.6)
scores.push({
@@ -877,6 +894,15 @@ function createObjects(
source: ScoreSource.API,
projectId: trace.projectId,
timestamp: generationTsEnd,
createdAt: traceTs,
});
if (Math.random() > 0.8)
comments.push({
projectId: trace.projectId,
objectId: generation.id,
objectType: "OBSERVATION",
content: "Observation comment content",
});
for (let l = 0; l < Math.floor(Math.random() * 2); l++) {
@@ -892,6 +918,7 @@ function createObjects(
type: ObservationType.EVENT,
id: `event-${v4()}`,
startTime: eventTs,
createdAt: eventTs,
name: `event-${i}-${j}-${k}-${l}`,
metadata: {
user: `user-${i}@langfuse.com`,
@@ -916,6 +943,7 @@ function createObjects(
configs,
sessions: uniqueSessions,
events,
comments,
};
}
@@ -1190,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(
@@ -0,0 +1,22 @@
import { z } from "zod";
const COMMENT_OBJECT_TYPES = [
"TRACE",
"OBSERVATION",
"SESSION",
"PROMPT",
] as const;
export const CreateCommentData = z.object({
projectId: z.string(),
content: z.string().trim().min(1).max(500),
objectId: z.string(),
objectType: z.enum(COMMENT_OBJECT_TYPES),
});
export const DeleteCommentData = z.object({
projectId: z.string(),
commentId: z.string(),
objectId: z.string(),
objectType: z.enum(COMMENT_OBJECT_TYPES),
});
@@ -0,0 +1,17 @@
export const planLabels = {
oss: "OSS",
"cloud:hobby": "Hobby",
"cloud:pro": "Pro",
"cloud:team": "Team",
"self-hosted:enterprise": "Enterprise",
} as const;
export type Plan = keyof typeof planLabels;
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);
@@ -144,7 +144,7 @@ const ValidatedScoreConfigSchema = z
/**
* Use this function when pulling a list of score configs from the database before using in the application to ensure type safety.
* All score configs are expected to pass the validation. If a score fails validation, it will be logged to Sentry.
* All score configs are expected to pass the validation. If a score fails validation, it will be logged to Otel.
* @param scoreConfigs
* @returns list of validated score configs
*/
@@ -155,7 +155,7 @@ export const ScorePropsAgainstConfig = z.union([
/**
* Use this function when pulling a list of scores from the database before using in the application to ensure type safety.
* All scores are expected to pass the validation. If a score fails validation, it will be logged to Sentry.
* All scores are expected to pass the validation. If a score fails validation, it will be logged to Otel.
* @param scores
* @returns list of validated scores
*/
+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;
};
+7
View File
@@ -2,6 +2,8 @@ export * from "./constants";
export * from "./queries";
export * from "./interfaces/filters";
export * from "./interfaces/orderBy";
export * from "./interfaces/cloudConfigSchema";
export * from "./interfaces/parseDbOrg";
export * from "./tableDefinitions";
export * from "./types";
export * from "./filterToPrisma";
@@ -13,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
@@ -30,6 +34,9 @@ export * from "./features/annotation/types";
export * from "./features/scores/scoreConfigTypes";
export * from "./features/scores/scoreTypes";
// comments
export * from "./features/comments/types";
// export db types only
export * from "@prisma/client";
export { type DB } from "../prisma/generated/types";
@@ -0,0 +1,22 @@
import { z } from "zod";
import { CloudConfigRateLimit } from "./rate-limits";
export const CloudConfigSchema = z.object({
plan: z.enum(["Hobby", "Pro", "Team", "Enterprise"]).optional(),
monthlyObservationLimit: z.number().int().positive().optional(),
// used for table and dashboard queries
defaultLookBackDays: z.number().int().positive().optional(),
// need to update stripe webhook if you change this, it fetches from db via these fields
stripe: z
.object({
customerId: z.string().optional(),
activeSubscriptionId: z.string().optional(),
activeProductId: z.string().optional(),
})
.optional(),
// custom rate limits for an organization
rateLimitOverrides: CloudConfigRateLimit.optional(),
});
export type CloudConfigSchema = z.infer<typeof CloudConfigSchema>;
@@ -1,5 +1,5 @@
import { CloudConfigSchema } from "@/src/features/organizations/utils/cloudConfigSchema";
import { type Organization } from "@langfuse/shared";
import { type Organization } from "@prisma/client";
import { CloudConfigSchema } from "./cloudConfigSchema";
type parsedOrg = Omit<Organization, "cloudConfig"> & {
cloudConfig: CloudConfigSchema | null;
@@ -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>;
};
+1
View File
@@ -24,3 +24,4 @@ export * from "./auth/types";
export * from "./ingestion/legacy/index";
export * from "./queues";
export * from "./ingestion/legacy/EventProcessor";
export * from "./instrumentation";
@@ -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;
@@ -0,0 +1,24 @@
# Instrumentation at Langfuse
Throughout our applications we want to use as much Otel as possible. This helps us to be flexible choosing our observability backend, and we will benefit from features and packages built by the Otel community.
## How to use Otel in your application
- Use the `instrument` or `instrument` functions to wrap your functions with Otel instrumentation. This will automatically create spans for your functions and send them to the Otel collector. If an instrumented function throws, exceptions will be added to the span and the span will be marked as failed.
- Use `recordGauge`, `recordCounter`, `recordHistogram` to record metrics. These will be sent to the Otel collector.
## Configuration options
- `web` and `worker` have an instrumentation.ts file, which configures otel for the application.
- For trpc, we use `@baselime/trpc-opentelemetry-middleware` to enrich spans with trpc inputs and outputs.
- When building adding new infrastructure, we should search for auto instumentations for our code base.
## Library support
- `dd-trace` has a direct dependency of `@opentelemetry/api` version < `1.9` [GH](https://github.com/DataDog/dd-trace-js/blob/ed9b0b30f7b0283579a9bf8c18e1f9deab18fecf/package.json#L81).
- `@prisma/instrumentation` requires at least `@opentelemetry/api` version `1.8` [GH](https://github.com/prisma/prisma/blob/d780290b13754420abcfa5d7592f02049c6cc005/packages/instrumentation/package.json#L25)
- We need to ensure that all other libraries we use are compatible with `@opentelemetry/api` version `1.8`. If we have a package using a later version, this will break the instrumentation and crash the container. We can check the versions with the following command:
```bash
pnpm --filter=web why @opentelemetry/api
```
@@ -0,0 +1,160 @@
import * as opentelemetry from "@opentelemetry/api";
import * as dd from "dd-trace";
// type CallbackFn<T> = () => T;
export type SpanCtx = {
name: string;
spanKind?: opentelemetry.SpanKind; // https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/api.md#spankind
rootSpan?: boolean; // https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/overview.md#traces
traceScope?: string;
};
type AsyncCallbackFn<T> = () => Promise<T>;
export async function instrumentAsync<T>(
ctx: SpanCtx,
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,
{
root: ctx.rootSpan,
kind: ctx.spanKind,
},
(span) => {
try {
const result = callback();
span.end();
return result;
} catch (ex) {
traceException(ex as opentelemetry.Exception, span);
span.end();
throw ex;
}
}
);
}
export const getCurrentSpan = () => opentelemetry.trace.getActiveSpan();
export const traceException = (
ex: unknown,
span?: opentelemetry.Span,
code?: string
) => {
const activeSpan = span ?? getCurrentSpan();
if (!activeSpan) {
return;
}
const exception = {
code: code,
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
activeSpan.recordException(exception);
//adds tags for datadog error tracking
activeSpan.setAttributes({
"error.stack": exception.stack,
"error.message": exception.message,
"error.type": exception.name,
});
activeSpan.setStatus({
code: opentelemetry.SpanStatusCode.ERROR,
message: exception.message,
});
};
export const addUserToSpan = (
attributes: { userId?: string; projectId?: string; email?: string },
span?: opentelemetry.Span
) => {
const activeSpan = span ?? getCurrentSpan();
if (!activeSpan) {
return;
}
attributes.userId && activeSpan.setAttribute("user.id", attributes.userId);
attributes.email && activeSpan.setAttribute("user.email", attributes.email);
attributes.projectId &&
activeSpan.setAttribute("project.id", attributes.projectId);
};
export const getTracer = (name: string) => opentelemetry.trace.getTracer(name);
export const recordGauge = (
stat: string,
value?: number | undefined,
tags?:
| {
[tag: string]: string | number;
}
| undefined
) => {
dd.dogstatsd.gauge(stat, value, tags);
};
export const recordIncrement = (
stat: string,
value?: number | undefined,
tags?: { [tag: string]: string | number } | undefined
) => {
dd.dogstatsd.increment(stat, value, tags);
};
export const recordHistogram = (
stat: string,
value?: number | undefined,
tags?: { [tag: string]: string | number } | undefined
) => {
dd.dogstatsd.histogram(stat, value, tags);
};
+3
View File
@@ -6,6 +6,7 @@ export enum EventName {
BatchExport = "BatchExport",
EvaluationExecution = "EvaluationExecution",
LegacyIngestion = "LegacyIngestion",
CloudUsageMetering = "CloudUsageMetering",
}
export const LegacyIngestionEvent = z.object({
@@ -60,6 +61,7 @@ export enum QueueName {
RepeatQueue = "repeat-queue",
IngestionFlushQueue = "ingestion-flush-queue",
LegacyIngestionQueue = "legacy-ingestion-queue",
CloudUsageMeteringQueue = "cloud-usage-metering-queue",
}
export enum QueueJobs {
@@ -69,6 +71,7 @@ export enum QueueJobs {
EnqueueBatchExportJobs = "enqueue-batch-export-jobs",
FlushIngestionEntity = "flush-ingestion-entity",
LegacyIngestionJob = "legacy-ingestion-job",
CloudUsageMeteringJob = "cloud-usage-metering-job",
}
export type TQueueJobTypes = {
@@ -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,27 +1,33 @@
import { Queue } from "bullmq";
import { QueueName, TQueueJobTypes } from "../queues";
import { redis } from "./redis";
import { createNewRedisInstance } from "./redis";
let legacyIngestionQueue: Queue<
TQueueJobTypes[QueueName.LegacyIngestionQueue]
> | null = null;
export class LegacyIngestionQueue {
private static instance: Queue<
TQueueJobTypes[QueueName.LegacyIngestionQueue]
> | null = null;
export const getLegacyIngestionQueue = () => {
if (legacyIngestionQueue) return legacyIngestionQueue;
public static getInstance(): Queue<
TQueueJobTypes[QueueName.LegacyIngestionQueue]
> | null {
if (LegacyIngestionQueue.instance) return LegacyIngestionQueue.instance;
legacyIngestionQueue = redis
? new Queue<TQueueJobTypes[QueueName.LegacyIngestionQueue]>(
QueueName.LegacyIngestionQueue,
{
connection: redis,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100,
attempts: 5,
const newRedis = createNewRedisInstance({ enableOfflineQueue: false });
LegacyIngestionQueue.instance = newRedis
? new Queue<TQueueJobTypes[QueueName.LegacyIngestionQueue]>(
QueueName.LegacyIngestionQueue,
{
connection: newRedis,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100,
attempts: 5,
},
},
}
)
: null;
)
: null;
return legacyIngestionQueue;
};
return LegacyIngestionQueue.instance;
}
}
+23 -11
View File
@@ -1,18 +1,30 @@
import Redis from "ioredis";
import Redis, { RedisOptions } from "ioredis";
import { env } from "../../env";
export const createNewRedisInstance = (
additionalOptions: Partial<RedisOptions> = {},
) => {
return env.REDIS_CONNECTION_STRING
? new Redis(env.REDIS_CONNECTION_STRING, {
maxRetriesPerRequest: null,
enableAutoPipelining: env.REDIS_ENABLE_AUTO_PIPELINING === "true",
...additionalOptions,
})
: 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",
...additionalOptions,
})
: 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,9 +14,15 @@ 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: 1_000,
},
})
: null;
@@ -50,7 +56,7 @@ export function convertTraceUpsertEventsToRedisEvents(
name: QueueJobs.TraceUpsert as const,
},
opts: {
removeOnFail: 10000,
removeOnFail: 1_000,
removeOnComplete: true,
attempts: 5,
backoff: {
@@ -9,7 +9,7 @@ export class PromptService {
constructor(
private prisma: PrismaClient,
private redis: Redis | null,
private metricIncrementer?: // used for Sentry metrics
private metricIncrementer?: // used for otel metrics
(name: string, value?: number) => void,
cacheEnabled?: boolean // used for testing
) {
@@ -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,
},
];
+3679 -1697
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"]
+78 -46
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] : []),
],
},
{
@@ -80,22 +104,22 @@ const nextConfig = {
// Required to check authentication status from langfuse.com
...(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== undefined
? [
{
source: "/api/auth/session",
headers: [
{
key: "Access-Control-Allow-Origin",
value: "https://langfuse.com",
},
{ key: "Access-Control-Allow-Credentials", value: "true" },
{ key: "Access-Control-Allow-Methods", value: "GET,POST" },
{
key: "Access-Control-Allow-Headers",
value: "Content-Type, Authorization",
},
],
},
]
{
source: "/api/auth/session",
headers: [
{
key: "Access-Control-Allow-Origin",
value: "https://langfuse.com",
},
{ key: "Access-Control-Allow-Credentials", value: "true" },
{ key: "Access-Control-Allow-Methods", value: "GET,POST" },
{
key: "Access-Control-Allow-Headers",
value: "Content-Type, Authorization",
},
],
},
]
: []),
// all files in /public/generated are public and can be accessed from any origin, e.g. to render an API reference based on our openapi schema
{
@@ -115,7 +139,7 @@ const nextConfig = {
},
// webassembly support for @dqbd/tiktoken
webpack(config) {
webpack(config, { isServer }) {
config.experiments = {
asyncWebAssembly: true,
layers: true,
@@ -125,36 +149,44 @@ const nextConfig = {
},
};
const sentryOptions = {
// Additional config options for the Sentry Webpack plugin. Keep in mind that
// the following options are set automatically, and overriding them is not
// recommended:
// release, url, authToken, configFile, stripPrefix,
// urlPrefix, include, ignore
export default withSentryConfig(nextConfig, {
// For all available options, see:
// https://github.com/getsentry/sentry-webpack-plugin#options
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
silent: true, // Suppresses all logs
authToken: env.SENTRY_AUTH_TOKEN,
// Only print logs for uploading source maps in CI
silent: !process.env.CI,
// For all available options, see:
// https://github.com/getsentry/sentry-webpack-plugin#options.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
// See the sections below for information on the following options:
// 'Configure Source Maps':
// - disableServerWebpackPlugin
// - disableClientWebpackPlugin
// - hideSourceMaps
hideSourceMaps: true,
// - widenClientFileUpload
// 'Configure Legacy Browser Support':
// - transpileClientSDK
// 'Configure Serverside Auto-instrumentation':
// - autoInstrumentServerFunctions
// - excludeServerRoutes
// 'Configure Tunneling':
// - tunnelRoute
// Upload a larger set of source maps for prettier stack traces (increases build time)
widenClientFileUpload: true,
// Automatically annotate React components to show their full name in breadcrumbs and session replay
reactComponentAnnotation: {
enabled: true,
},
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
// This can increase your server load as well as your hosting bill.
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
// side errors will fail.
tunnelRoute: "/api/monitoring-tunnel",
};
export default withSentryConfig(nextConfig, sentryOptions);
// Hides source maps from generated client bundles
hideSourceMaps: true,
// Automatically tree-shake Sentry logger statements to reduce bundle size
disableLogger: true,
// Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.)
// See the following for more information:
// https://docs.sentry.io/product/crons/
// https://vercel.com/docs/cron-jobs
automaticVercelMonitors: false,
});
+55 -36
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "2.71.0",
"version": "2.76.0",
"private": true,
"license": "MIT",
"engines": {
@@ -23,6 +23,8 @@
},
"dependencies": {
"@anthropic-ai/tokenizer": "^0.0.4",
"@appsignal/opentelemetry-instrumentation-bullmq": "^0.7.1",
"@baselime/trpc-opentelemetry-middleware": "^0.1.2",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lint": "^6.8.0",
"@headlessui/react": "1.7.18",
@@ -37,33 +39,46 @@
"@marsidev/react-turnstile": "^0.5.4",
"@mui/x-tree-view": "^7.6.2",
"@next-auth/prisma-adapter": "^1.0.7",
"@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",
"@opentelemetry/api": ">=1.0.0 <1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.44.0",
"@opentelemetry/context-async-hooks": "^1.25.1",
"@opentelemetry/core": "^1.14.0",
"@opentelemetry/instrumentation": "^0.52.1",
"@opentelemetry/instrumentation-http": "^0.52.1",
"@opentelemetry/instrumentation-ioredis": "^0.42.0",
"@opentelemetry/instrumentation-undici": "^0.4.0",
"@opentelemetry/resources": "^1.25.1",
"@opentelemetry/sdk-metrics": "1.23.0",
"@opentelemetry/sdk-node": "^0.50.0",
"@opentelemetry/sdk-trace-base": "^1.25.1",
"@opentelemetry/sdk-trace-node": "^1.25.1",
"@opentelemetry/semantic-conventions": "^1.25.1",
"@prisma/instrumentation": "^5.13.0",
"@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/profiling-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",
@@ -73,29 +88,30 @@
"@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",
"decimal.js": "^10.4.3",
"dompurify": "^3.1.5",
"graphql": "^16.9.0",
"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.436.0",
"next": "^14.2.6",
"next-auth": "^4.24.7",
"next-query-params": "^5.0.0",
"next-themes": "^0.3.0",
@@ -103,20 +119,23 @@
"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",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
"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",
@@ -135,7 +154,7 @@
"@types/dompurify": "^3.0.5",
"@types/eslint": "^8.56.7",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.5",
"@types/lodash": "^4.17.7",
"@types/node": "20.10.5",
"@types/react": "^18.2.79",
"@types/react-dom": "^18.2.25",
@@ -146,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",
"tailwindcss": "^3.4.4",
"prettier-plugin-tailwindcss": "^0.6.6",
"tailwindcss": "^3.4.10",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.7.2",
"tsx": "^4.19.0",
"typescript": "^5.4.5",
"wait-for-expect": "^3.0.2"
},
+42
View File
@@ -0,0 +1,42 @@
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(),
// 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_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: 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
if (event.exception) {
Sentry.showReportDialog({ eventId: event.event_id });
}
return event;
},
// ...
// Note: if you want to override the automatic release value, do not set a
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
// that it will also get attached to your source maps
});
+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", () => {
+321 -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(),
});
@@ -396,6 +548,167 @@ describe("Authenticate API calls", () => {
});
});
describe("invalidates api keys in redis", () => {
const redis = new Redis("redis://:myredissecret@127.0.0.1:6379", {
maxRetriesPerRequest: null,
});
beforeEach(async () => {
// if we do not remove the key, it will remain in the cache and
// calling the test twice will not add the key to the cache
const keys = await redis.keys("api-key*");
console.log("before each deleting keys", keys);
if (keys.length > 0) {
console.log("before each deleting keys. actually deleting", keys);
await redis.del(keys);
}
});
afterEach(async () => {
// if we do not remove the key, it will remain in the cache and
// calling the test twice will not add the key to the cache
const keys = await redis.keys("api-key*");
console.log("after each deleting keys", keys);
if (keys.length > 0) {
await redis.del(keys);
}
});
afterAll(async () => {
redis.disconnect();
});
it("should invalidate organization API keys in redis", async () => {
await createAPIKey();
// put keys into cache
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
const apiKey = await prisma.apiKey.findUnique({
where: { publicKey: "pk-lf-1234567890" },
});
expect(apiKey).not.toBeNull();
const cachedKey = await redis.get(
`api-key:${apiKey?.fastHashedSecretKey}`,
);
expect(cachedKey).not.toBeNull();
await new ApiAuthService(prisma, redis).invalidateOrgApiKeys(
"seed-org-id",
);
const invalidatedCachedKey = await redis.get(
`api-key:${apiKey?.fastHashedSecretKey}`,
);
expect(invalidatedCachedKey).toBeNull();
});
it("if no keys in redis, invalidating org keys should do nothing", async () => {
await createAPIKey();
await prisma.apiKey.update({
where: { publicKey: "pk-lf-1234567890" },
data: {
fastHashedSecretKey: Math.random().toString(36).substring(2, 15),
},
});
await new ApiAuthService(prisma, redis).invalidateOrgApiKeys(
"seed-org-id",
);
const keys = await redis.keys("api-key*");
expect(keys.length).toBe(0);
});
it("if no keys in redis, invalidating org keys without fast hash should do nothing", async () => {
await createAPIKey();
await new ApiAuthService(prisma, redis).invalidateOrgApiKeys(
"seed-org-id",
);
const keys = await redis.keys("api-key*");
expect(keys.length).toBe(0);
});
it("should invalidate project API keys in redis", async () => {
await createAPIKey();
// put keys into cache
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
);
const apiKey = await prisma.apiKey.findUnique({
where: { publicKey: "pk-lf-1234567890" },
});
expect(apiKey).not.toBeNull();
const cachedKey = await redis.get(
`api-key:${apiKey?.fastHashedSecretKey}`,
);
expect(cachedKey).not.toBeNull();
await new ApiAuthService(prisma, redis).invalidateProjectApiKeys(
"7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
);
const invalidatedCachedKey = await redis.get(
`api-key:${apiKey?.fastHashedSecretKey}`,
);
expect(invalidatedCachedKey).toBeNull();
});
it("if no keys in redis, invalidating project keys should do nothing", async () => {
await createAPIKey();
await prisma.apiKey.update({
where: { publicKey: "pk-lf-1234567890" },
data: {
fastHashedSecretKey: Math.random().toString(36).substring(2, 15),
},
});
await new ApiAuthService(prisma, redis).invalidateProjectApiKeys(
"7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
);
const keys = await redis.keys("api-key*");
expect(keys.length).toBe(0);
});
it("if no keys in redis, invalidating project keys without fast hash should do nothing", async () => {
await createAPIKey();
await prisma.apiKey.update({
where: { publicKey: "pk-lf-1234567890" },
data: {
fastHashedSecretKey: Math.random().toString(36).substring(2, 15),
},
});
await new ApiAuthService(prisma, redis).invalidateProjectApiKeys(
"7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
);
const keys = await redis.keys("api-key*");
expect(keys.length).toBe(0);
});
});
const createAPIKey = async () => {
const seedApiKey = {
id: "seed-api-key",
+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);
});
});
@@ -0,0 +1,5 @@
import { stripeWebhookApiHandler } from "@/src/ee/features/billing/server/stripeWebhookApiHandler";
export const dynamic = "force-dynamic";
export const POST = stripeWebhookApiHandler;
+16
View File
@@ -0,0 +1,16 @@
export const metadata = {
title: 'Next.js',
description: 'Generated by Next.js',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
@@ -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 />
+9 -3
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,8 +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.</p>
<CreateLLMApiKeyDialog />
<p className="text-xs">
No LLM API key set in project.{" "}
{evalModelsOnly && "For evals, only OpenAI models are supported."}
</p>
<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
@@ -2,9 +2,9 @@ import { Button } from "@/src/components/ui/button";
import { AlertCircle } from "lucide-react";
import { useSession } from "next-auth/react";
import { useRouter } from "next/router";
import { captureException } from "@sentry/nextjs";
import { useEffect } from "react";
import Link from "next/link";
import { captureException } from "@sentry/nextjs";
export const ErrorPage = ({
title = "Error",
+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>
);
+11 -2
View File
@@ -43,6 +43,10 @@ export default function Header({
title: string;
breadcrumb?: { name: string; href?: string }[];
status?: Status;
label?: {
text: string;
href: string;
};
help?: { description: string; href?: string };
featureBetaURL?: string;
actionButtons?: React.ReactNode;
@@ -91,6 +95,11 @@ export default function Header({
) : null}
</div>
{props.status && <StatusBadge type={props.status} />}
{props.label && (
<Link href={props.label.href}>
<StatusBadge type={props.label.text} />
</Link>
)}
</div>
<div className="flex flex-wrap items-center gap-3">
{props.actionButtons ?? null}
@@ -203,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) => {
@@ -290,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) => {
+21 -15
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();
@@ -132,24 +136,25 @@ export default function Layout(props: PropsWithChildren) {
// Feature Flags
if (
!(
route.featureFlag === undefined ||
enableExperimentalFeatures ||
session.data?.user?.featureFlags[route.featureFlag]
)
route.featureFlag !== undefined &&
!enableExperimentalFeatures &&
!cloudAdmin &&
session.data?.user?.featureFlags[route.featureFlag] !== true
)
return null;
// check entitlements
if (
route.entitlement !== undefined &&
!entitlements.includes(route.entitlement)
!entitlements.includes(route.entitlement) &&
!cloudAdmin
)
return null;
// RBAC
if (
route.projectRbacScope !== undefined &&
!cloudAdmin &&
(!project ||
!organization ||
!hasProjectAccess({
@@ -345,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">
@@ -417,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}
@@ -485,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}
@@ -510,8 +515,9 @@ export default function Layout(props: PropsWithChildren) {
</Transition>
</Menu>
</div>
<div className="lg:pl-56">
{env.NEXT_PUBLIC_DEMO_PROJECT_ID &&
<div className="lg:pl-52">
{env.NEXT_PUBLIC_DEMO_ORG_ID &&
env.NEXT_PUBLIC_DEMO_PROJECT_ID &&
routerProjectId === env.NEXT_PUBLIC_DEMO_PROJECT_ID &&
Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION) ? (
<div className="flex w-full items-center border-b border-dark-yellow bg-light-yellow px-4 py-2 lg:sticky lg:top-0 lg:z-40">
@@ -530,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>
@@ -632,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",
@@ -646,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"
/>
@@ -661,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}
>
+8 -4
View File
@@ -11,28 +11,32 @@ const statusCategories = {
export type Status =
(typeof statusCategories)[keyof typeof statusCategories][number];
export const StatusBadge = (props: { className?: string; type: Status }) => {
export const StatusBadge = (props: {
className?: string;
type: Status | (string & {});
}) => {
let badgeColor = "bg-muted-gray text-primary";
let dotColor = "bg-muted-foreground";
let dotPingColor = "bg-muted-foreground";
let showDot = true;
let showDot = false;
if (statusCategories.active.includes(props.type)) {
badgeColor = "bg-light-green text-dark-green";
dotColor = "animate-ping bg-dark-green";
dotPingColor = "bg-dark-green";
showDot = true;
} else if (statusCategories.pending.includes(props.type)) {
badgeColor = "bg-light-yellow text-dark-yellow";
dotColor = "animate-ping bg-dark-yellow";
dotPingColor = "bg-dark-yellow";
showDot = true;
} else if (statusCategories.error.includes(props.type)) {
badgeColor = "bg-light-red text-dark-red";
dotColor = "animate-ping bg-dark-red";
dotPingColor = "bg-dark-red";
showDot = false;
showDot = true;
} else if (statusCategories.completed.includes(props.type)) {
badgeColor = "bg-light-green text-dark-green";
showDot = false;
}
return (
+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>;
+19 -1
View File
@@ -16,6 +16,7 @@ import { useEffect, useState } from "react";
import { AnnotateDrawer } from "@/src/features/scores/components/AnnotateDrawer";
import { Button } from "@/src/components/ui/button";
import useLocalStorage from "@/src/components/useLocalStorage";
import { CommentDrawerButton } from "@/src/features/comments/CommentDrawerButton";
// some projects have thousands of traces in a sessions, paginate to avoid rendering all at once
const PAGE_SIZE = 50;
@@ -52,6 +53,15 @@ export const SessionPage: React.FC<{
string[]
>("emptySelectedConfigIds", []);
const commentCounts = api.comments.getCountByObjectId.useQuery(
{
projectId,
objectId: sessionId,
objectType: "SESSION",
},
{ enabled: session.isSuccess },
);
if (session.error?.data?.code === "UNAUTHORIZED")
return <ErrorPage message="You do not have access to this session." />;
@@ -87,6 +97,14 @@ export const SessionPage: React.FC<{
}
listKey="sessions"
/>,
<CommentDrawerButton
key="comment"
variant="outline"
projectId={projectId}
objectId={sessionId}
objectType="SESSION"
count={commentCounts.data?.get(sessionId)}
/>,
]}
/>
<div className="flex flex-wrap gap-2">
@@ -178,7 +196,7 @@ const SessionIO = ({
},
);
return (
<div className="col-span-2 flex flex-col gap-2 p-0">
<div className="col-span-2 grid grid-flow-row gap-2 p-0">
{!trace.data ? (
<JsonSkeleton
className="h-full w-full overflow-hidden px-2 py-1"
+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
@@ -23,6 +23,7 @@ import { ScoresPreview } from "@/src/components/trace/ScoresPreview";
import { JumpToPlaygroundButton } from "@/src/ee/features/playground/page/components/JumpToPlaygroundButton";
import { AnnotateDrawer } from "@/src/features/scores/components/AnnotateDrawer";
import useLocalStorage from "@/src/components/useLocalStorage";
import { CommentDrawerButton } from "@/src/features/comments/CommentDrawerButton";
export const ObservationPreview = (props: {
observations: Array<ObservationReturnType>;
@@ -30,6 +31,7 @@ export const ObservationPreview = (props: {
scores: APIScore[];
currentObservationId: string;
traceId: string;
commentCounts?: Map<string, number>;
}) => {
const [selectedTab, setSelectedTab] = useQueryParam(
"view",
@@ -163,6 +165,12 @@ export const ObservationPreview = (props: {
</div>
</div>
<div className="flex flex-wrap gap-2">
<CommentDrawerButton
projectId={preloadedObservation.projectId}
objectId={preloadedObservation.id}
objectType="OBSERVATION"
count={props.commentCounts?.get(preloadedObservation.id)}
/>
<AnnotateDrawer
projectId={props.projectId}
traceId={preloadedObservation.traceId}
@@ -178,7 +186,6 @@ export const ObservationPreview = (props: {
source="generation"
generation={observationWithInputAndOutput.data}
analyticsEventName="trace_detail:test_in_playground_button_click"
fullWidth
/>
)}
{observationWithInputAndOutput.data ? (
+45 -23
View File
@@ -14,6 +14,7 @@ import {
nestObservations,
treeItemColors,
} from "@/src/components/trace/lib/helpers";
import { CommentCountIcon } from "@/src/features/comments/CommentCountIcon";
export const ObservationTree = (props: {
observations: ObservationReturnType[];
@@ -27,6 +28,8 @@ export const ObservationTree = (props: {
setCurrentObservationId: (id: string | undefined) => void;
showMetrics: boolean;
showScores: boolean;
observationCommentCounts?: Map<string, number>;
traceCommentCounts?: Map<string, number>;
className?: string;
}) => {
const nestedObservations = nestObservations(props.observations);
@@ -37,6 +40,7 @@ export const ObservationTree = (props: {
collapseAll={props.collapseAll}
trace={props.trace}
scores={props.scores}
comments={props.traceCommentCounts}
currentObservationId={props.currentObservationId}
setCurrentObservationId={props.setCurrentObservationId}
showMetrics={props.showMetrics}
@@ -47,6 +51,7 @@ export const ObservationTree = (props: {
collapsedObservations={props.collapsedObservations}
toggleCollapsedObservation={props.toggleCollapsedObservation}
scores={props.scores}
comments={props.observationCommentCounts}
indentationLevel={1}
currentObservationId={props.currentObservationId}
setCurrentObservationId={props.setCurrentObservationId}
@@ -62,6 +67,7 @@ const ObservationTreeTraceNode = (props: {
expandAll: () => void;
collapseAll: () => void;
scores: APIScore[];
comments: Map<string, number> | undefined;
currentObservationId: string | undefined;
setCurrentObservationId: (id: string | undefined) => void;
showMetrics?: boolean;
@@ -78,24 +84,31 @@ 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="flex-1 break-all text-sm">{props.trace.name}</span>
<Button
onClick={(ev) => (ev.stopPropagation(), props.expandAll())}
size="xs"
variant="ghost"
title="Expand all"
>
<PlusCircleIcon className="h-4 w-4" />
</Button>
<Button
onClick={(ev) => (ev.stopPropagation(), props.collapseAll())}
size="xs"
variant="ghost"
title="Collapse all"
>
<MinusCircle className="h-4 w-4" />
</Button>
<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)} />
) : null}
<div className="flex flex-1 justify-end">
<Button
onClick={(ev) => (ev.stopPropagation(), props.expandAll())}
size="xs"
variant="ghost"
title="Expand all"
>
<PlusCircleIcon className="h-4 w-4" />
</Button>
<Button
onClick={(ev) => (ev.stopPropagation(), props.collapseAll())}
size="xs"
variant="ghost"
title="Collapse all"
>
<MinusCircle className="h-4 w-4" />
</Button>
</div>
</div>
{props.showMetrics && props.trace.latency ? (
@@ -120,6 +133,7 @@ const ObservationTreeNode = (props: {
collapsedObservations: string[];
toggleCollapsedObservation: (id: string) => void;
scores: APIScore[];
comments?: Map<string, number> | undefined;
indentationLevel: number;
currentObservationId: string | undefined;
setCurrentObservationId: (id: string | undefined) => void;
@@ -154,9 +168,16 @@ const ObservationTreeNode = (props: {
<ColorCodedObservationType
observationType={observation.type}
/>
<span className="flex-1 break-all text-sm">
{observation.name}
</span>
<div className="grid flex-1 grid-cols-[auto,1fr] gap-2">
<span className="break-all text-sm">
{observation.name}
</span>
{props.comments ? (
<CommentCountIcon
count={props.comments.get(observation.id)}
/>
) : null}
</div>
{observation.children.length === 0 ? null : (
<Toggle
onClick={(ev) => (
@@ -172,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"
}
@@ -244,6 +265,7 @@ const ObservationTreeNode = (props: {
collapsedObservations={props.collapsedObservations}
toggleCollapsedObservation={props.toggleCollapsedObservation}
scores={props.scores}
comments={props.comments}
indentationLevel={props.indentationLevel + 1}
currentObservationId={props.currentObservationId}
setCurrentObservationId={props.setCurrentObservationId}
@@ -264,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),
)}
>
@@ -20,15 +20,18 @@ import ScoresTable from "@/src/components/table/use-cases/scores";
import { ScoresPreview } from "@/src/components/trace/ScoresPreview";
import { AnnotateDrawer } from "@/src/features/scores/components/AnnotateDrawer";
import useLocalStorage from "@/src/components/useLocalStorage";
import { CommentDrawerButton } from "@/src/features/comments/CommentDrawerButton";
export const TracePreview = ({
trace,
observations,
scores,
commentCounts,
}: {
trace: Trace & { latency?: number };
observations: ObservationReturnType[];
scores: APIScore[];
commentCounts?: Map<string, number>;
}) => {
const [selectedTab, setSelectedTab] = useQueryParam(
"view",
@@ -99,6 +102,12 @@ export const TracePreview = ({
</div>
</div>
<div className="flex flex-wrap gap-2">
<CommentDrawerButton
projectId={trace.projectId}
objectId={trace.id}
objectType="TRACE"
count={commentCounts?.get(trace.id)}
/>
<AnnotateDrawer
projectId={trace.projectId}
traceId={trace.id}
+49 -3
View File
@@ -29,6 +29,8 @@ import {
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;
@@ -94,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={{
@@ -103,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>
@@ -181,6 +186,7 @@ function TraceTreeItem({
scores,
observations,
cardWidth,
commentCounts,
}: {
observation: NestedObservation;
level: number;
@@ -190,6 +196,7 @@ function TraceTreeItem({
scores: APIScore[];
observations: Array<ObservationReturnType>;
cardWidth: number;
commentCounts?: Map<string, number>;
}) {
const { startTime, endTime } = observation || {};
const [backgroundColor, setBackgroundColor] = useState("");
@@ -232,6 +239,7 @@ function TraceTreeItem({
projectId={projectId}
currentObservationId={observation.id}
traceId={observation.traceId}
commentCounts={commentCounts}
/>
</div>
</>
@@ -250,6 +258,7 @@ function TraceTreeItem({
scores={scores}
observations={observations}
cardWidth={cardWidth}
commentCounts={commentCounts}
/>
))
: null}
@@ -303,6 +312,41 @@ export function TraceTimelineView({
[nestedObservations],
);
const session = useSession();
const observationCommentCounts = api.comments.getCountByObjectType.useQuery(
{
projectId: trace.projectId,
objectType: "OBSERVATION",
},
{
trpc: {
context: {
skipBatch: true,
},
},
refetchOnMount: false, // prevents refetching loops
enabled: session.status === "authenticated",
},
);
const traceCommentCounts = api.comments.getCountByObjectId.useQuery(
{
projectId: trace.projectId,
objectId: trace.id,
objectType: "TRACE",
},
{
trpc: {
context: {
skipBatch: true,
},
},
refetchOnMount: false, // prevents refetching loops
enabled: session.status === "authenticated",
},
);
if (!latency) return null;
const stepSize = calculateStepSize(latency, SCALE_WIDTH);
@@ -321,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">
@@ -414,6 +458,7 @@ export function TraceTimelineView({
trace={trace}
observations={observations}
scores={scores}
commentCounts={traceCommentCounts.data}
/>
</div>
</TreeItemInner>
@@ -431,6 +476,7 @@ export function TraceTimelineView({
scores={scores}
observations={observations}
cardWidth={cardWidth}
commentCounts={observationCommentCounts.data}
/>
))
: null}
+45 -5
View File
@@ -34,6 +34,7 @@ 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,6 +58,41 @@ export function Trace(props: {
[],
);
const session = useSession();
const observationCommentCounts = api.comments.getCountByObjectType.useQuery(
{
projectId: props.trace.projectId,
objectType: "OBSERVATION",
},
{
trpc: {
context: {
skipBatch: true,
},
},
refetchOnMount: false, // prevents refetching loops
enabled: session.status === "authenticated",
},
);
const traceCommentCounts = api.comments.getCountByObjectId.useQuery(
{
projectId: props.trace.projectId,
objectId: props.trace.id,
objectType: "TRACE",
},
{
trpc: {
context: {
skipBatch: true,
},
},
refetchOnMount: false, // prevents refetching loops
enabled: session.status === "authenticated",
},
);
const toggleCollapsedObservation = useCallback(
(id: string) => {
if (collapsedObservations.includes(id)) {
@@ -114,6 +150,7 @@ export function Trace(props: {
trace={props.trace}
observations={props.observations}
scores={props.scores}
commentCounts={traceCommentCounts.data}
/>
) : (
<ObservationPreview
@@ -122,6 +159,7 @@ export function Trace(props: {
projectId={props.projectId}
currentObservationId={currentObservationId}
traceId={props.trace.id}
commentCounts={observationCommentCounts.data}
/>
)}
</div>
@@ -135,7 +173,7 @@ export function Trace(props: {
});
setScoresOnObservationTree(e);
}}
size="sm"
size="xs"
title="Show scores"
>
<Award className="h-4 w-4" />
@@ -148,7 +186,7 @@ export function Trace(props: {
});
setMetricsOnObservationTree(e);
}}
size="sm"
size="xs"
title="Show metrics"
>
{metricsOnObservationTree ? (
@@ -171,6 +209,8 @@ export function Trace(props: {
setCurrentObservationId={setCurrentObservationId}
showMetrics={metricsOnObservationTree}
showScores={scoresOnObservationTree}
observationCommentCounts={observationCommentCounts.data}
traceCommentCounts={traceCommentCounts.data}
className="flex w-full flex-col overflow-y-auto"
/>
</div>
@@ -183,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) {
@@ -224,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={[
@@ -357,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}
+9 -3
View File
@@ -6,6 +6,12 @@ import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "@/src/utils/tailwind";
import { useMediaQuery } from "react-responsive";
type DrawerContentProps = React.ComponentPropsWithoutRef<
typeof DrawerPrimitive.Content
> & {
overlayClassName?: string;
};
// https://tailwindcss.com/docs/responsive-design
const TAILWIND_MD_MEDIA_QUERY = 768;
@@ -48,10 +54,10 @@ DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
DrawerContentProps
>(({ className, children, overlayClassName, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerOverlay className={overlayClassName} />
<DrawerPrimitive.Content
ref={ref}
className={cn(
+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}

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