Compare commits

...
36 Commits
Author SHA1 Message Date
Marc Klingen 2608878d20 chore: release v2.4.2
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests (18) (push) Waiting to run
CI/CD / tests (20) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2024-02-13 15:53:15 +01:00
Marc Klingen 62a2857619 docs: add DB_EXPORT_PAGE_SIZE to .env.prod.example 2024-02-13 15:48:49 +01:00
0c1942c2b3 fix(docker): remove the need for runtime variables at build time (#1067)
---------

Co-authored-by: Marc Klingen <git@marcklingen.com>
2024-02-13 15:40:16 +01:00
b95c93cdb1 fix: memory optimizes generation.export trpc to allow for larger exports (#1124)
* fix: memory optimizes generation.export trpc

* test: disable S3 uploads in export test

* refactor: modularize generations.export

* feat: adds streaming to S3 bucket

* feat: adds DB_EXPORT_PAGE_SIZE environment variable

* refactor: clean up generationsRouter

---------

Co-authored-by: Max Deichmann <m.deichmann@tum.de>
2024-02-13 12:55:12 +00:00
Marc Klingen 3691099c74 chore: release v2.4.1
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests (18) (push) Waiting to run
CI/CD / tests (20) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2024-02-13 13:26:51 +01:00
Marc KlingenandGitHub 3f1ae71eec fix: feedback api route (#1146)
Fixes #1144
2024-02-13 13:14:27 +01:00
Max DeichmannandGitHub 668cf3f6c2 Feat: update openai pricing (#1145) 2024-02-13 11:36:22 +00:00
Marc KlingenandGitHub 6221be155d feat(security): add audit logging (#1142) 2024-02-13 11:35:54 +01:00
Marc KlingenandGitHub 3e13d191db chore: drop middleware previously used for IP blocklisting (#1143) 2024-02-12 22:53:07 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
61297826f4 chore(deps): bump lucide-react from 0.323.0 to 0.330.0 (#1140)
Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 0.323.0 to 0.330.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/0.330.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-02-12 23:47:36 +01:00
Max DeichmannandGitHub 3268ef6488 style: adjust contributing template (#1136)
remove
2024-02-12 20:54:20 +01:00
Marc Klingen 4aba13ec35 chore: release v2.4.0
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests (18) (push) Waiting to run
CI/CD / tests (20) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2024-02-12 17:52:28 +01:00
Marc Klingen 0f5d5cd9a0 fix(ui): invalidation of traces after delete 2024-02-12 17:46:58 +01:00
Marc KlingenandGitHub 78df145e20 fix(ui): invalidation of traces when deleting a single trace (#1134) 2024-02-12 12:00:57 +00:00
Max DeichmannandGitHub b35618f1c9 refactor: add betterstack env variable to env.prod.example (#1132) 2024-02-12 10:17:51 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a139ada85e chore(deps): bump the sentry group with 3 updates (#1110)
Bumps the sentry group with 3 updates: [@sentry/nextjs](https://github.com/getsentry/sentry-javascript), [@sentry/profiling-node](https://github.com/getsentry/sentry-javascript) and [@sentry/types](https://github.com/getsentry/sentry-javascript).


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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-12 00:07:57 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
cdfb0c6b7c chore(deps): bump posthog-js from 1.104.4 to 1.105.7 (#1131)
Bumps [posthog-js](https://github.com/PostHog/posthog-js) from 1.104.4 to 1.105.7.
- [Release notes](https://github.com/PostHog/posthog-js/releases)
- [Changelog](https://github.com/PostHog/posthog-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/PostHog/posthog-js/compare/v1.104.4...1.105.7)

---
updated-dependencies:
- dependency-name: posthog-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-02-12 01:02:10 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c0bbf39b29 chore(deps-dev): bump the patches group with 3 updates (#1130)
Bumps the patches group with 3 updates: [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom), [postcss](https://github.com/postcss/postcss) and [tsx](https://github.com/privatenumber/tsx).


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

Updates `postcss` from 8.4.34 to 8.4.35
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.4.34...8.4.35)

Updates `tsx` from 4.7.0 to 4.7.1
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/develop/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.7.0...v4.7.1)

---
updated-dependencies:
- dependency-name: "@types/react-dom"
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: patches
- dependency-name: postcss
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: patches
- dependency-name: tsx
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: patches
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-11 23:44:28 +00:00
Marc Klingen a6900ad3c4 fix(ui): use lightner colors in observation tree 2024-02-12 00:31:00 +01:00
21940a0464 feat(ui): improve trace view: collapse/expand observation tree + color coding of observation types (#916)
---------

Co-authored-by: Marc Klingen <git@marcklingen.com>
2024-02-11 23:47:46 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
fe642f8e9e chore(deps-dev): bump @typescript-eslint/eslint-plugin from 6.20.0 to 6.21.0 (#1112)
chore(deps-dev): bump @typescript-eslint/eslint-plugin

Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 6.20.0 to 6.21.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v6.21.0/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  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-02-11 22:05:53 +00:00
c803b0d9c2 feat(ui): delete prompts and prompt versions + deduplication of prompt variables (#1116)
* fix and feat: update Var renaming and add functionality to delete prompt and versions

* pass ci by removing unused components

* ux improvements

---------

Co-authored-by: Marc Klingen <git@marcklingen.com>
2024-02-11 22:00:31 +00:00
Marc KlingenandGitHub e7086cf044 fix(ci): temporarily fix modelmatch test by waiting 5 sec (#1129)
* fix(ci): temporarily fix modelmatch test by waiting 5 sec

* fix
2024-02-11 21:56:19 +00:00
Marc KlingenandGitHub 9d49bbe987 chore(ui): responsive layout (#1126) 2024-02-10 03:29:38 +00:00
Marc Klingen f2c792d3dc chore: release v2.3.0
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests (18) (push) Waiting to run
CI/CD / tests (20) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2024-02-09 21:38:20 +01:00
Marc Klingen 0527fcf097 fix(ui): add space 2024-02-09 21:30:06 +01:00
Max DeichmannandGitHub 85737221d9 feat: send sdk logs to betterstack (#1125) 2024-02-09 20:01:46 +00:00
ffe95cf427 feat(ui): add request dashboard button (#1122)
Co-authored-by: Max Deichmann <m.deichmann@tum.de>
2024-02-09 19:25:08 +00:00
adec1476ea feat(ui): add quickstart examples to api key creation popup (#1119)
---------

Co-authored-by: Marc Klingen <git@marcklingen.com>
2024-02-09 19:10:21 +00:00
c5d7772fe6 chore(ui): convert model pricing in UI to per 1000 units (#1123)
Co-authored-by: Max Deichmann <m.deichmann@tum.de>
2024-02-09 19:05:04 +00:00
Max DeichmannandGitHub a88a6b8026 fix: upgrade API keys (#1120) 2024-02-09 16:44:48 +00:00
Marc Klingen 5d77e09eed docs: update readme and contributing 2024-02-09 15:38:22 +01:00
Hassieb PakzadandGitHub 85e5ea4661 fix: allow bearer auth for score ingestion via /api/public/ingestion (#1117)
* fix: allow bearer auth for score ingestion

* tests: add test for opening ingestion endpoint for scoring

* test: adds db check for bearer auth tests in ingestion
2024-02-09 13:51:22 +01:00
366df5a5c9 Fix decimals in scores filter on traces + generations table (#1012)
Co-authored-by: gitstart-langfuse <gitstart-langfuse@users.noreply.github.com>
Co-authored-by: GitStart-LangFuse <151670644+gitstart-langfuse@users.noreply.github.com>
Co-authored-by: Max Deichmann <m.deichmann@tum.de>
2024-02-09 08:44:12 +01:00
4441383dfd docs: update readme (#1107)
docs: update and significantly shorten readme
---------

Co-authored-by: Clemo <121163007+clemra@users.noreply.github.com>
2024-02-08 22:18:02 +00:00
Richard KrümmelandGitHub 7f7696e9ff feat(ui): add in-product notifications (#1057) 2024-02-08 19:41:30 +00:00
75 changed files with 3193 additions and 1413 deletions
+5 -1
View File
@@ -29,4 +29,8 @@ S3_ENDPOINT=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_BUCKET_NAME=
S3_REGION=
S3_REGION=
# Set during docker build of application
# Used to disable environment verification at build time
# DOCKER_BUILD=1
+6
View File
@@ -57,6 +57,9 @@ SALT="salt"
# S3_BUCKET_NAME=
# S3_REGION=
# Exports are streamed to S3 in pages to avoid memory issues
# The page size can be adjusted if needed to optimize performance
# DB_EXPORT_PAGE_SIZE=1000
@@ -81,6 +84,9 @@ SALT="salt"
# NEXT_SENTRY_PROJECT=
# SENTRY_AUTH_TOKEN=
# Betterstack
# LANGFUSE_TEAM_BETTERSTACK_TOKEN=
# Demo project that users can use to try the platform
# NEXT_PUBLIC_DEMO_PROJECT_ID=
-7
View File
@@ -9,10 +9,3 @@ body:
attributes:
label: Additional information
description: Add any other information related to the change here. If your idea is related to any issues or discussions, link them here.
- type: checkboxes
id: contribute
attributes:
label: Contribute
description: Are you willing to contribute to the implementation of this idea?
options:
- label: Yes, I can implement this and raise a PR
-18
View File
@@ -33,10 +33,6 @@ jobs:
test-docker-build:
runs-on: ubuntu-latest
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
NEXTAUTH_SECRET: "secret"
SALT: "salt"
NEXTAUTH_URL: "http://localhost:3030"
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
@@ -54,11 +50,6 @@ jobs:
with:
context: .
push: false
build-args: |
DATABASE_URL=${{ env.DATABASE_URL }}
NEXTAUTH_SECRET=${{ env.NEXTAUTH_SECRET }}
NEXTAUTH_URL=${{ env.NEXTAUTH_URL }}
SALT=${{ env.SALT }}
tests:
runs-on: ubuntu-latest
@@ -152,10 +143,6 @@ jobs:
environment: "protected branches"
runs-on: ubuntu-latest
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
NEXTAUTH_SECRET: "secret"
SALT: "salt"
NEXTAUTH_URL: "http://localhost:3030"
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
permissions:
@@ -198,8 +185,3 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
DATABASE_URL=${{ env.DATABASE_URL }}
NEXTAUTH_SECRET=${{ env.NEXTAUTH_SECRET }}
NEXTAUTH_URL=${{ env.NEXTAUTH_URL }}
SALT=${{ env.SALT }}
+5 -1
View File
@@ -48,4 +48,8 @@ yarn-error.log*
/generated/typescript-server
# openapi spec that is copied during build
/public/openapi*.yml
/public/openapi*.yml
# vscode
.devcontainer
+12 -4
View File
@@ -2,22 +2,30 @@
First off, thanks for taking the time to contribute! ❤️
Langfuse is an open-source observability and analytics solution for LLM-based applications. We welcome contributions through GitHub pull requests. This document outlines our conventions regarding development workflow, commit message formatting, contact points, and other resources. Our goal is to simplify the process and ensure that your contributions are easily accepted.
The best ways to contribute to Langfuse:
We gratefully welcome improvements to documentation as well as to code.
- Submit and vote on [Ideas](https://github.com/orgs/langfuse/discussions/categories/ideas)
- Create and comment on [Issues](https://github.com/langfuse/langfuse/issues)
- Open a PR.
We welcome contributions through GitHub pull requests. This document outlines our conventions regarding development workflow, commit message formatting, contact points, and other resources. Our goal is to simplify the process and ensure that your contributions are easily accepted.
We gratefully welcome improvements to documentation ([docs repo](https://github.com/langfuse/langfuse-docs)), the core application (this repo) and the SDKs ([Python](https://github.com/langfuse/langfuse-python), [JS](https://github.com/langfuse/langfuse-js)).
The maintainers are available on [Discord](https://langfuse.com/discord) in case you have any questions.
> And if you like the project, but just don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about:
> And if you like the project, but just don't have time to contribute code, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about:
>
> - Star the project;
> - Tweet about it;
> - Refer to this project in your project's readme;
> - Submit and vote on [Ideas](https://github.com/orgs/langfuse/discussions/categories/ideas);
> - Create and comment on [Issues](https://github.com/langfuse/langfuse/issues);
> - Mention the project at local meetups and tell your friends/colleagues.
## Making a change
_Before making any significant changes, please [open an issue](https://github.com/langfuse/langfuse/issues)._ Discussing your proposed changes ahead of time will make the contribution process smooth for everyone.
_Before making any significant changes, please [open an issue](https://github.com/langfuse/langfuse/issues)._ Discussing your proposed changes ahead of time will make the contribution process smooth for everyone. Large changes that were not discussed in an issue may be rejected.
Once we've discussed your changes and you've got your code ready, make sure that tests are passing and open your pull request.
+5 -16
View File
@@ -1,19 +1,11 @@
# Base image
FROM node:20-alpine AS base
ARG DATABASE_URL
ARG NEXTAUTH_SECRET
ARG NEXTAUTH_URL
ARG SALT
# It's important to update the index before installing packages to ensure you're getting the latest versions.
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk update && apk upgrade --no-cache libcrypto3 libssl3 libc6-compat
FROM base AS deps
ARG DATABASE_URL
ARG NEXTAUTH_SECRET
ARG NEXTAUTH_URL
ARG SALT
WORKDIR /app
@@ -29,10 +21,6 @@ RUN \
# Rebuild the source code only when needed
FROM base AS builder
ARG DATABASE_URL
ARG NEXTAUTH_SECRET
ARG NEXTAUTH_URL
ARG SALT
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
@@ -46,6 +34,9 @@ RUN rm -f ./src/middleware.ts
# Uncomment the following line in case you want to disable telemetry during the build.
ENV NEXT_TELEMETRY_DISABLED 1
# Disable validation of environment variables during build
ENV DOCKER_BUILD 1
# Generate prisma client
RUN npx prisma generate
@@ -54,10 +45,6 @@ RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
ARG DATABASE_URL
ARG NEXTAUTH_SECRET
ARG NEXTAUTH_URL
ARG SALT
RUN apk add --no-cache dumb-init
@@ -66,6 +53,8 @@ WORKDIR /app
ENV NODE_ENV production
# Uncomment the following line in case you want to disable telemetry during runtime.
ENV NEXT_TELEMETRY_DISABLED 1
# Needed to re-enable validation of environment variables during runtime
ENV DOCKER_BUILD 0
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
+90 -174
View File
@@ -2,51 +2,43 @@
<a href="https://langfuse.com">
<h1>🪢 Langfuse</h1>
</a>
<div>
<h3> <a href="https://cloud.langfuse.com">
<strong>Sign up</strong>
</a> ·
<a href="https://langfuse.com/docs/deployment/self-host">
<strong>Self Host Langfuse</strong>
</a> ·
<a href="https://langfuse.com/demo">
<strong>Demo Project (live data)</strong>
</a>
</h3>
<h3>
Open source observability & analytics for LLM-based applications
Langfuse is the open source LLM engineering platform.
</h3>
<div>
<strong>Observability:</strong> Explore and debug complex logs & traces in a visual UI
</div>
<div>
<strong>Analytics:</strong> Measure & improve costs, latency and response quality
<div>
Debug, analyze and iterate - together
</div>
</br>
<div>
<a href="https://cloud.langfuse.com">
<strong>Langfuse Cloud »</strong>
</a>
</br>
<a href="https://langfuse.com/docs/deployment/self-host">
<strong>Self Host Langfuse »</strong>
</a>
</br>
<a href="https://discord.gg/7NXusRtqYU">
<strong>Join the Langfuse Discord »</strong>
</a>
</br>
<a href="https://langfuse.com">
<strong>langfuse.com</strong>
</a> ·
<a href="https://langfuse.com/docs">
<strong>Docs</strong>
</a> ·
<a href="https://github.com/langfuse/langfuse/issues/new?labels=%F0%9F%90%9E%E2%9D%94+unconfirmed+bug&projects=&template=bug_report.yml&title=bug%3A+">
<a href="https://langfuse.com/issue">
<strong>Report Bug</strong>
</a> ·
<a href="https://github.com/orgs/langfuse/discussions/new?category=ideas">
<a href="https://langfuse.com/idea">
<strong>Feature Request</strong>
</a>
</a> ·
<a href="https://langfuse.com/changelog">
<strong>Changelog</strong>
</a> ·
<a href="https://langfuse.com/discord">
<strong>Discord</strong>
</a>
</div>
</br>
</br>
<div>
<img src="https://img.shields.io/badge/License-MIT-red.svg?style=flat-square" alt="MIT License">
<a href="https://discord.gg/7NXusRtqYU"><img src="https://img.shields.io/discord/1111061815649124414?style=flat-square&logo=Discord&logoColor=white&label=Discord&color=%23434EE4" alt="Discord"></a>
<a href="https://github.com/langfuse/langfuse"><img src="https://img.shields.io/github/stars/langfuse/langfuse?style=flat-square&logo=GitHub&label=langfuse%2Flangfuse" alt="Github Repo Stars"></a>
<a href="https://github.com/langfuse/langfuse/releases"><img src="https://img.shields.io/github/v/release/langfuse/langfuse?include_prereleases&style=flat-square" alt="langfuse releases"></a>
<a href="https://github.com/langfuse/langfuse/actions/workflows/pipeline.yml?query=branch:main"><img src="https://img.shields.io/github/actions/workflow/status/langfuse/langfuse/pipeline.yml?style=flat-square&label=All%20tests" alt="CI test status"></a>
<a href="https://status.langfuse.com"><img src="https://uptime.betterstack.com/status-badges/v1/monitor/udlc.svg" alt="Uptime Status"/></a>
<a href="https://www.ycombinator.com/companies/langfuse"><img src="https://img.shields.io/badge/Y%20Combinator-W23-orange?style=flat-square" alt="Y Combinator W23"></a>
<a href="https://github.com/langfuse/langfuse/pkgs/container/langfuse"><img alt="Docker Image" src="https://img.shields.io/badge/docker-langfuse-blue?logo=Docker&logoColor=white&style=flat-square"></a>
<a href="https://www.npmjs.com/package/langfuse"><img src="https://img.shields.io/npm/v/langfuse?style=flat-square&label=npm+langfuse" alt="langfuse npm package"></a>
@@ -54,61 +46,44 @@
</div>
</div>
</br>
</div>
</br>
## What is Langfuse?
## Overview
Langfuse is an open source observability & analytics solution for LLM-based applications. It is mostly geared towards production usage but some users also use it for local development of their LLM applications.
### Develop
Langfuse is focused on applications built on top of LLMs. Many new abstractions and common best practices evolved recently, e.g. agents, chained prompts, embedding-based retrieval, LLM access to REPLs & APIs. These make applications more powerful but also unpredictable for developers as they cannot fully anticipate how changes impact the quality, cost and overall latency of their application. Thus Langfuse helps to monitor and debug these applications.
- **Observability:** Instrument your app and start ingesting traces to Langfuse ([Quickstart](https://langfuse.com/docs/get-started), [Integrations](https://langfuse.com/docs/integrations) [Tracing](https://langfuse.com/docs/tracing))
- **Langfuse UI:** Inspect and debug complex logs ([Demo](https://langfuse.com/docs/demo), [Tracing](https://langfuse.com/docs/tracing))
- **Prompts:** Manage, version and deploy prompts from within Langfuse ([Prompt Management](https://langfuse.com/docs/prompts))
**Demo (2 min)**
### Monitor
- **Analytics:** Track metrics (cost, latency, quality) and gain insights from dashboards & data exports ([Analytics](https://langfuse.com/docs/analytics))
- **Evals:** Collect and calculate scores for your LLM completions ([Scores & Evaluations](https://langfuse.com/docs/scores))
- Run model-based evaluations ([Model-based evaluations](https://langfuse.com/docs/scores/model-based-evals))
- Collect user feedback ([User Feedback](https://langfuse.com/docs/scores/user-feedback))
- Manually score observations in Langfuse ([Manual Scores](https://langfuse.com/docs/scores/manually))
### Test
- **Experiments:** Track and test app behaviour before deploying a new version
- Datasets let you test expected in and output pairs and benchmark performance before deployiong ([Datasets](https://langfuse.com/docs/datasets))
- Track versions and releases in your application ([Experimentation](https://langfuse.com/docs/experimentation), [Prompt Management](https://langfuse.com/docs/prompts))
### Video: Langfuse in two minutes
https://github.com/langfuse/langfuse/assets/2834609/6041347a-b517-4a11-8737-93ef8f8af49f
_Muted by default, enable sound for voice-over_
Explore demo project in Langfuse here (free account required): https://langfuse.com/demo
### Observability
Langfuse offers an admin UI to explore the ingested data.
- Nested view of LLM app executions; detailed information along the traces on: latency, cost, scores
- Segment execution traces by user feedback, to e.g. identify production issues
### Analytics
Reporting on
- Token usage by model
- Volume of traces
- Scores/evals
Broken down by
- Users
- Releases
- Prompt/chain versions
- Prompt/chain types
- Time
→ Expect releases with more ways to analyze the data over the next weeks.
## Get started
### Step 1: Run Server
### Langfuse Cloud
#### Langfuse Cloud
Managed deployment by the Langfuse team, generous free-tier (hobby plan), no credit card required.
Managed deployment by the Langfuse team, generous free-tier (hobby plan) available, no credit card required.
**[» Langfuse Cloud](https://cloud.langfuse.com)**
Links: [Create account](https://cloud.langfuse.com), [learn more](https://cloud.langfuse.com)
#### Localhost
Requirements: docker, docker compose (e.g. using Docker Desktop)
### Localhost (docker)
```bash
# Clone repository
@@ -119,143 +94,84 @@ cd langfuse
docker compose up -d
```
#### Self-host (Docker)
[→ Learn more about deploying locally](https://langfuse.com/docs/deployment/local)
[→ Instructions](https://langfuse.com/docs/deployment/self-host)
### Self-host (docker)
[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/template/gmbqa_)
Langfuse is simple to self-host and keep updated. It currently requires only a single docker container.
[→ Self Hosting Instructions](https://langfuse.com/docs/deployment/self-host)
### Step 2: Data ingestion
Templated deployments: [Railway, GCP Cloud Run, AWS Fargate, Kubernetes and others](https://langfuse.com/docs/deployment/self-host#platform-specific-information)
#### SDKs to instrument application
## Get Started
Fully async, typed SDKs to instrument any LLM application. Currently available for Python & JS/TS.
### API Keys
[Guide](https://langfuse.com/docs/guides/sdk-integration) with an example of how the SDK can be used
You require a Langfuse public and secret key to get started. Sign up [here](https://cloud.langfuse.com) and find them in your project settings.
| Package | Description | Links |
| --------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| [![PyPI Version](https://img.shields.io/pypi/v/langfuse.svg?style=flat-square&label=pypi+langfuse)](https://pypi.python.org/pypi/langfuse) | Python | [docs](https://langfuse.com/docs/integrations/sdk/python), [repo](https://github.com/langfuse/langfuse-python) |
| [![npm Version](https://img.shields.io/npm/v/langfuse?style=flat-square&label=npm+langfuse)](https://www.npmjs.com/package/langfuse) | JS/TS: Node >= 18, Edge runtimes | [docs](https://langfuse.com/docs/integrations/sdk/typescript), [repo](https://github.com/langfuse/langfuse-js) |
| [![npm package](https://img.shields.io/npm/v/langfuse-node?style=flat-square&label=npm+langfuse-node)](https://www.npmjs.com/package/langfuse-node) | JS/TS: Node <18 | [docs](https://langfuse.com/docs/integrations/sdk/typescript), [repo](https://github.com/langfuse/langfuse-js) |
### Ingesting Data · Instrumenting Your Application
#### Langchain applications
Note: We recommend using our fully async, typed [SDKs](https://langfuse.com/docs/sdk) that allow you to instrument any LLM application with any underlying model. They are available in [Python](https://langfuse.com/docs/sdk/python) & [JS/TS](https://langfuse.com/docs/sdk/typescript). The SDKs will always be the most fully featured and stable way to ingest data into Langfuse.
The Langfuse callback handler automatically instruments Langchain applications. Currently available for Python and JS/TS.
You may want to use another integration to get started quickly or implement a use case that we do not yet support. However, we recommend to migrate to the Langfuse SDKs over time to ensure performance and stability.
**Python**
See our the [→ Quickstart](https://langfuse.com/docs/get-started) to get started in integrating Langfuse.
```shell
pip install langfuse
```
### Integrations
```python
# Initialize Langfuse handler
from langfuse.callback import CallbackHandler
handler = CallbackHandler(PUBLIC_KEY, SECRET_KEY)
| Integration | Supports | Description |
| -------------------------------------------------------- | ------------- | ------------------------------------------------------------------------------- |
| [**SDK** - _recommended_](https://langfuse.com/docs/sdk) | Python, JS/TS | Manual instrumentation using the SDKs for full flexibility. |
| [OpenAI](https://langfuse.com/docs/openai) | Python | Automated instrumentation using drop-in replacement of OpenAI SDK. |
| [Langchain](https://langfuse.com/docs/langchain) | Python, JS/TS | Automated instrumentation by passing callback handler to Langchain application. |
| [API](https://langfuse.com/docs/api) | | Directly call the public API. OpenAPI spec available. |
# Setup Langchain
from langchain.chains import LLMChain
...
chain = LLMChain(llm=llm, prompt=prompt)
External projects/packages that integrate with Langfuse:
# Add Langfuse handler as callback
chain.run(input="<user_input", callbacks=[handler])
```
| Name | Description |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| [LiteLLM](/https://langfuse.comdocs/litellm) | Use any LLM as a drop in replacement for GPT. Use Azure, OpenAI, Cohere, Anthropic, Ollama, VLLM, Sagemaker, HuggingFace, Replicate (100+ LLMs). |
| [Flowise](https://langfuse.com/docs/flowise) | JS/TS no-code builder for customized LLM flows. |
| [Langflow](https://langfuse.com/docs/langflow) | Python-based UI for LangChain, designed with react-flow to provide an effortless way to experiment and prototype flows. |
→ [Langchain integration docs for Python](https://langfuse.com/docs/integrations/langchain/python)
## Questions and feedback
**JS/TS**
### Ideas and roadmap
[Langchain integration docs for JS/TS](https://langfuse.com/docs/integrations/langchain/typescript)
- [GitHub Discussions](https://github.com/orgs/langfuse/discussions)
- [Feature Requests](https://langfuse.com/idea)
#### Add scores/evaluations to traces (optional)
### Support and feedback
Quality/evaluation of traces is tracked via scores ([docs](https://langfuse.com/docs/scores)). Scores are related to traces and optionally to observations. Scores can be added via:
In order of preference the best way to communicate with us:
- **Backend SDKs** (see docs above): `{trace, event, span, generation}.score()`
- **API** (see docs below): `POST /api/public/scores`
- **Client-side using Web SDK**, e.g. to capture user feedback or other user-based quality metrics:
```sh
npm install langfuse
```
```ts
// Client-side (browser)
import { LangfuseWeb } from "langfuse";
const langfuseWeb = new LangfuseWeb({
publicKey: process.env.LANGFUSE_PUBLIC_KEY,
});
// frontend handler (example: React)
export function UserFeedbackComponent(props: { traceId: string }) {
const handleUserFeedback = async (value: number) => {
await langfuseWeb.score({
traceId: props.traceId,
name: "user_feedback",
value,
});
};
return (
<div>
<button onClick={() => handleUserFeedback(1)}>👍</button>
<button onClick={() => handleUserFeedback(-1)}>👎</button>
</div>
);
}
```
#### API
[**Api reference**](https://langfuse.com/docs/integrations/api)
- POST/PATCH routes to ingest data
- GET routes to use data in downstream applications (e.g. embedded analytics)
## Questions / Feedback
The maintainers are very active in the Langfuse [Discord](https://langfuse.com/discord) and are happy to answer questions or discuss feedback/ideas regarding the future of the project.
- [GitHub Discussions](https://github.com/orgs/langfuse/discussions): Contribute [ideas](https://langfuse.com/idea) [support requests](https://github.com/orgs/langfuse/discussions/categories/support) and [report bugs](https://github.com/langfuse/langfuse/issues/new?labels=%F0%9F%90%9E%E2%9D%94+unconfirmed+bug&projects=&template=bug_report.yml&title=bug%3A+) (preferred as we create a permanent, indexed artifact for other community members)
- [Discord](https://langfuse.com/discord): For community support and to chat directly with maintainers
- Privately: Email contact at langfuse dot com
## Contributing to Langfuse
Join the community [on Discord](https://discord.gg/7NXusRtqYU).
To contribute, send us a PR, raise a GitHub issue, or email at contributing@langfuse.com
### Development setup
See [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to setup a development environment.
- Vote on [Ideas](https://github.com/orgs/langfuse/discussions/categories/ideas)
- Raise and comment on [Issues](https://github.com/langfuse/langfuse/issues)
- Open a PR - see [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to setup a development environment.
## License
Langfuse is MIT licensed, except for `ee/` folder. See [LICENSE](LICENSE) and [docs](https://langfuse.com/docs/open-source) for more details.
This repository is MIT licensed, except for the `ee/` folder. See [LICENSE](LICENSE) and [docs](https://langfuse.com/docs/open-source) for more details.
## Misc
### Upgrade Langfuse (localhost)
### GET API to export your data
```bash
# Stop server and db
docker compose down
[**GET routes**](https://langfuse.com/docs/integrations/api) to use data in downstream applications (e.g. embedded analytics).
# Pull latest changes
git pull
docker-compose pull
### Security & Privacy
# Run server and db
docker compose up -d
```
### Run Langfuse in CI for integration tests
Checkout GitHub Actions workflows of [Python SDK](https://github.com/langfuse/langfuse-python/blob/main/.github/workflows/ci.yml) and [JS/TS SDK](https://github.com/langfuse/langfuse-js/blob/main/.github/workflows/ci.yml).
We take data security and privacy seriously. Please refer to our [Security and Privacy](https://langfuse.com/security) page for more information.
### Telemetry
By default, Langfuse automatically reports basic usage statistics to a centralized server (PostHog).
By default, Langfuse automatically reports basic usage statistics of self-hosted instances to a centralized server (PostHog).
This helps us to:
-6
View File
@@ -4,17 +4,11 @@ services:
langfuse-server:
build:
dockerfile: Dockerfile
args:
- DATABASE_URL=postgresql://postgres:postgres@db:5432/postgres
- NEXTAUTH_SECRET=mysecret
- SALT=mysalt
- NEXTAUTH_URL=http://localhost:3000
depends_on:
- db
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://postgres:postgres@db:5432/postgres
- NEXTAUTH_SECRET=mysecret
- SALT=mysalt
-1
View File
@@ -8,7 +8,6 @@ services:
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://postgres:postgres@db:5432/postgres
- NEXTAUTH_SECRET=mysecret
- SALT=mysalt
+205 -271
View File
@@ -1,16 +1,17 @@
{
"name": "langfuse-core",
"version": "2.2.0",
"version": "2.4.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "langfuse-core",
"version": "2.2.0",
"version": "2.4.2",
"hasInstallScript": true,
"dependencies": {
"@anthropic-ai/tokenizer": "^0.0.4",
"@aws-sdk/client-s3": "^3.507.0",
"@aws-sdk/lib-storage": "^3.511.0",
"@aws-sdk/s3-request-presigner": "^3.507.0",
"@headlessui/react": "^1.7.18",
"@heroicons/react": "^2.1.1",
@@ -39,8 +40,8 @@
"@radix-ui/react-tooltip": "^1.0.7",
"@react-email/components": "^0.0.14",
"@react-email/render": "^0.0.12",
"@sentry/nextjs": "^7.99.0",
"@sentry/profiling-node": "^1.3.5",
"@sentry/nextjs": "^7.100.1",
"@sentry/profiling-node": "^7.100.1",
"@sentry/types": "^7.88.0",
"@t3-oss/env-nextjs": "^0.8.0",
"@tailwindcss/forms": "^0.5.7",
@@ -51,7 +52,6 @@
"@trpc/next": "^10.45.0",
"@trpc/react-query": "^10.45.0",
"@trpc/server": "^10.45.0",
"@vercel/edge-config": "^0.4.1",
"bcryptjs": "^2.4.3",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
@@ -63,12 +63,12 @@
"exponential-backoff": "^3.1.1",
"js-tiktoken": "^1.0.10",
"lodash": "^4.17.21",
"lucide-react": "^0.323.0",
"lucide-react": "^0.330.0",
"next": "^14.1.0",
"next-auth": "^4.24.5",
"next-query-params": "^5.0.0",
"nodemailer": "^6.9.9",
"posthog-js": "^1.104.4",
"posthog-js": "^1.105.7",
"posthog-node": "^3.6.2",
"react": "18.2.0",
"react-day-picker": "^8.10.0",
@@ -77,6 +77,7 @@
"react-icons": "^5.0.1",
"react-responsive": "^9.0.2",
"react18-json-view": "^0.2.7",
"sonner": "^1.4.0",
"superjson": "2.2.1",
"tailwind-merge": "^2.2.1",
"tailwindcss-animate": "^1.0.7",
@@ -99,9 +100,9 @@
"@types/node": "20.10.5",
"@types/nodemailer": "^6.4.14",
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.18",
"@types/react-dom": "^18.2.19",
"@types/uuid": "^9.0.8",
"@typescript-eslint/eslint-plugin": "^6.20.0",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"autoprefixer": "^10.4.17",
"dotenv-cli": "^7.3.0",
@@ -109,7 +110,7 @@
"eslint-config-next": "^14.1.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"postcss": "^8.4.34",
"postcss": "^8.4.35",
"prettier": "^3.2.5",
"prettier-plugin-tailwindcss": "^0.5.11",
"prisma": "^5.9.1",
@@ -118,7 +119,7 @@
"tailwindcss": "^3.4.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.7.0",
"tsx": "^4.7.1",
"typescript": "^5.3.3"
},
"optionalDependencies": {
@@ -664,6 +665,26 @@
"node": ">=14.0.0"
}
},
"node_modules/@aws-sdk/lib-storage": {
"version": "3.511.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.511.0.tgz",
"integrity": "sha512-inEbSyqzGxiQs8aEnkGdxw9ZDn370mRHOdE1TB/GvVe9buQVyZ2hQvOY5WBVOaIGDIxGpuUzVvr4o89XreU19w==",
"dependencies": {
"@smithy/abort-controller": "^2.1.1",
"@smithy/middleware-endpoint": "^2.4.1",
"@smithy/smithy-client": "^2.3.1",
"buffer": "5.6.0",
"events": "3.3.0",
"stream-browserify": "3.0.0",
"tslib": "^2.5.0"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"@aws-sdk/client-s3": "^3.0.0"
}
},
"node_modules/@aws-sdk/middleware-bucket-endpoint": {
"version": "3.502.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.502.0.tgz",
@@ -4968,57 +4989,57 @@
}
},
"node_modules/@sentry-internal/feedback": {
"version": "7.99.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-7.99.0.tgz",
"integrity": "sha512-exIO1o+bE0MW4z30FxC0cYzJ4ZHSMlDPMHCBDPzU+MWGQc/fb8s58QUrx5Dnm6HTh9G3H+YlroCxIo9u0GSwGQ==",
"version": "7.100.1",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-7.100.1.tgz",
"integrity": "sha512-yqcRVnjf+qS+tC4NxOKLJOaSJ+csHmh/dHUzvCTkf5rLsplwXYRnny2r0tqGTQ4tuXMxwgSMKPYwicg81P+xuw==",
"dependencies": {
"@sentry/core": "7.99.0",
"@sentry/types": "7.99.0",
"@sentry/utils": "7.99.0"
"@sentry/core": "7.100.1",
"@sentry/types": "7.100.1",
"@sentry/utils": "7.100.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@sentry-internal/replay-canvas": {
"version": "7.99.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-7.99.0.tgz",
"integrity": "sha512-PoIkfusToDq0snfl2M6HJx/1KJYtXxYhQplrn11kYadO04SdG0XGXf4h7wBTMEQ7LDEAtQyvsOu4nEQtTO3YjQ==",
"version": "7.100.1",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-7.100.1.tgz",
"integrity": "sha512-TnqxqJGhbFhhYRhTG2WLFer+lVieV7mNGeIxFBiw1L4kuj8KGl+C0sknssKyZSRVJFSahhHIosHJGRMkkD//7g==",
"dependencies": {
"@sentry/core": "7.99.0",
"@sentry/replay": "7.99.0",
"@sentry/types": "7.99.0",
"@sentry/utils": "7.99.0"
"@sentry/core": "7.100.1",
"@sentry/replay": "7.100.1",
"@sentry/types": "7.100.1",
"@sentry/utils": "7.100.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@sentry-internal/tracing": {
"version": "7.99.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/tracing/-/tracing-7.99.0.tgz",
"integrity": "sha512-z3JQhHjoM1KdM20qrHwRClKJrNLr2CcKtCluq7xevLtXHJWNAQQbafnWD+Aoj85EWXBzKt9yJMv2ltcXJ+at+w==",
"version": "7.100.1",
"resolved": "https://registry.npmjs.org/@sentry-internal/tracing/-/tracing-7.100.1.tgz",
"integrity": "sha512-+u9RRf5eL3StiyiRyAHZmdkAR7GTSGx4Mt4Lmi5NEtCcWlTGZ1QgW2r8ZbhouVmTiJkjhQgYCyej3cojtazeJg==",
"dependencies": {
"@sentry/core": "7.99.0",
"@sentry/types": "7.99.0",
"@sentry/utils": "7.99.0"
"@sentry/core": "7.100.1",
"@sentry/types": "7.100.1",
"@sentry/utils": "7.100.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@sentry/browser": {
"version": "7.99.0",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-7.99.0.tgz",
"integrity": "sha512-bgfoUv3wkwwLgN5YUOe0ibB3y268ZCnamZh6nLFqnY/UBKC1+FXWFdvzVON/XKUm62LF8wlpCybOf08ebNj2yg==",
"version": "7.100.1",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-7.100.1.tgz",
"integrity": "sha512-IxHQ08ixf0bmaWpe4yt1J4UUsOpg02fxax9z3tOQYXw5MSzz5pDXn8M8DFUVJB3wWuyXhHXTub9yD3VIP9fnoA==",
"dependencies": {
"@sentry-internal/feedback": "7.99.0",
"@sentry-internal/replay-canvas": "7.99.0",
"@sentry-internal/tracing": "7.99.0",
"@sentry/core": "7.99.0",
"@sentry/replay": "7.99.0",
"@sentry/types": "7.99.0",
"@sentry/utils": "7.99.0"
"@sentry-internal/feedback": "7.100.1",
"@sentry-internal/replay-canvas": "7.100.1",
"@sentry-internal/tracing": "7.100.1",
"@sentry/core": "7.100.1",
"@sentry/replay": "7.100.1",
"@sentry/types": "7.100.1",
"@sentry/utils": "7.100.1"
},
"engines": {
"node": ">=8"
@@ -5045,25 +5066,25 @@
}
},
"node_modules/@sentry/core": {
"version": "7.99.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.99.0.tgz",
"integrity": "sha512-vOAtzcAXEUtS/oW7wi3wMkZ3hsb5Ch96gKyrrj/mXdOp2zrcwdNV6N9/pawq2E9P/7Pw8AXw4CeDZztZrjQLuA==",
"version": "7.100.1",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.100.1.tgz",
"integrity": "sha512-f+ItUge/o9AjlveQq0ZUbQauKlPH1FIJbC1TRaYLJ4KNfOdrsh8yZ29RmWv0cFJ/e+FGTr603gWpRPObF5rM8Q==",
"dependencies": {
"@sentry/types": "7.99.0",
"@sentry/utils": "7.99.0"
"@sentry/types": "7.100.1",
"@sentry/utils": "7.100.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@sentry/integrations": {
"version": "7.99.0",
"resolved": "https://registry.npmjs.org/@sentry/integrations/-/integrations-7.99.0.tgz",
"integrity": "sha512-q4Nwpc27DTWlR7nDerd1o6KHlT/0usK+3xfBTZ1feVIAHCxt6ohCyZdoQ97+4kQiJJdX47MEmJYsXUlj62yZNg==",
"version": "7.100.1",
"resolved": "https://registry.npmjs.org/@sentry/integrations/-/integrations-7.100.1.tgz",
"integrity": "sha512-RUyZHcsN3Plc8G4hJN3BCMdbwS8ljUY3E3iLjzucA4HroBsGk5AMc6n7Pp/QqFIRgxrPjKEgA52Wgy5Nq6dSvw==",
"dependencies": {
"@sentry/core": "7.99.0",
"@sentry/types": "7.99.0",
"@sentry/utils": "7.99.0",
"@sentry/core": "7.100.1",
"@sentry/types": "7.100.1",
"@sentry/utils": "7.100.1",
"localforage": "^1.8.1"
},
"engines": {
@@ -5071,18 +5092,18 @@
}
},
"node_modules/@sentry/nextjs": {
"version": "7.99.0",
"resolved": "https://registry.npmjs.org/@sentry/nextjs/-/nextjs-7.99.0.tgz",
"integrity": "sha512-8eeEPFJjRBiCp2sFUhDLQFdWFagQ2yBvmALZIOIuoMei69N+clYVSxz84beeztbLal0zvRadJO5LAkBCb6d66Q==",
"version": "7.100.1",
"resolved": "https://registry.npmjs.org/@sentry/nextjs/-/nextjs-7.100.1.tgz",
"integrity": "sha512-JIDS8oQrr/xrU5llXNVoLnFJCFdS7+k6FVrtW7JnP9B+0NmILEVS3jqbabhh+af9Ch67DPZ2G4KWDRIDZE5HSQ==",
"dependencies": {
"@rollup/plugin-commonjs": "24.0.0",
"@sentry/core": "7.99.0",
"@sentry/integrations": "7.99.0",
"@sentry/node": "7.99.0",
"@sentry/react": "7.99.0",
"@sentry/types": "7.99.0",
"@sentry/utils": "7.99.0",
"@sentry/vercel-edge": "7.99.0",
"@sentry/core": "7.100.1",
"@sentry/integrations": "7.100.1",
"@sentry/node": "7.100.1",
"@sentry/react": "7.100.1",
"@sentry/types": "7.100.1",
"@sentry/utils": "7.100.1",
"@sentry/vercel-edge": "7.100.1",
"@sentry/webpack-plugin": "1.21.0",
"chalk": "3.0.0",
"resolve": "1.22.8",
@@ -5104,47 +5125,44 @@
}
},
"node_modules/@sentry/node": {
"version": "7.99.0",
"resolved": "https://registry.npmjs.org/@sentry/node/-/node-7.99.0.tgz",
"integrity": "sha512-34wYtLddnPcQ8qvKq62AfxowaMFw+GMUZGv7fIs9FxeBqqqn6Ckl0gFCTADudIIBQ3rSbmN7sHJIXdyiQv+pcw==",
"version": "7.100.1",
"resolved": "https://registry.npmjs.org/@sentry/node/-/node-7.100.1.tgz",
"integrity": "sha512-jB6tBLr7BpgdE2SlYZu343vvpa5jMFnqyFlprr+jdDu/ayNF4idB0qFwQe8p4C6LI6M/MNDRLVOgPBiCjjZSpw==",
"dependencies": {
"@sentry-internal/tracing": "7.99.0",
"@sentry/core": "7.99.0",
"@sentry/types": "7.99.0",
"@sentry/utils": "7.99.0"
"@sentry-internal/tracing": "7.100.1",
"@sentry/core": "7.100.1",
"@sentry/types": "7.100.1",
"@sentry/utils": "7.100.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@sentry/profiling-node": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@sentry/profiling-node/-/profiling-node-1.3.5.tgz",
"integrity": "sha512-n2bfEbtLW3WuIMQGyxKJKzBNZOb1JYfMeJQ2WQn/42F++69m+u7T0S3EDGRN0Y//fbt5+r0any+4r3kChRXZkQ==",
"version": "7.100.1",
"resolved": "https://registry.npmjs.org/@sentry/profiling-node/-/profiling-node-7.100.1.tgz",
"integrity": "sha512-Q/B7SntzB/qt0Y/MZK8dBy8PIf6nCT/kdnn+wrTIFicxozqsF3hq5vmKvGyVUWobb3FrNjc2dSuDXTijN1xmkQ==",
"hasInstallScript": true,
"dependencies": {
"detect-libc": "^2.0.2",
"node-abi": "^3.52.0"
},
"bin": {
"sentry-prune-profiler-binaries": "scripts/prune-profiler-binaries.mjs"
"sentry-prune-profiler-binaries": "scripts/prune-profiler-binaries.js"
},
"engines": {
"node": ">=8.0.0"
},
"peerDependencies": {
"@sentry/node": "^7.44.1"
}
},
"node_modules/@sentry/react": {
"version": "7.99.0",
"resolved": "https://registry.npmjs.org/@sentry/react/-/react-7.99.0.tgz",
"integrity": "sha512-RtHwgzMHJhzJfSQpVG0SDPQYMTGDX3Q37/YWI59S4ALMbSW4/F6n/eQAvGVYZKbh2UCSqgFuRWaXOYkSZT17wA==",
"version": "7.100.1",
"resolved": "https://registry.npmjs.org/@sentry/react/-/react-7.100.1.tgz",
"integrity": "sha512-EdrBtrXVLK2LSx4Rvz/nQP7HZUZQmr+t3GHV8436RAhF6vs5mntACVMBoQJRWiUvtZ1iRo3rIsIdah7DLiFPgQ==",
"dependencies": {
"@sentry/browser": "7.99.0",
"@sentry/core": "7.99.0",
"@sentry/types": "7.99.0",
"@sentry/utils": "7.99.0",
"@sentry/browser": "7.100.1",
"@sentry/core": "7.100.1",
"@sentry/types": "7.100.1",
"@sentry/utils": "7.100.1",
"hoist-non-react-statics": "^3.3.2"
},
"engines": {
@@ -5155,47 +5173,47 @@
}
},
"node_modules/@sentry/replay": {
"version": "7.99.0",
"resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-7.99.0.tgz",
"integrity": "sha512-gyN/I2WpQrLAZDT+rScB/0jnFL2knEVBo8U8/OVt8gNP20Pq8T/rDZKO/TG0cBfvULDUbJj2P4CJryn2p/O2rA==",
"version": "7.100.1",
"resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-7.100.1.tgz",
"integrity": "sha512-B1NFjzGEFaqejxBRdUyEzH8ChXc2kfiqlA/W/Lg0aoWIl2/7nuMk+l4ld9gW5F5bIAXDTVd5vYltb1lWEbpr7w==",
"dependencies": {
"@sentry-internal/tracing": "7.99.0",
"@sentry/core": "7.99.0",
"@sentry/types": "7.99.0",
"@sentry/utils": "7.99.0"
"@sentry-internal/tracing": "7.100.1",
"@sentry/core": "7.100.1",
"@sentry/types": "7.100.1",
"@sentry/utils": "7.100.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@sentry/types": {
"version": "7.99.0",
"resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.99.0.tgz",
"integrity": "sha512-94qwOw4w40sAs5mCmzcGyj8ZUu/KhnWnuMZARRq96k+SjRW/tHFAOlIdnFSrt3BLPvSOK7R3bVAskZQ0N4FTmA==",
"version": "7.100.1",
"resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.100.1.tgz",
"integrity": "sha512-fLM+LedHuKzOd8IhXBqaQuym+AA519MGjeczBa5kGakes/BbAsUMwsNfjsKQedp7Kh44RgYF99jwoRPK2oDrXw==",
"engines": {
"node": ">=8"
}
},
"node_modules/@sentry/utils": {
"version": "7.99.0",
"resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-7.99.0.tgz",
"integrity": "sha512-cYZy5WNTkWs5GgggGnjfGqC44CWir0pAv4GVVSx0fsup4D4pMKBJPrtub15f9uC+QkUf3vVkqwpBqeFxtmJQTQ==",
"version": "7.100.1",
"resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-7.100.1.tgz",
"integrity": "sha512-Ve6dXr1o6xiBe3VCoJgiutmBKrugryI65EZAbYto5XI+t+PjiLLf9wXtEMF24ZrwImo4Lv3E9Uqza+fWkEbw6A==",
"dependencies": {
"@sentry/types": "7.99.0"
"@sentry/types": "7.100.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@sentry/vercel-edge": {
"version": "7.99.0",
"resolved": "https://registry.npmjs.org/@sentry/vercel-edge/-/vercel-edge-7.99.0.tgz",
"integrity": "sha512-9Uw3Iuy8KMlcv71ifnaguwndb1NkHeOAbYcBEeq9W+n0f5ocFZvMlnwszSlVNAL3cK+hlpcBhelXNAO/mBWCfg==",
"version": "7.100.1",
"resolved": "https://registry.npmjs.org/@sentry/vercel-edge/-/vercel-edge-7.100.1.tgz",
"integrity": "sha512-SEWX7KAQreAQREHv+AYm50f/yK8nkq0DQHBQhr+UNZFKbWtSvytbkSmt4HgvOO6nbx9jeAIcg6Z1IKPYNGLKfg==",
"dependencies": {
"@sentry-internal/tracing": "7.99.0",
"@sentry/core": "7.99.0",
"@sentry/types": "7.99.0",
"@sentry/utils": "7.99.0"
"@sentry-internal/tracing": "7.100.1",
"@sentry/core": "7.100.1",
"@sentry/types": "7.100.1",
"@sentry/utils": "7.100.1"
},
"engines": {
"node": ">=8"
@@ -6590,9 +6608,9 @@
}
},
"node_modules/@types/react-dom": {
"version": "18.2.18",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.18.tgz",
"integrity": "sha512-TJxDm6OfAX2KJWJdMEVTwWke5Sc/E/RlnPGvGfS0W7+6ocy2xhDVQVh/KvC2Uf7kACs+gDytdusDSdWfWkaNzw==",
"version": "18.2.19",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.19.tgz",
"integrity": "sha512-aZvQL6uUbIJpjZk4U8JZGbau9KDeAwMfmhyWorxgBkqDIEf6ROjRozcmPIicqsUwPUjbkDfHKgGee1Lq65APcA==",
"devOptional": true,
"dependencies": {
"@types/react": "*"
@@ -6605,9 +6623,9 @@
"devOptional": true
},
"node_modules/@types/semver": {
"version": "7.5.6",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz",
"integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==",
"version": "7.5.7",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.7.tgz",
"integrity": "sha512-/wdoPq1QqkSj9/QOeKkFquEuPzQbHTWAMPH/PaUMB+JuR31lXhlWXRZ52IpfDYVlDOUBvX09uBrPwxGT1hjNBg==",
"dev": true
},
"node_modules/@types/stack-utils": {
@@ -6654,16 +6672,16 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.20.0.tgz",
"integrity": "sha512-fTwGQUnjhoYHeSF6m5pWNkzmDDdsKELYrOBxhjMrofPqCkoC2k3B2wvGHFxa1CTIqkEn88nlW1HVMztjo2K8Hg==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz",
"integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==",
"dev": true,
"dependencies": {
"@eslint-community/regexpp": "^4.5.1",
"@typescript-eslint/scope-manager": "6.20.0",
"@typescript-eslint/type-utils": "6.20.0",
"@typescript-eslint/utils": "6.20.0",
"@typescript-eslint/visitor-keys": "6.20.0",
"@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/type-utils": "6.21.0",
"@typescript-eslint/utils": "6.21.0",
"@typescript-eslint/visitor-keys": "6.21.0",
"debug": "^4.3.4",
"graphemer": "^1.4.0",
"ignore": "^5.2.4",
@@ -6716,7 +6734,7 @@
}
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": {
"node_modules/@typescript-eslint/scope-manager": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz",
"integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==",
@@ -6733,113 +6751,14 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz",
"integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==",
"dev": true,
"engines": {
"node": "^16.0.0 || >=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz",
"integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "6.21.0",
"@typescript-eslint/visitor-keys": "6.21.0",
"debug": "^4.3.4",
"globby": "^11.1.0",
"is-glob": "^4.0.3",
"minimatch": "9.0.3",
"semver": "^7.5.4",
"ts-api-utils": "^1.0.1"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz",
"integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "6.21.0",
"eslint-visitor-keys": "^3.4.1"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/parser/node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dev": true,
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/@typescript-eslint/parser/node_modules/minimatch": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
"integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
"dev": true,
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.20.0.tgz",
"integrity": "sha512-p4rvHQRDTI1tGGMDFQm+GtxP1ZHyAh64WANVoyEcNMpaTFn3ox/3CcgtIlELnRfKzSs/DwYlDccJEtr3O6qBvA==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "6.20.0",
"@typescript-eslint/visitor-keys": "6.20.0"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.20.0.tgz",
"integrity": "sha512-qnSobiJQb1F5JjN0YDRPHruQTrX7ICsmltXhkV536mp4idGAYrIyr47zF/JmkJtEcAVnIz4gUYJ7gOZa6SmN4g==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz",
"integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==",
"dev": true,
"dependencies": {
"@typescript-eslint/typescript-estree": "6.20.0",
"@typescript-eslint/utils": "6.20.0",
"@typescript-eslint/typescript-estree": "6.21.0",
"@typescript-eslint/utils": "6.21.0",
"debug": "^4.3.4",
"ts-api-utils": "^1.0.1"
},
@@ -6860,9 +6779,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.20.0.tgz",
"integrity": "sha512-MM9mfZMAhiN4cOEcUOEx+0HmuaW3WBfukBZPCfwSqFnQy0grXYtngKCqpQN339X3RrwtzspWJrpbrupKYUSBXQ==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz",
"integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==",
"dev": true,
"engines": {
"node": "^16.0.0 || >=18.0.0"
@@ -6873,13 +6792,13 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.20.0.tgz",
"integrity": "sha512-RnRya9q5m6YYSpBN7IzKu9FmLcYtErkDkc8/dKv81I9QiLLtVBHrjz+Ev/crAqgMNW2FCsoZF4g2QUylMnJz+g==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz",
"integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "6.20.0",
"@typescript-eslint/visitor-keys": "6.20.0",
"@typescript-eslint/types": "6.21.0",
"@typescript-eslint/visitor-keys": "6.21.0",
"debug": "^4.3.4",
"globby": "^11.1.0",
"is-glob": "^4.0.3",
@@ -6925,17 +6844,17 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.20.0.tgz",
"integrity": "sha512-/EKuw+kRu2vAqCoDwDCBtDRU6CTKbUmwwI7SH7AashZ+W+7o8eiyy6V2cdOqN49KsTcASWsC5QeghYuRDTyOOg==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz",
"integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==",
"dev": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"@types/json-schema": "^7.0.12",
"@types/semver": "^7.5.0",
"@typescript-eslint/scope-manager": "6.20.0",
"@typescript-eslint/types": "6.20.0",
"@typescript-eslint/typescript-estree": "6.20.0",
"@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0",
"@typescript-eslint/typescript-estree": "6.21.0",
"semver": "^7.5.4"
},
"engines": {
@@ -6950,12 +6869,12 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.20.0.tgz",
"integrity": "sha512-E8Cp98kRe4gKHjJD4NExXKz/zOJ1A2hhZc+IMVD6i7w4yjIvh6VyuRI0gRtxAsXtoC35uGMaQ9rjI2zJaXDEAw==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz",
"integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "6.20.0",
"@typescript-eslint/types": "6.21.0",
"eslint-visitor-keys": "^3.4.1"
},
"engines": {
@@ -6972,22 +6891,6 @@
"integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==",
"dev": true
},
"node_modules/@vercel/edge-config": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@vercel/edge-config/-/edge-config-0.4.1.tgz",
"integrity": "sha512-4Mc3H7lE+x4RrL17nY8CWeEorvJHbkNbQTy9p8H1tO7y11WeKj5xeZSr07wNgfWInKXDUwj5FZ3qd/jIzjPxug==",
"dependencies": {
"@vercel/edge-config-fs": "0.1.0"
},
"engines": {
"node": ">=14.6"
}
},
"node_modules/@vercel/edge-config-fs": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@vercel/edge-config-fs/-/edge-config-fs-0.1.0.tgz",
"integrity": "sha512-NRIBwfcS0bUoUbRWlNGetqjvLSwgYH/BqKqDN7vK1g32p7dN96k0712COgaz6VFizAm9b0g6IG6hR6+hc0KCPg=="
},
"node_modules/abab": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz",
@@ -7841,6 +7744,15 @@
"node-int64": "^0.4.0"
}
},
"node_modules/buffer": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz",
"integrity": "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==",
"dependencies": {
"base64-js": "^1.0.2",
"ieee754": "^1.1.4"
}
},
"node_modules/buffer-crc32": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
@@ -10244,6 +10156,14 @@
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/execa": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
@@ -11243,7 +11163,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"dev": true,
"funding": [
{
"type": "github",
@@ -13588,9 +13507,9 @@
}
},
"node_modules/lucide-react": {
"version": "0.323.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.323.0.tgz",
"integrity": "sha512-rTXZFILl2Y4d1SG9p1Mdcf17AcPvPvpc/egFIzUrp7IUy60MUQo3Oi1mu8LGYXUVwuRZYsSMt3csHRW5mAovJg==",
"version": "0.330.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.330.0.tgz",
"integrity": "sha512-CQwY+Fpbt2kxCoVhuN0RCZDCYlbYnqB870Bl/vIQf3ER/cnDDQ6moLmEkguRyruAUGd4j3Lc4mtnJosXnqHheA==",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0"
}
@@ -14987,9 +14906,9 @@
}
},
"node_modules/postcss": {
"version": "8.4.34",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.34.tgz",
"integrity": "sha512-4eLTO36woPSocqZ1zIrFD2K1v6wH7pY1uBh0JIM2KKfrVtGvPFiAku6aNOP0W1Wr9qwnaCsF0Z+CrVnryB2A8Q==",
"version": "8.4.35",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz",
"integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==",
"funding": [
{
"type": "opencollective",
@@ -15119,9 +15038,9 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="
},
"node_modules/posthog-js": {
"version": "1.104.4",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.104.4.tgz",
"integrity": "sha512-eZyNh0mhyfC129udFh5ln1QnUy67cPnRITVFvcOK4hdniM1v+T+cPxAkQK+4CjdHvvLM8hjh6OhiMWfppYqUzA==",
"version": "1.105.7",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.105.7.tgz",
"integrity": "sha512-skpVufQrYllZ4Hi5bdBfe1F9pzeym1rlXUuvKbEYbMhmA+FCz47ZZ0zDX6a72A5hqPW5h7ZBTEJZbwad7jYt1A==",
"dependencies": {
"fflate": "^0.4.8",
"preact": "^10.19.3"
@@ -15913,7 +15832,6 @@
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"dev": true,
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
@@ -16603,7 +16521,6 @@
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"dev": true,
"funding": [
{
"type": "github",
@@ -16872,6 +16789,15 @@
"integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==",
"dev": true
},
"node_modules/sonner": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-1.4.0.tgz",
"integrity": "sha512-nvkTsIuOmi9e5Wz5If8ldasJjZNVfwiXYijBi2dbijvTQnQppvMcXTFNxL/NUFWlI2yJ1JX7TREDsg+gYm9WyA==",
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
},
"node_modules/source-map-js": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
@@ -16969,6 +16895,15 @@
"node": ">= 0.4"
}
},
"node_modules/stream-browserify": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz",
"integrity": "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==",
"dependencies": {
"inherits": "~2.0.4",
"readable-stream": "^3.5.0"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
@@ -16981,7 +16916,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"dev": true,
"dependencies": {
"safe-buffer": "~5.2.0"
}
@@ -17665,9 +17599,9 @@
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
},
"node_modules/tsx": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.7.0.tgz",
"integrity": "sha512-I+t79RYPlEYlHn9a+KzwrvEwhJg35h/1zHsLC2JXvhC2mdynMv6Zxzvhv5EMV6VF5qJlLlkSnMVvdZV3PSIGcg==",
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.7.1.tgz",
"integrity": "sha512-8d6VuibXHtlN5E3zFkgY8u4DX7Y3Z27zvvPKVmLon/D4AjuKzarkUBTLDBgj9iTQ0hg5xM7c/mYiRVM+HETf0g==",
"dev": true,
"dependencies": {
"esbuild": "~0.19.10",
+11 -10
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse-core",
"version": "2.2.0",
"version": "2.4.2",
"private": true,
"scripts": {
"prebuild": "cp generated/openapi-client/openapi.yml public/openapi-client.yml && cp generated/openapi-server/openapi.yml public/openapi-server.yml",
@@ -31,6 +31,7 @@
"dependencies": {
"@anthropic-ai/tokenizer": "^0.0.4",
"@aws-sdk/client-s3": "^3.507.0",
"@aws-sdk/lib-storage": "^3.511.0",
"@aws-sdk/s3-request-presigner": "^3.507.0",
"@headlessui/react": "^1.7.18",
"@heroicons/react": "^2.1.1",
@@ -59,8 +60,8 @@
"@radix-ui/react-tooltip": "^1.0.7",
"@react-email/components": "^0.0.14",
"@react-email/render": "^0.0.12",
"@sentry/nextjs": "^7.99.0",
"@sentry/profiling-node": "^1.3.5",
"@sentry/nextjs": "^7.100.1",
"@sentry/profiling-node": "^7.100.1",
"@sentry/types": "^7.88.0",
"@t3-oss/env-nextjs": "^0.8.0",
"@tailwindcss/forms": "^0.5.7",
@@ -71,7 +72,6 @@
"@trpc/next": "^10.45.0",
"@trpc/react-query": "^10.45.0",
"@trpc/server": "^10.45.0",
"@vercel/edge-config": "^0.4.1",
"bcryptjs": "^2.4.3",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
@@ -83,12 +83,12 @@
"exponential-backoff": "^3.1.1",
"js-tiktoken": "^1.0.10",
"lodash": "^4.17.21",
"lucide-react": "^0.323.0",
"lucide-react": "^0.330.0",
"next": "^14.1.0",
"next-auth": "^4.24.5",
"next-query-params": "^5.0.0",
"nodemailer": "^6.9.9",
"posthog-js": "^1.104.4",
"posthog-js": "^1.105.7",
"posthog-node": "^3.6.2",
"react": "18.2.0",
"react-day-picker": "^8.10.0",
@@ -97,6 +97,7 @@
"react-icons": "^5.0.1",
"react-responsive": "^9.0.2",
"react18-json-view": "^0.2.7",
"sonner": "^1.4.0",
"superjson": "2.2.1",
"tailwind-merge": "^2.2.1",
"tailwindcss-animate": "^1.0.7",
@@ -119,9 +120,9 @@
"@types/node": "20.10.5",
"@types/nodemailer": "^6.4.14",
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.18",
"@types/react-dom": "^18.2.19",
"@types/uuid": "^9.0.8",
"@typescript-eslint/eslint-plugin": "^6.20.0",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"autoprefixer": "^10.4.17",
"dotenv-cli": "^7.3.0",
@@ -129,7 +130,7 @@
"eslint-config-next": "^14.1.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"postcss": "^8.4.34",
"postcss": "^8.4.35",
"prettier": "^3.2.5",
"prettier-plugin-tailwindcss": "^0.5.11",
"prisma": "^5.9.1",
@@ -138,7 +139,7 @@
"tailwindcss": "^3.4.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.7.0",
"tsx": "^4.7.1",
"typescript": "^5.3.3"
},
"ct3aMetadata": {
@@ -0,0 +1,28 @@
-- CreateTable
CREATE TABLE "audit_logs" (
"id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"user_id" TEXT NOT NULL,
"project_id" TEXT NOT NULL,
"user_project_role" "MembershipRole" NOT NULL,
"resource_type" TEXT NOT NULL,
"resource_id" TEXT NOT NULL,
"action" TEXT NOT NULL,
"before" TEXT,
"after" TEXT,
CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "audit_logs_project_id_idx" ON "audit_logs"("project_id");
-- CreateIndex
CREATE INDEX "audit_logs_created_at_idx" ON "audit_logs"("created_at");
-- AddForeignKey
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,25 @@
-- This is an empty migration.
DELETE FROM models
WHERE id in ('clruwnahl00040al78f1lb0at');
INSERT INTO models (
id,
project_id,
model_name,
match_pattern,
start_date,
input_price,
output_price,
total_price,
unit,
tokenizer_id,
tokenizer_config
)
VALUES
-- according to email, gpt-3.5-turbo and gpt-3.5-turbo-16k will point to 0125 models as of 2024-02-16
-- gpt-3.5-turbo-0125 now supports 16k token length. 16k model will point to regular 3.5 turbo model according to mail.
('clruwnahl00040al78f1lb0at', NULL, 'gpt-3.5-turbo', '(?i)^(gpt-)(35|3.5)(-turbo)$', '2024-02-16', 0.0000005, 0.0000015, NULL, 'TOKENS', 'openai', '{ "tokensPerMessage": 3, "tokensPerName": 1, "tokenizerModel": "gpt-3.5-turbo" }'),
('clsk9lntu000008jwfc51bbqv', NULL, 'gpt-3.5-turbo-16k', '(?i)^(gpt-)(35|3.5)(-turbo-16k)$', '2024-02-16', 0.0000005, 0.0000015, NULL, 'TOKENS', 'openai', '{ "tokensPerMessage": 3, "tokensPerName": 1, "tokenizerModel": "gpt-3.5-turbo-16k" }')
+22
View File
@@ -72,6 +72,7 @@ model User {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
featureFlags String[] @default([]) @map("feature_flags")
AuditLog AuditLog[]
@@map("users")
}
@@ -101,6 +102,7 @@ model Project {
sessions TraceSession[]
Prompt Prompt[]
Model Model[]
AuditLog AuditLog[]
@@map("projects")
}
@@ -492,3 +494,23 @@ view ObservationView {
@@map("observations_view")
}
model AuditLog {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
userId String @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
projectId String @map("project_id")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
userProjectRole MembershipRole @map("user_project_role")
resourceType String @map("resource_type")
resourceId String @map("resource_id")
action String
before String? //stringified JSON
after String? // stringified JSON
@@index([projectId])
@@index([createdAt])
@@map("audit_logs")
}
@@ -0,0 +1,194 @@
/** @jest-environment node */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { pruneDatabase } from "@/src/__tests__/test-utils";
import { ModelUsageUnit } from "@/src/constants";
import { appRouter } from "@/src/server/api/root";
import { createInnerTRPCContext } from "@/src/server/api/trpc";
import { prisma } from "@/src/server/db";
import type { Session } from "next-auth";
describe("observations.export RPC", () => {
const numberOfGenerations = 5;
const projectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
beforeAll(async () => {
// Disable S3 upload
process.env.S3_ENDPOINT = "";
await pruneDatabase();
const traceId = "trace-1";
await prisma.trace.create({
data: {
id: traceId,
name: "trace-name",
userId: "user-1",
projectId,
metadata: { key: "value" },
release: "1.0.0",
version: "2.0.0",
},
});
for (let i = 1; i <= numberOfGenerations; i++) {
await prisma.observation.create({
data: {
type: "GENERATION",
id: `generation-${i}`,
name: `generation-${i}`,
model: "gpt-3.5-turbo",
totalCost: 1,
startTime: new Date("2021-01-01T00:00:00.000Z"),
endTime: new Date("2021-01-01T00:00:05.000Z"),
project: { connect: { id: projectId } },
traceId,
input: [
{
role: "system",
content: "Be a helpful assistant",
},
{
role: "user",
content: "How can i create a React component?",
},
],
output: {
completion: `Creating a React component can be done in two ways.`,
},
metadata: {
user: `user-@langfuse.com`,
},
unit: ModelUsageUnit.Tokens,
},
});
}
});
afterAll(async () => await pruneDatabase());
const session: Session = {
expires: "1",
user: {
id: "clgb17vnp000008jjere5g15i",
name: "John Doe",
projects: [
{
id: projectId,
role: "ADMIN",
name: "test",
},
],
featureFlags: {
templateFlag: true,
},
admin: true,
},
};
const ctx = createInnerTRPCContext({ session });
const caller = appRouter.createCaller({ ...ctx, prisma });
it("should return a CSV file", async () => {
const result = await caller.generations.export({
fileFormat: "CSV",
orderBy: { column: "id", order: "ASC" },
filter: [
{
column: "start_time",
type: "datetime",
operator: ">",
value: new Date("1990-01-01"),
},
],
projectId,
searchQuery: null,
});
if (result.type !== "data")
throw new Error("No data returned. Is S3 accidentally enabled?");
const { data, fileName } = result;
const fileExtension = fileName.split(".").pop();
expect(fileName).toContain(`lf-export-${projectId}`);
expect(fileExtension).toBe("csv");
expect(data.split("\n").filter(Boolean).length).toBe(
numberOfGenerations + 1,
);
});
it("should return a JSON file", async () => {
const result = await caller.generations.export({
fileFormat: "JSON",
orderBy: { column: "id", order: "ASC" },
filter: [
{
column: "start_time",
type: "datetime",
operator: ">",
value: new Date("1990-01-01"),
},
],
projectId,
searchQuery: null,
});
if (result.type !== "data")
throw new Error("No data returned. Is S3 accidentally enabled?");
const { data, fileName } = result;
const fileExtension = fileName.split(".").pop();
expect(fileName).toContain(`lf-export-${projectId}`);
expect(fileExtension).toBe("json");
expect(JSON.parse(data).length).toBe(numberOfGenerations);
});
it("should return a OPENAI-JSONL file", async () => {
const result = await caller.generations.export({
fileFormat: "OPENAI-JSONL",
orderBy: { column: "id", order: "ASC" },
filter: [
{
column: "start_time",
type: "datetime",
operator: ">",
value: new Date("1990-01-01"),
},
],
projectId,
searchQuery: null,
});
if (result.type !== "data")
throw new Error("No data returned. Is S3 accidentally enabled?");
const { data, fileName } = result;
const fileExtension = fileName.split(".").pop();
expect(fileName).toContain(`lf-export-${projectId}`);
expect(fileExtension).toBe("jsonl");
expect(data.split("\n").filter(Boolean).length).toBe(numberOfGenerations);
});
it("should throw on unsupported file formats", async () => {
const unsupportedFileFormat = "XLSX";
const call = caller.generations.export({
fileFormat: unsupportedFileFormat as unknown as "JSON",
orderBy: { column: "id", order: "ASC" },
filter: [
{
column: "start_time",
type: "datetime",
operator: ">",
value: new Date("1990-01-01"),
},
],
projectId,
searchQuery: null,
});
await expect(call).rejects.toThrow();
});
});
+357 -6
View File
@@ -795,10 +795,10 @@ describe("/api/public/ingestion API Endpoint", () => {
expect("errors" in responseOne.body).toBe(true);
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
expect(responseOne.body?.errors.length).toBe(1);
expect(responseOne.body.errors.length).toBe(1);
expect("successes" in responseOne.body).toBe(true);
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
expect(responseOne.body?.successes.length).toBe(1);
expect(responseOne.body.successes.length).toBe(1);
const dbTrace = await prisma.trace.findMany({
where: {
@@ -847,10 +847,10 @@ describe("/api/public/ingestion API Endpoint", () => {
expect("errors" in responseOne.body).toBe(true);
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
expect(responseOne.body?.errors.length).toBe(1);
expect(responseOne.body.errors.length).toBe(1);
expect("successes" in responseOne.body).toBe(true);
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
expect(responseOne.body?.successes.length).toBe(1);
expect(responseOne.body.successes.length).toBe(1);
const dbTrace = await prisma.trace.findMany({
where: {
@@ -897,10 +897,10 @@ describe("/api/public/ingestion API Endpoint", () => {
expect("errors" in responseOne.body).toBe(true);
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
expect(responseOne.body?.errors.length).toBe(1);
expect(responseOne.body.errors.length).toBe(1);
expect("successes" in responseOne.body).toBe(true);
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
expect(responseOne.body?.successes.length).toBe(1);
expect(responseOne.body.successes.length).toBe(1);
const dbTrace = await prisma.trace.findMany({
where: {
@@ -1414,4 +1414,355 @@ IB Home / . . . / News / News about the IB / Why ChatGPT is an o
expect(cleanedEvent).toStrictEqual(expected);
});
});
it("should allow score ingestion via Basic auth", async () => {
const projectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
const traceId = "trace_id";
const scoreId = "score_id";
const scoreEventId = "score_event_id";
const scoreName = "score-name";
const scoreValue = 100.5;
// Seed db with a trace to be scored
await prisma.trace.create({
data: {
id: traceId,
name: "trace-name",
project: { connect: { id: projectId } },
},
});
const response = await makeAPICall("POST", "/api/public/ingestion", {
batch: [
{
id: scoreEventId,
type: "score-create",
timestamp: new Date().toISOString(),
body: {
id: scoreId,
name: scoreName,
value: scoreValue,
traceId: traceId,
},
},
],
});
expect(response.status).toBe(207);
expect(response.body.successes.length).toBe(1);
expect(response.body.successes[0]?.id).toBe(scoreEventId);
expect(response.body.errors.length).toBe(0);
const dbScore = await prisma.score.findUnique({
where: {
id: scoreId,
},
});
expect(dbScore?.id).toBe(scoreId);
expect(dbScore?.traceId).toBe(traceId);
expect(dbScore?.name).toBe(scoreName);
expect(dbScore?.value).toBe(scoreValue);
});
it("should allow score ingestion via Bearer auth", async () => {
const projectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
const traceId = "trace_id";
const bearerAuth = "Bearer pk-lf-1234567890";
const scoreId = "score_id";
const scoreEventId = "score_event_id";
const scoreName = "score-name";
const scoreValue = 100.5;
// Seed db with a trace to be scored
await prisma.trace.create({
data: {
id: traceId,
name: "trace-name",
project: { connect: { id: projectId } },
},
});
const response = await makeAPICall(
"POST",
"/api/public/ingestion",
{
batch: [
{
id: scoreEventId,
type: "score-create",
timestamp: new Date().toISOString(),
body: {
id: scoreId,
name: scoreName,
value: scoreValue,
traceId: traceId,
},
},
],
},
bearerAuth,
);
expect(response.status).toBe(207);
expect(response.body.successes.length).toEqual(1);
expect(response.body.successes[0]?.id).toBe(scoreEventId);
expect(response.body.errors.length).toBe(0);
const dbScore = await prisma.score.findUnique({
where: {
id: scoreId,
},
});
expect(dbScore?.id).toBe(scoreId);
expect(dbScore?.traceId).toBe(traceId);
expect(dbScore?.name).toBe(scoreName);
expect(dbScore?.value).toBe(scoreValue);
});
it("should throw an Auth error on Bearer Auth for all events that are NOT 'score-create'", async () => {
const projectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
const traceId = "trace_id";
const bearerAuth = "Bearer pk-lf-1234567890";
const scoreId = "score_id";
const scoreEventId = "score_event_id";
const scoreName = "score-name";
const scoreValue = 100.5;
const generationId = v4();
const spanId = v4();
const anotherTraceId = "another_trace_id";
// Seed db with a trace to be scored
await prisma.trace.create({
data: {
id: traceId,
name: "trace-name",
project: { connect: { id: projectId } },
},
});
const response = await makeAPICall(
"POST",
"/api/public/ingestion",
{
metadata: {
sdk_verion: "1.0.0",
sdk_name: "python",
},
batch: [
{
id: v4(),
type: "trace-create",
timestamp: new Date().toISOString(),
body: {
id: anotherTraceId,
name: "trace-name",
userId: "user-1",
metadata: { key: "value" },
release: "1.0.0",
version: "2.0.0",
tags: ["tag-1", "tag-2"],
},
},
{
id: v4(),
type: "observation-create",
timestamp: new Date().toISOString(),
body: {
id: generationId,
traceId: traceId,
type: "GENERATION",
name: "generation-name",
startTime: "2021-01-01T00:00:00.000Z",
endTime: "2021-01-01T00:00:00.000Z",
modelParameters: { key: "value" },
input: { key: "value" },
metadata: { key: "value" },
version: "2.0.0",
},
},
{
id: v4(),
type: "observation-update",
timestamp: new Date().toISOString(),
body: {
id: generationId,
type: "GENERATION",
output: { key: "this is a great gpt output" },
},
},
{
id: v4(),
type: "observation-create",
timestamp: new Date().toISOString(),
body: {
id: spanId,
traceId: traceId,
type: "SPAN",
name: "span-name",
startTime: "2021-01-01T00:00:00.000Z",
endTime: "2021-01-01T00:00:00.000Z",
input: { input: "value" },
metadata: { meta: "value" },
version: "2.0.0",
},
},
{
id: scoreEventId,
type: "score-create",
timestamp: new Date().toISOString(),
body: {
id: scoreId,
name: "score-name",
value: 100.5,
traceId: traceId,
},
},
],
},
bearerAuth,
);
expect(response.status).toBe(207);
expect(response.body.successes.length).toEqual(1);
expect(response.body.successes[0]?.id).toEqual(scoreEventId);
expect(response.body.errors.length).toEqual(4);
const dbScore = await prisma.score.findUnique({
where: {
id: scoreId,
},
});
expect(await prisma.trace.count()).toBe(1);
expect(await prisma.trace.count({ where: { id: traceId } })).toBe(1);
expect(await prisma.observation.count()).toBe(0);
expect(dbScore?.id).toBe(scoreId);
expect(dbScore?.traceId).toBe(traceId);
expect(dbScore?.name).toBe(scoreName);
expect(dbScore?.value).toBe(scoreValue);
});
it("should error on Bearer Auth for a trace from different project", async () => {
const otherProjectId = "other_project_id";
const traceId = "trace_id";
const bearerAuth = "Bearer pk-lf-1234567890";
const scoreId = "score_id";
const scoreEventId = "score_event_id";
const scoreName = "score-name";
const scoreValue = 100.5;
// Seed db with a trace to be scored
try {
await prisma.project.create({
data: {
id: otherProjectId,
name: "another-project",
},
});
await prisma.trace.create({
data: {
id: traceId,
name: "trace-name",
project: { connect: { id: otherProjectId } },
},
});
const response = await makeAPICall(
"POST",
"/api/public/ingestion",
{
batch: [
{
id: scoreEventId,
type: "score-create",
timestamp: new Date().toISOString(),
body: {
id: scoreId,
name: scoreName,
value: scoreValue,
traceId: traceId,
},
},
],
},
bearerAuth,
);
expect(response.status).toBe(207);
expect(response.body.successes.length).toBe(0);
expect(response.body).toHaveProperty("errors");
expect(response.body.errors.length).toEqual(1);
expect(response.body.errors[0]?.id).toEqual(scoreEventId);
expect(await prisma.trace.count()).toBe(1);
expect(await prisma.trace.count({ where: { id: traceId } })).toBe(1);
const dbScore = await prisma.score.findUnique({
where: {
id: scoreId,
},
});
expect(dbScore).toBeNull();
} finally {
await prisma.project.delete({ where: { id: otherProjectId } });
}
});
it("should error on Bearer Auth with a trace that does not exist", async () => {
const traceId = "trace_id";
const bearerAuth = "Bearer pk-lf-1234567890";
const scoreId = "score_id";
const scoreEventId = "score_event_id";
const scoreName = "score-name";
const scoreValue = 100.5;
const response = await makeAPICall(
"POST",
"/api/public/ingestion",
{
batch: [
{
id: scoreEventId,
type: "score-create",
timestamp: new Date().toISOString(),
body: {
id: scoreId,
name: scoreName,
value: scoreValue,
traceId: traceId,
},
},
],
},
bearerAuth,
);
expect(response.status).toBe(207);
expect(response.body.successes.length).toBe(0);
expect(response.body.errors.length).toBe(1);
expect(response.body.errors[0]?.id).toBe(scoreEventId);
expect(await prisma.trace.count()).toBe(0);
const dbScore = await prisma.score.findUnique({
where: {
id: scoreId,
},
});
expect(dbScore).toBeNull();
});
});
+5 -1
View File
@@ -92,5 +92,9 @@ describe("model match", () => {
expect(observation.promptTokens).toBeGreaterThan(0);
expect(observation.completionTokens).toBeGreaterThan(0);
});
});
// temporary fix: wait for 5 additional seconds to ensure that the model match is complete
// had issue with the test failing because the model match was not complete and logged to console
await new Promise((resolve) => setTimeout(resolve, 5000));
}, 10000);
});
+22 -5
View File
@@ -27,21 +27,38 @@ export function createBasicAuthHeader(
return `Basic ${base64Credentials}`;
}
export type IngestionAPIResponse = {
errors: ErrorIngestion[];
successes: SuccessfulIngestion[];
};
export type SuccessfulIngestion = {
id: string;
status: number;
};
export type ErrorIngestion = {
id: string;
status: number;
message: string;
error: string;
};
export async function makeAPICall(
method: "POST" | "GET" | "PUT" | "DELETE" | "PATCH",
url: string,
body?: unknown,
auth?: string,
) {
const finalUrl = `http://localhost:3000/${url}`;
const authorization =
auth || createBasicAuthHeader("pk-lf-1234567890", "sk-lf-1234567890");
const options = {
method: method,
headers: {
Accept: "application/json",
"Content-Type": "application/json;charset=UTF-8",
Authorization: createBasicAuthHeader(
"pk-lf-1234567890",
"sk-lf-1234567890",
),
Authorization: authorization,
},
// Conditionally include the body property if the method is not "GET"
...(method !== "GET" &&
@@ -50,7 +67,7 @@ export async function makeAPICall(
const a = await fetch(finalUrl, options);
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
return { body: await a.json(), status: a.status };
return { body: (await a.json()) as IngestionAPIResponse, status: a.status };
}
export const setupUserAndProject = async () => {
+2 -5
View File
@@ -30,12 +30,9 @@ export function DeleteTrace({
const mutDeleteTraces = api.traces.deleteMany.useMutation({
onSuccess: () => {
setIsDeleted(true);
void utils.traces.invalidate();
if (!isTableAction) {
void router
.push(`/project/${projectId}/traces`)
.then(() => utils.traces.invalidate());
} else {
void utils.traces.invalidate();
void router.push(`/project/${projectId}/traces`);
}
},
});
+1 -1
View File
@@ -40,7 +40,7 @@ export default function Header({
[...props.breadcrumb.map((i) => i.href).filter(Boolean)].pop();
return (
<div className={cn(level === "h2" ? "mb-8" : "mb-1")}>
<div className={cn(level === "h2" ? "mb-4" : "mb-1")}>
<div>
{backHref ? (
<nav className="sm:hidden" aria-label="Back">
+28 -32
View File
@@ -8,7 +8,6 @@ import { useRouter } from "next/router";
import clsx from "clsx";
import { Code, MessageSquarePlus, Info, ChevronRightIcon } from "lucide-react";
import { signOut, useSession } from "next-auth/react";
import { ChevronDownIcon } from "@heroicons/react/20/solid";
import { cn } from "@/src/utils/tailwind";
import {
Avatar,
@@ -24,6 +23,13 @@ import { env } from "@/src/env.mjs";
import { LangfuseLogo } from "@/src/components/LangfuseLogo";
import { Spinner } from "@/src/components/layouts/spinner";
import { hasAccess } from "@/src/features/rbac/utils/checkAccess";
import { Toaster } from "@/src/components/ui/sonner";
import {
NOTIFICATIONS,
useCheckNotification,
} from "@/src/features/notifications/checkNotifications";
import { ChevronDownIcon } from "@heroicons/react/20/solid";
import useLocalStorage from "@/src/components/useLocalStorage";
const userNavigation = [
{
@@ -46,6 +52,9 @@ export default function Layout(props: PropsWithChildren) {
const [sidebarOpen, setSidebarOpen] = useState(false);
const router = useRouter();
const session = useSession();
useCheckNotification(NOTIFICATIONS, session.status === "authenticated");
const enableExperimentalFeatures =
api.environment.enableExperimentalFeatures.useQuery().data ?? false;
@@ -483,26 +492,27 @@ export default function Layout(props: PropsWithChildren) {
<Info className="h-4 w-4" />
<span className="font-semibold">DEMO (view-only)</span>
</div>
<div>Live data from the Langfuse Q&A Chatbot.</div>
<div>Use demo RAG chat to see live data in this project.</div>
</div>
<Button size="sm" asChild className="ml-2">
<Link
href={
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "EU"
? "https://langfuse.com/docs/qa-chatbot"
: "https://docs-staging.langfuse.com/docs/qa-chatbot"
? "https://langfuse.com/docs/demo"
: "https://docs-staging.langfuse.com/docs/demo"
}
target="_blank"
>
{env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "EU"
? "Q&A Chatbot ↗"
: "Q&A Chatbot (staging) ↗"}
? "Use Chat ↗"
: "Use Chat (staging) ↗"}
</Link>
</Button>
</div>
) : null}
<main className="p-4">{props.children}</main>
<Toaster visibleToasts={1} />
</div>
</div>
</>
@@ -522,29 +532,11 @@ const MainNavigation: React.FC<{
nav: NavigationItem[];
onNavitemClick?: () => void;
}> = ({ nav, onNavitemClick }) => {
const STORAGE_KEY = "sidebar-tracing-default-open";
const getDefaultOpen = () => {
const savedState = localStorage.getItem(STORAGE_KEY);
if (savedState !== null) {
try {
return JSON.parse(savedState) as boolean;
} catch (e) {
console.error("Error parsing saved state: ", e);
}
}
return false;
};
const [isOpen, setIsOpen] = useLocalStorage(
"sidebar-tracing-default-open",
false,
);
const handleDropDownClick = () => {
const savedState = localStorage.getItem(STORAGE_KEY);
const isOpen =
savedState !== null ? (JSON.parse(savedState) as boolean) : false;
const newState = !isOpen;
localStorage.setItem(
"sidebar-tracing-default-open",
JSON.stringify(newState),
);
};
return (
<li>
<ul role="list" className="-mx-2 space-y-1">
@@ -590,15 +582,14 @@ const MainNavigation: React.FC<{
<Disclosure
as="div"
defaultOpen={
item.children.some((child) => child.current) ||
getDefaultOpen()
item.children.some((child) => child.current) || isOpen
}
>
{({ open }) => (
<>
<Disclosure.Button
className="group flex w-full items-center gap-x-3 rounded-md p-2 text-left text-sm font-semibold leading-6 hover:bg-gray-50 hover:text-indigo-600"
onClick={handleDropDownClick}
onClick={() => setIsOpen(!isOpen)}
>
{item.icon && (
<item.icon
@@ -656,7 +647,12 @@ const MainNavigation: React.FC<{
) : null}
</li>
))}
<FeedbackButtonWrapper className="w-full">
<FeedbackButtonWrapper
className="w-full"
title="Provide feedback"
description="What do you think about this project? What can be improved?"
type="feedback"
>
<li className="group flex cursor-pointer gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:bg-gray-50 hover:text-indigo-600">
<MessageSquarePlus
className="h-6 w-6 shrink-0 text-gray-400 group-hover:text-indigo-600"
+30 -9
View File
@@ -31,10 +31,10 @@ const modelConfigDescriptions = {
"Regex pattern to match `model` parameter of generations to model pricing",
startDate:
"Date to start pricing model. If not set, model is active unless a more recent version exists.",
inputPrice: "Price per unit of input",
outputPrice: "Price per unit of output",
inputPrice: "Price per 1000 units of input",
outputPrice: "Price per 1000 units of output",
totalPrice:
"Price per unit, for models that don't have input/output specific prices",
"Price per 1000 units, for models that don't have input/output specific prices",
unit: "Unit of measurement for generative model, can be TOKENS, CHARACTERS, SECONDS, MILLISECONDS, or IMAGES.",
tokenizerId:
"Tokenizer used for this model to calculate token counts if none are ingested. Pick from list of supported tokenizers.",
@@ -107,7 +107,14 @@ export default function ModelTable({ projectId }: { projectId: string }) {
{
accessorKey: "inputPrice",
id: "inputPrice",
header: "Input Price",
header: () => {
return (
<>
Input Price{" "}
<span className="text-xs text-gray-400">/ 1k units</span>
</>
);
},
headerTooltip: {
description: modelConfigDescriptions.inputPrice,
},
@@ -116,7 +123,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
return value ? (
<span className="text-xs">
{usdFormatter(value.toNumber(), 2, 8)}
{usdFormatter(value.toNumber() * 1000, 2, 8)}
</span>
) : (
<span className="text-xs">-</span>
@@ -129,13 +136,20 @@ export default function ModelTable({ projectId }: { projectId: string }) {
headerTooltip: {
description: modelConfigDescriptions.outputPrice,
},
header: "Output Price",
header: () => {
return (
<>
Output Price{" "}
<span className="text-xs text-gray-400">/ 1k units</span>
</>
);
},
cell: ({ row }) => {
const value: Decimal | undefined = row.getValue("outputPrice");
return value ? (
<span className="text-xs">
{usdFormatter(value.toNumber(), 2, 8)}
{usdFormatter(value.toNumber() * 1000, 2, 8)}
</span>
) : (
<span className="text-xs">-</span>
@@ -145,7 +159,14 @@ export default function ModelTable({ projectId }: { projectId: string }) {
{
accessorKey: "totalPrice",
id: "totalPrice",
header: "Total Price",
header: () => {
return (
<>
Total Price{" "}
<span className="text-xs text-gray-400">/ 1k units</span>
</>
);
},
headerTooltip: {
description: modelConfigDescriptions.totalPrice,
},
@@ -154,7 +175,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
return value ? (
<span className="text-xs">
{usdFormatter(value.toNumber(), 2, 8)}
{usdFormatter(value.toNumber() * 1000, 2, 8)}
</span>
) : (
<span className="text-xs">-</span>
+167 -86
View File
@@ -1,14 +1,21 @@
import { type NestedObservation } from "@/src/utils/types";
import { cn } from "@/src/utils/tailwind";
import { type Trace, type Score } from "@prisma/client";
import { type Trace, type Score, $Enums } from "@prisma/client";
import { GroupedScoreBadges } from "@/src/components/grouped-score-badge";
import { Fragment } from "react";
import { type ObservationReturnType } from "@/src/server/api/routers/traces";
import { LevelColors } from "@/src/components/level-colors";
import { formatInterval } from "@/src/utils/dates";
import { MinusCircle, MinusIcon, PlusCircleIcon, PlusIcon } from "lucide-react";
import { Toggle } from "@/src/components/ui/toggle";
import { Button } from "@/src/components/ui/button";
export const ObservationTree = (props: {
observations: ObservationReturnType[];
collapsedObservations: string[];
toggleCollapsedObservation: (id: string) => void;
collapseAll: () => void;
expandAll: () => void;
trace: Trace;
scores: Score[];
currentObservationId: string | undefined;
@@ -21,6 +28,8 @@ export const ObservationTree = (props: {
return (
<div className={props.className}>
<ObservationTreeTraceNode
expandAll={props.expandAll}
collapseAll={props.collapseAll}
trace={props.trace}
scores={props.scores}
currentObservationId={props.currentObservationId}
@@ -30,6 +39,8 @@ export const ObservationTree = (props: {
/>
<ObservationTreeNode
observations={nestedObservations}
collapsedObservations={props.collapsedObservations}
toggleCollapsedObservation={props.toggleCollapsedObservation}
scores={props.scores}
indentationLevel={1}
currentObservationId={props.currentObservationId}
@@ -40,8 +51,11 @@ export const ObservationTree = (props: {
</div>
);
};
const ObservationTreeTraceNode = (props: {
trace: Trace & { latency?: number };
expandAll: () => void;
collapseAll: () => void;
scores: Score[];
currentObservationId: string | undefined;
setCurrentObservationId: (id: string | undefined) => void;
@@ -50,7 +64,7 @@ const ObservationTreeTraceNode = (props: {
}) => (
<div
className={cn(
"group mb-0.5 flex cursor-pointer flex-col gap-1 rounded-sm p-1.5",
"group mb-0.5 flex cursor-pointer flex-col gap-1 rounded-sm p-1",
props.currentObservationId === undefined ||
props.currentObservationId === ""
? "bg-gray-100"
@@ -60,7 +74,23 @@ const ObservationTreeTraceNode = (props: {
>
<div className="flex gap-2">
<span className={cn("rounded-sm bg-gray-200 p-1 text-xs")}>TRACE</span>
<span className="text-sm">{props.trace.name}</span>
<span className="flex-1 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>
</div>
{props.showMetrics && props.trace.latency ? (
@@ -79,8 +109,11 @@ const ObservationTreeTraceNode = (props: {
) : null}
</div>
);
const ObservationTreeNode = (props: {
observations: NestedObservation[];
collapsedObservations: string[];
toggleCollapsedObservation: (id: string) => void;
scores: Score[];
indentationLevel: number;
currentObservationId: string | undefined;
@@ -91,96 +124,144 @@ const ObservationTreeNode = (props: {
<>
{props.observations
.sort((a, b) => a.startTime.getTime() - b.startTime.getTime())
.map((observation) => (
<Fragment key={observation.id}>
<div className="flex">
{Array.from({ length: props.indentationLevel }, (_, i) => (
<div className="mx-2 border-r" key={i} />
))}
<div
className={cn(
"group my-0.5 flex flex-1 cursor-pointer flex-col gap-1 rounded-sm p-1.5",
props.currentObservationId === observation.id
? "bg-gray-100"
: "hover:bg-gray-50",
)}
onClick={() => props.setCurrentObservationId(observation.id)}
>
<div className="flex gap-2">
<span
className={cn(
"self-start rounded-sm bg-gray-200 p-1 text-xs",
)}
>
{observation.type}
</span>
<span className="line-clamp-1 text-sm">{observation.name}</span>
</div>
{props.showMetrics &&
(observation.promptTokens ||
observation.completionTokens ||
observation.totalTokens ||
observation.endTime) && (
<div className="flex gap-2">
{observation.endTime ? (
<span className="text-xs text-gray-500">
{formatInterval(
(observation.endTime.getTime() -
observation.startTime.getTime()) /
1000,
)}
</span>
) : null}
{observation.promptTokens ||
observation.completionTokens ||
observation.totalTokens ? (
<span className="text-xs text-gray-500">
{observation.promptTokens} {" "}
{observation.completionTokens} ({" "}
{observation.totalTokens})
</span>
) : null}
</div>
.map((observation) => {
const collapsed = props.collapsedObservations.includes(observation.id);
return (
<Fragment key={observation.id}>
<div className="flex">
{Array.from({ length: props.indentationLevel }, (_, i) => (
<div className="mx-2 border-r" key={i} />
))}
<div
className={cn(
"group my-0.5 flex flex-1 cursor-pointer flex-col gap-1 rounded-sm p-1",
props.currentObservationId === observation.id
? "bg-gray-100"
: "hover:bg-gray-50",
)}
{observation.level !== "DEFAULT" ? (
<div className="flex">
<span
className={cn(
"rounded-sm p-0.5 text-xs",
LevelColors[observation.level].bg,
LevelColors[observation.level].text,
)}
>
{observation.level}
</span>
</div>
) : null}
{props.showScores &&
props.scores.find((s) => s.observationId === observation.id) ? (
<div className="flex flex-wrap gap-1">
<GroupedScoreBadges
scores={props.scores.filter(
(s) => s.observationId === observation.id,
)}
onClick={() => props.setCurrentObservationId(observation.id)}
>
<div className="flex gap-2">
<ColorCodedObservationType
observationType={observation.type}
/>
<span className="line-clamp-1 flex-1 text-sm">
{observation.name}
</span>
{observation.children.length === 0 ? null : (
<Toggle
onClick={(ev) => (
ev.stopPropagation(),
props.toggleCollapsedObservation(observation.id)
)}
variant="default"
pressed={collapsed}
size="xs"
className="w-7"
title={
collapsed ? "Expand children" : "Collapse children"
}
>
{collapsed ? (
<PlusIcon className="h-4 w-4" />
) : (
<MinusIcon className="h-4 w-4" />
)}
</Toggle>
)}
</div>
) : null}
{props.showMetrics &&
(observation.promptTokens ||
observation.completionTokens ||
observation.totalTokens ||
observation.endTime) && (
<div className="flex gap-2">
{observation.endTime ? (
<span className="text-xs text-gray-500">
{formatInterval(
(observation.endTime.getTime() -
observation.startTime.getTime()) /
1000,
)}
</span>
) : null}
{observation.promptTokens ||
observation.completionTokens ||
observation.totalTokens ? (
<span className="text-xs text-gray-500">
{observation.promptTokens} {" "}
{observation.completionTokens} ({" "}
{observation.totalTokens})
</span>
) : null}
</div>
)}
{observation.level !== "DEFAULT" ? (
<div className="flex">
<span
className={cn(
"rounded-sm p-0.5 text-xs",
LevelColors[observation.level].bg,
LevelColors[observation.level].text,
)}
>
{observation.level}
</span>
</div>
) : null}
{props.showScores &&
props.scores.find((s) => s.observationId === observation.id) ? (
<div className="flex flex-wrap gap-1">
<GroupedScoreBadges
scores={props.scores.filter(
(s) => s.observationId === observation.id,
)}
/>
</div>
) : null}
</div>
</div>
</div>
<ObservationTreeNode
observations={observation.children}
scores={props.scores}
indentationLevel={props.indentationLevel + 1}
currentObservationId={props.currentObservationId}
setCurrentObservationId={props.setCurrentObservationId}
showMetrics={props.showMetrics}
showScores={props.showScores}
/>
</Fragment>
))}
{!collapsed && (
<ObservationTreeNode
observations={observation.children}
collapsedObservations={props.collapsedObservations}
toggleCollapsedObservation={props.toggleCollapsedObservation}
scores={props.scores}
indentationLevel={props.indentationLevel + 1}
currentObservationId={props.currentObservationId}
setCurrentObservationId={props.setCurrentObservationId}
showMetrics={props.showMetrics}
showScores={props.showScores}
/>
)}
</Fragment>
);
})}
</>
);
const ColorCodedObservationType = (props: {
observationType: $Enums.ObservationType;
}) => {
const colors: Record<$Enums.ObservationType, string> = {
[$Enums.ObservationType.SPAN]: "bg-blue-100",
[$Enums.ObservationType.GENERATION]: "bg-orange-100",
[$Enums.ObservationType.EVENT]: "bg-green-100",
};
return (
<span
className={cn(
"self-start rounded-sm p-1 text-xs",
colors[props.observationType],
)}
>
{props.observationType}
</span>
);
};
export function nestObservations(
list: ObservationReturnType[],
): NestedObservation[] {
+56 -3
View File
@@ -23,6 +23,7 @@ import { Award, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
import { ScrollArea } from "@/src/components/ui/scroll-area";
import { usdFormatter } from "@/src/utils/numbers";
import Decimal from "decimal.js";
import { useCallback, useState } from "react";
export function Trace(props: {
observations: Array<ObservationReturnType>;
@@ -41,9 +42,57 @@ export function Trace(props: {
true,
);
const [collapsedObservations, setCollapsedObservations] = useState<string[]>(
[],
);
const toggleCollapsedObservation = useCallback(
(id: string) => {
if (collapsedObservations.includes(id)) {
setCollapsedObservations(collapsedObservations.filter((i) => i !== id));
} else {
setCollapsedObservations([...collapsedObservations, id]);
}
},
[collapsedObservations],
);
const collapseAll = useCallback(() => {
// exclude all parents of the current observation
let excludeParentObservations = new Set<string>();
let newExcludeParentObservations = new Set<string>();
do {
excludeParentObservations = new Set<string>([
...excludeParentObservations,
...newExcludeParentObservations,
]);
newExcludeParentObservations = new Set<string>(
props.observations
.filter(
(o) =>
o.parentObservationId !== null &&
(o.id === currentObservationId ||
excludeParentObservations.has(o.id)),
)
.map((o) => o.parentObservationId as string)
.filter((id) => !excludeParentObservations.has(id)),
);
} while (newExcludeParentObservations.size > 0);
setCollapsedObservations(
props.observations
.map((o) => o.id)
.filter((id) => !excludeParentObservations.has(id)),
);
}, [props.observations, currentObservationId]);
const expandAll = useCallback(() => {
setCollapsedObservations([]);
}, [setCollapsedObservations]);
return (
<div className="grid gap-4 md:h-full md:grid-cols-3">
<ScrollArea className="md:col-span-2 md:h-full">
<div className="grid gap-4 md:h-full md:grid-cols-5">
<ScrollArea className="md:col-span-3 md:h-full">
{currentObservationId === undefined ||
currentObservationId === "" ||
currentObservationId === null ? (
@@ -62,7 +111,7 @@ export function Trace(props: {
/>
)}
</ScrollArea>
<div className="md:flex md:h-full md:flex-col md:overflow-hidden">
<div className="md:col-span-2 md:flex md:h-full md:flex-col md:overflow-hidden">
<div className="mb-2 flex flex-shrink-0 flex-row justify-end gap-2">
<Toggle
pressed={scoresOnObservationTree}
@@ -92,6 +141,10 @@ export function Trace(props: {
<ScrollArea className="flex flex-grow">
<ObservationTree
observations={props.observations}
collapsedObservations={collapsedObservations}
toggleCollapsedObservation={toggleCollapsedObservation}
collapseAll={collapseAll}
expandAll={expandAll}
trace={props.trace}
scores={props.scores}
currentObservationId={currentObservationId ?? undefined}
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { Toaster as Sonner } from "sonner";
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
return (
<Sonner
theme={"light"}
className="toaster group"
position="top-right"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
);
};
export { Toaster };
+1
View File
@@ -17,6 +17,7 @@ const toggleVariants = cva(
},
size: {
default: "h-10 px-3",
xs: "h-6 px-1.5",
sm: "h-9 px-2.5",
lg: "h-11 px-5",
},
+3
View File
@@ -20,6 +20,9 @@ import { useState, useEffect } from "react";
*/
function useLocalStorage<T>(localStorageKey: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
if (typeof window === "undefined") {
return initialValue;
}
try {
const storedValue = localStorage.getItem(localStorageKey);
return storedValue ? (JSON.parse(storedValue) as T) : initialValue;
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v2.2.0";
export const VERSION = "v2.4.2";
+10
View File
@@ -26,6 +26,7 @@ export const env = createEnv({
),
NEXTAUTH_COOKIE_DOMAIN: z.string().optional(),
LANGFUSE_TEAM_SLACK_WEBHOOK: z.string().url().optional(),
LANGFUSE_TEAM_BETTERSTACK_TOKEN: z.string().optional(),
LANGFUSE_NEW_USER_SIGNUP_WEBHOOK: z.string().url().optional(),
// Add `.min(1) on ID and SECRET if you want to make sure they're not empty
LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES: z.enum(["true", "false"]).optional(),
@@ -57,6 +58,8 @@ export const env = createEnv({
S3_SECRET_ACCESS_KEY: z.string().optional(),
S3_BUCKET_NAME: z.string().optional(),
S3_REGION: z.string().optional(),
// Database exports
DB_EXPORT_PAGE_SIZE: z.number().optional(),
},
/**
@@ -91,6 +94,8 @@ export const env = createEnv({
LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES:
process.env.LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES,
LANGFUSE_TEAM_SLACK_WEBHOOK: process.env.LANGFUSE_TEAM_SLACK_WEBHOOK,
LANGFUSE_TEAM_BETTERSTACK_TOKEN:
process.env.LANGFUSE_TEAM_BETTERSTACK_TOKEN,
LANGFUSE_NEW_USER_SIGNUP_WEBHOOK:
process.env.LANGFUSE_NEW_USER_SIGNUP_WEBHOOK,
SALT: process.env.SALT,
@@ -117,5 +122,10 @@ export const env = createEnv({
S3_SECRET_ACCESS_KEY: process.env.S3_SECRET_ACCESS_KEY,
S3_BUCKET_NAME: process.env.S3_BUCKET_NAME,
S3_REGION: process.env.S3_REGION,
// Database exports
DB_EXPORT_PAGE_SIZE: process.env.DB_EXPORT_PAGE_SIZE,
},
// Skip validation in Docker builds
// DOCKER_BUILD is set in Dockerfile
skipValidation: process.env.DOCKER_BUILD === "1",
});
+57
View File
@@ -0,0 +1,57 @@
import { prisma as _prisma } from "@/src/server/db";
import { type MembershipRole } from "@prisma/client";
export type AuditableResource =
| "membership"
| "membershipInvitation"
| "datasetItem"
| "dataset"
| "trace"
| "project"
| "observation"
| "score"
| "model"
| "prompt"
| "session"
| "apiKey";
type AuditLog = {
resourceType: AuditableResource;
resourceId: string;
action: string;
before?: unknown;
after?: unknown;
} & (
| {
projectId: string;
userId: string;
userProjectRole: MembershipRole;
}
| {
session: {
user: {
id: string;
};
projectRole: MembershipRole;
projectId: string;
};
}
);
export async function auditLog(log: AuditLog, prisma?: typeof _prisma) {
await (prisma ?? _prisma).auditLog.create({
data: {
projectId: "projectId" in log ? log.projectId : log.session.projectId,
userId: "userId" in log ? log.userId : log.session.user.id,
userProjectRole:
"userProjectRole" in log
? log.userProjectRole
: log.session.projectRole,
resourceType: log.resourceType,
resourceId: log.resourceId,
action: log.action,
before: log.before ? JSON.stringify(log.before) : undefined,
after: log.after ? JSON.stringify(log.after) : undefined,
},
});
}
@@ -0,0 +1,28 @@
import { env } from "@/src/env.mjs";
export const sendToBetterstack = async (message: unknown) => {
if (!env.LANGFUSE_TEAM_BETTERSTACK_TOKEN)
throw new Error("LANGFUSE_TEAM_BETTERSTACK_TOKEN is not set");
const url = "https://in.logs.betterstack.com";
const headers = new Headers({
"Content-Type": "application/json",
Authorization: `Bearer ${env.LANGFUSE_TEAM_BETTERSTACK_TOKEN}`,
});
const payload = {
dt: new Date().toISOString(), // Gets the current date in ISO format (UTC)
message: JSON.stringify(message, null, 2),
};
const response = await fetch(url, {
method: "POST",
headers: headers,
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
};
@@ -1,42 +1,28 @@
import { useState, useEffect } from "react";
import { type VisibilityState } from "@tanstack/react-table";
import { type LangfuseColumnDef } from "@/src/components/table/types";
import useLocalStorage from "@/src/components/useLocalStorage";
import { useEffect } from "react";
function useColumnVisibility<TData>(
localStorageKey: string,
columns: LangfuseColumnDef<TData>[],
) {
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
() => {
try {
const savedVisibility = localStorage.getItem(localStorageKey);
const visibilityState: VisibilityState = savedVisibility
? (JSON.parse(savedVisibility) as VisibilityState)
: {};
// set default visibility for columns that are not in the saved state
columns.forEach((column) => {
if (
"accessorKey" in column &&
typeof column.accessorKey === "string"
) {
if (!(column.accessorKey in visibilityState)) {
visibilityState[column.accessorKey] =
column.defaultHidden === true ? false : true;
}
}
});
return visibilityState;
} catch (e) {
console.error("Error while loading saved column visibility", e);
return {};
const initialVisibilityState = () => {
const visibilityState: VisibilityState = {};
columns.forEach((column) => {
if ("accessorKey" in column && typeof column.accessorKey === "string") {
visibilityState[column.accessorKey] =
column.defaultHidden === true ? false : true;
}
},
);
});
return visibilityState;
};
const [columnVisibility, setColumnVisibility] =
useLocalStorage<VisibilityState>(localStorageKey, initialVisibilityState());
useEffect(() => {
const localStorageItem = localStorage.getItem(localStorageKey);
if (!localStorageItem || localStorageItem === "{}") {
if (Object.keys(columnVisibility).length === 0) {
const initialVisibility: VisibilityState = {};
columns.forEach((column) => {
if ("accessorKey" in column && typeof column.accessorKey === "string") {
@@ -45,23 +31,8 @@ function useColumnVisibility<TData>(
}
});
setColumnVisibility(initialVisibility);
} else {
// make sure all columns are in the visibility state
const visibilityState = JSON.parse(localStorageItem) as VisibilityState;
columns.forEach((column) => {
if ("accessorKey" in column && typeof column.accessorKey === "string") {
if (!(column.accessorKey in visibilityState)) {
visibilityState[column.accessorKey] =
column.defaultHidden === true ? false : true;
}
}
});
}
}, [columns, localStorageKey]);
useEffect(() => {
localStorage.setItem(localStorageKey, JSON.stringify(columnVisibility));
}, [columnVisibility, localStorageKey]);
}, [columnVisibility, columns, setColumnVisibility]);
return [columnVisibility, setColumnVisibility] as const;
}
+43 -4
View File
@@ -6,6 +6,7 @@ import {
} from "@/src/server/api/trpc";
import { type DatasetRuns, Prisma, type Dataset } from "@prisma/client";
import { throwIfNoAccess } from "@/src/features/rbac/utils/checkAccess";
import { auditLog } from "@/src/features/audit-logs/auditLog";
export const datasetRouter = createTRPCRouter({
allDatasets: protectedProjectProcedure
@@ -203,7 +204,7 @@ export const datasetRouter = createTRPCRouter({
projectId: input.projectId,
scope: "datasets:CUD",
});
return ctx.prisma.datasetItem.update({
const datasetItem = await ctx.prisma.datasetItem.update({
where: {
id: input.datasetItemId,
datasetId: input.datasetId,
@@ -226,6 +227,15 @@ export const datasetRouter = createTRPCRouter({
status: input.status,
},
});
await auditLog({
session: ctx.session,
resourceType: "datasetItem",
resourceId: input.datasetItemId,
projectId: input.projectId,
action: "update",
after: datasetItem,
});
return datasetItem;
}),
createDataset: protectedProjectProcedure
.input(z.object({ projectId: z.string(), name: z.string() }))
@@ -235,12 +245,23 @@ export const datasetRouter = createTRPCRouter({
projectId: input.projectId,
scope: "datasets:CUD",
});
return ctx.prisma.dataset.create({
const dataset = await ctx.prisma.dataset.create({
data: {
name: input.name,
projectId: input.projectId,
},
});
await auditLog({
session: ctx.session,
resourceType: "dataset",
resourceId: dataset.id,
projectId: input.projectId,
action: "create",
after: dataset,
});
return dataset;
}),
deleteDataset: protectedProjectProcedure
.input(z.object({ projectId: z.string(), datasetId: z.string() }))
@@ -250,12 +271,21 @@ export const datasetRouter = createTRPCRouter({
projectId: input.projectId,
scope: "datasets:CUD",
});
return ctx.prisma.dataset.delete({
const deletedDataset = await ctx.prisma.dataset.delete({
where: {
id: input.datasetId,
projectId: input.projectId,
},
});
await auditLog({
session: ctx.session,
resourceType: "dataset",
resourceId: deletedDataset.id,
projectId: input.projectId,
action: "delete",
before: deletedDataset,
});
return deletedDataset;
}),
createDatasetItem: protectedProjectProcedure
.input(
@@ -283,7 +313,7 @@ export const datasetRouter = createTRPCRouter({
throw new Error("Dataset not found");
}
return ctx.prisma.datasetItem.create({
const datasetItem = await ctx.prisma.datasetItem.create({
data: {
input: JSON.parse(input.input) as Prisma.InputJsonObject,
expectedOutput:
@@ -296,6 +326,15 @@ export const datasetRouter = createTRPCRouter({
sourceObservationId: input.sourceObservationId,
},
});
await auditLog({
session: ctx.session,
resourceType: "datasetItem",
resourceId: datasetItem.id,
projectId: input.projectId,
action: "create",
after: datasetItem,
});
return datasetItem;
}),
runitemsByRunIdOrItemId: protectedProjectProcedure
.input(
@@ -1,4 +1,3 @@
import * as Sentry from "@sentry/nextjs";
import { Button } from "@/src/components/ui/button";
import {
Dialog,
@@ -18,17 +17,28 @@ import {
FormItem,
FormMessage,
} from "@/src/components/ui/form";
import { type PropsWithChildren, useState } from "react";
import { useState } from "react";
import { useSession } from "next-auth/react";
import { Textarea } from "@/src/components/ui/textarea";
interface FeedbackDialogProps {
className?: string;
children: React.ReactNode;
title: string;
description: string;
type: "feedback" | "dashboard";
}
const formSchema = z.object({
feedback: z.string().min(3, "Must have at least 3 characters"),
});
export function FeedbackButtonWrapper(
props: PropsWithChildren<{ className?: string }>,
) {
export function FeedbackButtonWrapper({
className,
children,
title,
description,
type,
}: FeedbackDialogProps) {
const [open, setOpen] = useState(false);
const session = useSession();
@@ -40,16 +50,6 @@ export function FeedbackButtonWrapper(
});
async function onSubmit(values: z.infer<typeof formSchema>) {
// Add to sentry
if (process.env.NEXT_PUBLIC_SENTRY_DSN) {
const eventId = Sentry.captureMessage(`User submitted feedback`);
Sentry.captureUserFeedback({
event_id: eventId,
email: session.data?.user?.email ?? "",
name: session.data?.user?.name ?? "",
comments: values.feedback,
});
}
try {
const res = await fetch("https://cloud.langfuse.com/api/feedback", {
method: "POST",
@@ -57,6 +57,7 @@ export function FeedbackButtonWrapper(
"Content-Type": "application/json",
},
body: JSON.stringify({
type,
...values,
url: window.location.href,
user: session.data?.user,
@@ -84,27 +85,25 @@ export function FeedbackButtonWrapper(
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger className={props.className} asChild>
{props.children}
<DialogTrigger className={className} asChild>
{children}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle className="mb-5">Provide feedback</DialogTitle>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<Form {...form}>
<form
// eslint-disable-next-line @typescript-eslint/no-misused-promises
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8"
className="space-y-4"
>
<FormField
control={form.control}
name="feedback"
render={({ field }) => (
<FormItem>
<FormDescription>
What do you think about this project? What can be improved?
</FormDescription>
<FormDescription>{description}</FormDescription>
<FormControl>
<Textarea {...field} />
</FormControl>
@@ -1,5 +1,5 @@
import { env } from "@/src/env.mjs";
import { runFeedbackCorsMiddleware } from "@/src/features/feedback/server/corsMiddleware";
import { sendToSlack } from "@/src/features/slack/server/slack-webhook";
import { type NextApiRequest, type NextApiResponse } from "next";
// Collects feedack from users that do not use the cloud version of the app
@@ -10,17 +10,7 @@ export default async function feedbackApiHandler(
await runFeedbackCorsMiddleware(req, res);
try {
if (!env.LANGFUSE_TEAM_SLACK_WEBHOOK)
throw new Error("LANGFUSE_TEAM_SLACK_WEBHOOK is not set");
const slackResponse = await fetch(env.LANGFUSE_TEAM_SLACK_WEBHOOK, {
method: "POST",
body: JSON.stringify({ rawBody: JSON.stringify(req.body, null, 2) }),
headers: {
"Content-Type": "application/json",
},
});
const slackResponse = await sendToSlack(req.body);
if (slackResponse.status === 200) {
res.status(200).json({ status: "OK" });
} else {
@@ -292,18 +292,17 @@ function FilterBuilderForm({
) : filter.type === "number" ||
filter.type === "numberObject" ? (
<Input
value={filter.value?.toString() ?? ""}
value={filter.value ?? undefined}
type="number"
step="0.01"
lang="en-US"
onChange={(e) =>
handleFilterChange(
{
...filter,
value:
isNaN(Number(e.target.value)) ||
e.target.value.endsWith(".")
? e.target.value
: Number(e.target.value),
value: isNaN(Number(e.target.value))
? e.target.value
: Number(e.target.value),
},
i,
)
@@ -58,7 +58,7 @@ export function filterToPrismaSql(
break;
case "number":
case "numberObject":
valuePrisma = Prisma.sql`${filter.value}::DOUBLE PRECISION`;
valuePrisma = Prisma.sql`${filter.value.toString()}::DOUBLE PRECISION`;
break;
case "string":
case "stringObject":
@@ -0,0 +1,47 @@
import { X } from "lucide-react";
export interface TNotification {
id: number;
releaseDate: Date;
message: string | JSX.Element;
description?: JSX.Element | string;
}
interface NotificationProps {
notification: TNotification;
setLastSeenId: (id: number) => void;
dismissToast: (t?: string | number | undefined) => void;
toast: string | number;
}
const Notification: React.FC<NotificationProps> = ({
notification,
setLastSeenId,
dismissToast,
toast,
}) => (
<div className="flex justify-between">
<div className="flex min-w-[300px] flex-1 flex-col justify-center">
<div className="m-0 text-sm font-medium leading-tight text-gray-800">
{notification.message}
</div>
{notification.description && (
<div className="mt-2 flex-1 text-sm leading-tight text-gray-800">
{notification.description}
</div>
)}
</div>
<button
className="flex h-6 w-6 cursor-pointer items-start justify-end border-none bg-transparent p-0 text-gray-800 transition-colors duration-200"
onClick={() => {
setLastSeenId(notification.id);
dismissToast(toast);
}}
aria-label="Close"
>
<X size={14} />
</button>
</div>
);
export default Notification;
@@ -0,0 +1,95 @@
import { useEffect } from "react";
import Link from "next/link";
import { toast } from "sonner";
import useLocalStorage from "@/src/components/useLocalStorage";
import Notification, {
type TNotification,
} from "@/src/features/notifications/Notification";
import { Button } from "@/src/components/ui/button";
import { env } from "@/src/env.mjs";
export const NOTIFICATIONS: TNotification[] = [
{
id: 1,
releaseDate: new Date("2024-01-29"),
message: "New: Custom model prices",
description: (
<div>
<p>
Langfuse now supports any LLM model for usage and cost tracking. The
highlights:
</p>
<ul className="ms-4 mt-2 list-outside list-disc">
<li>Define your model definitions (price, usage).</li>
<li>Optionally, ingest cost via the API/SDK</li>
<li>Support for usage in tokens, seconds and characters</li>
</ul>
{env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === undefined && (
<p className="mt-2">
Self-hosted: The upgrade to v2 includes a breaking change. After
upgrading, you must run a migration script to ensure accurate usage
and cost calculation for previously ingested traces. Please refer to
the post for details.
</p>
)}
<Button size="sm" variant="secondary" className="mt-3">
<Link href="https://langfuse.com/changelog/2024-01-29-custom-model-prices">
Changelog post
</Link>
</Button>
</div>
),
},
];
export const useCheckNotification = (
notification: TNotification[],
authenticated: boolean,
) => {
const [lastSeenId, setLastSeenId] = useLocalStorage<number>(
"lastSeenNotificationId",
0,
);
useEffect(() => {
if (!authenticated) {
return;
}
const timeoutId = setTimeout(() => {
notification
.reverse()
.filter(
(n) =>
// only show notifications that are less than 30 days old
(new Date().getTime() - n.releaseDate.getTime()) /
(1000 * 60 * 60 * 24) <=
30,
)
.forEach((n) => {
if (n.id > lastSeenId) {
toast.custom(
(t) => (
<Notification
notification={n}
setLastSeenId={setLastSeenId}
dismissToast={toast.dismiss}
toast={t}
/>
),
{
// needed to upsert toasts in case it is rendered multiple times
id: n.id.toString(),
duration: 600_000, // 10 minutes
style: {
padding: "1rem",
border: "1px solid #e2e8f0",
borderRadius: "0.5rem",
},
},
);
}
});
}, 1500);
return () => clearTimeout(timeoutId);
}, [lastSeenId, notification, setLastSeenId, authenticated]);
};
+40 -5
View File
@@ -1,8 +1,13 @@
import { createTRPCRouter, protectedProcedure } from "@/src/server/api/trpc";
import {
createTRPCRouter,
protectedProcedure,
protectedProjectProcedure,
} from "@/src/server/api/trpc";
import * as z from "zod";
import { throwIfNoAccess } from "@/src/features/rbac/utils/checkAccess";
import { TRPCError } from "@trpc/server";
import { projectNameSchema } from "@/src/features/auth/lib/projectNameSchema";
import { auditLog } from "@/src/features/audit-logs/auditLog";
export const projectsRouter = createTRPCRouter({
all: protectedProcedure.query(async ({ ctx }) => {
@@ -66,6 +71,15 @@ export const projectsRouter = createTRPCRouter({
},
},
});
await auditLog({
resourceType: "project",
resourceId: project.id,
action: "create",
userId: ctx.session.user.id,
projectId: project.id,
userProjectRole: "OWNER",
after: project,
});
return {
id: project.id,
@@ -74,7 +88,7 @@ export const projectsRouter = createTRPCRouter({
};
}),
update: protectedProcedure
update: protectedProjectProcedure
.input(
z.object({
projectId: z.string(),
@@ -88,7 +102,7 @@ export const projectsRouter = createTRPCRouter({
scope: "project:update",
});
await ctx.prisma.project.update({
const project = await ctx.prisma.project.update({
where: {
id: input.projectId,
},
@@ -96,10 +110,17 @@ export const projectsRouter = createTRPCRouter({
name: input.newName,
},
});
await auditLog({
session: ctx.session,
resourceType: "project",
resourceId: input.projectId,
action: "update",
after: project,
});
return true;
}),
delete: protectedProcedure
delete: protectedProjectProcedure
.input(
z.object({
projectId: z.string(),
@@ -111,6 +132,12 @@ export const projectsRouter = createTRPCRouter({
projectId: input.projectId,
scope: "project:delete",
});
await auditLog({
session: ctx.session,
resourceType: "project",
resourceId: input.projectId,
action: "delete",
});
await ctx.prisma.project.delete({
where: {
@@ -121,7 +148,7 @@ export const projectsRouter = createTRPCRouter({
return true;
}),
transfer: protectedProcedure
transfer: protectedProjectProcedure
.input(
z.object({
projectId: z.string(),
@@ -145,6 +172,14 @@ export const projectsRouter = createTRPCRouter({
if (newOwner.id === ctx.session.user.id)
throw new Error("You cannot transfer project to yourself");
await auditLog({
session: ctx.session,
resourceType: "project",
resourceId: input.projectId,
action: "transfer",
after: { ownerId: newOwner.id },
});
return ctx.prisma.$transaction([
// Add new owner, upsert to update role if already exists
ctx.prisma.membership.upsert({
@@ -0,0 +1,90 @@
import { Button } from "@/src/components/ui/button";
import { useHasAccess } from "@/src/features/rbac/utils/checkAccess";
import { api } from "@/src/utils/api";
import { Trash2 } from "lucide-react";
import { useState } from "react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/src/components/ui/popover";
import { useRouter } from "next/router";
export function DeletePromptVersion({
promptVersionId,
projectId,
version,
countVersions,
}: {
promptVersionId: string;
projectId: string;
version: number;
countVersions: number;
}) {
const utils = api.useUtils();
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
const hasAccess = useHasAccess({ projectId, scope: "prompts:CUD" });
const mutDeletePromptVersion = api.prompts.deleteVersion.useMutation({
onSuccess: () => {
void utils.prompts.invalidate();
if (countVersions > 1) {
void router.replace(
{
pathname: router.pathname,
query: { ...router.query, version: undefined },
},
undefined,
{ shallow: true },
);
} else {
void router.push(`/project/${projectId}/prompts`);
}
},
});
if (!hasAccess) {
return null;
}
return (
<Popover
key={promptVersionId}
open={isOpen}
onOpenChange={() => setIsOpen(!isOpen)}
>
<PopoverTrigger asChild>
<Button variant="outline" type="button" size="icon">
<Trash2 className="h-5 w-5" />
</Button>
</PopoverTrigger>
<PopoverContent>
<h2 className="text-md mb-3 font-semibold">Please confirm</h2>
<p className="mb-3 text-sm">
This action deletes the prompt version. Requests of version{" "}
<code className="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold">
{version}
</code>
of this prompt will return an error.
</p>
<div className="flex justify-end space-x-4">
<Button
type="button"
variant="destructive"
loading={mutDeletePromptVersion.isLoading}
onClick={() => {
void mutDeletePromptVersion.mutateAsync({
promptVersionId,
projectId,
});
setIsOpen(false);
}}
>
Delete Prompt Version
</Button>
</div>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,69 @@
import { Button } from "@/src/components/ui/button";
import { useHasAccess } from "@/src/features/rbac/utils/checkAccess";
import { api } from "@/src/utils/api";
import { Trash2 } from "lucide-react";
import { useState } from "react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/src/components/ui/popover";
export function DeletePrompt({
projectId,
promptName,
}: {
projectId: string;
promptName: string;
}) {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const hasAccess = useHasAccess({ projectId, scope: "prompts:CUD" });
const mutDeletePrompt = api.prompts.delete.useMutation({
onSuccess: () => {
void utils.prompts.invalidate();
},
});
if (!hasAccess) {
return null;
}
return (
<Popover open={isOpen} onOpenChange={() => setIsOpen(!isOpen)}>
<PopoverTrigger asChild>
<Button variant="ghost" size="xs">
<Trash2 className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent>
<h2 className="text-md mb-3 font-semibold">Please confirm</h2>
<p className="mb-3 text-sm">
This action permanently deletes this prompt. All requests to fetch
prompt{" "}
<code className="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold">
{promptName}
</code>{" "}
will error.
</p>
<div className="flex justify-end space-x-4">
<Button
type="button"
variant="destructive"
loading={mutDeletePrompt.isLoading}
onClick={() => {
void mutDeletePrompt.mutateAsync({
projectId,
promptName,
});
setIsOpen(false);
}}
>
Delete Prompt
</Button>
</div>
</PopoverContent>
</Popover>
);
}
@@ -153,11 +153,9 @@ export const NewPromptForm = (props: {
props.onFormSuccess?.();
form.reset();
// go to the following page after creating the prompt
if (newPrompt) {
void router.push(
`/project/${props.projectId}/prompts/${newPrompt.name}`,
);
}
void router.push(
`/project/${props.projectId}/prompts/${newPrompt.name}`,
);
})
.catch((error) => {
console.error(error);
@@ -14,6 +14,7 @@ import { PromotePrompt } from "@/src/features/prompts/components/promote-prompt"
import { ScrollArea } from "@radix-ui/react-scroll-area";
import { useQueryParam, NumberParam } from "use-query-params";
import router from "next/router";
import { DeletePromptVersion } from "@/src/features/prompts/components/delete-prompt-version";
export type PromptDetailProps = {
projectId: string;
@@ -77,6 +78,12 @@ export const PromptDetail = (props: PromptDetailProps) => {
<Pencil className="h-5 w-5" />
</Button>
</CreatePromptDialog>
<DeletePromptVersion
projectId={props.projectId}
promptVersionId={prompt.id}
version={prompt.version}
countVersions={promptHistory.data.length}
/>
<DetailPageNav
key="nav"
currentId={prompt.name}
@@ -5,6 +5,7 @@ import { Button } from "@/src/components/ui/button";
import { useDetailPageLists } from "@/src/features/navigate-detail-pages/context";
import { CreatePromptDialog } from "@/src/features/prompts/components/new-prompt-button";
import { useHasAccess } from "@/src/features/rbac/utils/checkAccess";
import { DeletePrompt } from "@/src/features/prompts/components/delete-prompt";
import { api } from "@/src/utils/api";
import { type RouterOutput } from "@/src/utils/types";
@@ -71,6 +72,18 @@ export function PromptTable(props: { projectId: string }) {
return createdAt.toLocaleString();
},
},
{
accessorKey: "actions",
header: "Actions",
cell: ({ row }) => {
return (
<DeletePrompt
projectId={props.projectId}
promptName={row.getValue("name")}
/>
);
},
},
];
const convertToTableRow = (
+126 -1
View File
@@ -6,6 +6,7 @@ import {
} from "@/src/server/api/trpc";
import { throwIfNoAccess } from "@/src/features/rbac/utils/checkAccess";
import { type Prompt, type PrismaClient } from "@prisma/client";
import { auditLog } from "@/src/features/audit-logs/auditLog";
export const CreatePrompt = z.object({
projectId: z.string(),
@@ -70,7 +71,7 @@ export const promptRouter = createTRPCRouter({
scope: "prompts:CUD",
});
return await createPrompt({
const prompt = await createPrompt({
projectId: input.projectId,
name: input.name,
prompt: input.prompt,
@@ -78,6 +79,116 @@ export const promptRouter = createTRPCRouter({
createdBy: ctx.session.user.id,
prisma: ctx.prisma,
});
if (!prompt) {
throw new Error("Failed to create prompt");
}
await auditLog(
{
session: ctx.session,
resourceType: "prompt",
resourceId: prompt.id,
action: "create",
after: prompt,
},
ctx.prisma,
);
return prompt;
} catch (e) {
console.log(e);
throw e;
}
}),
delete: protectedProjectProcedure
.input(
z.object({
projectId: z.string(),
promptName: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
try {
throwIfNoAccess({
session: ctx.session,
projectId: input.projectId,
scope: "prompts:CUD",
});
// fetch prompts before deletion to enable audit logging
const prompts = await ctx.prisma.prompt.findMany({
where: {
projectId: input.projectId,
name: input.promptName,
},
});
for (const prompt of prompts) {
await auditLog(
{
session: ctx.session,
resourceType: "prompt",
resourceId: prompt.id,
action: "delete",
before: prompt,
},
ctx.prisma,
);
}
await ctx.prisma.prompt.deleteMany({
where: {
projectId: input.projectId,
id: {
in: prompts.map((p) => p.id),
},
},
});
} catch (e) {
console.log(e);
throw e;
}
}),
deleteVersion: protectedProjectProcedure
.input(
z.object({
promptVersionId: z.string(),
projectId: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
try {
throwIfNoAccess({
session: ctx.session,
projectId: input.projectId,
scope: "prompts:CUD",
});
const promptVersion = await ctx.prisma.prompt.findFirstOrThrow({
where: {
id: input.promptVersionId,
projectId: input.projectId,
},
});
await auditLog(
{
session: ctx.session,
resourceType: "prompt",
resourceId: input.promptVersionId,
action: "delete",
before: promptVersion,
},
ctx.prisma,
);
await ctx.prisma.prompt.delete({
where: {
id: input.promptVersionId,
projectId: input.projectId,
},
});
} catch (e) {
console.log(e);
throw e;
@@ -99,6 +210,20 @@ export const promptRouter = createTRPCRouter({
},
});
await auditLog(
{
session: ctx.session,
resourceType: "prompt",
resourceId: toBePromotedPrompt.id,
action: "promote",
after: {
...toBePromotedPrompt,
isActive: true,
},
},
ctx.prisma,
);
const latestActivePrompt = await ctx.prisma.prompt.findFirst({
where: {
projectId: input.projectId,
@@ -11,7 +11,7 @@ import {
import { CodeView } from "@/src/components/ui/code";
import { useHasAccess } from "@/src/features/rbac/utils/checkAccess";
import { usePostHog } from "posthog-js/react";
import { env } from "@/src/env.mjs";
import { QuickstartExamples } from "@/src/features/public-api/components/QuickstartExamples";
export function CreateApiKeyButton(props: { projectId: string }) {
const utils = api.useUtils();
@@ -21,8 +21,7 @@ export function CreateApiKeyButton(props: { projectId: string }) {
scope: "apiKeys:create",
});
const hostname =
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== "EU" ? window.origin : undefined;
const hostname = window.origin;
const mutCreateApiKey = api.apiKeys.create.useMutation({
onSuccess: () => utils.apiKeys.invalidate(),
@@ -66,28 +65,15 @@ export function CreateApiKeyButton(props: { projectId: string }) {
Create new API keys
</Button>
</DialogTrigger>
<DialogContent onPointerDownOutside={(e) => e.preventDefault()}>
{hostname ? (
<>
<DialogTitle>Hostname</DialogTitle>
<div className="mb-6">
<div className="my-2">
When connecting to Langfuse, use this hostname / baseurl.
</div>
<CodeView content={hostname} />
</div>
</>
) : null}
<DialogContent
onPointerDownOutside={(e) => e.preventDefault()}
className="max-w-full md:max-w-xl"
>
<DialogTitle>API Keys</DialogTitle>
<div className="mb-2">
<div className="text-md font-semibold">Secret Key</div>
<div className="my-2">
Please save this secret key.{" "}
<span className="font-semibold">
You will not be able to view it again
</span>
. If you lose it, you will need to generate a new one.
This key can only be viewed once. You can always generate a new key.
</div>
<CodeView content={generatedKeys?.secretKey ?? "Loading ..."} />
</div>
@@ -95,6 +81,20 @@ export function CreateApiKeyButton(props: { projectId: string }) {
<div className="text-md mb-2 font-semibold">Public Key</div>
<CodeView content={generatedKeys?.publicKey ?? "Loading ..."} />
</div>
<div>
<div className="text-md mb-2 font-semibold">Host</div>
<CodeView content={hostname} />
</div>
{generatedKeys && (
<div className="mb-2">
<div className="text-md my-2 font-semibold">Usage</div>
<QuickstartExamples
secretKey={generatedKeys.secretKey}
publicKey={generatedKeys.publicKey}
host={hostname}
/>
</div>
)}
</DialogContent>
</Dialog>
);
@@ -0,0 +1,210 @@
import { CodeView } from "@/src/components/ui/code";
import {
Tabs,
TabsList,
TabsContent,
TabsTrigger,
} from "@/src/components/ui/tabs";
export const QuickstartExamples = ({
secretKey,
publicKey,
host,
}: {
secretKey: string;
publicKey: string;
host: string;
}) => (
<Tabs defaultValue="python">
<TabsList>
<TabsTrigger value="python">Python</TabsTrigger>
<TabsTrigger value="js">JS/TS</TabsTrigger>
<TabsTrigger value="openai">OpenAI</TabsTrigger>
<TabsTrigger value="langchain">Langchain</TabsTrigger>
<TabsTrigger value="langchain-js">Langchain JS</TabsTrigger>
<TabsTrigger value="other">Other</TabsTrigger>
</TabsList>
<TabsContent value="python">
<CodeView content="pip install langfuse" className="mb-2 bg-blue-50" />
<CodeView
className="bg-blue-50"
content={`from langfuse import Langfuse\n\nlangfuse = Langfuse(\n secret_key="${secretKey}",\n public_key="${publicKey}",\n host="${host}"\n)`}
/>
<p className="mt-3 text-xs text-gray-600">
See{" "}
<a
href="https://langfuse.com/docs/get-started"
className="underline"
target="_blank"
rel="noopener noreferrer"
>
Quickstart
</a>{" "}
and{" "}
<a
href="https://langfuse.com/docs/sdk/python"
className="underline"
target="_blank"
rel="noopener noreferrer"
>
Python docs
</a>{" "}
for more details.
</p>
</TabsContent>
<TabsContent value="js">
<CodeView content="npm install langfuse" className="mb-2 bg-blue-50" />
<CodeView
className="bg-blue-50"
content={`import { Langfuse } from "langfuse";\n\nconst langfuse = new Langfuse({\n secretKey: "${secretKey}",\n publicKey: "${publicKey}",\n baseUrl: "${host}"\n});`}
/>
<p className="mt-3 text-xs text-gray-600">
See{" "}
<a
href="https://langfuse.com/docs/get-started"
className="underline"
target="_blank"
rel="noopener noreferrer"
>
Quickstart
</a>{" "}
and{" "}
<a
href="https://langfuse.com/docs/sdk/typescript"
className="underline"
target="_blank"
rel="noopener noreferrer"
>
JS/TS docs
</a>{" "}
for more details.
</p>
</TabsContent>
<TabsContent value="openai">
<p className="mt-2 text-xs text-gray-600">
The integration is a drop-in replacement for the OpenAI Python SDK. By
changing the import, Langfuse will capture all LLM calls and send them
to Langfuse asynchronously.
</p>
<CodeView content="pip install langfuse" className="my-2 bg-blue-50" />
<CodeView
title=".env"
content={`LANGFUSE_SECRET_KEY=${secretKey};\nLANGFUSE_PUBLIC_KEY=${publicKey};\nLANGFUSE_HOST="${host}";`}
className="my-2 bg-blue-50"
/>
<CodeView
content={`# remove: import openai\n\nfrom langfuse.openai import openai`}
className="my-2 bg-blue-50"
/>
<p className="mt-2 text-xs text-gray-600">
Use the OpenAI SDK as you would normally. See the{" "}
<a
href="https://langfuse.com/docs/integrations/openai"
className="underline"
target="_blank"
rel="noopener noreferrer"
>
OpenAI Integration docs
</a>{" "}
for more details.
</p>
</TabsContent>
<TabsContent value="langchain">
<p className="mt-2 text-xs text-gray-600">
The integration uses the Langchain callback system to automatically
capture detailed traces of your Langchain executions.
</p>
<CodeView content="pip install langfuse" className="my-2 bg-blue-50" />
<CodeView
title=".env"
content={`LANGFUSE_SECRET_KEY=${secretKey};\nLANGFUSE_PUBLIC_KEY=${publicKey};\nLANGFUSE_HOST="${host}";`}
className="my-2 bg-blue-50"
/>
<CodeView content={LANGCHAIN_PYTHON_CODE} className="my-2 bg-blue-50" />
<p className="mt-2 text-xs text-gray-600">
See the{" "}
<a
href="https://langfuse.com/docs/integrations/langchain/python"
className="underline"
target="_blank"
rel="noopener noreferrer"
>
Langchain Integration docs
</a>{" "}
for more details.
</p>
</TabsContent>
<TabsContent value="langchain-js">
<p className="mt-2 text-xs text-gray-600">
The integration uses the Langchain callback system to automatically
capture detailed traces of your Langchain executions.
</p>
<CodeView
content="npm install langfuse-langchain"
className="my-2 bg-blue-50"
/>
<CodeView
title=".env"
content={`LANGFUSE_SECRET_KEY=${secretKey};\nLANGFUSE_PUBLIC_KEY=${publicKey};\LANGFUSE_BASEURL="${host}";`}
className="my-2 bg-blue-50"
/>
<CodeView content={LANGCHAIN_JS_CODE} className="my-2 bg-blue-50" />
<p className="mt-2 text-xs text-gray-600">
See the{" "}
<a
href="https://langfuse.com/docs/integrations/langchain/typescript"
className="underline"
target="_blank"
rel="noopener noreferrer"
>
Langchain Integration docs
</a>{" "}
for more details.
</p>
</TabsContent>
<TabsContent value="other">
<p className="mt-2 text-xs text-gray-600">
Use the{" "}
<a
href="https://api.reference.langfuse.com/"
className="underline"
target="_blank"
rel="noopener noreferrer"
>
API
</a>{" "}
or one of the{" "}
<a
href="https://langfuse.com/docs/integrations"
className="underline"
target="_blank"
rel="noopener noreferrer"
>
native integrations
</a>{" "}
(e.g. LiteLLM, Flowise, and Langflow) to integrate with Langfuse.
</p>
</TabsContent>
</Tabs>
);
const LANGCHAIN_PYTHON_CODE = `from langfuse.callback import CallbackHandler
# <Your Langchain code here>
# Add handler to run/invoke/call/chat
agent.run("<user_input>", callbacks=[handler])`;
const LANGCHAIN_JS_CODE = `import { CallbackHandler } from "langfuse-langchain";
// Initialize Langfuse callback handler
const handler = new CallbackHandler();
// Your Langchain implementation
const chain = new LLMChain(...);
// Add handler as callback when running the Langchain agent
await chain.call(
{ input: "<user_input>" },
{ callbacks: [handler] }
);`;
@@ -1,3 +1,4 @@
import { auditLog } from "@/src/features/audit-logs/auditLog";
import { generateKeySet } from "@/src/features/public-api/lib/apiKeys";
import { throwIfNoAccess } from "@/src/features/rbac/utils/checkAccess";
import {
@@ -64,6 +65,13 @@ export const apiKeysRouter = createTRPCRouter({
},
});
await auditLog({
session: ctx.session,
resourceType: "apiKey",
resourceId: apiKey.id,
action: "create",
});
return {
id: apiKey.id,
createdAt: apiKey.createdAt,
@@ -86,6 +94,12 @@ export const apiKeysRouter = createTRPCRouter({
projectId: input.projectId,
scope: "apiKeys:delete",
});
await auditLog({
session: ctx.session,
resourceType: "apiKey",
resourceId: input.id,
action: "delete",
});
// Make sure the API key exists and belongs to the project the user has access to
const apiKey = await ctx.prisma.apiKey.findFirstOrThrow({
@@ -1,3 +1,4 @@
import { auditLog } from "@/src/features/audit-logs/auditLog";
import { sendProjectInvitation } from "@/src/features/email/lib/project-invitation";
import { throwIfNoAccess } from "@/src/features/rbac/utils/checkAccess";
import {
@@ -5,6 +6,7 @@ import {
protectedProjectProcedure,
} from "@/src/server/api/trpc";
import { MembershipRole } from "@prisma/client";
import { TRPCError } from "@trpc/server";
import * as z from "zod";
export const projectMembersRouter = createTRPCRouter({
@@ -75,8 +77,7 @@ export const projectMembersRouter = createTRPCRouter({
if (input.userId === ctx.session.user.id)
throw new Error("You cannot remove yourself from a project");
// use deleteMany to protect against deleting owner with where clause
return ctx.prisma.membership.deleteMany({
const membership = await ctx.prisma.membership.findFirst({
where: {
projectId: input.projectId,
userId: input.userId,
@@ -85,6 +86,26 @@ export const projectMembersRouter = createTRPCRouter({
},
},
});
if (!membership) throw new TRPCError({ code: "NOT_FOUND" });
await auditLog({
session: ctx.session,
resourceType: "membership",
resourceId: membership.projectId + "--" + membership.userId,
action: "delete",
before: membership,
});
// use ids from membership to make sure owners cannot delete themselves
return await ctx.prisma.membership.delete({
where: {
projectId_userId: {
projectId: membership.projectId,
userId: membership.userId,
},
},
});
}),
deleteInvitation: protectedProjectProcedure
.input(
@@ -100,6 +121,13 @@ export const projectMembersRouter = createTRPCRouter({
scope: "members:delete",
});
await auditLog({
session: ctx.session,
resourceType: "membershipInvitation",
resourceId: input.id,
action: "delete",
});
return await ctx.prisma.membershipInvitation.delete({
where: {
id: input.id,
@@ -132,13 +160,21 @@ export const projectMembersRouter = createTRPCRouter({
},
});
if (user) {
return await ctx.prisma.membership.create({
const membership = await ctx.prisma.membership.create({
data: {
userId: user.id,
projectId: input.projectId,
role: input.role,
},
});
await auditLog({
session: ctx.session,
resourceType: "membership",
resourceId: input.projectId + "--" + user.id,
action: "create",
after: membership,
});
return membership;
} else {
const invitation = await ctx.prisma.membershipInvitation.create({
data: {
@@ -148,6 +184,13 @@ export const projectMembersRouter = createTRPCRouter({
senderId: ctx.session.user.id,
},
});
await auditLog({
session: ctx.session,
resourceType: "membershipInvitation",
resourceId: invitation.id,
action: "create",
after: invitation,
});
const project = await ctx.prisma.project.findFirst({
where: {
@@ -0,0 +1,14 @@
import { env } from "@/src/env.mjs";
export const sendToSlack = async (message: unknown) => {
if (!env.LANGFUSE_TEAM_SLACK_WEBHOOK)
throw new Error("LANGFUSE_TEAM_SLACK_WEBHOOK is not set");
return await fetch(env.LANGFUSE_TEAM_SLACK_WEBHOOK, {
method: "POST",
body: JSON.stringify({ rawBody: JSON.stringify(message, null, 2) }),
headers: {
"Content-Type": "application/json",
},
});
};
+2 -1
View File
@@ -9,6 +9,7 @@ import {
PopoverContent,
} from "@/src/components/ui/popover";
import { Command, CommandList, CommandGroup } from "cmdk";
import { cn } from "@/src/utils/tailwind";
type TagManagerProps = {
tags: string[];
@@ -61,7 +62,7 @@ const TagManager = ({
setSelectedTags={setSelectedTags}
/>
<CommandList
className={availableTags.length > 0 ? "mt-2" : undefined}
className={cn("overflow-auto", availableTags.length > 0 && "mt-2")}
>
<CommandGroup>
{availableTags.slice(0, 5).map((value: string) => (
-35
View File
@@ -1,35 +0,0 @@
// Middleware to block requests from certain IPs on Langfuse Cloud
// Not included in the self-host build, removed in Dockerfile
// import { type NextApiRequest } from "next";
// import { type NextRequest } from "next/server";
// import { get } from "@vercel/edge-config";
// export async function middleware(req: NextRequest) {
// try {
// if (process.env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== undefined) {
// const config = await get("blockedIps");
// const blockedIps = Array.isArray(config) ? config : [config];
// const ip = getIP(req);
// if (ip && blockedIps.includes(ip)) {
// console.log("Blocked request by ip: ", ip);
// return new Response("Access denied", { status: 403 });
// }
// }
// return;
// } catch (e) {
// console.error("Server side error in middleware: ", e);
// return new Response("Internal server error", { status: 500 });
// }
// }
// export default function getIP(request: Request | NextApiRequest) {
// const xff =
// request instanceof Request
// ? request.headers.get("x-forwarded-for")
// : request.headers["x-forwarded-for"];
// return xff ? (Array.isArray(xff) ? xff[0] : xff.split(",")[0]) : "127.0.0.1";
// }
+7 -6
View File
@@ -30,7 +30,7 @@ import * as Sentry from "@sentry/nextjs";
export const config = {
api: {
bodyParser: {
sizeLimit: "3mb",
sizeLimit: "4.5mb",
},
},
};
@@ -56,11 +56,6 @@ export default async function handler(
message: authCheck.error,
});
if (authCheck.scope.accessLevel !== "all")
return res.status(403).json({
message: "Access denied",
});
const batchType = z.object({
batch: z.array(z.unknown()),
metadata: jsonSchema.nullish(),
@@ -246,6 +241,12 @@ const handleSingleEvent = async (
processor = new SdkLogProcessor(cleanedEvent);
}
// Deny access to non-score events if the access level is not "all"
// This is an additional safeguard to auth checks in EventProcessor
if (apiScope.accessLevel !== "all" && type !== eventTypes.SCORE_CREATE) {
throw new AuthenticationError("Access denied. Event type not allowed.");
}
return await processor.process(apiScope);
};
+32 -7
View File
@@ -25,6 +25,9 @@ import {
import { isValidOption } from "@/src/utils/types";
import { api } from "@/src/utils/api";
import { usePostHog } from "posthog-js/react";
import { FeedbackButtonWrapper } from "@/src/features/feedback/component/FeedbackButton";
import { BarChart2 } from "lucide-react";
import { Button } from "@/src/components/ui/button";
export type DashboardDateRange = {
from: Date;
@@ -92,13 +95,35 @@ export default function Start() {
return (
<div className="md:container">
<Header title={project?.name ?? "Dashboard"} />
<DatePickerWithRange
dateRange={dateRange}
setAgg={setAgg}
setDateRangeAndOption={setDateRangeAndOption}
selectedOption={selectedOption}
className=" max-w-full overflow-x-auto"
/>
<div className="flex flex-wrap items-center justify-between">
<DatePickerWithRange
dateRange={dateRange}
setAgg={setAgg}
setDateRangeAndOption={setDateRangeAndOption}
selectedOption={selectedOption}
className="max-w-full overflow-x-auto"
/>
<FeedbackButtonWrapper
title="Request Chart"
description="Your feedback matters! Let the Langfuse team know what additional data or metrics you'd like to see in your dashboard."
type="dashboard"
className="hidden md:flex"
>
<Button
id="date"
variant={"outline"}
className={
"group justify-start gap-x-3 text-left font-semibold text-gray-700 hover:bg-gray-50 hover:text-indigo-600"
}
>
<BarChart2
className="hidden h-6 w-6 shrink-0 text-gray-700 group-hover:text-indigo-600 lg:block"
aria-hidden="true"
/>
Request Chart
</Button>
</FeedbackButtonWrapper>
</div>
<div className="grid w-full grid-cols-1 gap-4 overflow-hidden lg:grid-cols-2 xl:grid-cols-6">
<TracesBarListChart
className="col-span-1 xl:col-span-2 "
-573
View File
@@ -1,573 +0,0 @@
import { z } from "zod";
import {
createTRPCRouter,
protectedProjectProcedure,
} from "@/src/server/api/trpc";
import { Prisma, type ObservationView } from "@prisma/client";
import { jsonSchema, paginationZod } from "@/src/utils/zod";
import { singleFilter } from "@/src/server/api/interfaces/filters";
import {
datetimeFilterToPrismaSql,
filterToPrismaSql,
} from "@/src/features/filters/server/filterToPrisma";
import {
type ObservationOptions,
observationsTableCols,
} from "@/src/server/api/definitions/observationsTable";
import { usdFormatter } from "@/src/utils/numbers";
import { env } from "@/src/env.mjs";
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import {
exportFileFormats,
exportOptions,
} from "@/src/server/api/interfaces/exportTypes";
import { orderBy } from "@/src/server/api/interfaces/orderBy";
import { orderByToPrismaSql } from "@/src/features/orderBy/server/orderByToPrisma";
const GenerationTableOptions = z.object({
projectId: z.string(), // Required for protectedProjectProcedure
filter: z.array(singleFilter),
searchQuery: z.string().nullable(),
orderBy: orderBy,
});
const ListInputs = GenerationTableOptions.extend({
...paginationZod,
});
// extend generationfilteroptions with export options
const ExportInputs = GenerationTableOptions.extend({
fileFormat: z.enum(exportFileFormats),
});
export const generationsRouter = createTRPCRouter({
all: protectedProjectProcedure
.input(ListInputs)
.query(async ({ input, ctx }) => {
// ATTENTION: When making changes to this query, make sure to also update the export query
const searchCondition = input.searchQuery
? Prisma.sql`AND (
o."id" ILIKE ${`%${input.searchQuery}%`} OR
o."name" ILIKE ${`%${input.searchQuery}%`} OR
o."model" ILIKE ${`%${input.searchQuery}%`} OR
t."name" ILIKE ${`%${input.searchQuery}%`}
)`
: Prisma.empty;
const filterCondition = filterToPrismaSql(
input.filter,
observationsTableCols,
);
const orderByCondition = orderByToPrismaSql(
input.orderBy,
observationsTableCols,
);
// to improve query performance, add timeseries filter to observation queries as well
const startTimeFilter = input.filter.find(
(f) => f.column === "start_time" && f.type === "datetime",
);
const datetimeFilter =
startTimeFilter && startTimeFilter.type === "datetime"
? datetimeFilterToPrismaSql(
"start_time",
startTimeFilter.operator,
startTimeFilter.value,
)
: Prisma.empty;
const generations = await ctx.prisma.$queryRaw<
Array<
ObservationView & {
traceId: string;
traceName: string;
latency: number | null;
}
>
>(
Prisma.sql`
WITH observations_with_latency AS (
SELECT
o.*,
CASE WHEN o.end_time IS NULL THEN NULL ELSE (EXTRACT(EPOCH FROM o."end_time") - EXTRACT(EPOCH FROM o."start_time"))::double precision END AS "latency"
FROM observations_view o
WHERE o.type = 'GENERATION'
AND o.project_id = ${input.projectId}
${datetimeFilter}
),
-- used for filtering
scores_avg AS (
SELECT
trace_id,
observation_id,
jsonb_object_agg(name::text, avg_value::double precision) AS scores_avg
FROM (
SELECT
trace_id,
observation_id,
name,
avg(value) avg_value
FROM
scores
GROUP BY
1,
2,
3
ORDER BY
1) tmp
GROUP BY
1, 2
)
SELECT
o.id,
o.name,
o.model,
o.start_time as "startTime",
o.end_time as "endTime",
o.latency,
o.input,
o.output,
o.metadata,
o.trace_id as "traceId",
t.name as "traceName",
o.completion_start_time as "completionStartTime",
o.prompt_tokens as "promptTokens",
o.completion_tokens as "completionTokens",
o.total_tokens as "totalTokens",
o.level,
o.status_message as "statusMessage",
o.version,
o.model_id as "modelId",
o.input_price as "inputPrice",
o.output_price as "outputPrice",
o.total_price as "totalPrice",
o.calculated_input_cost as "calculatedInputCost",
o.calculated_output_cost as "calculatedOutputCost",
o.calculated_total_cost as "calculatedTotalCost"
FROM observations_with_latency o
JOIN traces t ON t.id = o.trace_id
LEFT JOIN scores_avg AS s_avg ON s_avg.trace_id = t.id and s_avg.observation_id = o.id
WHERE
t.project_id = ${input.projectId}
${searchCondition}
${filterCondition}
${orderByCondition}
LIMIT ${input.limit}
OFFSET ${input.page * input.limit}
`,
);
const totalGenerations = await ctx.prisma.$queryRaw<
Array<{ count: bigint }>
>(
Prisma.sql`
WITH observations_with_latency AS (
SELECT
o.*,
CASE WHEN o.end_time IS NULL THEN NULL ELSE (EXTRACT(EPOCH FROM o."end_time") - EXTRACT(EPOCH FROM o."start_time"))::double precision END AS "latency"
FROM observations_view o
WHERE o.type = 'GENERATION'
AND o.project_id = ${input.projectId}
${datetimeFilter}
),
-- used for filtering
scores_avg AS (
SELECT
trace_id,
observation_id,
jsonb_object_agg(name::text, avg_value::double precision) AS scores_avg
FROM (
SELECT
trace_id,
observation_id,
name,
avg(value) avg_value
FROM
scores
GROUP BY
1,
2,
3
ORDER BY
1) tmp
GROUP BY
1, 2
)
SELECT
count(*)
FROM observations_with_latency o
JOIN traces t ON t.id = o.trace_id
LEFT JOIN scores_avg AS s_avg ON s_avg.trace_id = t.id and s_avg.observation_id = o.id
WHERE
t.project_id = ${input.projectId}
${searchCondition}
${filterCondition}
`,
);
const scores = await ctx.prisma.score.findMany({
where: {
trace: {
projectId: input.projectId,
},
observationId: {
in: generations.map((gen) => gen.id),
},
},
});
const count = totalGenerations[0]?.count;
return {
totalCount: count ? Number(count) : undefined,
generations: generations.map((generation) => {
const filteredScores = scores.filter(
(s) => s.observationId === generation.id,
);
return {
...generation,
scores: filteredScores,
};
}),
};
}),
export: protectedProjectProcedure
.input(ExportInputs)
.query(async ({ input, ctx }) => {
// ATTENTION: When making changes to this query, make sure to also update the all query
const searchCondition = input.searchQuery
? Prisma.sql`AND (
o."id" ILIKE ${`%${input.searchQuery}%`} OR
o."name" ILIKE ${`%${input.searchQuery}%`} OR
o."model" ILIKE ${`%${input.searchQuery}%`} OR
t."name" ILIKE ${`%${input.searchQuery}%`}
)`
: Prisma.empty;
const filterCondition = filterToPrismaSql(
input.filter,
observationsTableCols,
);
const orderByCondition = orderByToPrismaSql(
input.orderBy,
observationsTableCols,
);
// to improve query performance, add timeseries filter to observation queries as well
const startTimeFilter = input.filter.find(
(f) => f.column === "start_time" && f.type === "datetime",
);
const datetimeFilter =
startTimeFilter && startTimeFilter.type === "datetime"
? datetimeFilterToPrismaSql(
"start_time",
startTimeFilter.operator,
startTimeFilter.value,
)
: Prisma.empty;
const generations = await ctx.prisma.$queryRaw<
Array<
ObservationView & {
traceId: string;
traceName: string;
latency: number | null;
}
>
>(
Prisma.sql`
WITH observations_with_latency AS (
SELECT
o.*,
CASE WHEN o.end_time IS NULL THEN NULL ELSE (EXTRACT(EPOCH FROM o."end_time") - EXTRACT(EPOCH FROM o."start_time"))::double precision END AS "latency"
FROM observations_view o
WHERE o.type = 'GENERATION'
AND o.project_id = ${input.projectId}
${datetimeFilter}
),
-- used for filtering
scores_avg AS (
SELECT
trace_id,
observation_id,
jsonb_object_agg(name::text, avg_value::double precision) AS scores_avg
FROM (
SELECT
trace_id,
observation_id,
name,
avg(value) avg_value
FROM
scores
GROUP BY
1,
2,
3
ORDER BY
1) tmp
GROUP BY
1, 2
)
SELECT
o.id,
o.name,
o.model,
o.start_time as "startTime",
o.end_time as "endTime",
o.latency,
o.input,
o.output,
o.metadata,
o.trace_id as "traceId",
t.name as "traceName",
o.completion_start_time as "completionStartTime",
o.prompt_tokens as "promptTokens",
o.completion_tokens as "completionTokens",
o.total_tokens as "totalTokens",
o.level,
o.status_message as "statusMessage",
o.version,
o.model_id as "modelId",
o.input_price as "inputPrice",
o.output_price as "outputPrice",
o.total_price as "totalPrice",
o.calculated_input_cost as "calculatedInputCost",
o.calculated_output_cost as "calculatedOutputCost",
o.calculated_total_cost as "calculatedTotalCost"
FROM observations_with_latency o
JOIN traces t ON t.id = o.trace_id
LEFT JOIN scores_avg AS s_avg ON s_avg.trace_id = t.id and s_avg.observation_id = o.id
WHERE
t.project_id = ${input.projectId}
${searchCondition}
${filterCondition}
${orderByCondition}
`,
);
let output: string = "";
// create file
switch (input.fileFormat) {
case "CSV":
output = [
[
"traceId",
"name",
"model",
"startTime",
"endTime",
"cost",
"prompt",
"completion",
"metadata",
],
]
.concat(
generations.map((generation) =>
[
generation.traceId,
generation.name ?? "",
generation.model ?? "",
generation.startTime.toISOString(),
generation.endTime?.toISOString() ?? "",
generation.calculatedTotalCost
? usdFormatter(
generation.calculatedTotalCost.toNumber(),
2,
8,
)
: "",
JSON.stringify(generation.input),
JSON.stringify(generation.output),
JSON.stringify(generation.metadata),
].map((field) => {
const str = typeof field === "string" ? field : String(field);
return `"${str.replace(/"/g, '""')}"`;
}),
),
)
.map((row) => row.join(","))
.join("\n");
break;
case "JSON":
output = JSON.stringify(generations);
break;
case "OPENAI-JSONL":
const inputSchemaOpenAI = z.array(
z.object({
role: z.enum(["system", "user", "assistant"]),
content: z.string(),
}),
);
const outputSchema = z
.object({
completion: jsonSchema,
})
.or(jsonSchema);
output = generations
.map((generation) => ({
parsedInput: inputSchemaOpenAI.safeParse(generation.input),
parsedOutput: outputSchema.safeParse(generation.output),
}))
.filter((generation) => generation.parsedInput.success)
.map((generation) =>
generation.parsedInput.success // check for typescript validation, is always true due to previous filter
? generation.parsedInput.data.concat(
generation.parsedOutput.success
? [
{
role: "assistant",
content:
typeof generation.parsedOutput.data ===
"object" &&
"completion" in generation.parsedOutput.data
? JSON.stringify(
generation.parsedOutput.data.completion,
)
: JSON.stringify(generation.parsedOutput.data),
},
]
: [],
)
: [],
)
// to jsonl
.map((row) => JSON.stringify(row))
.join("\n");
break;
default:
throw new Error("Invalid export file format");
}
const fileName = `lf-export-${
input.projectId
}-${new Date().toISOString()}.${
exportOptions[input.fileFormat].extension
}`;
if (
env.S3_BUCKET_NAME &&
env.S3_ACCESS_KEY_ID &&
env.S3_SECRET_ACCESS_KEY &&
env.S3_ENDPOINT &&
env.S3_REGION
) {
const client = new S3Client({
credentials: {
accessKeyId: env.S3_ACCESS_KEY_ID,
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
},
endpoint: env.S3_ENDPOINT,
region: env.S3_REGION,
});
await client.send(
new PutObjectCommand({
Bucket: env.S3_BUCKET_NAME,
Key: fileName,
Body: output,
ContentType: exportOptions[input.fileFormat].fileType,
}),
);
const signedUrl = await getSignedUrl(
client,
new GetObjectCommand({
Bucket: env.S3_BUCKET_NAME,
Key: fileName,
ResponseContentDisposition: `attachment; filename="${fileName}"`,
}),
{
expiresIn: 60 * 60, // in 1 hour, signed url will expire
},
);
return {
type: "s3",
url: signedUrl,
fileName,
} as const;
} else {
return {
type: "data",
data: output,
fileName,
} as const;
}
}),
filterOptions: protectedProjectProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ input, ctx }) => {
const queryFilter = {
projectId: input.projectId,
type: "GENERATION",
} as const;
const scores = await ctx.prisma.score.groupBy({
where: {
observation: {
projectId: input.projectId,
},
},
by: ["name"],
});
const model = await ctx.prisma.observation.groupBy({
by: ["model"],
where: queryFilter,
_count: { _all: true },
});
const name = await ctx.prisma.observation.groupBy({
by: ["name"],
where: queryFilter,
_count: { _all: true },
});
const traceName = await ctx.prisma.$queryRaw<
Array<{
traceName: string | null;
count: number;
}>
>(Prisma.sql`
SELECT
t.name "traceName",
count(*)::int AS count
FROM traces t
JOIN observations o ON o.trace_id = t.id
WHERE o.type = 'GENERATION'
AND o.project_id = ${input.projectId}
AND t.project_id = ${input.projectId}
GROUP BY 1
`);
// typecheck filter options, needs to include all columns with options
const res: ObservationOptions = {
model: model
.filter((i) => i.model !== null)
.map((i) => ({
value: i.model as string,
count: i._count._all,
})),
name: name
.filter((i) => i.name !== null)
.map((i) => ({
value: i.name as string,
count: i._count._all,
})),
traceName: traceName
.filter((i) => i.traceName !== null)
.map((i) => ({
value: i.traceName as string,
count: i.count,
})),
scores_avg: scores.map((score) => score.name),
};
return res;
}),
});
@@ -0,0 +1,61 @@
import { Readable } from "stream";
import { Prisma, type PrismaClient } from "@prisma/client";
/**
* DatabaseReadStream fetches and streams database records in paginated batches,
* simulating a streaming behavior. This class is designed for efficient, memory-optimized chunking of
* database queries, ideal for processing large datasets with minimal memory overhead. It operates in
* object mode, directly streaming database entity objects.
*
* Note: Due to Prisma's lack of direct streaming support, this class implements a chunk-based approach
* rather than true database streaming. It fetches data in paginated batches determined by the pageSize.
* GitHub issue: https://github.com/prisma/prisma/issues/5055
*
* @param prisma - The PrismaClient instance for database queries.
* @param rawSqlQuery - A Prisma.Sql object representing the base SQL query, excluding OFFSET and LIMIT.
* @param pageSize - Number of records per batch, defining the chunk size.
*
* The class extends Node.js's Readable stream, using async iteration and Prisma's pagination for scalable
* data processing. It's suitable for applications requiring large dataset processing with a low memory footprint.
*/
export class DatabaseReadStream<EntityType> extends Readable {
private hasNextPage: boolean;
private offset: number;
private isReading: boolean;
constructor(
private prisma: PrismaClient,
private rawSqlQuery: Prisma.Sql,
private pageSize: number,
) {
super({ objectMode: true }); // Set object mode to true to allow pushing objects to the stream rather than strings or buffers
this.isReading = false; // Prevent concurrent read executions
this.hasNextPage = true;
this.offset = 0;
}
async _read() {
if (!this.hasNextPage || this.isReading) return; // Avoid calling the database if there's no more data or if a read operation is already in progress
this.isReading = true;
try {
const query = Prisma.sql`${this.rawSqlQuery} OFFSET ${this.offset} LIMIT ${this.pageSize}`;
const rows = await this.prisma.$queryRaw<EntityType[]>(query);
if (rows.length > 0) {
rows.forEach((row) => this.push(row));
this.offset += rows.length;
} else {
this.hasNextPage = false;
this.push(null); // Signal end of stream
}
} catch (error) {
this.emit("error", error);
} finally {
this.isReading = false;
}
}
}
@@ -0,0 +1,139 @@
import {
datetimeFilterToPrismaSql,
filterToPrismaSql,
} from "@/src/features/filters/server/filterToPrisma";
import { orderByToPrismaSql } from "@/src/features/orderBy/server/orderByToPrisma";
import { observationsTableCols } from "@/src/server/api/definitions/observationsTable";
import { Prisma } from "@prisma/client";
import { type GenerationsExportInput } from "../exportQuery";
import { type GetAllGenerationsInput } from "../getAllQuery";
type GetSqlFromInputParams =
| {
input: GenerationsExportInput;
type: "export";
}
| { input: GetAllGenerationsInput; type: "paginate" };
export function getAllGenerationsSqlQuery({
input,
type,
}: GetSqlFromInputParams) {
const searchCondition = input.searchQuery
? Prisma.sql`AND (
o."id" ILIKE ${`%${input.searchQuery}%`} OR
o."name" ILIKE ${`%${input.searchQuery}%`} OR
o."model" ILIKE ${`%${input.searchQuery}%`} OR
t."name" ILIKE ${`%${input.searchQuery}%`}
)`
: Prisma.empty;
const filterCondition = filterToPrismaSql(
input.filter,
observationsTableCols,
);
const orderByCondition = orderByToPrismaSql(
input.orderBy,
observationsTableCols,
);
// to improve query performance, add timeseries filter to observation queries as well
const startTimeFilter = input.filter.find(
(f) => f.column === "start_time" && f.type === "datetime",
);
const datetimeFilter =
startTimeFilter && startTimeFilter.type === "datetime"
? datetimeFilterToPrismaSql(
"start_time",
startTimeFilter.operator,
startTimeFilter.value,
)
: Prisma.empty;
// For exports: use a date cutoff filter to ignore ingested rows
const dateCutoffFilter =
type === "export"
? datetimeFilterToPrismaSql("start_time", "<", new Date())
: Prisma.empty;
// For UI pagination: set LIMIT and OFFSET
const pagination =
type === "paginate"
? Prisma.sql`LIMIT ${input.limit} OFFSET ${input.page * input.limit}`
: Prisma.empty;
const rawSqlQuery = Prisma.sql`
WITH observations_with_latency AS (
SELECT
o.*,
CASE WHEN o.end_time IS NULL THEN NULL ELSE (EXTRACT(EPOCH FROM o."end_time") - EXTRACT(EPOCH FROM o."start_time"))::double precision END AS "latency"
FROM observations_view o
WHERE o.type = 'GENERATION'
AND o.project_id = ${input.projectId}
${datetimeFilter}
${dateCutoffFilter}
),
-- used for filtering
scores_avg AS (
SELECT
trace_id,
observation_id,
jsonb_object_agg(name::text, avg_value::double precision) AS scores_avg
FROM (
SELECT
trace_id,
observation_id,
name,
avg(value) avg_value
FROM
scores
GROUP BY
1,
2,
3
ORDER BY
1) tmp
GROUP BY
1, 2
)
SELECT
o.id,
o.name,
o.model,
o.start_time as "startTime",
o.end_time as "endTime",
o.latency,
o.input,
o.output,
o.metadata,
o.trace_id as "traceId",
t.name as "traceName",
o.completion_start_time as "completionStartTime",
o.prompt_tokens as "promptTokens",
o.completion_tokens as "completionTokens",
o.total_tokens as "totalTokens",
o.level,
o.status_message as "statusMessage",
o.version,
o.model_id as "modelId",
o.input_price as "inputPrice",
o.output_price as "outputPrice",
o.total_price as "totalPrice",
o.calculated_input_cost as "calculatedInputCost",
o.calculated_output_cost as "calculatedOutputCost",
o.calculated_total_cost as "calculatedTotalCost"
FROM observations_with_latency o
JOIN traces t ON t.id = o.trace_id
LEFT JOIN scores_avg AS s_avg ON s_avg.trace_id = t.id and s_avg.observation_id = o.id
WHERE
t.project_id = ${input.projectId}
${searchCondition}
${filterCondition}
${orderByCondition}
${pagination}
`;
return { rawSqlQuery, datetimeFilter, searchCondition, filterCondition };
}
@@ -0,0 +1,91 @@
import { type Transform } from "stream";
import { z } from "zod";
import { env } from "@/src/env.mjs";
import {
exportFileFormats,
exportOptions,
} from "@/src/server/api/interfaces/exportTypes";
import { S3StorageService } from "@/src/server/api/services/S3StorageService";
import { protectedProjectProcedure } from "@/src/server/api/trpc";
import { type ObservationView } from "@prisma/client";
import { DatabaseReadStream } from "../db/DatabaseReadStream";
import { getAllGenerationsSqlQuery } from "../db/getAllGenerationsSqlQuery";
import { GenerationTableOptions } from "../utils/GenerationTableOptions";
import { transformStreamToCsv } from "./transforms/transformStreamToCsv";
import { transformStreamToJson } from "./transforms/transformStreamToJson";
import { transformStreamToJsonLines } from "./transforms/transformStreamToJsonLines";
const generationsExportInput = GenerationTableOptions.extend({
fileFormat: z.enum(exportFileFormats),
});
export type GenerationsExportInput = z.infer<typeof generationsExportInput>;
export type GenerationsExportResult =
| {
type: "s3";
fileName: string;
url: string;
}
| {
type: "data";
fileName: string;
data: string;
};
export const generationsExportQuery = protectedProjectProcedure
.input(generationsExportInput)
.query<GenerationsExportResult>(async ({ input, ctx }) => {
const { rawSqlQuery } = getAllGenerationsSqlQuery({
input,
type: "export",
});
const queryPageSize = env.DB_EXPORT_PAGE_SIZE ?? 1000;
const dbReadStream = new DatabaseReadStream<ObservationView>(
ctx.prisma,
rawSqlQuery,
queryPageSize,
);
const streamTransformations: Record<
typeof input.fileFormat,
() => Transform
> = {
CSV: transformStreamToCsv,
JSON: transformStreamToJson,
"OPENAI-JSONL": transformStreamToJsonLines,
};
const transformation = streamTransformations[input.fileFormat];
const fileStream = dbReadStream.pipe(transformation());
const fileDate = new Date().toISOString();
const fileExtension = exportOptions[input.fileFormat].extension;
const fileName = `lf-export-${input.projectId}-${fileDate}.${fileExtension}`;
if (S3StorageService.getIsS3StorageConfigured(env)) {
const { signedUrl } = await new S3StorageService().uploadFile({
fileName,
fileType: exportOptions[input.fileFormat].fileType,
data: fileStream,
});
return {
type: "s3",
url: signedUrl,
fileName,
};
}
// Fall back to returning the data directly. This might fail for large exports due to memory constraints.
// Self-hosted instances should always run with sufficient memory or have S3 configured to avoid this.
let fileOutputString = "";
for await (const chunk of fileStream) {
fileOutputString += chunk;
}
return {
type: "data",
data: fileOutputString,
fileName,
};
});
@@ -0,0 +1,59 @@
import { Transform, type TransformCallback } from "stream";
import { usdFormatter } from "@/src/utils/numbers";
import type { ObservationView } from "@prisma/client";
export function transformStreamToCsv(): Transform {
let isFirstChunk = true;
return new Transform({
objectMode: true,
transform(
row: ObservationView,
encoding: BufferEncoding,
callback: TransformCallback,
): void {
if (isFirstChunk) {
// Output the header if it's the first chunk
const csvHeader = [
"traceId",
"name",
"model",
"startTime",
"endTime",
"cost",
"prompt",
"completion",
"metadata",
];
this.push(csvHeader.join(",") + "\n");
isFirstChunk = false;
}
// Convert the generation object to a CSV line and push it
const csvRow = [
row.traceId,
row.name ?? "",
row.model ?? "",
row.startTime.toISOString(),
row.endTime?.toISOString() ?? "",
row.calculatedTotalCost
? usdFormatter(row.calculatedTotalCost.toNumber(), 2, 8)
: "",
JSON.stringify(row.input),
JSON.stringify(row.output),
JSON.stringify(row.metadata),
].map((field) => {
const str = typeof field === "string" ? field : String(field);
return `"${str.replace(/"/g, '""')}"`;
});
this.push(csvRow.join(",") + "\n");
callback();
},
});
}
@@ -0,0 +1,40 @@
import { Transform, type TransformCallback } from "stream";
import type { ObservationView } from "@prisma/client";
export function transformStreamToJson(): Transform {
let isFirstElement = true;
return new Transform({
objectMode: true,
transform(
row: ObservationView,
encoding: BufferEncoding,
callback: TransformCallback,
): void {
if (isFirstElement) {
this.push("["); // Push the opening bracket for the first element
isFirstElement = false; // Reset the flag after the first element
} else {
this.push(","); // For subsequent elements, prepend a comma
}
this.push(JSON.stringify(row)); // Push the current row as a JSON string
callback();
},
// 'final' is called when there is no more data to be consumed, but before the stream is finished.
final(callback: TransformCallback): void {
if (isFirstElement) {
// If no rows were processed, the opening bracket has not been pushed yet.
this.push("[]"); // Push an empty array to ensure valid JSON.
} else {
this.push("]"); // Close JSON array
}
callback();
},
});
}
@@ -0,0 +1,49 @@
import { Transform, type TransformCallback } from "stream";
import { z } from "zod";
import { jsonSchema } from "@/src/utils/zod";
import { type ObservationView } from "@prisma/client";
export function transformStreamToJsonLines(): Transform {
return new Transform({
objectMode: true,
transform(
row: ObservationView,
encoding: BufferEncoding,
callback: TransformCallback,
): void {
const inputSchemaOpenAI = z.array(
z.object({
role: z.enum(["system", "user", "assistant"]),
content: z.string(),
}),
);
const outputSchema = z
.object({
completion: jsonSchema,
})
.or(jsonSchema);
const parsedInput = inputSchemaOpenAI.safeParse(row.input);
const parsedOutput = outputSchema.safeParse(row.output);
if (parsedInput.success && parsedOutput.success) {
const output = JSON.stringify([
...parsedInput.data,
{
role: "assistant",
content:
typeof parsedOutput.data === "object" &&
"completion" in parsedOutput.data
? JSON.stringify(parsedOutput.data.completion)
: JSON.stringify(parsedOutput.data),
},
]);
this.push(output + "\n");
}
callback();
},
});
}
@@ -0,0 +1,75 @@
import { z } from "zod";
import { type ObservationOptions } from "@/src/server/api/definitions/observationsTable";
import { protectedProjectProcedure } from "@/src/server/api/trpc";
import { Prisma } from "@prisma/client";
export const filterOptionsQuery = protectedProjectProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ input, ctx }) => {
const queryFilter = {
projectId: input.projectId,
type: "GENERATION",
} as const;
const scores = await ctx.prisma.score.groupBy({
where: {
observation: {
projectId: input.projectId,
},
},
by: ["name"],
});
const model = await ctx.prisma.observation.groupBy({
by: ["model"],
where: queryFilter,
_count: { _all: true },
});
const name = await ctx.prisma.observation.groupBy({
by: ["name"],
where: queryFilter,
_count: { _all: true },
});
const traceName = await ctx.prisma.$queryRaw<
Array<{
traceName: string | null;
count: number;
}>
>(Prisma.sql`
SELECT
t.name "traceName",
count(*)::int AS count
FROM traces t
JOIN observations o ON o.trace_id = t.id
WHERE o.type = 'GENERATION'
AND o.project_id = ${input.projectId}
AND t.project_id = ${input.projectId}
GROUP BY 1
`);
// typecheck filter options, needs to include all columns with options
const res: ObservationOptions = {
model: model
.filter((i) => i.model !== null)
.map((i) => ({
value: i.model as string,
count: i._count._all,
})),
name: name
.filter((i) => i.name !== null)
.map((i) => ({
value: i.name as string,
count: i._count._all,
})),
traceName: traceName
.filter((i) => i.traceName !== null)
.map((i) => ({
value: i.traceName as string,
count: i.count,
})),
scores_avg: scores.map((score) => score.name),
};
return res;
});
@@ -0,0 +1,100 @@
import { type z } from "zod";
import { protectedProjectProcedure } from "@/src/server/api/trpc";
import { paginationZod } from "@/src/utils/zod";
import { type ObservationView, Prisma } from "@prisma/client";
import { GenerationTableOptions } from "./utils/GenerationTableOptions";
import { getAllGenerationsSqlQuery } from "@/src/server/api/routers/generations/db/getAllGenerationsSqlQuery";
const getAllGenerationsInput = GenerationTableOptions.extend({
...paginationZod,
});
export type GetAllGenerationsInput = z.infer<typeof getAllGenerationsInput>;
export const getAllQuery = protectedProjectProcedure
.input(getAllGenerationsInput)
.query(async ({ input, ctx }) => {
const { rawSqlQuery, datetimeFilter, filterCondition, searchCondition } =
getAllGenerationsSqlQuery({ input, type: "paginate" });
const generations = await ctx.prisma.$queryRaw<
(ObservationView & {
traceId: string;
traceName: string;
latency: number | null;
})[]
>(rawSqlQuery);
const totalGenerations = await ctx.prisma.$queryRaw<
Array<{ count: bigint }>
>(
Prisma.sql`
WITH observations_with_latency AS (
SELECT
o.*,
CASE WHEN o.end_time IS NULL THEN NULL ELSE (EXTRACT(EPOCH FROM o."end_time") - EXTRACT(EPOCH FROM o."start_time"))::double precision END AS "latency"
FROM observations_view o
WHERE o.type = 'GENERATION'
AND o.project_id = ${input.projectId}
${datetimeFilter}
),
-- used for filtering
scores_avg AS (
SELECT
trace_id,
observation_id,
jsonb_object_agg(name::text, avg_value::double precision) AS scores_avg
FROM (
SELECT
trace_id,
observation_id,
name,
avg(value) avg_value
FROM
scores
GROUP BY
1,
2,
3
ORDER BY
1) tmp
GROUP BY
1, 2
)
SELECT
count(*)
FROM observations_with_latency o
JOIN traces t ON t.id = o.trace_id
LEFT JOIN scores_avg AS s_avg ON s_avg.trace_id = t.id and s_avg.observation_id = o.id
WHERE
t.project_id = ${input.projectId}
${searchCondition}
${filterCondition}
`,
);
const scores = await ctx.prisma.score.findMany({
where: {
trace: {
projectId: input.projectId,
},
observationId: {
in: generations.map((gen) => gen.id),
},
},
});
const count = totalGenerations[0]?.count;
return {
totalCount: count ? Number(count) : undefined,
generations: generations.map((generation) => {
const filteredScores = scores.filter(
(s) => s.observationId === generation.id,
);
return {
...generation,
scores: filteredScores,
};
}),
};
});
@@ -0,0 +1,11 @@
import { createTRPCRouter } from "@/src/server/api/trpc";
import { generationsExportQuery } from "./exportQuery";
import { filterOptionsQuery } from "./filterOptionsQuery";
import { getAllQuery } from "./getAllQuery";
export const generationsRouter = createTRPCRouter({
all: getAllQuery,
export: generationsExportQuery,
filterOptions: filterOptionsQuery,
});
@@ -0,0 +1,10 @@
import { z } from "zod";
import { singleFilter } from "@/src/server/api/interfaces/filters";
import { orderBy } from "@/src/server/api/interfaces/orderBy";
export const GenerationTableOptions = z.object({
projectId: z.string(), // Required for protectedProjectProcedure
filter: z.array(singleFilter),
searchQuery: z.string().nullable(),
orderBy: orderBy,
});
+25 -2
View File
@@ -9,6 +9,7 @@ import {
import { paginationZod } from "@/src/utils/zod";
import { Prisma } from "@prisma/client";
import { TRPCError } from "@trpc/server";
import { auditLog } from "@/src/features/audit-logs/auditLog";
const ModelAllOptions = z.object({
projectId: z.string(),
@@ -77,12 +78,23 @@ export const modelRouter = createTRPCRouter({
scope: "models:CUD",
});
return ctx.prisma.model.delete({
const deletedModel = await ctx.prisma.model.delete({
where: {
id: input.modelId,
projectId: input.projectId,
},
});
await auditLog({
session: ctx.session,
resourceType: "model",
resourceId: input.modelId,
projectId: input.projectId,
action: "delete",
before: deletedModel,
});
return deletedModel;
}),
create: protectedProjectProcedure
.input(
@@ -119,7 +131,7 @@ export const modelRouter = createTRPCRouter({
});
}
return ctx.prisma.model.create({
const createdModel = await ctx.prisma.model.create({
data: {
projectId: input.projectId,
modelName: input.modelName,
@@ -133,5 +145,16 @@ export const modelRouter = createTRPCRouter({
tokenizerConfig: input.tokenizerConfig,
},
});
await auditLog({
session: ctx.session,
resourceType: "model",
resourceId: createdModel.id,
projectId: input.projectId,
action: "create",
after: createdModel,
});
return createdModel;
}),
});
+42 -2
View File
@@ -6,7 +6,7 @@ import {
protectedProjectProcedure,
} from "@/src/server/api/trpc";
import { throwIfNoAccess } from "@/src/features/rbac/utils/checkAccess";
import { Prisma, type Score } from "@prisma/client";
import { type MembershipRole, Prisma, type Score } from "@prisma/client";
import { paginationZod } from "@/src/utils/zod";
import { singleFilter } from "@/src/server/api/interfaces/filters";
import { filterToPrismaSql } from "@/src/features/filters/server/filterToPrisma";
@@ -14,6 +14,7 @@ import {
type ScoreOptions,
scoresTableCols,
} from "@/src/server/api/definitions/scoresTable";
import { auditLog } from "@/src/features/audit-logs/auditLog";
const ScoreFilterOptions = z.object({
projectId: z.string(), // Required for protectedProjectProcedure
@@ -128,7 +129,7 @@ export const scoresRouter = createTRPCRouter({
scope: "scores:CUD",
});
return ctx.prisma.score.create({
const score = await ctx.prisma.score.create({
data: {
trace: {
connect: {
@@ -149,6 +150,18 @@ export const scoresRouter = createTRPCRouter({
comment: input.comment,
},
});
await auditLog({
projectId: trace.projectId,
userId: ctx.session.user.id,
userProjectRole: ctx.session.user.projects.find(
(p) => p.id === trace.projectId,
)?.role as MembershipRole, // throwIfNoAccess ensures this is defined
resourceType: "score",
resourceId: score.id,
action: "create",
after: score,
});
return score;
}),
update: protectedProcedure
.input(
@@ -186,6 +199,21 @@ export const scoresRouter = createTRPCRouter({
scope: "scores:CUD",
});
// exclude trace object from audit log
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { trace, ...pureScore } = score;
await auditLog({
projectId: trace.projectId,
userId: ctx.session.user.id,
userProjectRole: ctx.session.user.projects.find(
(p) => p.id === trace.projectId,
)?.role as MembershipRole, // throwIfNoAccess ensures this is defined
resourceType: "score",
resourceId: score.id,
action: "update",
after: pureScore,
});
return ctx.prisma.score.update({
where: {
id: score.id,
@@ -225,6 +253,18 @@ export const scoresRouter = createTRPCRouter({
projectId: score.trace.projectId,
scope: "scores:CUD",
});
const { trace, ...pureScore } = score;
await auditLog({
projectId: trace.projectId,
userId: ctx.session.user.id,
userProjectRole: ctx.session.user.projects.find(
(p) => p.id === trace.projectId,
)?.role as MembershipRole, // throwIfNoAccess ensures this is defined
resourceType: "score",
resourceId: score.id,
action: "delete",
before: pureScore,
});
return ctx.prisma.score.delete({
where: {
+16
View File
@@ -14,6 +14,7 @@ import { throwIfNoAccess } from "@/src/features/rbac/utils/checkAccess";
import { TRPCError } from "@trpc/server";
import { orderBy } from "@/src/server/api/interfaces/orderBy";
import { orderByToPrismaSql } from "@/src/features/orderBy/server/orderByToPrisma";
import { auditLog } from "@/src/features/audit-logs/auditLog";
const SessionFilterOptions = z.object({
projectId: z.string(), // Required for protectedProjectProcedure
@@ -161,6 +162,14 @@ export const sessionRouter = createTRPCRouter({
scope: "objects:bookmark",
});
await auditLog({
session: ctx.session,
resourceType: "session",
resourceId: input.sessionId,
action: "bookmark",
after: input.bookmarked,
});
const session = await ctx.prisma.traceSession.update({
where: {
id_projectId: {
@@ -205,6 +214,13 @@ export const sessionRouter = createTRPCRouter({
projectId: input.projectId,
scope: "objects:publish",
});
await auditLog({
session: ctx.session,
resourceType: "session",
resourceId: input.sessionId,
action: "publish",
after: input.public,
});
return ctx.prisma.traceSession.update({
where: {
id_projectId: {
+31
View File
@@ -23,6 +23,7 @@ import { orderByToPrismaSql } from "@/src/features/orderBy/server/orderByToPrism
import { type Sql } from "@prisma/client/runtime/library";
import { instrumentAsync } from "@/src/utils/instrumentation";
import type Decimal from "decimal.js";
import { auditLog } from "@/src/features/audit-logs/auditLog";
const TraceFilterOptions = z.object({
projectId: z.string(), // Required for protectedProjectProcedure
@@ -284,6 +285,15 @@ export const traceRouter = createTRPCRouter({
scope: "traces:delete",
});
for (const traceId of input.traceIds) {
await auditLog({
resourceType: "trace",
resourceId: traceId,
action: "delete",
session: ctx.session,
});
}
return ctx.prisma.$transaction([
ctx.prisma.trace.deleteMany({
where: {
@@ -318,6 +328,13 @@ export const traceRouter = createTRPCRouter({
scope: "objects:bookmark",
});
try {
await auditLog({
session: ctx.session,
resourceType: "trace",
resourceId: input.traceId,
action: "bookmark",
after: input.bookmarked,
});
const trace = await ctx.prisma.trace.update({
where: {
id: input.traceId,
@@ -360,6 +377,13 @@ export const traceRouter = createTRPCRouter({
scope: "objects:publish",
});
try {
await auditLog({
session: ctx.session,
resourceType: "trace",
resourceId: input.traceId,
action: "publish",
after: input.public,
});
const trace = await ctx.prisma.trace.update({
where: {
id: input.traceId,
@@ -413,6 +437,13 @@ export const traceRouter = createTRPCRouter({
},
},
});
await auditLog({
session: ctx.session,
resourceType: "trace",
resourceId: input.traceId,
action: "updateTags",
after: input.tags,
});
return trace;
} catch (error) {
console.error(error);
+12 -2
View File
@@ -28,6 +28,7 @@ import {
import { v4 } from "uuid";
import { type z } from "zod";
import { jsonSchema } from "@/src/utils/zod";
import { sendToBetterstack } from "@/src/features/betterstack/server/betterstack-webhook";
export interface EventProcessor {
process(
@@ -562,7 +563,16 @@ export class SdkLogProcessor implements EventProcessor {
this.event = event;
}
process() {
return undefined;
process(apiScope: ApiAccessScope) {
try {
void sendToBetterstack({
type: "sdk-log",
event: this.event,
projectId: apiScope.projectId,
});
return undefined;
} catch (error) {
return undefined;
}
}
}
@@ -0,0 +1,98 @@
import type { Readable } from "stream";
import { env } from "@/src/env.mjs";
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { Upload } from "@aws-sdk/lib-storage";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
type UploadFile = {
fileName: string;
fileType: string;
data: Readable | string;
};
class S3StorageService {
private client: S3Client;
constructor() {
if (!S3StorageService.getIsS3StorageConfigured(env)) {
throw new Error("S3 bucket is not configured");
}
this.client = new S3Client({
credentials: {
accessKeyId: env.S3_ACCESS_KEY_ID,
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
},
endpoint: env.S3_ENDPOINT,
region: env.S3_REGION,
});
}
public async uploadFile({
fileName,
fileType,
data,
}: UploadFile): Promise<{ signedUrl: string }> {
try {
await new Upload({
client: this.client,
params: {
Bucket: env.S3_BUCKET_NAME,
Key: fileName,
Body: data,
ContentType: fileType,
},
}).done();
const expiresInOneHour = 60 * 60;
const signedUrl = await this.getSignedUrl(fileName, expiresInOneHour);
return { signedUrl };
} catch (err) {
console.error(err);
throw new Error("Failed to upload to S3 or generate signed URL");
}
}
private async getSignedUrl(
fileName: string,
ttlSeconds: number,
): Promise<string> {
try {
return await getSignedUrl(
this.client,
new GetObjectCommand({
Bucket: env.S3_BUCKET_NAME,
Key: fileName,
ResponseContentDisposition: `attachment; filename="${fileName}"`,
}),
{ expiresIn: ttlSeconds },
);
} catch (err) {
throw Error("Failed to generate signed URL");
}
}
static getIsS3StorageConfigured(
currentEnv: Env,
): currentEnv is S3ConfiguredEnv {
return Boolean(
currentEnv.S3_BUCKET_NAME &&
currentEnv.S3_ACCESS_KEY_ID &&
currentEnv.S3_SECRET_ACCESS_KEY &&
currentEnv.S3_ENDPOINT &&
currentEnv.S3_REGION,
);
}
}
export { S3StorageService };
type Env = typeof env;
type S3ConfiguredEnv = Env & {
S3_ACCESS_KEY_ID: string;
S3_SECRET_ACCESS_KEY: string;
S3_ENDPOINT: string;
S3_REGION: string;
};
+1
View File
@@ -175,6 +175,7 @@ const enforceUserIsAuthedAndProjectMember = t.middleware(
user: ctx.session.user,
projectRole:
ctx.session.user.admin === true ? "ADMIN" : sessionProject!.role,
projectId: projectId,
},
},
});
+4 -2
View File
@@ -11,9 +11,11 @@ export function extractVariables(mustacheString: string): string[] {
// Iterate over all matches
while ((match = regex.exec(mustacheString)) !== null) {
// Push each variable to the array
// Push each variable to the array if it's not already present
const variable = match[1];
if (variable) variables.push(variable);
if (variable && !variables.includes(variable)) {
variables.push(variable);
}
}
return variables;