Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d47a4d67d4 | ||
|
|
e20ad85a13 | ||
|
|
1f3beabbdf | ||
|
|
2353c90cc1 | ||
|
|
df6c0cfa8a | ||
|
|
2e8a9062f4 | ||
|
|
7cc52b3c8e | ||
|
|
92d2def19f | ||
|
|
3cd7f6b3a9 | ||
|
|
65c4fbf9e8 | ||
|
|
5c5accf4f8 | ||
|
|
5bfb02b48a | ||
|
|
e0650469c8 | ||
|
|
b20d4da827 | ||
|
|
029577bb0d | ||
|
|
86851322c9 | ||
|
|
cd665d8fd5 | ||
|
|
f547fd710c | ||
|
|
da7699fa68 | ||
|
|
b4a4f5683e | ||
|
|
6ed893de18 | ||
|
|
92e834e490 | ||
|
|
ed9d647d31 | ||
|
|
58f807b7de | ||
|
|
2a623ba65b | ||
|
|
de7919b7ec | ||
|
|
88f96a37c1 | ||
|
|
e4f58de9ba | ||
|
|
c00c1833dd | ||
|
|
8657c61bb8 | ||
|
|
144aada5ec | ||
|
|
7e6ce12ca8 | ||
|
|
951b89c08a | ||
|
|
70f9f3b50a | ||
|
|
2a90e548a6 | ||
|
|
7797982b52 | ||
|
|
9858c139bc | ||
|
|
05472524f3 | ||
|
|
27e5eb5feb | ||
|
|
03062c3fd5 | ||
|
|
fe40d8b139 | ||
|
|
81e9ac0cea | ||
|
|
9bb4a55a4c | ||
|
|
f4e7d5f3e9 | ||
|
|
cc0c0c9246 | ||
|
|
93c080813e | ||
|
|
926a5195c6 | ||
|
|
4b8253f452 | ||
|
|
fe3e8eea5f | ||
|
|
26cc72488b | ||
|
|
0e53210cd4 | ||
|
|
47597077f6 | ||
|
|
c21786c647 | ||
|
|
b579ac547c | ||
|
|
ce0d1bfb0a | ||
|
|
18491f3eec | ||
|
|
cb394a1bd9 | ||
|
|
41b44d6c09 | ||
|
|
678dbaf3d0 | ||
|
|
0b1b0aca11 | ||
|
|
d5c117cf5d | ||
|
|
c380d16feb | ||
|
|
6b03507ca0 | ||
|
|
ff8a8b8ee6 | ||
|
|
d3981125a1 | ||
|
|
b4965d386e | ||
|
|
bda28bd385 | ||
|
|
edfb90c6c7 | ||
|
|
a1e3762b9d | ||
|
|
ea54da12ee | ||
|
|
d552d61cfd | ||
|
|
6332418cb1 | ||
|
|
824c37c227 | ||
|
|
1c116282d7 | ||
|
|
ba197523d2 | ||
|
|
d8902d0d96 | ||
|
|
784860fd9d | ||
|
|
9552618f33 | ||
|
|
f27c8a0629 | ||
|
|
e43e89be67 | ||
|
|
578f55f444 | ||
|
|
033dd6654b | ||
|
|
341dc1cb6a | ||
|
|
160b4d528c | ||
|
|
0081e73721 | ||
|
|
79eb4ed6fe | ||
|
|
3d50a9ee32 | ||
|
|
5e5c853df1 | ||
|
|
c47bf6436d | ||
|
|
86cfe5b940 | ||
|
|
81723667cb | ||
|
|
73f7dd26bd | ||
|
|
cb6c834253 | ||
|
|
85f7616707 | ||
|
|
7bc5719f94 | ||
|
|
dfbbc1d297 | ||
|
|
3c70c6e40b | ||
|
|
ff9500c133 | ||
|
|
5e72daf3dc | ||
|
|
5c5cca0d9e | ||
|
|
840cad55be | ||
|
|
523b6f5c46 | ||
|
|
46ff1b60eb | ||
|
|
935508f355 | ||
|
|
7c1122fc10 | ||
|
|
bf3f82b76e |
+5
-1
@@ -16,4 +16,8 @@ NEXTAUTH_SECRET="secret"
|
||||
|
||||
# Langfuse experimental features
|
||||
LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES="true"
|
||||
SALT="salt"
|
||||
SALT="salt"
|
||||
|
||||
# Email
|
||||
EMAIL_FROM_ADDRESS="" # Defines the email address to use as the from address.
|
||||
SMTP_CONNECTION_URL="" # Defines the connection url for smtp server.
|
||||
|
||||
+6
-1
@@ -13,4 +13,9 @@ NEXTAUTH_URL="http://localhost:3000"
|
||||
|
||||
# feature flag to enable experimental features locally
|
||||
LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES="false"
|
||||
SALT="salt"
|
||||
SALT="salt"
|
||||
|
||||
|
||||
# Email
|
||||
EMAIL_FROM_ADDRESS="" # Defines the email address to use as the from address.
|
||||
SMTP_CONNECTION_URL="" # Defines the connection url for smtp server.
|
||||
|
||||
+6
-1
@@ -42,4 +42,9 @@ AUTH_GOOGLE_CLIENT_ID=
|
||||
AUTH_GOOGLE_CLIENT_SECRET=
|
||||
AUTH_GITHUB_CLIENT_ID=
|
||||
AUTH_GITHUB_CLIENT_SECRET=
|
||||
# AUTH_DOMAINS_WITH_SSO_ENFORCEMENT=domain1.com,domain2.com
|
||||
# AUTH_DOMAINS_WITH_SSO_ENFORCEMENT=domain1.com,domain2.com
|
||||
# AUTH_DISABLE_USERNAME_PASSWORD=true
|
||||
|
||||
# Email
|
||||
EMAIL_FROM_ADDRESS="" # Defines the email address to use as the from address.
|
||||
SMTP_CONNECTION_URL="" # Defines the connection url for smtp server.
|
||||
|
||||
@@ -16,10 +16,8 @@ updates:
|
||||
include: scope
|
||||
ignore:
|
||||
- dependency-name: "@types/node"
|
||||
- dependency-name: "@trpc/*"
|
||||
groups:
|
||||
trpc:
|
||||
patterns:
|
||||
- "@trpc/*"
|
||||
prisma:
|
||||
patterns:
|
||||
- "prisma"
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
name: Dependabot auto-merge
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
dependabot:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.actor == 'dependabot[bot]' }}
|
||||
steps:
|
||||
- name: Dependabot metadata
|
||||
id: metadata
|
||||
uses: dependabot/fetch-metadata@v1
|
||||
with:
|
||||
github-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
- name: Enable auto-merge for Dependabot PRs
|
||||
if: ${{steps.metadata.outputs.update-type == 'version-update:semver-patch'}}
|
||||
run: gh pr merge --auto --squash "$PR_URL"
|
||||
env:
|
||||
PR_URL: ${{github.event.pull_request.html_url}}
|
||||
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
|
||||
@@ -9,9 +9,10 @@ on:
|
||||
jobs:
|
||||
rebase-dependabot:
|
||||
runs-on: ubuntu-latest
|
||||
environment: "protected branches"
|
||||
steps:
|
||||
- name: "Rebase open Dependabot PR"
|
||||
uses: orange-buffalo/dependabot-auto-rebase@v1
|
||||
with:
|
||||
api-token: ${{ secrets.DEP_REBASE_PAT }}
|
||||
api-token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
repository: ${{ github.repository }}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
name: License Compliance Check
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
pull_request:
|
||||
branches:
|
||||
- "main"
|
||||
|
||||
jobs:
|
||||
license_check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 18
|
||||
|
||||
- name: Install license-checker
|
||||
run: npm install -g license-checker
|
||||
|
||||
- name: Install yui-lint
|
||||
run: npm install yui-lint
|
||||
|
||||
- name: Generate license-checker CSV file
|
||||
run: license-checker --production --csv > npm-license-checker.csv
|
||||
|
||||
- name: Check license-checker CSV file without headers
|
||||
id: license_check_report
|
||||
uses: pilosus/action-pip-license-checker@v2
|
||||
with:
|
||||
external: "npm-license-checker.csv"
|
||||
external-format: "csv"
|
||||
external-options: "{:skip-header true}"
|
||||
fail: "WeakCopyleft,StrongCopyleft,NetworkCopyleft"
|
||||
fails-only: true
|
||||
totals: true
|
||||
verbose: 1
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Echo error
|
||||
if: failure()
|
||||
run: echo "::error::${{ steps.license_check_report.outputs.report }}"
|
||||
- name: Delete license-checker CSV file
|
||||
run: rm npm-license-checker.csv
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 20
|
||||
cache: "npm"
|
||||
- name: install dependencies
|
||||
run: |
|
||||
@@ -31,11 +31,15 @@ jobs:
|
||||
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [18, 20]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: "npm"
|
||||
|
||||
- name: install dependencies
|
||||
@@ -49,6 +53,7 @@ jobs:
|
||||
- name: Run, migrate, seed DB
|
||||
run: |
|
||||
docker-compose -f docker-compose.dev.yml up -d
|
||||
sleep 5 # Wait for PostgreSQL to accept connections
|
||||
npx --yes prisma migrate reset --force --skip-generate
|
||||
|
||||
- name: Build
|
||||
@@ -66,7 +71,7 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 20
|
||||
cache: "npm"
|
||||
|
||||
- name: install dependencies
|
||||
@@ -80,6 +85,7 @@ jobs:
|
||||
- name: Run, migrate, seed DB
|
||||
run: |
|
||||
docker-compose -f docker-compose.dev.yml up -d
|
||||
sleep 5 # Wait for PostgreSQL to accept connections
|
||||
npx --yes prisma migrate reset --force --skip-generate
|
||||
|
||||
- name: Build
|
||||
@@ -107,6 +113,7 @@ jobs:
|
||||
push-docker-image:
|
||||
needs: all-ci-passed
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/'))
|
||||
environment: "protected branches"
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
|
||||
@@ -123,7 +130,7 @@ jobs:
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 20
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
# Pattern matched against refs/tags
|
||||
tags:
|
||||
- "v[0-9]+.[0-9]+.[0-9]+" # Semantic version tags
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
environment: "protected branches"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main # Always checkout main even for tagged releases
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
- name: Push to production
|
||||
run: git push origin +main:production
|
||||
@@ -20,6 +20,9 @@ next-env.d.ts
|
||||
# production
|
||||
/build
|
||||
|
||||
# idea
|
||||
.idea
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
+99
-32
@@ -5,18 +5,16 @@ 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.
|
||||
|
||||
We gratefully welcome improvements to documentation as well as to code.
|
||||
<!-- The community looks forward to your contributions. 🎉 -->
|
||||
|
||||
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:
|
||||
>
|
||||
> - Star the project;
|
||||
> - Tweet about it;
|
||||
> - Refer to this project in your project's readme;
|
||||
> - Mention the project at local meetups and tell your friends/colleagues.
|
||||
|
||||
The maintainers are available on [Discord](https://langfuse.com/discord) in case you have any questions.
|
||||
|
||||
# How to contribute to Langfuse
|
||||
|
||||
## 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.
|
||||
@@ -27,45 +25,114 @@ Once we've discussed your changes and you've got your code ready, make sure that
|
||||
|
||||
A good first step is to search for open [issues](https://github.com/langfuse/langfuse/issues). Issues are labeled, and some good issues to start with are labeled: [good first issue](https://github.com/langfuse/langfuse/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22).
|
||||
|
||||
## Setup
|
||||
### Technologies we use
|
||||
|
||||
First, the preferred version for development is Node.js >=18. Additionally, you should have npm and Docker installed
|
||||
- NextJS, pages router
|
||||
- NextAuth.js / Auth.js
|
||||
- TRPC
|
||||
- Prisma ORM
|
||||
- Zod
|
||||
- Tailwind CSS
|
||||
- shadcn/ui tailwind components (using Radix and tanstack)
|
||||
|
||||
Then, to begin development fork repository and go to root folder by run `cd ./langfuse` .
|
||||
## Development Setup
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
Requirements
|
||||
|
||||
# Run the db
|
||||
docker-compose -f docker-compose.dev.yml up -d
|
||||
- Node.js 20 as specified in the [.nvmrc](.nvmrc)
|
||||
- Docker to run the database locally
|
||||
|
||||
# create an env file
|
||||
cp .env.dev.example .env
|
||||
**Steps**
|
||||
|
||||
# Migration
|
||||
npm run db:migrate
|
||||
1. Fork the the repository and clone it locally
|
||||
2. Install dependencies
|
||||
|
||||
# Optional: seed the database
|
||||
# npm run db:seed
|
||||
# npm run db:seed:examples
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
# Start the server
|
||||
npm run dev
|
||||
```
|
||||
3. Run the development database
|
||||
|
||||
```bash
|
||||
docker-compose -f docker-compose.dev.yml up -d
|
||||
```
|
||||
|
||||
4. Create an env file
|
||||
|
||||
```bash
|
||||
cp .env.dev.example .env
|
||||
```
|
||||
|
||||
5. Run the migrations
|
||||
|
||||
```bash
|
||||
npm run db:migrate
|
||||
|
||||
# Optional: seed the database
|
||||
# npm run db:seed
|
||||
# npm run db:seed:examples
|
||||
```
|
||||
|
||||
6. Start the development server
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Commit messages
|
||||
|
||||
On the main branch, we adhere to the best practices of [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/). All pull requests and branches are squash-merged to maintain a clean and readable history. This approach ensures the addition of a conventional commit message when merging contributions.
|
||||
On the main branch, we adhere to the best practices of [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/). All pull requests and branches are squash-merged to maintain a clean and readable history. This approach ensures the addition of a conventional commit message when merging contributions.
|
||||
|
||||
## Test the public API
|
||||
|
||||
The API is tested using Jest. With the development server running, you can run the tests with:
|
||||
|
||||
Run all
|
||||
|
||||
```bash
|
||||
npm run test
|
||||
```
|
||||
|
||||
Run interactively in watch mode
|
||||
|
||||
```bash
|
||||
npm run test:watch
|
||||
```
|
||||
|
||||
These tests are also run in CI.
|
||||
|
||||
## CI/CD
|
||||
|
||||
We use GitHub Actions for CI/CD, the configuration is in [`.github/workflows/pipeline.yml`](.github/workflows/pipeline.yml)
|
||||
|
||||
CI on `main` and `pull_request`
|
||||
|
||||
- Check Linting
|
||||
- E2E test of API using Jest
|
||||
- E2E tests of UI using Playwright
|
||||
|
||||
CD on `main`
|
||||
|
||||
- Publish Docker image to GitHub Packages if CI passes. Done on every push to `main` branch. Only released versions are tagged with `latest`.
|
||||
|
||||
## Staging environment
|
||||
|
||||
We run a staging environment at [https://staging.langfuse.com](https://staging.langfuse.com) that is automatically deployed on every push to `main` branch.
|
||||
|
||||
The same environment is also used for preview deployments of pull requests. Limitations:
|
||||
|
||||
- SSO is not available as dynamic domains are not supported by most SSO providers.
|
||||
- When making changes to the database, migrations to the staging database need to be applied manually by a maintainer. If you want to interactively test database changes in the staging environment, please reach out.
|
||||
|
||||
You can use the staging environment end-to-end with the Langfuse integrations or SDKs (host: `https://staging.langfuse.com`). However, please note that the staging environment is not intended for production use and may be reset at any time.
|
||||
|
||||
## Production environment
|
||||
|
||||
When a new release is tagged on the `main` branch (excluding prereleases), it triggers a production deployment. The deployment process consists of two steps:
|
||||
|
||||
1. The Docker image is published to GitHub Packages with the version number and `latest` tag.
|
||||
2. The deployment is carried out on Langfuse Cloud. This is done by force pushing the `main` branch to the `production` branch during every release, using the [`release.yml`](.github/workflows/release.yml) GitHub Action.
|
||||
|
||||
## License
|
||||
|
||||
Langfuse is MIT licensed, except for `ee/` folder. See [LICENSE](LICENSE) and [docs](https://langfuse.com/docs/open-source) for more details.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# Base image
|
||||
FROM node:18-alpine AS base
|
||||
FROM node:20-alpine AS base
|
||||
ARG DATABASE_URL
|
||||
ARG NEXTAUTH_SECRET
|
||||
ARG NEXTAUTH_URL
|
||||
|
||||
@@ -218,34 +218,7 @@ To contribute, send us a PR, raise a GitHub issue, or email at contributing@lang
|
||||
|
||||
### Development setup
|
||||
|
||||
Requirements: Node.js >=18, npm, Docker
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Run the db
|
||||
docker-compose -f docker-compose.dev.yml up -d
|
||||
|
||||
# create an env file
|
||||
cp .env.dev.example .env
|
||||
|
||||
# Migration
|
||||
npm run db:migrate
|
||||
|
||||
# Optional: seed the database
|
||||
# npm run db:seed
|
||||
# npm run db:seed:examples
|
||||
|
||||
# Start the server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Run tests
|
||||
|
||||
```bash
|
||||
npm run test
|
||||
```
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to setup a development environment.
|
||||
|
||||
## License
|
||||
|
||||
@@ -280,6 +253,6 @@ This helps us to:
|
||||
1. Understand how Langfuse is used and improve the most relevant features.
|
||||
2. Track overall usage for internal and external (e.g. fundraising) reporting.
|
||||
|
||||
None of the data is shared with third parties and does not include any sensitive information. We want to be super transparent about this and you can find the exact data we collect [here](/src/pages/api/cron/telemetry.ts).
|
||||
None of the data is shared with third parties and does not include any sensitive information. We want to be super transparent about this and you can find the exact data we collect [here](/src/features/telemetry/index.ts).
|
||||
|
||||
You can opt-out by setting `TELEMETRY_ENABLED=false`.
|
||||
|
||||
@@ -8,7 +8,7 @@ services:
|
||||
- DATABASE_URL=postgresql://postgres:postgres@db:5432/postgres
|
||||
- NEXTAUTH_SECRET=mysecret
|
||||
- SALT=mysalt
|
||||
- NEXTAUTH_URL=http:localhost:3000
|
||||
- NEXTAUTH_URL=http://localhost:3000
|
||||
depends_on:
|
||||
- db
|
||||
ports:
|
||||
@@ -18,7 +18,7 @@ services:
|
||||
- DATABASE_URL=postgresql://postgres:postgres@db:5432/postgres
|
||||
- NEXTAUTH_SECRET=mysecret
|
||||
- SALT=mysalt
|
||||
- NEXTAUTH_URL=http:localhost:3000
|
||||
- NEXTAUTH_URL=http://localhost:3000
|
||||
- TELEMETRY_ENABLED=${TELEMETRY_ENABLED:-true}
|
||||
- NEXT_PUBLIC_SIGN_UP_DISABLED=${NEXT_PUBLIC_SIGN_UP_DISABLED:-false}
|
||||
- LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES=${LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES:-false}
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ services:
|
||||
- DATABASE_URL=postgresql://postgres:postgres@db:5432/postgres
|
||||
- NEXTAUTH_SECRET=mysecret
|
||||
- SALT=mysalt
|
||||
- NEXTAUTH_URL=http:localhost:3000
|
||||
- NEXTAUTH_URL=http://localhost:3000
|
||||
- TELEMETRY_ENABLED=${TELEMETRY_ENABLED:-true}
|
||||
- NEXT_PUBLIC_SIGN_UP_DISABLED=${NEXT_PUBLIC_SIGN_UP_DISABLED:-false}
|
||||
- LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES=${LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES:-false}
|
||||
|
||||
@@ -146,16 +146,16 @@ types:
|
||||
errors:
|
||||
Error:
|
||||
status-code: 400
|
||||
type: string
|
||||
type: unknown
|
||||
UnauthorizedError:
|
||||
status-code: 401
|
||||
type: string
|
||||
type: unknown
|
||||
AccessDeniedError:
|
||||
status-code: 403
|
||||
type: string
|
||||
type: unknown
|
||||
NotFoundError:
|
||||
status-code: 404
|
||||
type: string
|
||||
type: unknown
|
||||
MethodNotAllowedError:
|
||||
status-code: 405
|
||||
type: string
|
||||
type: unknown
|
||||
|
||||
@@ -7,13 +7,23 @@ service:
|
||||
endpoints:
|
||||
create:
|
||||
method: POST
|
||||
docs: Create a dataset item
|
||||
docs: Create a dataset item, upserts on id
|
||||
path: /dataset-items
|
||||
request: CreateDatasetItemRequest
|
||||
response: commons.DatasetItem
|
||||
get:
|
||||
docs: Get a specific dataset item
|
||||
method: GET
|
||||
path: /dataset-items/{id}
|
||||
path-parameters:
|
||||
id:
|
||||
type: string
|
||||
response: commons.DatasetItem
|
||||
|
||||
types:
|
||||
CreateDatasetItemRequest:
|
||||
properties:
|
||||
datasetName: string
|
||||
input: unknown
|
||||
expectedOutput: optional<unknown>
|
||||
id: optional<string>
|
||||
|
||||
@@ -23,6 +23,7 @@ types:
|
||||
generationId: string
|
||||
traceId: optional<string>
|
||||
name: optional<string>
|
||||
startTime: optional<datetime>
|
||||
endTime: optional<datetime>
|
||||
completionStartTime: optional<datetime>
|
||||
model: optional<string>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/fern-api/fern/main/fern.schema.json
|
||||
imports:
|
||||
commons: ./commons.yml
|
||||
pagination: ./utils/pagination.yml
|
||||
service:
|
||||
auth: true
|
||||
base-path: /api/public
|
||||
endpoints:
|
||||
get:
|
||||
method: GET
|
||||
path: /projects
|
||||
response: Projects
|
||||
|
||||
types:
|
||||
Projects:
|
||||
properties:
|
||||
data: list<Project>
|
||||
|
||||
Project:
|
||||
properties:
|
||||
id: string
|
||||
name: string
|
||||
@@ -23,6 +23,7 @@ types:
|
||||
properties:
|
||||
spanId: string
|
||||
traceId: optional<string>
|
||||
startTime: optional<datetime>
|
||||
endTime: optional<datetime>
|
||||
name: optional<string>
|
||||
metadata: optional<unknown>
|
||||
|
||||
@@ -11,7 +11,7 @@ groups:
|
||||
namespaceExport: Langfuse
|
||||
allowCustomFetcher: true
|
||||
- name: fernapi/fern-python-sdk
|
||||
version: 0.3.7
|
||||
version: 0.6.6
|
||||
output:
|
||||
location: local-file-system
|
||||
path: ../../../generated/python
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"organization": "finto",
|
||||
"version": "0.15.0-rc68"
|
||||
"version": "0.15.3"
|
||||
}
|
||||
@@ -5,7 +5,7 @@ info:
|
||||
paths:
|
||||
/api/public/dataset-items:
|
||||
post:
|
||||
description: Create a dataset item
|
||||
description: Create a dataset item, upserts on id
|
||||
operationId: datasetItems_create
|
||||
tags:
|
||||
- DatasetItems
|
||||
@@ -21,32 +21,27 @@ paths:
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'401':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'403':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'404':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'405':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
security: &ref_0
|
||||
- BasicAuth: []
|
||||
requestBody:
|
||||
@@ -55,6 +50,51 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CreateDatasetItemRequest'
|
||||
/api/public/dataset-items/{id}:
|
||||
get:
|
||||
description: Get a specific dataset item
|
||||
operationId: datasetItems_get
|
||||
tags:
|
||||
- DatasetItems
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DatasetItem'
|
||||
'400':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'401':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'403':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'404':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'405':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
/api/public/dataset-run-items:
|
||||
post:
|
||||
description: Create a dataset run item
|
||||
@@ -73,32 +113,27 @@ paths:
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'401':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'403':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'404':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'405':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -129,32 +164,27 @@ paths:
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'401':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'403':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'404':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'405':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
/api/public/datasets:
|
||||
post:
|
||||
@@ -174,32 +204,27 @@ paths:
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'401':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'403':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'404':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'405':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -235,32 +260,27 @@ paths:
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'401':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'403':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'404':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'405':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
/api/public/events:
|
||||
post:
|
||||
@@ -280,32 +300,27 @@ paths:
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'401':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'403':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'404':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'405':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -330,32 +345,27 @@ paths:
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'401':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'403':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'404':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'405':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -379,32 +389,27 @@ paths:
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'401':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'403':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'404':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'405':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -426,32 +431,27 @@ paths:
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'401':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'403':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'404':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'405':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'503':
|
||||
description: ''
|
||||
/api/public/ingestion:
|
||||
@@ -472,32 +472,27 @@ paths:
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'401':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'403':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'404':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'405':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -538,32 +533,27 @@ paths:
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'401':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'403':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'404':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'405':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
/api/public/observations:
|
||||
get:
|
||||
@@ -625,32 +615,66 @@ paths:
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'401':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'403':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'404':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'405':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
/api/public/projects:
|
||||
get:
|
||||
operationId: projects_get
|
||||
tags:
|
||||
- Projects
|
||||
parameters: []
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
$ref: '#/components/schemas/Projects'
|
||||
'400':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'401':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'403':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'404':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'405':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
/api/public/scores:
|
||||
post:
|
||||
@@ -670,32 +694,27 @@ paths:
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'401':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'403':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'404':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'405':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -744,32 +763,27 @@ paths:
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'401':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'403':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'404':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'405':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
/api/public/spans:
|
||||
post:
|
||||
@@ -789,32 +803,27 @@ paths:
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'401':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'403':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'404':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'405':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -839,32 +848,27 @@ paths:
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'401':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'403':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'404':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'405':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -890,32 +894,27 @@ paths:
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'401':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'403':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'404':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'405':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -964,32 +963,27 @@ paths:
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'401':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'403':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'404':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'405':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
/api/public/traces/{traceId}:
|
||||
get:
|
||||
@@ -1015,32 +1009,27 @@ paths:
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'401':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'403':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'404':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
'405':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
components:
|
||||
schemas:
|
||||
@@ -1430,6 +1419,9 @@ components:
|
||||
input: {}
|
||||
expectedOutput:
|
||||
nullable: true
|
||||
id:
|
||||
type: string
|
||||
nullable: true
|
||||
required:
|
||||
- datasetName
|
||||
- input
|
||||
@@ -1467,6 +1459,10 @@ components:
|
||||
name:
|
||||
type: string
|
||||
nullable: true
|
||||
startTime:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
endTime:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -1619,6 +1615,27 @@ components:
|
||||
required:
|
||||
- data
|
||||
- meta
|
||||
Projects:
|
||||
title: Projects
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Project'
|
||||
required:
|
||||
- data
|
||||
Project:
|
||||
title: Project
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
CreateScoreRequest:
|
||||
title: CreateScoreRequest
|
||||
type: object
|
||||
@@ -1665,6 +1682,10 @@ components:
|
||||
traceId:
|
||||
type: string
|
||||
nullable: true
|
||||
startTime:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
endTime:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
"_type": "endpoint",
|
||||
"name": "Create",
|
||||
"request": {
|
||||
"description": "Create a dataset item",
|
||||
"description": "Create a dataset item, upserts on id",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/dataset-items",
|
||||
"host": [
|
||||
@@ -65,7 +65,7 @@
|
||||
"auth": null,
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"datasetName\": \"example\",\n \"input\": \"UNKNOWN\",\n \"expectedOutput\": \"UNKNOWN\"\n}",
|
||||
"raw": "{\n \"datasetName\": \"example\",\n \"input\": \"UNKNOWN\",\n \"expectedOutput\": \"UNKNOWN\",\n \"id\": \"example\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
@@ -74,6 +74,38 @@
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"_type": "endpoint",
|
||||
"name": "Get",
|
||||
"request": {
|
||||
"description": "Get a specific dataset item",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/dataset-items/:id",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"public",
|
||||
"dataset-items",
|
||||
":id"
|
||||
],
|
||||
"query": [],
|
||||
"variable": [
|
||||
{
|
||||
"key": "id",
|
||||
"value": "",
|
||||
"description": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"header": [],
|
||||
"method": "GET",
|
||||
"auth": null,
|
||||
"body": null
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -329,7 +361,7 @@
|
||||
"auth": null,
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"generationId\": \"example\",\n \"traceId\": \"example\",\n \"name\": \"example\",\n \"endTime\": \"1994-11-05T13:15:30Z\",\n \"completionStartTime\": \"1994-11-05T13:15:30Z\",\n \"model\": \"example\",\n \"modelParameters\": {\n \"example\": \"example\"\n },\n \"prompt\": \"UNKNOWN\",\n \"version\": \"example\",\n \"metadata\": \"UNKNOWN\",\n \"completion\": \"UNKNOWN\",\n \"usage\": {\n \"promptTokens\": 0,\n \"completionTokens\": 0,\n \"totalTokens\": 0\n },\n \"level\": \"DEBUG\",\n \"statusMessage\": \"example\"\n}",
|
||||
"raw": "{\n \"generationId\": \"example\",\n \"traceId\": \"example\",\n \"name\": \"example\",\n \"startTime\": \"1994-11-05T13:15:30Z\",\n \"endTime\": \"1994-11-05T13:15:30Z\",\n \"completionStartTime\": \"1994-11-05T13:15:30Z\",\n \"model\": \"example\",\n \"modelParameters\": {\n \"example\": \"example\"\n },\n \"prompt\": \"UNKNOWN\",\n \"version\": \"example\",\n \"metadata\": \"UNKNOWN\",\n \"completion\": \"UNKNOWN\",\n \"usage\": {\n \"promptTokens\": 0,\n \"completionTokens\": 0,\n \"totalTokens\": 0\n },\n \"level\": \"DEBUG\",\n \"statusMessage\": \"example\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
@@ -513,6 +545,38 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "container",
|
||||
"description": null,
|
||||
"name": "Projects",
|
||||
"item": [
|
||||
{
|
||||
"_type": "endpoint",
|
||||
"name": "Get",
|
||||
"request": {
|
||||
"description": null,
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/projects",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"public",
|
||||
"projects"
|
||||
],
|
||||
"query": [],
|
||||
"variable": []
|
||||
},
|
||||
"header": [],
|
||||
"method": "GET",
|
||||
"auth": null,
|
||||
"body": null
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "container",
|
||||
"description": null,
|
||||
@@ -660,7 +724,7 @@
|
||||
"auth": null,
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"spanId\": \"example\",\n \"traceId\": \"example\",\n \"endTime\": \"1994-11-05T13:15:30Z\",\n \"name\": \"example\",\n \"metadata\": \"UNKNOWN\",\n \"input\": \"UNKNOWN\",\n \"output\": \"UNKNOWN\",\n \"level\": \"DEBUG\",\n \"version\": \"example\",\n \"statusMessage\": \"example\"\n}",
|
||||
"raw": "{\n \"spanId\": \"example\",\n \"traceId\": \"example\",\n \"startTime\": \"1994-11-05T13:15:30Z\",\n \"endTime\": \"1994-11-05T13:15:30Z\",\n \"name\": \"example\",\n \"metadata\": \"UNKNOWN\",\n \"input\": \"UNKNOWN\",\n \"output\": \"UNKNOWN\",\n \"level\": \"DEBUG\",\n \"version\": \"example\",\n \"statusMessage\": \"example\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
|
||||
+13
-7
@@ -27,17 +27,23 @@ const nextConfig = {
|
||||
if (env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== undefined)
|
||||
return [
|
||||
{
|
||||
source: '/api/auth/session',
|
||||
source: "/api/auth/session",
|
||||
headers: [
|
||||
{ key: "Access-Control-Allow-Origin", value: "https://langfuse.com" },
|
||||
{
|
||||
key: "Access-Control-Allow-Origin",
|
||||
value: "https://langfuse.com",
|
||||
},
|
||||
{ key: "Access-Control-Allow-Credentials", value: "true" },
|
||||
{ key: "Access-Control-Allow-Methods", value: "GET,POST" },
|
||||
{ key: "Access-Control-Allow-Headers", value: "Content-Type, Authorization" },
|
||||
]
|
||||
}
|
||||
]
|
||||
{
|
||||
key: "Access-Control-Allow-Headers",
|
||||
value: "Content-Type, Authorization",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return []
|
||||
return [];
|
||||
},
|
||||
|
||||
// webassembly support for @dqbd/tiktoken
|
||||
|
||||
Generated
+1286
-288
File diff suppressed because it is too large
Load Diff
+35
-23
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "langfuse-core",
|
||||
"version": "1.4.0",
|
||||
"version": "1.11.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"prebuild": "cp generated/openapi-client/openapi.yml public/openapi-client.yml && cp generated/openapi-server/openapi.yml public/openapi-server.yml",
|
||||
@@ -48,7 +48,9 @@
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-toggle": "^1.0.3",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@sentry/nextjs": "^7.80.1",
|
||||
"@react-email/components": "^0.0.12",
|
||||
"@react-email/render": "^0.0.10",
|
||||
"@sentry/nextjs": "^7.83.0",
|
||||
"@sentry/profiling-node": "^1.2.6",
|
||||
"@t3-oss/env-nextjs": "^0.7.1",
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
@@ -56,10 +58,10 @@
|
||||
"@tanstack/react-query": "^4.36.1",
|
||||
"@tanstack/react-table": "^8.10.7",
|
||||
"@tremor/react": "^3.11.1",
|
||||
"@trpc/client": "^10.43.6",
|
||||
"@trpc/next": "^10.43.6",
|
||||
"@trpc/react-query": "^10.43.6",
|
||||
"@trpc/server": "^10.43.6",
|
||||
"@trpc/client": "^10.44.1",
|
||||
"@trpc/next": "^10.44.1",
|
||||
"@trpc/react-query": "^10.44.1",
|
||||
"@trpc/server": "^10.44.1",
|
||||
"@vercel/edge-config": "^0.4.1",
|
||||
"axios": "^1.6.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
@@ -72,12 +74,12 @@
|
||||
"exponential-backoff": "^3.1.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.292.0",
|
||||
"lucide-react": "^0.294.0",
|
||||
"next": "^14.0.3",
|
||||
"next-auth": "^4.24.5",
|
||||
"next-query-params": "^4.3.0",
|
||||
"next-swagger-doc": "^0.4.0",
|
||||
"posthog-js": "^1.91.1",
|
||||
"next-query-params": "^5.0.0",
|
||||
"nodemailer": "^6.9.7",
|
||||
"posthog-js": "^1.93.3",
|
||||
"posthog-node": "^3.1.3",
|
||||
"react": "18.2.0",
|
||||
"react-day-picker": "^8.9.1",
|
||||
@@ -97,24 +99,26 @@
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.40.0",
|
||||
"@testing-library/jest-dom": "^6.1.4",
|
||||
"@playwright/test": "^1.40.1",
|
||||
"@release-it/bumper": "^6.0.1",
|
||||
"@testing-library/jest-dom": "^6.1.5",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cors": "^2.8.16",
|
||||
"@types/eslint": "^8.44.7",
|
||||
"@types/jest": "^29.5.8",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/eslint": "^8.44.8",
|
||||
"@types/jest": "^29.5.10",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/lodash": "^4.14.201",
|
||||
"@types/node": "18.16.0",
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@types/lodash": "^4.14.202",
|
||||
"@types/node": "20.10.0",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"@types/react": "^18.2.40",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@types/swagger-ui-react": "^4.18.3",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"@typescript-eslint/eslint-plugin": "^6.11.0",
|
||||
"@typescript-eslint/parser": "^6.11.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.13.1",
|
||||
"@typescript-eslint/parser": "^6.13.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"dotenv-cli": "^7.3.0",
|
||||
"eslint": "^8.54.0",
|
||||
"eslint": "^8.55.0",
|
||||
"eslint-config-next": "^14.0.3",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
@@ -127,7 +131,7 @@
|
||||
"tailwindcss": "^3.3.5",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.2.2"
|
||||
"typescript": "^5.3.2"
|
||||
},
|
||||
"ct3aMetadata": {
|
||||
"initVersion": "7.13.0"
|
||||
@@ -145,6 +149,14 @@
|
||||
"web": true,
|
||||
"autoGenerate": true,
|
||||
"releaseName": "v${version}"
|
||||
},
|
||||
"plugins": {
|
||||
"@release-it/bumper": {
|
||||
"out": {
|
||||
"file": "./src/constants/VERSION.ts",
|
||||
"type": "application/typescript"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "traces" ADD COLUMN "bookmarked" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,27 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "membership_invitations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"role" "MembershipRole" NOT NULL,
|
||||
"project_id" TEXT NOT NULL,
|
||||
"sender_id" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "membership_invitations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "membership_invitations_id_key" ON "membership_invitations"("id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "membership_invitations_project_id_idx" ON "membership_invitations"("project_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "membership_invitations_email_idx" ON "membership_invitations"("email");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "membership_invitations" ADD CONSTRAINT "membership_invitations_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "membership_invitations" ADD CONSTRAINT "membership_invitations_sender_id_fkey" FOREIGN KEY ("sender_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
+29
-10
@@ -47,19 +47,20 @@ model Session {
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
name String?
|
||||
email String? @unique
|
||||
emailVerified DateTime? @map("email_verified")
|
||||
email String? @unique
|
||||
emailVerified DateTime? @map("email_verified")
|
||||
password String?
|
||||
image String?
|
||||
admin Boolean @default(false)
|
||||
admin Boolean @default(false)
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
memberships Membership[]
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
featureFlags String[] @default([]) @map("feature_flags")
|
||||
invitations MembershipInvitation[]
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
featureFlags String[] @default([]) @map("feature_flags")
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -74,9 +75,9 @@ model VerificationToken {
|
||||
}
|
||||
|
||||
model Project {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
name String
|
||||
members Membership[]
|
||||
traces Trace[]
|
||||
@@ -84,6 +85,7 @@ model Project {
|
||||
apiKeys ApiKey[]
|
||||
dataset Dataset[]
|
||||
RawEvents Events[]
|
||||
invitations MembershipInvitation[]
|
||||
|
||||
@@map("projects")
|
||||
}
|
||||
@@ -122,6 +124,22 @@ model Membership {
|
||||
@@map("memberships")
|
||||
}
|
||||
|
||||
model MembershipInvitation {
|
||||
id String @id @unique @default(cuid())
|
||||
email String
|
||||
role MembershipRole
|
||||
projectId String @map("project_id")
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
senderId String? @map("sender_id")
|
||||
sender User? @relation(fields: [senderId], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
|
||||
@@index([projectId])
|
||||
@@index([email])
|
||||
@@map("membership_invitations")
|
||||
}
|
||||
|
||||
enum MembershipRole {
|
||||
OWNER
|
||||
ADMIN
|
||||
@@ -141,6 +159,7 @@ model Trace {
|
||||
projectId String @map("project_id")
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
public Boolean @default(false)
|
||||
bookmarked Boolean @default(false)
|
||||
|
||||
scores Score[]
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { prisma } from "@/src/server/db";
|
||||
import { jsonSchema } from "@/src/utils/zod";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { ProfilingIntegration } from "@sentry/profiling-node";
|
||||
import type { TransactionEvent } from "@sentry/types";
|
||||
|
||||
if (process.env.NEXT_PUBLIC_SENTRY_DSN)
|
||||
Sentry.init({
|
||||
// debug: true,
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
|
||||
// Set tracesSampleRate to 1.0 to capture 100%
|
||||
@@ -18,6 +21,35 @@ if (process.env.NEXT_PUBLIC_SENTRY_DSN)
|
||||
new Sentry.Integrations.Prisma({ client: prisma }),
|
||||
],
|
||||
|
||||
// filter out passwords from the signup request body
|
||||
// transaction events are sentry transactions which include logs and spans.
|
||||
beforeSendTransaction(transaction: TransactionEvent) {
|
||||
if (
|
||||
transaction.request &&
|
||||
typeof transaction.request.data === "string" &&
|
||||
transaction.request.url &&
|
||||
transaction.request.url.includes("api/auth/signup")
|
||||
) {
|
||||
const jsonBody = jsonSchema.safeParse(transaction.request.data);
|
||||
|
||||
if (
|
||||
jsonBody.success &&
|
||||
typeof jsonBody.data === "object" &&
|
||||
"data" in jsonBody.data
|
||||
) {
|
||||
delete jsonBody.data.password;
|
||||
transaction.request.data = JSON.stringify(jsonBody.data);
|
||||
transaction.request.data = JSON.stringify(jsonBody);
|
||||
} else {
|
||||
console.log("Signup: Non Json Request body");
|
||||
}
|
||||
|
||||
return transaction;
|
||||
}
|
||||
|
||||
return transaction;
|
||||
},
|
||||
|
||||
// ...
|
||||
|
||||
// Note: if you want to override the automatic release value, do not set a
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { prisma } from "@/src/server/db";
|
||||
import { makeAPICall, pruneDatabase } from "@/src/__tests__/test-utils";
|
||||
import { v4 } from "uuid";
|
||||
|
||||
describe("/api/public/datasets and /api/public/dataset-items API Endpoints", () => {
|
||||
beforeEach(async () => await pruneDatabase());
|
||||
afterEach(async () => await pruneDatabase());
|
||||
|
||||
it("should create and get a dataset", async () => {
|
||||
await makeAPICall("POST", "/api/public/datasets", {
|
||||
name: "dataset-name",
|
||||
});
|
||||
|
||||
const dbDataset = await prisma.dataset.findMany({
|
||||
where: {
|
||||
name: "dataset-name",
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbDataset.length).toBeGreaterThan(0);
|
||||
|
||||
const getDataset = await makeAPICall(
|
||||
"GET",
|
||||
`/api/public/datasets/dataset-name`,
|
||||
);
|
||||
|
||||
expect(getDataset.status).toBe(200);
|
||||
expect(getDataset.body).toMatchObject({
|
||||
name: "dataset-name",
|
||||
});
|
||||
});
|
||||
|
||||
it("should create and get a dataset item (via datasets and individually)", async () => {
|
||||
await makeAPICall("POST", "/api/public/datasets", {
|
||||
name: "dataset-name",
|
||||
});
|
||||
await makeAPICall("POST", "/api/public/dataset-items", {
|
||||
datasetName: "dataset-name",
|
||||
input: { key: "value" },
|
||||
expectedOutput: { key: "value" },
|
||||
});
|
||||
const dbDatasetItem = await prisma.datasetItem.findFirst({
|
||||
where: {
|
||||
dataset: {
|
||||
name: "dataset-name",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbDatasetItem).not.toBeNull();
|
||||
|
||||
const getDataset = await makeAPICall(
|
||||
"GET",
|
||||
`/api/public/datasets/dataset-name`,
|
||||
);
|
||||
expect(getDataset.status).toBe(200);
|
||||
expect(getDataset.body).toMatchObject({
|
||||
name: "dataset-name",
|
||||
items: [
|
||||
{
|
||||
id: dbDatasetItem!.id,
|
||||
input: { key: "value" },
|
||||
expectedOutput: { key: "value" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const getDatasetItem = await makeAPICall(
|
||||
"GET",
|
||||
`/api/public/dataset-items/${dbDatasetItem!.id}`,
|
||||
);
|
||||
expect(getDatasetItem.status).toBe(200);
|
||||
expect(getDatasetItem.body).toMatchObject({
|
||||
id: dbDatasetItem!.id,
|
||||
input: { key: "value" },
|
||||
expectedOutput: { key: "value" },
|
||||
});
|
||||
});
|
||||
|
||||
it("should upsert a dataset item", async () => {
|
||||
await makeAPICall("POST", "/api/public/datasets", {
|
||||
name: "dataset-name",
|
||||
});
|
||||
|
||||
const item1 = await makeAPICall("POST", "/api/public/dataset-items", {
|
||||
id: "dataset-item-id",
|
||||
datasetName: "dataset-name",
|
||||
input: { key: "value" },
|
||||
});
|
||||
expect(item1.status).toBe(200);
|
||||
expect(item1.body).toMatchObject({
|
||||
id: "dataset-item-id",
|
||||
});
|
||||
|
||||
const item2 = await makeAPICall("POST", "/api/public/dataset-items", {
|
||||
id: "dataset-item-id",
|
||||
datasetName: "dataset-name",
|
||||
input: { key: "value2" },
|
||||
});
|
||||
expect(item2.status).toBe(200);
|
||||
expect(item2.body).toMatchObject({
|
||||
id: "dataset-item-id",
|
||||
input: { key: "value2" },
|
||||
});
|
||||
|
||||
const dbDatasetItem = await prisma.datasetItem.findFirst({
|
||||
where: { id: "dataset-item-id" },
|
||||
});
|
||||
expect(dbDatasetItem).not.toBeNull();
|
||||
expect(dbDatasetItem?.input).toMatchObject({ key: "value2" });
|
||||
});
|
||||
|
||||
it("should create and get a dataset run", async () => {
|
||||
const dataset = await makeAPICall("POST", "/api/public/datasets", {
|
||||
name: "dataset-name",
|
||||
});
|
||||
expect(dataset.status).toBe(200);
|
||||
expect(dataset.body).toMatchObject({
|
||||
name: "dataset-name",
|
||||
});
|
||||
await makeAPICall("POST", "/api/public/dataset-items", {
|
||||
datasetName: "dataset-name",
|
||||
id: "dataset-item-id",
|
||||
input: { key: "value" },
|
||||
expectedOutput: { key: "value" },
|
||||
});
|
||||
const traceId = v4();
|
||||
const observationId = v4();
|
||||
const response = await makeAPICall("POST", "/api/public/ingestion", {
|
||||
batch: [
|
||||
{
|
||||
id: v4(),
|
||||
type: "trace-create",
|
||||
timestamp: new Date().toISOString(),
|
||||
body: {
|
||||
id: traceId,
|
||||
name: "trace-name",
|
||||
userId: "user-1",
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "2.0.0",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: v4(),
|
||||
type: "observation-create",
|
||||
timestamp: new Date().toISOString(),
|
||||
body: {
|
||||
id: observationId,
|
||||
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",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(response.status).toBe(207);
|
||||
|
||||
const runItem = await makeAPICall("POST", "/api/public/dataset-run-items", {
|
||||
datasetItemId: "dataset-item-id",
|
||||
observationId: observationId,
|
||||
runName: "run-name",
|
||||
});
|
||||
const dbRun = await prisma.datasetRuns.findFirst({
|
||||
where: {
|
||||
name: "run-name",
|
||||
},
|
||||
});
|
||||
expect(dbRun).not.toBeNull();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
expect(dbRun?.datasetId).toBe(dataset.body.id);
|
||||
expect(runItem.status).toBe(200);
|
||||
expect(runItem.body).toMatchObject({
|
||||
datasetItemId: "dataset-item-id",
|
||||
observationId: observationId,
|
||||
datasetRunId: dbRun?.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -247,6 +247,108 @@ describe("/api/public/ingestion API Endpoint", () => {
|
||||
expect(dbTrace.length).toBe(1);
|
||||
});
|
||||
|
||||
it("should fail for auth errors", async () => {
|
||||
const traceId = v4();
|
||||
const scoreId = v4();
|
||||
|
||||
const responseOne = await makeAPICall("POST", "/api/public/ingestion", {
|
||||
batch: [
|
||||
{
|
||||
id: v4(),
|
||||
type: "trace-create",
|
||||
timestamp: new Date().toISOString(),
|
||||
body: {
|
||||
id: traceId,
|
||||
name: "trace-name",
|
||||
userId: "user-1",
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "2.0.0",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: v4(),
|
||||
type: "score-create",
|
||||
timestamp: new Date().toISOString(),
|
||||
body: {
|
||||
id: scoreId,
|
||||
name: "score-name",
|
||||
value: 100.5,
|
||||
traceId: "some-random-id",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
console.log(responseOne.body);
|
||||
expect(responseOne.status).toBe(207);
|
||||
|
||||
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("successes" in responseOne.body).toBe(true);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
expect(responseOne.body?.successes.length).toBe(1);
|
||||
|
||||
const dbTrace = await prisma.trace.findMany({
|
||||
where: {
|
||||
name: "trace-name",
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbTrace.length).toBe(1);
|
||||
});
|
||||
|
||||
it("should fail for resource not found", async () => {
|
||||
const traceId = v4();
|
||||
|
||||
const responseOne = await makeAPICall("POST", "/api/public/ingestion", {
|
||||
batch: [
|
||||
{
|
||||
id: v4(),
|
||||
type: "trace-create",
|
||||
timestamp: new Date().toISOString(),
|
||||
body: {
|
||||
id: traceId,
|
||||
name: "trace-name",
|
||||
userId: "user-1",
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "2.0.0",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: v4(),
|
||||
type: "observation-update",
|
||||
timestamp: new Date().toISOString(),
|
||||
body: {
|
||||
id: "some-random-id",
|
||||
type: "GENERATION",
|
||||
output: { key: "this is a great gpt output" },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
console.log(responseOne.body);
|
||||
expect(responseOne.status).toBe(207);
|
||||
|
||||
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("successes" in responseOne.body).toBe(true);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
expect(responseOne.body?.successes.length).toBe(1);
|
||||
|
||||
const dbTrace = await prisma.trace.findMany({
|
||||
where: {
|
||||
name: "trace-name",
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbTrace.length).toBe(1);
|
||||
});
|
||||
|
||||
it("should update all token counts if update does not contain model name", async () => {
|
||||
const traceId = v4();
|
||||
const generationId = v4();
|
||||
|
||||
@@ -9,6 +9,9 @@ export const pruneDatabase = async () => {
|
||||
await prisma.score.deleteMany();
|
||||
await prisma.observation.deleteMany();
|
||||
await prisma.trace.deleteMany();
|
||||
await prisma.datasetItem.deleteMany();
|
||||
await prisma.dataset.deleteMany();
|
||||
await prisma.datasetRuns.deleteMany();
|
||||
};
|
||||
|
||||
export function createBasicAuthHeader(
|
||||
@@ -24,7 +27,7 @@ export function createBasicAuthHeader(
|
||||
export async function makeAPICall(
|
||||
method: "POST" | "GET" | "PUT" | "DELETE" | "PATCH",
|
||||
url: string,
|
||||
body: unknown,
|
||||
body?: unknown,
|
||||
) {
|
||||
const finalUrl = `http://localhost:3000/${url}`;
|
||||
const options = {
|
||||
@@ -37,7 +40,9 @@ export async function makeAPICall(
|
||||
"sk-lf-1234567890",
|
||||
),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
// Conditionally include the body property if the method is not "GET"
|
||||
...(method !== "GET" &&
|
||||
body !== undefined && { body: JSON.stringify(body) }),
|
||||
};
|
||||
const a = await fetch(finalUrl, options);
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { StarIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { useHasAccess } from "@/src/features/rbac/utils/checkAccess";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function BookmarkTrace({
|
||||
traceId,
|
||||
projectId,
|
||||
isBookmarked,
|
||||
size = "sm",
|
||||
}: {
|
||||
traceId: string;
|
||||
projectId: string;
|
||||
isBookmarked: boolean;
|
||||
size?: "sm" | "xs";
|
||||
}) {
|
||||
const utils = api.useUtils();
|
||||
const [cacheIsBookmarked, setCacheIsBookmarked] = useState<boolean | null>(
|
||||
null,
|
||||
);
|
||||
const localIsBookmarked = cacheIsBookmarked ?? isBookmarked;
|
||||
const hasAccess = useHasAccess({ projectId, scope: "traces:bookmark" });
|
||||
const mutBookmarkTrace = api.traces.bookmark.useMutation({
|
||||
onSuccess: () => {
|
||||
setCacheIsBookmarked(!localIsBookmarked);
|
||||
void utils.traces.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setCacheIsBookmarked(null);
|
||||
}, [isBookmarked]);
|
||||
|
||||
const handleBookmarkClick = () => {
|
||||
if (!hasAccess) return;
|
||||
void mutBookmarkTrace.mutateAsync({
|
||||
traceId,
|
||||
projectId,
|
||||
bookmarked: !localIsBookmarked,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={size}
|
||||
onClick={handleBookmarkClick}
|
||||
disabled={!hasAccess}
|
||||
loading={mutBookmarkTrace.isLoading}
|
||||
>
|
||||
<StarIcon
|
||||
className={`h-4 w-4 ${
|
||||
localIsBookmarked ? "fill-current text-yellow-500" : "text-gray-500"
|
||||
}`}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
PopoverTrigger,
|
||||
} from "@/src/components/ui/popover";
|
||||
import { useHasAccess } from "@/src/features/rbac/utils/checkAccess";
|
||||
import { useState } from "react";
|
||||
|
||||
export function DeleteTrace({
|
||||
traceId,
|
||||
@@ -19,13 +20,15 @@ export function DeleteTrace({
|
||||
projectId: string;
|
||||
isTableAction?: boolean;
|
||||
}) {
|
||||
const [isDeleted, setIsDeleted] = useState(false);
|
||||
const router = useRouter();
|
||||
const utils = api.useContext();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const hasAccess = useHasAccess({ projectId, scope: "traces:delete" });
|
||||
|
||||
const mutDeleteTrace = api.traces.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
setIsDeleted(true);
|
||||
if (!isTableAction) {
|
||||
void router
|
||||
.push(`/project/${projectId}/traces`)
|
||||
@@ -41,7 +44,7 @@ export function DeleteTrace({
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<Popover key={traceId}>
|
||||
<PopoverTrigger asChild>
|
||||
{isTableAction ? (
|
||||
<Button variant="ghost" size="xs">
|
||||
@@ -63,7 +66,7 @@ export function DeleteTrace({
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
loading={mutDeleteTrace.isLoading}
|
||||
loading={mutDeleteTrace.isLoading || isDeleted}
|
||||
onClick={() =>
|
||||
void mutDeleteTrace.mutateAsync({ traceId, projectId })
|
||||
}
|
||||
|
||||
@@ -8,30 +8,27 @@ import Link from "next/link";
|
||||
|
||||
export type DocPopupProps = {
|
||||
description: React.ReactNode;
|
||||
link: string;
|
||||
href: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
};
|
||||
|
||||
export default function DocPopup({ description, link, size }: DocPopupProps) {
|
||||
let sizeClass = "w-4 h-4";
|
||||
switch (size) {
|
||||
case "sm": {
|
||||
sizeClass = "w-4 h-4";
|
||||
}
|
||||
case "md": {
|
||||
sizeClass = "w-6 h-6";
|
||||
}
|
||||
case "lg": {
|
||||
sizeClass = "w-8 h-8";
|
||||
}
|
||||
}
|
||||
export default function DocPopup({
|
||||
description,
|
||||
href,
|
||||
size = "sm",
|
||||
}: DocPopupProps) {
|
||||
const sizes = {
|
||||
sm: "w-4 h-4",
|
||||
md: "w-6 h-6",
|
||||
lg: "w-8 h-8",
|
||||
};
|
||||
|
||||
return (
|
||||
<HoverCard openDelay={200}>
|
||||
<HoverCardTrigger className="mx-2 cursor-pointer">
|
||||
<Link href={link} rel="noopener" target="_blank">
|
||||
<HoverCardTrigger className="mx-2 cursor-pointer" asChild>
|
||||
<Link href={href} rel="noopener" target="_blank">
|
||||
<div className="whitespace-nowrap text-gray-500 sm:pl-0">
|
||||
<HelpCircle className={sizeClass} />
|
||||
<HelpCircle className={sizes[size]} />
|
||||
</div>
|
||||
</Link>
|
||||
</HoverCardTrigger>
|
||||
|
||||
@@ -16,6 +16,17 @@ export default function Header(props: {
|
||||
const projectId = router.query.projectId;
|
||||
|
||||
const project = session.data?.user?.projects.find((p) => p.id === projectId);
|
||||
const breadcrumb = [
|
||||
...(project && projectId && currentPath !== "/project/[projectId]"
|
||||
? [
|
||||
{
|
||||
name: project.name,
|
||||
href: `/project/${projectId as string}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(props.breadcrumb ?? []),
|
||||
];
|
||||
const backHref =
|
||||
props.breadcrumb &&
|
||||
[...props.breadcrumb.map((i) => i.href).filter(Boolean)].pop();
|
||||
@@ -37,10 +48,10 @@ export default function Header(props: {
|
||||
</Link>
|
||||
</nav>
|
||||
) : null}
|
||||
{props.breadcrumb ? (
|
||||
{breadcrumb ? (
|
||||
<nav className="hidden sm:flex" aria-label="Breadcrumb">
|
||||
<ol role="list" className="flex items-center space-x-4">
|
||||
{props.breadcrumb.map(({ name, href }, index) => (
|
||||
{breadcrumb.map(({ name, href }, index) => (
|
||||
<li key={index}>
|
||||
<div className="flex items-center">
|
||||
{index !== 0 && (
|
||||
@@ -71,11 +82,6 @@ export default function Header(props: {
|
||||
<div className="mt-2 flex flex-wrap items-center justify-between gap-5">
|
||||
<div className="flex items-center gap-3 md:gap-5">
|
||||
<div className="min-w-0">
|
||||
{project && projectId && currentPath !== "/project/[projectId]" ? (
|
||||
<div className="text-sm font-medium text-gray-500">
|
||||
{project.name}
|
||||
</div>
|
||||
) : null}
|
||||
<h2 className="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight">
|
||||
{props.title}
|
||||
</h2>
|
||||
|
||||
@@ -21,7 +21,8 @@ import { FeedbackButtonWrapper } from "@/src/features/feedback/component/Feedbac
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import Head from "next/head";
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { LangfuseIcon, LangfuseLogo } from "@/src/components/LangfuseLogo";
|
||||
import { LangfuseLogo } from "@/src/components/LangfuseLogo";
|
||||
import { Spinner } from "@/src/components/layouts/spinner";
|
||||
|
||||
const userNavigation = [
|
||||
{
|
||||
@@ -41,7 +42,7 @@ export default function Layout(props: PropsWithChildren) {
|
||||
const router = useRouter();
|
||||
const session = useSession();
|
||||
const enableExperimentalFeatures =
|
||||
api.environment.enableExperimentalFeatures.useQuery().data;
|
||||
api.environment.enableExperimentalFeatures.useQuery().data ?? false;
|
||||
|
||||
const projectId = router.query.projectId as string | undefined;
|
||||
const navigation = ROUTES.filter(
|
||||
@@ -66,9 +67,7 @@ export default function Layout(props: PropsWithChildren) {
|
||||
|
||||
const currentPathName = navigation.find(({ current }) => current)?.name;
|
||||
|
||||
const projects = api.projects.all.useQuery(undefined, {
|
||||
enabled: session.status === "authenticated",
|
||||
});
|
||||
const projects = session.data?.user?.projects ?? [];
|
||||
|
||||
if (session.status === "loading") return <Spinner message="Loading" />;
|
||||
|
||||
@@ -104,12 +103,12 @@ export default function Layout(props: PropsWithChildren) {
|
||||
|
||||
const hideNavigation =
|
||||
session.status === "unauthenticated" ||
|
||||
projects.data?.length === 0 ||
|
||||
projects.length === 0 ||
|
||||
pathsWithoutNavigation.includes(router.pathname) ||
|
||||
router.pathname.startsWith("/public/");
|
||||
if (hideNavigation)
|
||||
return (
|
||||
<main className="h-full bg-gray-50 px-4 py-4 sm:px-6 lg:px-8">
|
||||
<main className="min-h-screen bg-gray-50 px-4 py-4 sm:px-6 lg:px-8">
|
||||
{props.children}
|
||||
</main>
|
||||
);
|
||||
@@ -241,7 +240,7 @@ export default function Layout(props: PropsWithChildren) {
|
||||
<NewProjectButton size="xs" />
|
||||
</div>
|
||||
<ul role="list" className="-mx-2 mt-2 space-y-1">
|
||||
{projects.data?.map((project) => (
|
||||
{projects.map((project) => (
|
||||
<li key={project.name}>
|
||||
<Link
|
||||
href={`/project/${project.id}`}
|
||||
@@ -294,10 +293,10 @@ export default function Layout(props: PropsWithChildren) {
|
||||
{/* Static sidebar for desktop */}
|
||||
<div className="hidden xl:fixed xl:inset-y-0 xl:z-50 xl:flex xl:w-72 xl:flex-col">
|
||||
{/* Sidebar component, swap this element with another sidebar if you like */}
|
||||
<div className="flex grow flex-col gap-y-5 overflow-y-auto border-r border-gray-200 bg-white px-6 pb-3 pt-7">
|
||||
<LangfuseLogo size="xl" className="mb-4" />
|
||||
<nav className="flex flex-1 flex-col">
|
||||
<ul role="list" className="flex flex-1 flex-col gap-y-4">
|
||||
<div className="flex h-screen grow flex-col gap-y-5 border-r border-gray-200 bg-white pt-7">
|
||||
<LangfuseLogo size="xl" className="mb-2 px-6" />
|
||||
<nav className="flex h-full flex-1 flex-col overflow-y-auto px-6 pb-3">
|
||||
<ul role="list" className="flex h-full flex-col gap-y-4">
|
||||
<li>
|
||||
<ul role="list" className="-mx-2 space-y-1">
|
||||
{navigation.map((item) => (
|
||||
@@ -344,7 +343,7 @@ export default function Layout(props: PropsWithChildren) {
|
||||
<NewProjectButton size="xs" />
|
||||
</div>
|
||||
<ul role="list" className="-mx-2 mt-2 space-y-1">
|
||||
{projects.data?.map((project, index) => (
|
||||
{projects.map((project, index) => (
|
||||
<li key={project.name}>
|
||||
<Link
|
||||
href={`/project/${project.id}`}
|
||||
@@ -389,7 +388,7 @@ export default function Layout(props: PropsWithChildren) {
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li className="-mx-6 ">
|
||||
<li className="-mx-6">
|
||||
<Menu as="div" className="relative">
|
||||
<Menu.Button className="flex w-full items-center gap-x-4 p-1.5 px-6 py-3 text-sm font-semibold leading-6 text-gray-900 hover:bg-gray-50">
|
||||
<span className="sr-only">Open user menu</span>
|
||||
@@ -507,6 +506,8 @@ export default function Layout(props: PropsWithChildren) {
|
||||
<div className="xl:pl-72">
|
||||
{env.NEXT_PUBLIC_DEMO_PROJECT_ID &&
|
||||
projectId === env.NEXT_PUBLIC_DEMO_PROJECT_ID &&
|
||||
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION &&
|
||||
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== "STAGING" &&
|
||||
!session.data?.user?.email?.endsWith("@langfuse.com") ? (
|
||||
<div className="flex w-full items-center border-b border-yellow-500 bg-yellow-100 px-4 py-2 xl:sticky xl:top-0 xl:z-40">
|
||||
<div className="flex flex-1 flex-wrap gap-1">
|
||||
@@ -516,9 +517,13 @@ export default function Layout(props: PropsWithChildren) {
|
||||
</div>
|
||||
<div>Live data from the Langfuse Q&A Chatbot.</div>
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" asChild className="ml-2">
|
||||
<Link href="https://langfuse.com/docs/demo" target="_blank">
|
||||
Learn more ↗
|
||||
|
||||
<Button size="sm" asChild className="ml-2">
|
||||
<Link
|
||||
href="https://langfuse.com/docs/qa-chatbot"
|
||||
target="_blank"
|
||||
>
|
||||
Q&A Chatbot ↗
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -531,16 +536,3 @@ export default function Layout(props: PropsWithChildren) {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Spinner(props: { message: string }) {
|
||||
return (
|
||||
<div className="flex min-h-full flex-1 flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<LangfuseIcon className="mx-auto motion-safe:animate-spin" size={42} />
|
||||
<h2 className="mt-5 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">
|
||||
{props.message} ...
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { LangfuseIcon } from "@/src/components/LangfuseLogo";
|
||||
|
||||
export function Spinner(props: { message: string }) {
|
||||
return (
|
||||
<div className="flex min-h-full flex-1 flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<LangfuseIcon className="mx-auto motion-safe:animate-spin" size={42} />
|
||||
<h2 className="mt-5 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">
|
||||
{props.message} ...
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -135,16 +135,16 @@ export default function ScoresTable({
|
||||
scores.isLoading
|
||||
? { isLoading: true, isError: false }
|
||||
: scores.isError
|
||||
? {
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
error: scores.error.message,
|
||||
}
|
||||
: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: scores.data?.map((t) => convertToTableRow(t)),
|
||||
}
|
||||
? {
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
error: scores.error.message,
|
||||
}
|
||||
: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: scores.data?.map((t) => convertToTableRow(t)),
|
||||
}
|
||||
}
|
||||
pagination={{
|
||||
pageCount: Math.ceil(totalCount / paginationState.pageSize),
|
||||
|
||||
@@ -21,8 +21,10 @@ import {
|
||||
useQueryParams,
|
||||
withDefault,
|
||||
} from "use-query-params";
|
||||
import { BookmarkTrace } from "@/src/components/bookmark-trace";
|
||||
|
||||
export type TraceTableRow = {
|
||||
bookmarked: boolean;
|
||||
id: string;
|
||||
timestamp: string;
|
||||
name: string;
|
||||
@@ -111,6 +113,7 @@ export default function TracesTable({
|
||||
trace: RouterOutput["traces"]["all"][0],
|
||||
): TraceTableRow => {
|
||||
return {
|
||||
bookmarked: trace.bookmarked,
|
||||
id: trace.id,
|
||||
timestamp: trace.timestamp.toLocaleString(),
|
||||
name: trace.name ?? "",
|
||||
@@ -129,6 +132,24 @@ export default function TracesTable({
|
||||
};
|
||||
|
||||
const columns: ColumnDef<TraceTableRow>[] = [
|
||||
{
|
||||
accessorKey: "bookmarked",
|
||||
header: undefined,
|
||||
cell: ({ row }) => {
|
||||
const isBookmarked = row.getValue("bookmarked");
|
||||
const traceId = row.getValue("id");
|
||||
|
||||
return typeof traceId === "string" &&
|
||||
typeof isBookmarked === "boolean" ? (
|
||||
<BookmarkTrace
|
||||
traceId={traceId}
|
||||
projectId={projectId}
|
||||
isBookmarked={isBookmarked}
|
||||
size="xs"
|
||||
/>
|
||||
) : undefined;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: "ID",
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useRouter } from "next/router";
|
||||
import { type ObservationReturnType } from "@/src/server/api/routers/traces";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { DeleteTrace } from "@/src/components/delete-trace";
|
||||
import { BookmarkTrace } from "@/src/components/bookmark-trace";
|
||||
|
||||
export function Trace(props: {
|
||||
observations: Array<ObservationReturnType>;
|
||||
@@ -89,8 +90,20 @@ export function TracePage({ traceId }: { traceId: string }) {
|
||||
<div className="flex flex-col overflow-hidden xl:container lg:h-[calc(100vh-100px)] xl:h-[calc(100vh-50px)]">
|
||||
<Header
|
||||
title="Trace Detail"
|
||||
breadcrumb={[
|
||||
{
|
||||
name: "Traces",
|
||||
href: `/project/${router.query.projectId as string}/traces`,
|
||||
},
|
||||
{ name: traceId },
|
||||
]}
|
||||
actionButtons={
|
||||
<>
|
||||
<BookmarkTrace
|
||||
traceId={trace.data.id}
|
||||
projectId={trace.data.projectId}
|
||||
isBookmarked={trace.data.bookmarked}
|
||||
/>
|
||||
<PublishTraceSwitch
|
||||
traceId={trace.data.id}
|
||||
projectId={trace.data.projectId}
|
||||
|
||||
@@ -78,7 +78,7 @@ export { Button, buttonVariants };
|
||||
|
||||
function Spinner() {
|
||||
return (
|
||||
<div className="flex h-3/4 items-center justify-center">
|
||||
<div className="flex h-1/2 items-center justify-center">
|
||||
<Loader2 className="h-full w-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const VERSION = "v1.11.1";
|
||||
+19
-1
@@ -33,12 +33,21 @@ export const env = createEnv({
|
||||
required_error:
|
||||
"A strong Salt is required to encrypt API keys securely. See: https://langfuse.com/docs/deployment/self-host#deploy-the-container",
|
||||
}),
|
||||
// Add newly signed up users to default project with role
|
||||
LANGFUSE_DEFAULT_PROJECT_ID: z.string().optional(),
|
||||
LANGFUSE_DEFAULT_PROJECT_ROLE: z
|
||||
.enum(["ADMIN", "MEMBER", "VIEWER"])
|
||||
.optional(),
|
||||
// AUTH
|
||||
AUTH_GOOGLE_CLIENT_ID: z.string().optional(),
|
||||
AUTH_GOOGLE_CLIENT_SECRET: z.string().optional(),
|
||||
AUTH_GITHUB_CLIENT_ID: z.string().optional(),
|
||||
AUTH_GITHUB_CLIENT_SECRET: z.string().optional(),
|
||||
AUTH_DOMAINS_WITH_SSO_ENFORCEMENT: z.string().optional(),
|
||||
AUTH_DISABLE_USERNAME_PASSWORD: z.enum(["true", "false"]).optional(),
|
||||
// EMAIL
|
||||
EMAIL_FROM_ADDRESS: z.string().optional(),
|
||||
SMTP_CONNECTION_URL: z.string().optional(),
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -48,7 +57,9 @@ export const env = createEnv({
|
||||
*/
|
||||
client: {
|
||||
// NEXT_PUBLIC_CLIENTVAR: z.string().min(1),
|
||||
NEXT_PUBLIC_LANGFUSE_CLOUD_REGION: z.enum(["US", "EU"]).optional(),
|
||||
NEXT_PUBLIC_LANGFUSE_CLOUD_REGION: z
|
||||
.enum(["US", "EU", "STAGING"])
|
||||
.optional(),
|
||||
NEXT_PUBLIC_DEMO_PROJECT_ID: z.string().optional(),
|
||||
NEXT_PUBLIC_SIGN_UP_DISABLED: z.enum(["true", "false"]).optional(),
|
||||
},
|
||||
@@ -74,6 +85,9 @@ export const env = createEnv({
|
||||
LANGFUSE_NEW_USER_SIGNUP_WEBHOOK:
|
||||
process.env.LANGFUSE_NEW_USER_SIGNUP_WEBHOOK,
|
||||
SALT: process.env.SALT,
|
||||
// Default project and role
|
||||
LANGFUSE_DEFAULT_PROJECT_ID: process.env.LANGFUSE_DEFAULT_PROJECT_ID,
|
||||
LANGFUSE_DEFAULT_PROJECT_ROLE: process.env.LANGFUSE_DEFAULT_PROJECT_ROLE,
|
||||
// AUTH
|
||||
AUTH_GOOGLE_CLIENT_ID: process.env.AUTH_GOOGLE_CLIENT_ID,
|
||||
AUTH_GOOGLE_CLIENT_SECRET: process.env.AUTH_GOOGLE_CLIENT_SECRET,
|
||||
@@ -81,5 +95,9 @@ export const env = createEnv({
|
||||
AUTH_GITHUB_CLIENT_SECRET: process.env.AUTH_GITHUB_CLIENT_SECRET,
|
||||
AUTH_DOMAINS_WITH_SSO_ENFORCEMENT:
|
||||
process.env.AUTH_DOMAINS_WITH_SSO_ENFORCEMENT,
|
||||
AUTH_DISABLE_USERNAME_PASSWORD: process.env.AUTH_DISABLE_USERNAME_PASSWORD,
|
||||
// Email
|
||||
EMAIL_FROM_ADDRESS: process.env.EMAIL_FROM_ADDRESS,
|
||||
SMTP_CONNECTION_URL: process.env.SMTP_CONNECTION_URL,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { env } from "@/src/env.mjs";
|
||||
|
||||
export const CloudPrivacyNotice = ({ action }: { action: string }) =>
|
||||
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== undefined ? (
|
||||
<div className="mt-10 text-center text-xs text-gray-500">
|
||||
By {action} you are agreeing to our{" "}
|
||||
<a
|
||||
href="https://langfuse.com/tos"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="italic"
|
||||
>
|
||||
Terms of Service
|
||||
</a>
|
||||
,{" "}
|
||||
<a
|
||||
href="https://langfuse.com/privacy"
|
||||
rel="noopener noreferrer"
|
||||
className="italic"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
, and{" "}
|
||||
<a
|
||||
href="https://langfuse.com/cookie-policy"
|
||||
rel="noopener noreferrer"
|
||||
className="italic"
|
||||
>
|
||||
Cookie Policy
|
||||
</a>
|
||||
. You also confirm that the entered data is accurate.
|
||||
</div>
|
||||
) : null;
|
||||
@@ -0,0 +1,83 @@
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/src/components/ui/tabs";
|
||||
import { Divider } from "@tremor/react";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
|
||||
const regions =
|
||||
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "STAGING"
|
||||
? [
|
||||
{
|
||||
name: "EU (Staging)",
|
||||
hostname: "staging.langfuse.com",
|
||||
flag: "🇪🇺",
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
name: "US",
|
||||
hostname: "us.cloud.langfuse.com",
|
||||
flag: "🇺🇸",
|
||||
},
|
||||
{
|
||||
name: "EU",
|
||||
hostname: "cloud.langfuse.com",
|
||||
flag: "🇪🇺",
|
||||
},
|
||||
];
|
||||
|
||||
export function CloudRegionSwitch({
|
||||
isSignUpPage,
|
||||
}: {
|
||||
isSignUpPage?: boolean;
|
||||
}) {
|
||||
const posthog = usePostHog();
|
||||
|
||||
if (env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === undefined) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex w-full flex-col justify-between gap-3 md:flex-row md:items-center">
|
||||
<div>
|
||||
<span className="text-sm font-medium leading-none">Data Region</span>
|
||||
<p className="text-xs text-gray-500">
|
||||
Regions are strictly separated.
|
||||
</p>
|
||||
{env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== undefined &&
|
||||
isSignUpPage ? (
|
||||
<p className="text-xs text-gray-500">
|
||||
{env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "EU"
|
||||
? "✅ Demo project available"
|
||||
: "❌ Choose EU for demo project access"}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Tabs value={env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION}>
|
||||
<TabsList>
|
||||
{regions.map((region) => (
|
||||
<TabsTrigger
|
||||
key={region.name}
|
||||
value={region.name}
|
||||
onClick={() => {
|
||||
posthog.capture(
|
||||
"cloud_region_switch",
|
||||
{
|
||||
region: region.name,
|
||||
},
|
||||
{
|
||||
send_instantly: true,
|
||||
},
|
||||
);
|
||||
window.location.hostname = region.hostname;
|
||||
}}
|
||||
>
|
||||
<span className="mr-2 text-xl leading-none">{region.flag}</span>
|
||||
{region.name}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<Divider className="text-gray-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { prisma } from "@/src/server/db";
|
||||
|
||||
export async function createProjectMembershipsOnSignup(user: {
|
||||
id: string;
|
||||
email: string | null;
|
||||
}) {
|
||||
try {
|
||||
// Langfuse Cloud: Demo project access
|
||||
const demoProjectId = env.NEXT_PUBLIC_DEMO_PROJECT_ID
|
||||
? (
|
||||
await prisma.project.findUnique({
|
||||
where: {
|
||||
id: env.NEXT_PUBLIC_DEMO_PROJECT_ID,
|
||||
},
|
||||
})
|
||||
)?.id
|
||||
: undefined;
|
||||
if (demoProjectId !== undefined) {
|
||||
await prisma.membership.create({
|
||||
data: {
|
||||
projectId: demoProjectId,
|
||||
userId: user.id,
|
||||
role: "VIEWER",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// set default project access
|
||||
const defaultProjectID = env.LANGFUSE_DEFAULT_PROJECT_ID
|
||||
? (
|
||||
await prisma.project.findUnique({
|
||||
where: {
|
||||
id: env.LANGFUSE_DEFAULT_PROJECT_ID,
|
||||
},
|
||||
})
|
||||
)?.id
|
||||
: undefined;
|
||||
if (defaultProjectID !== undefined) {
|
||||
await prisma.membership.create({
|
||||
data: {
|
||||
projectId: defaultProjectID,
|
||||
userId: user.id,
|
||||
role: env.LANGFUSE_DEFAULT_PROJECT_ROLE ?? "VIEWER",
|
||||
},
|
||||
});
|
||||
}
|
||||
// Invites do not work for users without emails (some future SSO users)
|
||||
if (user.email) await processMembershipInvitations(user.email, user.id);
|
||||
} catch (e) {
|
||||
console.error("Error assigning project access to new user", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function processMembershipInvitations(email: string, userId: string) {
|
||||
const invitationsForUser = await prisma.membershipInvitation.findMany({
|
||||
where: {
|
||||
email: email.toLowerCase(),
|
||||
},
|
||||
});
|
||||
|
||||
if (invitationsForUser.length > 0) {
|
||||
const membershipsData = invitationsForUser.map((invitation) => {
|
||||
return {
|
||||
userId: userId,
|
||||
projectId: invitation.projectId,
|
||||
role: invitation.role,
|
||||
};
|
||||
});
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.membership.createMany({
|
||||
data: membershipsData,
|
||||
}),
|
||||
prisma.membershipInvitation.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: invitationsForUser.map((invitation) => invitation.id),
|
||||
},
|
||||
email: email.toLowerCase(),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { createProjectMembershipsOnSignup } from "@/src/features/auth/lib/createProjectMembershipsOnSignup";
|
||||
import { prisma } from "@/src/server/db";
|
||||
import { compare, hash } from "bcryptjs";
|
||||
|
||||
@@ -30,35 +30,16 @@ export async function createUserEmailPassword(
|
||||
throw new Error("User with email already exists. Please sign in.");
|
||||
}
|
||||
|
||||
// set demoProjectId if env exists and project exists in db
|
||||
const demoProjectId = env.NEXT_PUBLIC_DEMO_PROJECT_ID
|
||||
? (
|
||||
await prisma.project.findUnique({
|
||||
where: {
|
||||
id: env.NEXT_PUBLIC_DEMO_PROJECT_ID,
|
||||
},
|
||||
})
|
||||
)?.id
|
||||
: undefined;
|
||||
|
||||
const newUser = await prisma.user.create({
|
||||
data: {
|
||||
email: email.toLowerCase(),
|
||||
password: hashedPassword,
|
||||
name,
|
||||
// if demo project id is set grant user access to it
|
||||
...(demoProjectId
|
||||
? {
|
||||
memberships: {
|
||||
create: {
|
||||
projectId: demoProjectId,
|
||||
role: "VIEWER",
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined),
|
||||
},
|
||||
});
|
||||
|
||||
await createProjectMembershipsOnSignup(newUser);
|
||||
|
||||
return newUser.id;
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ export function ChartScores(props: {
|
||||
<NoData noDataText="No data">
|
||||
<DocPopup
|
||||
description="Scores evaluate LLM quality and can be created manually or using the SDK."
|
||||
link="https://langfuse.com/docs/scores"
|
||||
href="https://langfuse.com/docs/scores"
|
||||
/>
|
||||
</NoData>
|
||||
)}
|
||||
|
||||
@@ -79,7 +79,7 @@ export const MetricTable = ({
|
||||
>
|
||||
<DocPopup
|
||||
description="Calculated multiplying the number of tokens with cost per token for each model."
|
||||
link="https://langfuse.com/docs/token-usage"
|
||||
href="https://langfuse.com/docs/token-usage"
|
||||
/>
|
||||
</TotalMetric>
|
||||
</DashboardTable>
|
||||
|
||||
@@ -137,7 +137,7 @@ export const ScoresTable = ({
|
||||
noDataChildren={
|
||||
<DocPopup
|
||||
description="Scores evaluate LLM quality and can be created manually or using the SDK."
|
||||
link="https://langfuse.com/docs/scores"
|
||||
href="https://langfuse.com/docs/scores"
|
||||
/>
|
||||
}
|
||||
noDataClassName="mt-0"
|
||||
|
||||
@@ -82,7 +82,7 @@ export const TracesBarListChart = ({
|
||||
<NoData noDataText="No data">
|
||||
<DocPopup
|
||||
description="Traces contain details about LLM applications and can be created using the SDK."
|
||||
link="https://langfuse.com/docs/integrations/sdk#1-backend-tracing"
|
||||
href="https://langfuse.com/docs/integrations/sdk#1-backend-tracing"
|
||||
/>
|
||||
</NoData>
|
||||
)}
|
||||
|
||||
@@ -85,7 +85,7 @@ export const TracesTimeSeriesChart = ({
|
||||
<NoData noDataText="No data available">
|
||||
<DocPopup
|
||||
description="Traces contain details about LLM applications and can be created using the SDK."
|
||||
link="https://langfuse.com/docs/integrations/sdk#1-backend-tracing"
|
||||
href="https://langfuse.com/docs/integrations/sdk#1-backend-tracing"
|
||||
/>
|
||||
</NoData>
|
||||
)}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { DashboardCard } from "@/src/features/dashboard/components/cards/Dashboa
|
||||
import { compactNumberFormatter, usdFormatter } from "@/src/utils/numbers";
|
||||
import { TabComponent } from "@/src/features/dashboard/components/TabsComponent";
|
||||
import { BarList } from "@tremor/react";
|
||||
import { isUndefinedOrNull } from "@/src/utils/types";
|
||||
import { TotalMetric } from "@/src/features/dashboard/components/TotalMetric";
|
||||
import { ExpandListButton } from "@/src/features/dashboard/components/cards/ChevronButton";
|
||||
import { useState } from "react";
|
||||
@@ -84,7 +83,6 @@ export const UserChart = ({
|
||||
value: item.totalTokenCost ? (item.totalTokenCost as number) : 0,
|
||||
};
|
||||
})
|
||||
.filter((i) => (isUndefinedOrNull(i.name) ? true : false))
|
||||
: [];
|
||||
|
||||
const totalCost = user.data?.reduce(
|
||||
@@ -151,7 +149,7 @@ export const UserChart = ({
|
||||
<NoData noDataText="No data">
|
||||
<DocPopup
|
||||
description="Consumption per user is tracked by passing their ids on traces."
|
||||
link="https://langfuse.com/docs/user-explorer"
|
||||
href="https://langfuse.com/docs/user-explorer"
|
||||
/>
|
||||
</NoData>
|
||||
)}
|
||||
|
||||
@@ -31,7 +31,7 @@ export function DatasetItemsTable({
|
||||
projectId: string;
|
||||
datasetId: string;
|
||||
}) {
|
||||
const utils = api.useContext();
|
||||
const utils = api.useUtils();
|
||||
const items = api.datasets.itemsByDatasetId.useQuery({
|
||||
projectId,
|
||||
datasetId,
|
||||
@@ -156,16 +156,16 @@ export function DatasetItemsTable({
|
||||
items.isLoading
|
||||
? { isLoading: true, isError: false }
|
||||
: items.isError
|
||||
? {
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
error: items.error.message,
|
||||
}
|
||||
: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: items.data?.map((t) => convertToTableRow(t)),
|
||||
}
|
||||
? {
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
error: items.error.message,
|
||||
}
|
||||
: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: items.data?.map((t) => convertToTableRow(t)),
|
||||
}
|
||||
}
|
||||
/>
|
||||
<NewDatasetItemButton
|
||||
|
||||
@@ -110,16 +110,16 @@ export function DatasetRunItemsTable(
|
||||
runItems.isLoading
|
||||
? { isLoading: true, isError: false }
|
||||
: runItems.isError
|
||||
? {
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
error: runItems.error.message,
|
||||
}
|
||||
: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: runItems.data?.map((t) => convertToTableRow(t)),
|
||||
}
|
||||
? {
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
error: runItems.error.message,
|
||||
}
|
||||
: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: runItems.data?.map((t) => convertToTableRow(t)),
|
||||
}
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -93,16 +93,16 @@ export function DatasetRunsTable(props: {
|
||||
runs.isLoading
|
||||
? { isLoading: true, isError: false }
|
||||
: runs.isError
|
||||
? {
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
error: runs.error.message,
|
||||
}
|
||||
: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: runs.data?.map((t) => convertToTableRow(t)),
|
||||
}
|
||||
? {
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
error: runs.error.message,
|
||||
}
|
||||
: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: runs.data?.map((t) => convertToTableRow(t)),
|
||||
}
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -29,7 +29,7 @@ type RowData = {
|
||||
};
|
||||
|
||||
export function DatasetsTable(props: { projectId: string }) {
|
||||
const utils = api.useContext();
|
||||
const utils = api.useUtils();
|
||||
const datasets = api.datasets.allDatasets.useQuery({
|
||||
projectId: props.projectId,
|
||||
});
|
||||
@@ -146,16 +146,16 @@ export function DatasetsTable(props: { projectId: string }) {
|
||||
datasets.isLoading
|
||||
? { isLoading: true, isError: false }
|
||||
: datasets.isError
|
||||
? {
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
error: datasets.error.message,
|
||||
}
|
||||
: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: datasets.data?.map((t) => convertToTableRow(t)),
|
||||
}
|
||||
? {
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
error: datasets.error.message,
|
||||
}
|
||||
: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: datasets.data?.map((t) => convertToTableRow(t)),
|
||||
}
|
||||
}
|
||||
/>
|
||||
<NewDatasetButton projectId={props.projectId} className="mt-4" />
|
||||
|
||||
@@ -56,7 +56,7 @@ export const EditDatasetItem = ({
|
||||
projectId: projectId,
|
||||
scope: "datasets:CUD",
|
||||
});
|
||||
const utils = api.useContext();
|
||||
const utils = api.useUtils();
|
||||
const item = api.datasets.itemById.useQuery({
|
||||
datasetId,
|
||||
projectId,
|
||||
|
||||
@@ -32,7 +32,7 @@ export const NewDatasetForm = (props: {
|
||||
},
|
||||
});
|
||||
|
||||
const utils = api.useContext();
|
||||
const utils = api.useUtils();
|
||||
const createDatasetMutation = api.datasets.createDataset.useMutation({
|
||||
onSuccess: () => utils.datasets.invalidate(),
|
||||
onError: (error) => setFormError(error.message),
|
||||
|
||||
@@ -83,7 +83,7 @@ export const NewDatasetItemForm = (props: {
|
||||
projectId: props.projectId,
|
||||
});
|
||||
|
||||
const utils = api.useContext();
|
||||
const utils = api.useUtils();
|
||||
const createDatasetItemMutation = api.datasets.createDatasetItem.useMutation({
|
||||
onSuccess: () => utils.datasets.invalidate(),
|
||||
onError: (error) => setFormError(error.message),
|
||||
|
||||
@@ -221,8 +221,8 @@ export const datasetRouter = createTRPCRouter({
|
||||
input.expectedOutput === ""
|
||||
? Prisma.DbNull
|
||||
: input.expectedOutput !== undefined
|
||||
? (JSON.parse(input.expectedOutput) as Prisma.InputJsonObject)
|
||||
: undefined,
|
||||
? (JSON.parse(input.expectedOutput) as Prisma.InputJsonObject)
|
||||
: undefined,
|
||||
sourceObservationId: input.sourceObservationId,
|
||||
status: input.status,
|
||||
},
|
||||
@@ -302,8 +302,8 @@ export const datasetRouter = createTRPCRouter({
|
||||
input.expectedOutput === ""
|
||||
? Prisma.DbNull
|
||||
: input.expectedOutput !== undefined
|
||||
? (JSON.parse(input.expectedOutput) as Prisma.InputJsonObject)
|
||||
: undefined,
|
||||
? (JSON.parse(input.expectedOutput) as Prisma.InputJsonObject)
|
||||
: undefined,
|
||||
datasetId: input.datasetId,
|
||||
sourceObservationId: input.sourceObservationId,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { createTransport } from "nodemailer";
|
||||
import { parseConnectionUrl } from "nodemailer/lib/shared/index.js";
|
||||
import { render } from "@react-email/render";
|
||||
|
||||
import { env } from "@/src/env.mjs";
|
||||
import ProjectInvitationTemplate from "@/src/features/email/templates/ProjectInvitation";
|
||||
|
||||
const langfuseUrls = {
|
||||
US: "https://us.cloud.langfuse.com",
|
||||
EU: "https://cloud.langfuse.com",
|
||||
STAGING: "https://staging.langfuse.com",
|
||||
};
|
||||
|
||||
const authUrl = env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION
|
||||
? langfuseUrls[env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION]
|
||||
: env.NEXTAUTH_URL;
|
||||
|
||||
export const sendProjectInvitation = async (
|
||||
to: string,
|
||||
inviterName: string,
|
||||
inviterEmail: string,
|
||||
projectName: string,
|
||||
) => {
|
||||
if (!env.EMAIL_FROM_ADDRESS || !env.SMTP_CONNECTION_URL) {
|
||||
console.error(
|
||||
"Missing environment variables for sending project invitation email.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const mailer = createTransport(parseConnectionUrl(env.SMTP_CONNECTION_URL));
|
||||
|
||||
const htmlTemplate = render(
|
||||
ProjectInvitationTemplate({
|
||||
invitedByUsername: inviterName,
|
||||
invitedByUserEmail: inviterEmail,
|
||||
projectName: projectName,
|
||||
recieverEmail: to,
|
||||
inviteLink: authUrl,
|
||||
}),
|
||||
);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: to,
|
||||
from: {
|
||||
address: env.EMAIL_FROM_ADDRESS,
|
||||
name: "Langfuse",
|
||||
},
|
||||
subject: `${inviterName} invited you to join "${projectName}"`,
|
||||
html: htmlTemplate,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
|
||||
import { env } from "@/src/env.mjs";
|
||||
|
||||
interface ProjectInvitationTemplateProps {
|
||||
invitedByUsername: string;
|
||||
invitedByUserEmail: string;
|
||||
projectName: string;
|
||||
recieverEmail: string;
|
||||
inviteLink: string;
|
||||
}
|
||||
|
||||
export const ProjectInvitationTemplate = ({
|
||||
invitedByUsername,
|
||||
invitedByUserEmail,
|
||||
projectName,
|
||||
recieverEmail,
|
||||
inviteLink,
|
||||
}: ProjectInvitationTemplateProps) => {
|
||||
const previewText = `Join ${invitedByUsername} on Langfuse`;
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind>
|
||||
<Body className="mx-auto my-auto bg-white font-sans">
|
||||
<Container className="mx-auto my-10 w-[465px] rounded border border-solid border-[#eaeaea] p-5">
|
||||
<Section className="mt-8">
|
||||
<Img
|
||||
src="https://static.langfuse.com/langfuse_logo_transactional_email.png"
|
||||
width="40"
|
||||
height="40"
|
||||
alt="Langfuse"
|
||||
className="mx-auto my-0"
|
||||
/>
|
||||
</Section>
|
||||
<Heading className="mx-0 my-[30px] p-0 text-center text-2xl font-normal text-black">
|
||||
Join <strong>{projectName}</strong> on <strong>Langfuse</strong>
|
||||
</Heading>
|
||||
<Text className="text-sm leading-6 text-black">Hello,</Text>
|
||||
<Text className="text-sm leading-6 text-black">
|
||||
<strong>{invitedByUsername}</strong> (
|
||||
<Link
|
||||
href={`mailto:${invitedByUserEmail}`}
|
||||
className="text-blue-600 no-underline"
|
||||
>
|
||||
{invitedByUserEmail}
|
||||
</Link>
|
||||
) has invited you to the <strong>{projectName}</strong> project on
|
||||
Langfuse.
|
||||
</Text>
|
||||
<Section className="mb-4 mt-8 text-center">
|
||||
<Button
|
||||
className="rounded bg-black px-5 py-3 text-center text-xs font-semibold text-white no-underline"
|
||||
href={inviteLink}
|
||||
>
|
||||
Accept Invitation
|
||||
</Button>
|
||||
<Text className="mt-2 text-xs leading-3 text-gray-600">
|
||||
(you need to create an account)
|
||||
</Text>
|
||||
</Section>
|
||||
<Text className="text-sm leading-6 text-black">
|
||||
or copy and paste this URL into your browser:{" "}
|
||||
<Link href={inviteLink} className="text-blue-600 no-underline">
|
||||
{inviteLink}
|
||||
</Link>
|
||||
</Text>
|
||||
<Hr className="mx-0 my-[26px] w-full border border-solid border-[#eaeaea]" />
|
||||
<Text className="text-xs leading-6 text-[#666666]">
|
||||
This invitation was intended for{" "}
|
||||
<span className="text-black">{recieverEmail}</span>. This invite
|
||||
was sent from{" "}
|
||||
<span className="text-black">{env.EMAIL_FROM_ADDRESS}</span>. If
|
||||
you were not expecting this invitation, you can ignore this email.
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectInvitationTemplate;
|
||||
@@ -58,11 +58,10 @@ export function FilterBuilder({
|
||||
) => {
|
||||
_setWipFilterState((prev) => {
|
||||
const newState = state instanceof Function ? state(prev) : state;
|
||||
onChange(
|
||||
newState.filter(
|
||||
(f) => singleFilter.safeParse(f).success,
|
||||
) as FilterState,
|
||||
);
|
||||
const validFilters = newState.filter(
|
||||
(f) => singleFilter.safeParse(f).success,
|
||||
) as FilterState;
|
||||
onChange(validFilters);
|
||||
return newState;
|
||||
});
|
||||
};
|
||||
@@ -97,11 +96,13 @@ export function FilterBuilder({
|
||||
{filter.type === "datetime"
|
||||
? new Date(filter.value).toLocaleDateString()
|
||||
: filter.type === "stringOptions"
|
||||
? filter.value.join(", ")
|
||||
: filter.type === "number" ||
|
||||
filter.type === "numberObject"
|
||||
? filter.value
|
||||
: `"${filter.value}"`}
|
||||
? filter.value.join(", ")
|
||||
: filter.type === "number" ||
|
||||
filter.type === "numberObject"
|
||||
? filter.value
|
||||
: filter.type === "boolean"
|
||||
? `${filter.value}`
|
||||
: `"${filter.value}"`}
|
||||
</span>
|
||||
);
|
||||
})
|
||||
@@ -332,6 +333,33 @@ function FilterBuilderForm({
|
||||
}
|
||||
values={Array.isArray(filter.value) ? filter.value : []}
|
||||
/>
|
||||
) : filter.type === "boolean" ? (
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
handleFilterChange(
|
||||
{
|
||||
...filter,
|
||||
value:
|
||||
value !== undefined && value !== ""
|
||||
? value === "true"
|
||||
: undefined,
|
||||
},
|
||||
i,
|
||||
);
|
||||
}}
|
||||
value={filter.value?.toString() ?? ""}
|
||||
>
|
||||
<SelectTrigger className="min-w-[60px]">
|
||||
<SelectValue placeholder="" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{["true", "false"].map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input disabled />
|
||||
)}
|
||||
|
||||
@@ -22,8 +22,8 @@ const CommaArrayParam = {
|
||||
f.type === "datetime"
|
||||
? f.value.toISOString()
|
||||
: f.type === "stringOptions"
|
||||
? f.value.join("|")
|
||||
: f.value
|
||||
? f.value.join("|")
|
||||
: f.value
|
||||
}`;
|
||||
if (DEBUG_QUERY_STATE) console.log("stringified", stringified);
|
||||
return stringified;
|
||||
@@ -42,12 +42,14 @@ const CommaArrayParam = {
|
||||
value === undefined || type === undefined
|
||||
? undefined
|
||||
: type === "datetime"
|
||||
? new Date(value)
|
||||
: type === "number" || type === "numberObject"
|
||||
? Number(value)
|
||||
: type === "stringOptions"
|
||||
? value.split("|")
|
||||
: value;
|
||||
? new Date(value)
|
||||
: type === "number" || type === "numberObject"
|
||||
? Number(value)
|
||||
: type === "stringOptions"
|
||||
? value.split("|")
|
||||
: type === "boolean"
|
||||
? value === "true"
|
||||
: value;
|
||||
if (DEBUG_QUERY_STATE) console.log("parsedValue", parsedValue);
|
||||
const parsed = singleFilter.safeParse({
|
||||
column,
|
||||
|
||||
@@ -7,6 +7,7 @@ const operatorReplacements = {
|
||||
"any of": "IN",
|
||||
"none of": "NOT IN",
|
||||
contains: "ILIKE",
|
||||
"does not contain": "NOT ILIKE",
|
||||
"starts with": "ILIKE",
|
||||
"ends with": "ILIKE",
|
||||
};
|
||||
@@ -57,6 +58,9 @@ export function filterToPrismaSql(
|
||||
filter.value.map((v) => Prisma.sql`${v}`),
|
||||
)})`;
|
||||
break;
|
||||
case "boolean":
|
||||
valuePrisma = Prisma.sql`${filter.value}`;
|
||||
break;
|
||||
}
|
||||
const jsonKeyPrisma =
|
||||
filter.type === "stringObject" || filter.type === "numberObject"
|
||||
@@ -69,10 +73,14 @@ export function filterToPrismaSql(
|
||||
const [valuePrefix, valueSuffix] =
|
||||
filter.type === "string" || filter.type === "stringObject"
|
||||
? [
|
||||
["contains", "ends with"].includes(filter.operator)
|
||||
["contains", "does not contain", "ends with"].includes(
|
||||
filter.operator,
|
||||
)
|
||||
? Prisma.raw("'%' || ")
|
||||
: Prisma.empty,
|
||||
["contains", "starts with"].includes(filter.operator)
|
||||
["contains", "does not contain", "starts with"].includes(
|
||||
filter.operator,
|
||||
)
|
||||
? Prisma.raw(" || '%'")
|
||||
: Prisma.empty,
|
||||
]
|
||||
|
||||
@@ -37,11 +37,11 @@ export function tokenCount(p: {
|
||||
messages: p.text,
|
||||
})
|
||||
: isString(p.text)
|
||||
? openAiStringTokenCount({ model: p.model, text: p.text })
|
||||
: openAiStringTokenCount({
|
||||
model: p.model,
|
||||
text: JSON.stringify(p.text),
|
||||
});
|
||||
? openAiStringTokenCount({ model: p.model, text: p.text })
|
||||
: openAiStringTokenCount({
|
||||
model: p.model,
|
||||
text: JSON.stringify(p.text),
|
||||
});
|
||||
} else if (isClaudeModel(p.model)) {
|
||||
return isString(p.text)
|
||||
? claudeStringTokenCount({ model: p.model, text: p.text })
|
||||
|
||||
@@ -58,7 +58,7 @@ export function ManualScoreButton({
|
||||
: s.observationId === null),
|
||||
);
|
||||
|
||||
const utils = api.useContext();
|
||||
const utils = api.useUtils();
|
||||
const onSuccess = async () => {
|
||||
await Promise.all([utils.scores.invalidate(), utils.traces.invalidate()]);
|
||||
};
|
||||
@@ -179,8 +179,8 @@ export function ManualScoreButton({
|
||||
{form.formState.isSubmitting
|
||||
? "Loading ..."
|
||||
: score
|
||||
? "Update"
|
||||
: "Create"}
|
||||
? "Update"
|
||||
: "Create"}
|
||||
</Button>
|
||||
{score && (
|
||||
<Button
|
||||
|
||||
@@ -32,7 +32,14 @@ export const DetailPageNav = (props: {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// don't trigger keyboard shortcuts if the user is typing in an input
|
||||
if (document.activeElement instanceof HTMLInputElement) return;
|
||||
if (
|
||||
event.target instanceof HTMLInputElement ||
|
||||
event.target instanceof HTMLTextAreaElement ||
|
||||
(event.target instanceof HTMLElement &&
|
||||
event.target.getAttribute("role") === "textbox")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "k" && previousPageId) {
|
||||
void router.push(props.path(previousPageId));
|
||||
|
||||
@@ -25,7 +25,7 @@ import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
|
||||
export function DeleteProjectButton(props: { projectId: string }) {
|
||||
const utils = api.useContext();
|
||||
const utils = api.useUtils();
|
||||
const router = useRouter();
|
||||
const session = useSession();
|
||||
const posthog = usePostHog();
|
||||
|
||||
@@ -44,7 +44,7 @@ export function NewProjectButton({ size = "default" }: NewProjectButtonProps) {
|
||||
name: "",
|
||||
},
|
||||
});
|
||||
const utils = api.useContext();
|
||||
const utils = api.useUtils();
|
||||
const router = useRouter();
|
||||
const posthog = usePostHog();
|
||||
const createProjectMutation = api.projects.create.useMutation({
|
||||
|
||||
@@ -29,7 +29,7 @@ const formSchema = z.object({
|
||||
newOwnerEmail: z.string().email(),
|
||||
});
|
||||
export function TransferOwnershipButton(props: { projectId: string }) {
|
||||
const utils = api.useContext();
|
||||
const utils = api.useUtils();
|
||||
const router = useRouter();
|
||||
const posthog = usePostHog();
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ function DeleteApiKeyButton(props: { projectId: string; apiKeyId: string }) {
|
||||
scope: "apiKeys:delete",
|
||||
});
|
||||
|
||||
const utils = api.useContext();
|
||||
const utils = api.useUtils();
|
||||
const mutDeleteApiKey = api.apiKeys.delete.useMutation({
|
||||
onSuccess: () => utils.apiKeys.invalidate(),
|
||||
});
|
||||
|
||||
@@ -5,22 +5,25 @@ import { PlusIcon } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/src/components/ui/dialog";
|
||||
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";
|
||||
|
||||
export function CreateApiKeyButton(props: { projectId: string }) {
|
||||
const utils = api.useContext();
|
||||
const utils = api.useUtils();
|
||||
const posthog = usePostHog();
|
||||
const hasAccess = useHasAccess({
|
||||
projectId: props.projectId,
|
||||
scope: "apiKeys:create",
|
||||
});
|
||||
|
||||
const hostname =
|
||||
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== "EU" ? window.origin : undefined;
|
||||
|
||||
const mutCreateApiKey = api.apiKeys.create.useMutation({
|
||||
onSuccess: () => utils.apiKeys.invalidate(),
|
||||
});
|
||||
@@ -64,9 +67,19 @@ export function CreateApiKeyButton(props: { projectId: string }) {
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent onPointerDownOutside={(e) => e.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>API Keys</DialogTitle>
|
||||
</DialogHeader>
|
||||
{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}
|
||||
|
||||
<DialogTitle>API Keys</DialogTitle>
|
||||
<div className="mb-2">
|
||||
<div className="text-md font-semibold">Secret Key</div>
|
||||
<div className="my-2">
|
||||
|
||||
@@ -125,6 +125,9 @@ async function findDbKeyOrThrow(publicKey: string) {
|
||||
const dbKey = await prisma.apiKey.findUnique({
|
||||
where: { publicKey },
|
||||
});
|
||||
if (!dbKey) throw new Error("Invalid public key");
|
||||
if (!dbKey) {
|
||||
console.log("No api key found for public key:", publicKey);
|
||||
throw new Error("Invalid public key");
|
||||
}
|
||||
return dbKey;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ export const SpanPatchSchema = z.object({
|
||||
spanId: z.string(),
|
||||
traceId: z.string().nullish(),
|
||||
name: z.string().nullish(),
|
||||
startTime: z.string().datetime({ offset: true }).nullish(),
|
||||
endTime: z.string().datetime({ offset: true }).nullish(),
|
||||
metadata: jsonSchema.nullish(),
|
||||
input: jsonSchema.nullish(),
|
||||
@@ -72,6 +73,7 @@ export const GenerationPatchSchema = z.object({
|
||||
generationId: z.string(),
|
||||
traceId: z.string().nullish(),
|
||||
name: z.string().nullish(),
|
||||
startTime: z.string().datetime({ offset: true }).nullish(),
|
||||
endTime: z.string().datetime({ offset: true }).nullish(),
|
||||
completionStartTime: z.string().datetime({ offset: true }).nullish(),
|
||||
model: z.string().nullish(),
|
||||
|
||||
@@ -15,7 +15,7 @@ export const PublishTraceSwitch = (props: {
|
||||
projectId: props.projectId,
|
||||
scope: "traces:publish",
|
||||
});
|
||||
const utils = api.useContext();
|
||||
const utils = api.useUtils();
|
||||
const mut = api.publishTraces.update.useMutation({
|
||||
onSuccess: () => utils.traces.invalidate(),
|
||||
});
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { throwIfNoAccess } from "@/src/features/rbac/utils/checkAccess";
|
||||
import { createTRPCRouter, protectedProcedure } from "@/src/server/api/trpc";
|
||||
import {
|
||||
createTRPCRouter,
|
||||
protectedProjectProcedure,
|
||||
} from "@/src/server/api/trpc";
|
||||
import * as z from "zod";
|
||||
|
||||
export const publishTracesRouter = createTRPCRouter({
|
||||
update: protectedProcedure
|
||||
update: protectedProjectProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
|
||||
@@ -51,9 +51,14 @@ export function CreateProjectMemberButton(props: { projectId: string }) {
|
||||
scope: "members:create",
|
||||
});
|
||||
|
||||
const utils = api.useContext();
|
||||
const utils = api.useUtils();
|
||||
const mutCreateProjectMember = api.projectMembers.create.useMutation({
|
||||
onSuccess: () => utils.projectMembers.invalidate(),
|
||||
onError: (error) =>
|
||||
form.setError("email", {
|
||||
type: "manual",
|
||||
message: error.message,
|
||||
}),
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
@@ -77,94 +82,93 @@ export function CreateProjectMemberButton(props: { projectId: string }) {
|
||||
form.reset();
|
||||
setOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
form.setError("email", {
|
||||
type: "manual",
|
||||
message: "User does not exist or already has access to this project",
|
||||
});
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="secondary" loading={mutCreateProjectMember.isLoading}>
|
||||
<PlusIcon className="-ml-0.5 mr-1.5 h-5 w-5" aria-hidden="true" />
|
||||
Add new member
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add new member to project</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-6"
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
loading={mutCreateProjectMember.isLoading}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="jsdoe@example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
User must already have an account
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="role"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Role</FormLabel>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(value as (typeof availableRoles)[number])
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a verified email to display" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{availableRoles.map((role) => (
|
||||
<SelectItem value={role} key={role}>
|
||||
{role}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Rights of role:{" "}
|
||||
{roleAccessRights[field.value].length
|
||||
? roleAccessRights[field.value].join(", ")
|
||||
: "none"}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
loading={form.formState.isSubmitting}
|
||||
<PlusIcon className="-ml-0.5 mr-1.5 h-5 w-5" aria-hidden="true" />
|
||||
Add new member
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add new member to project</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-6"
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
Grant access
|
||||
</Button>
|
||||
<FormMessage />
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="jsdoe@example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="role"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Role</FormLabel>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(value as (typeof availableRoles)[number])
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a verified email to display" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{availableRoles.map((role) => (
|
||||
<SelectItem value={role} key={role}>
|
||||
{role}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Rights of role:{" "}
|
||||
{roleAccessRights[field.value].length
|
||||
? roleAccessRights[field.value].join(", ")
|
||||
: "none"}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
Grant access
|
||||
</Button>
|
||||
<FormMessage />
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@ export function ProjectMembersTable({ projectId }: { projectId: string }) {
|
||||
|
||||
const session = useSession();
|
||||
|
||||
const utils = api.useContext();
|
||||
const memberships = api.projectMembers.get.useQuery(
|
||||
const utils = api.useUtils();
|
||||
const data = api.projectMembers.get.useQuery(
|
||||
{
|
||||
projectId: projectId,
|
||||
},
|
||||
@@ -35,9 +35,16 @@ export function ProjectMembersTable({ projectId }: { projectId: string }) {
|
||||
enabled: hasReadAccess,
|
||||
},
|
||||
);
|
||||
|
||||
const memberships = data.data?.memberships ?? []; // Active Members
|
||||
const invitations = data.data?.invitations ?? []; // Pending Members
|
||||
|
||||
const mutDeleteMembership = api.projectMembers.delete.useMutation({
|
||||
onSuccess: () => utils.projectMembers.invalidate(),
|
||||
});
|
||||
const mutDeleteInvitation = api.projectMembers.deleteInvitation.useMutation({
|
||||
onSuccess: () => utils.projectMembers.invalidate(),
|
||||
});
|
||||
|
||||
if (!hasReadAccess) return null;
|
||||
|
||||
@@ -57,7 +64,7 @@ export function ProjectMembersTable({ projectId }: { projectId: string }) {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="text-gray-500">
|
||||
{memberships.data?.map((m) => (
|
||||
{memberships.map((m) => (
|
||||
<TableRow key={m.userId} className="hover:bg-transparent">
|
||||
<TableCell>{m.user.name}</TableCell>
|
||||
<TableCell>{m.user.email}</TableCell>
|
||||
@@ -86,6 +93,53 @@ export function ProjectMembersTable({ projectId }: { projectId: string }) {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
{invitations.length > 0 ? (
|
||||
<>
|
||||
<h3 className="mb-3 text-sm font-semibold leading-4 text-gray-600">
|
||||
Pending Invites
|
||||
</h3>
|
||||
<Card className="mb-4">
|
||||
<Table className="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="text-gray-900">Email</TableHead>
|
||||
<TableHead className="text-gray-900">Role</TableHead>
|
||||
<TableHead className="text-gray-900">Sent by</TableHead>
|
||||
{hasDeleteAccess ? <TableHead /> : null}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="text-gray-500">
|
||||
{invitations.map((invite) => (
|
||||
<TableRow key={invite.id} className="hover:bg-transparent">
|
||||
<TableCell>{invite.email}</TableCell>
|
||||
<TableCell>{invite.role}</TableCell>
|
||||
<TableCell>
|
||||
{invite.sender ? invite.sender.name : ""}
|
||||
</TableCell>
|
||||
{hasDeleteAccess ? (
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
loading={mutDeleteInvitation.isLoading}
|
||||
onClick={() => {
|
||||
mutDeleteInvitation.mutate({
|
||||
id: invite.id,
|
||||
projectId: projectId,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
) : null}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</>
|
||||
) : null}
|
||||
<CreateProjectMemberButton projectId={projectId} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ const scopes = [
|
||||
"apiKeys:delete",
|
||||
|
||||
"traces:publish",
|
||||
"traces:bookmark",
|
||||
"traces:delete",
|
||||
|
||||
"scores:CUD",
|
||||
@@ -32,6 +33,7 @@ export const roleAccessRights: Record<MembershipRole, Scope[]> = {
|
||||
"apiKeys:create",
|
||||
"apiKeys:delete",
|
||||
"traces:publish",
|
||||
"traces:bookmark",
|
||||
"traces:delete",
|
||||
"scores:CUD",
|
||||
"project:delete",
|
||||
@@ -46,10 +48,17 @@ export const roleAccessRights: Record<MembershipRole, Scope[]> = {
|
||||
"apiKeys:create",
|
||||
"apiKeys:delete",
|
||||
"traces:publish",
|
||||
"traces:bookmark",
|
||||
"traces:delete",
|
||||
"scores:CUD",
|
||||
"datasets:CUD",
|
||||
],
|
||||
MEMBER: ["members:read", "traces:publish", "scores:CUD", "datasets:CUD"],
|
||||
MEMBER: [
|
||||
"members:read",
|
||||
"traces:publish",
|
||||
"traces:bookmark",
|
||||
"scores:CUD",
|
||||
"datasets:CUD",
|
||||
],
|
||||
VIEWER: [],
|
||||
};
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { sendProjectInvitation } from "@/src/features/email/lib/project-invitation";
|
||||
import { throwIfNoAccess } from "@/src/features/rbac/utils/checkAccess";
|
||||
import { createTRPCRouter, protectedProcedure } from "@/src/server/api/trpc";
|
||||
import {
|
||||
createTRPCRouter,
|
||||
protectedProjectProcedure,
|
||||
} from "@/src/server/api/trpc";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import * as z from "zod";
|
||||
|
||||
export const projectMembersRouter = createTRPCRouter({
|
||||
get: protectedProcedure
|
||||
get: protectedProjectProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
@@ -17,7 +21,7 @@ export const projectMembersRouter = createTRPCRouter({
|
||||
scope: "members:read",
|
||||
});
|
||||
|
||||
return await ctx.prisma.membership.findMany({
|
||||
const memberships = await ctx.prisma.membership.findMany({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
project: {
|
||||
@@ -38,8 +42,23 @@ export const projectMembersRouter = createTRPCRouter({
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const invitations = await ctx.prisma.membershipInvitation.findMany({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
},
|
||||
include: {
|
||||
sender: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return { memberships: memberships, invitations: invitations };
|
||||
}),
|
||||
delete: protectedProcedure
|
||||
delete: protectedProjectProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
@@ -67,7 +86,28 @@ export const projectMembersRouter = createTRPCRouter({
|
||||
},
|
||||
});
|
||||
}),
|
||||
create: protectedProcedure
|
||||
deleteInvitation: protectedProjectProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
projectId: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
throwIfNoAccess({
|
||||
session: ctx.session,
|
||||
projectId: input.projectId,
|
||||
scope: "members:delete",
|
||||
});
|
||||
|
||||
return await ctx.prisma.membershipInvitation.delete({
|
||||
where: {
|
||||
id: input.id,
|
||||
projectId: input.projectId,
|
||||
},
|
||||
});
|
||||
}),
|
||||
create: protectedProjectProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
@@ -91,14 +131,40 @@ export const projectMembersRouter = createTRPCRouter({
|
||||
email: input.email.toLowerCase(),
|
||||
},
|
||||
});
|
||||
if (!user) throw new Error("User not found");
|
||||
if (user) {
|
||||
return await ctx.prisma.membership.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
projectId: input.projectId,
|
||||
role: input.role,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const invitation = await ctx.prisma.membershipInvitation.create({
|
||||
data: {
|
||||
projectId: input.projectId,
|
||||
email: input.email.toLowerCase(),
|
||||
role: input.role,
|
||||
senderId: ctx.session.user.id,
|
||||
},
|
||||
});
|
||||
|
||||
return await ctx.prisma.membership.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
projectId: input.projectId,
|
||||
role: input.role,
|
||||
},
|
||||
});
|
||||
const project = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
id: input.projectId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) throw new Error("Project not found");
|
||||
|
||||
await sendProjectInvitation(
|
||||
input.email,
|
||||
ctx.session.user.name!,
|
||||
ctx.session.user.email!,
|
||||
project.name,
|
||||
);
|
||||
|
||||
return invitation;
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type Scope,
|
||||
} from "@/src/features/rbac/constants/roleAccessRights";
|
||||
import { type MembershipRole } from "@prisma/client";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { type Session } from "next-auth";
|
||||
import { useSession, type SessionContextValue } from "next-auth/react";
|
||||
|
||||
@@ -19,7 +20,12 @@ type HasAccessParams =
|
||||
|
||||
// For use in TRPC routes
|
||||
export const throwIfNoAccess = (p: HasAccessParams) => {
|
||||
if (!hasAccess(p)) throw new Error("No access");
|
||||
if (!hasAccess(p))
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message:
|
||||
"Unauthorized, user does not have access to this resource or action",
|
||||
});
|
||||
};
|
||||
|
||||
// For use in UI components as react hook
|
||||
|
||||
@@ -7,6 +7,6 @@ This helps us to:
|
||||
1. Understand how Langfuse is used and improve the most relevant features.
|
||||
2. Track overall usage for internal and external (e.g. fundraising) reporting.
|
||||
|
||||
None of the data is shared with third parties and does not include any sensitive information. We want to be super transparent about this and you can find the exact data we collect [here](/src/pages/api/cron/telemetry.ts).
|
||||
None of the data is shared with third parties and does not include any sensitive information. We want to be super transparent about this and you can find the exact data we collect [here](/src/features/telemetry/index.ts).
|
||||
|
||||
You can opt-out by setting `TELEMETRY_ENABLED=false`.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { VERSION } from "@/src/constants/VERSION";
|
||||
import { prisma } from "@/src/server/db";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { PostHog } from "posthog-node";
|
||||
@@ -225,17 +226,32 @@ async function posthogTelemetry({
|
||||
},
|
||||
});
|
||||
|
||||
// Domains (no PII)
|
||||
const domains = await prisma.$queryRaw<Array<{ domain: string }>>`
|
||||
SELECT
|
||||
substring(email FROM position('@' in email) + 1) as domain,
|
||||
count(id)::int as "userCount"
|
||||
FROM users
|
||||
WHERE email ILIKE '%@%'
|
||||
GROUP BY 1
|
||||
ORDER BY count(id) desc
|
||||
LIMIT 30
|
||||
`;
|
||||
|
||||
posthog.identify({
|
||||
distinctId: "docker:" + clientId,
|
||||
properties: {
|
||||
environment: process.env.NODE_ENV,
|
||||
userDomains: domains,
|
||||
docker: true,
|
||||
langfuseVersion: VERSION,
|
||||
},
|
||||
});
|
||||
posthog.capture({
|
||||
distinctId: "docker:" + clientId,
|
||||
event: "telemetry",
|
||||
properties: {
|
||||
langfuseVersion: VERSION,
|
||||
totalProjects: totalProjects,
|
||||
traces: countTraces,
|
||||
scores: countScores,
|
||||
|
||||
@@ -105,6 +105,7 @@ function UserTracking() {
|
||||
process.env.NEXT_PUBLIC_POSTHOG_HOST
|
||||
)
|
||||
posthog.identify(session.data.user?.id ?? undefined, {
|
||||
environment: process.env.NODE_ENV,
|
||||
email: session.data.user?.email ?? undefined,
|
||||
name: session.data.user?.name ?? undefined,
|
||||
featureFlags: session.data.user?.featureFlags ?? undefined,
|
||||
|
||||
@@ -3,6 +3,10 @@ import { createUserEmailPassword } from "@/src/features/auth/lib/emailPassword";
|
||||
import { signupSchema } from "@/src/features/auth/lib/signupSchema";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
/*
|
||||
* Sign-up endpoint (email/password users), creates user in database.
|
||||
* SSO users are created by the NextAuth adapters.
|
||||
*/
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
@@ -12,6 +16,13 @@ export default async function handler(
|
||||
res.status(422).json({ message: "Sign up is disabled." });
|
||||
return;
|
||||
}
|
||||
if (env.AUTH_DISABLE_USERNAME_PASSWORD === "true") {
|
||||
res.status(422).json({
|
||||
message:
|
||||
"Sign up with email and password is disabled for this instance. Please use SSO.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// parse and type check the request body with zod
|
||||
const validBody = signupSchema.safeParse(req.body);
|
||||
@@ -59,7 +70,8 @@ export default async function handler(
|
||||
// Trigger new user signup event
|
||||
if (
|
||||
env.LANGFUSE_NEW_USER_SIGNUP_WEBHOOK &&
|
||||
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION
|
||||
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION &&
|
||||
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== "STAGING"
|
||||
) {
|
||||
await fetch(env.LANGFUSE_NEW_USER_SIGNUP_WEBHOOK, {
|
||||
method: "POST",
|
||||
|
||||
@@ -16,6 +16,11 @@ export default async function handler(
|
||||
message: "Only runs on Langfuse Cloud, no LANGFUSE_CLOUD_REGION provided",
|
||||
});
|
||||
|
||||
if (env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "STAGING")
|
||||
return res.status(200).json({
|
||||
message: "Does not run on staging, LANGFUSE_CLOUD_REGION is STAGING",
|
||||
});
|
||||
|
||||
const posthog_event_user_id =
|
||||
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "US"
|
||||
? "langfuse-cloud-us"
|
||||
|
||||
@@ -4,11 +4,13 @@ import { cors, runMiddleware } from "@/src/features/public-api/server/cors";
|
||||
import { prisma } from "@/src/server/db";
|
||||
import { verifyAuthHeaderAndReturnScope } from "@/src/features/public-api/server/apiAuth";
|
||||
import { jsonSchema } from "@/src/utils/zod";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
const CreateDatasetItemSchema = z.object({
|
||||
datasetName: z.string(),
|
||||
input: jsonSchema,
|
||||
expectedOutput: jsonSchema.nullish(),
|
||||
id: z.string().nullish(),
|
||||
});
|
||||
|
||||
export default async function handler(
|
||||
@@ -30,14 +32,13 @@ export default async function handler(
|
||||
try {
|
||||
if (req.method === "POST") {
|
||||
console.log(
|
||||
"Trying to create dataset, project ",
|
||||
"Trying to upsert dataset item, project ",
|
||||
authCheck.scope.projectId,
|
||||
", body:",
|
||||
JSON.stringify(req.body, null, 2),
|
||||
);
|
||||
|
||||
const { datasetName, input, expectedOutput } =
|
||||
CreateDatasetItemSchema.parse(req.body);
|
||||
const itemBody = CreateDatasetItemSchema.parse(req.body);
|
||||
|
||||
// CHECK ACCESS SCOPE
|
||||
if (authCheck.scope.accessLevel !== "all")
|
||||
@@ -50,7 +51,7 @@ export default async function handler(
|
||||
const dataset = await prisma.dataset.findFirst({
|
||||
where: {
|
||||
projectId: authCheck.scope.projectId,
|
||||
name: datasetName,
|
||||
name: itemBody.datasetName,
|
||||
},
|
||||
});
|
||||
if (!dataset) {
|
||||
@@ -58,16 +59,30 @@ export default async function handler(
|
||||
message: "Dataset not found",
|
||||
});
|
||||
}
|
||||
const id = itemBody.id ?? uuidv4();
|
||||
|
||||
const item = await prisma.datasetItem.create({
|
||||
data: {
|
||||
input,
|
||||
expectedOutput: expectedOutput ?? undefined,
|
||||
const item = await prisma.datasetItem.upsert({
|
||||
where: {
|
||||
id,
|
||||
datasetId: dataset.id,
|
||||
},
|
||||
create: {
|
||||
id,
|
||||
input: itemBody.input,
|
||||
expectedOutput: itemBody.expectedOutput ?? undefined,
|
||||
datasetId: dataset.id,
|
||||
},
|
||||
update: {
|
||||
input: itemBody.input,
|
||||
expectedOutput: itemBody.expectedOutput ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
res.status(200).json(item);
|
||||
} else {
|
||||
res.status(405).json({
|
||||
message: "Method not allowed",
|
||||
});
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { type NextApiRequest, type NextApiResponse } from "next";
|
||||
import { z } from "zod";
|
||||
import { cors, runMiddleware } from "@/src/features/public-api/server/cors";
|
||||
import { prisma } from "@/src/server/db";
|
||||
import { verifyAuthHeaderAndReturnScope } from "@/src/features/public-api/server/apiAuth";
|
||||
|
||||
const GetDatasetItemQuerySchema = z.object({
|
||||
datasetItemId: z.string(),
|
||||
});
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
await runMiddleware(req, res, cors);
|
||||
|
||||
// CHECK AUTH
|
||||
const authCheck = await verifyAuthHeaderAndReturnScope(
|
||||
req.headers.authorization,
|
||||
);
|
||||
if (!authCheck.validKey)
|
||||
return res.status(401).json({
|
||||
message: authCheck.error,
|
||||
});
|
||||
// END CHECK AUTH
|
||||
|
||||
try {
|
||||
if (req.method === "GET") {
|
||||
console.log(
|
||||
"Trying to get dataset item, project ",
|
||||
authCheck.scope.projectId,
|
||||
", body:",
|
||||
JSON.stringify(req.body, null, 2),
|
||||
", query:",
|
||||
JSON.stringify(req.query, null, 2),
|
||||
);
|
||||
|
||||
const { datasetItemId } = GetDatasetItemQuerySchema.parse(req.query);
|
||||
|
||||
// CHECK ACCESS SCOPE
|
||||
if (authCheck.scope.accessLevel !== "all")
|
||||
return res.status(403).json({
|
||||
message: "Access denied",
|
||||
});
|
||||
// END CHECK ACCESS SCOPE
|
||||
|
||||
// Check access to dataset
|
||||
const datasetItem = await prisma.datasetItem.findFirst({
|
||||
where: {
|
||||
id: datasetItemId,
|
||||
dataset: {
|
||||
projectId: authCheck.scope.projectId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!datasetItem) {
|
||||
return res.status(404).json({
|
||||
message: "Dataset item not found (for this project)",
|
||||
});
|
||||
}
|
||||
res.status(200).json(datasetItem);
|
||||
} else {
|
||||
res.status(405).json({
|
||||
message: "Method not allowed",
|
||||
});
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "An unknown error occurred";
|
||||
res.status(400).json({
|
||||
message: "Invalid request data",
|
||||
error: errorMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { withSwagger } from "next-swagger-doc";
|
||||
|
||||
const swaggerHandler = withSwagger({
|
||||
definition: {
|
||||
openapi: "3.0.0",
|
||||
info: {
|
||||
title: "NextJS Swagger",
|
||||
version: "0.1.0",
|
||||
},
|
||||
},
|
||||
apiFolder: "src/pages/api/public",
|
||||
});
|
||||
export default swaggerHandler();
|
||||
@@ -245,24 +245,27 @@ export const handleBatchResult = (
|
||||
message: "Invalid request data",
|
||||
error: error.error.message,
|
||||
});
|
||||
} else if (error.error instanceof AuthenticationError) {
|
||||
returnedErrors.push({
|
||||
id: error.id,
|
||||
status: 401,
|
||||
message: "Authentication error",
|
||||
error: error.error.message,
|
||||
});
|
||||
} else if (error.error instanceof ResourceNotFoundError) {
|
||||
if (!errors.some((res) => res.id === error.id)) {
|
||||
returnedErrors.push({
|
||||
id: error.id,
|
||||
status: 404,
|
||||
message: "Resource not found",
|
||||
error: error.error.message,
|
||||
});
|
||||
}
|
||||
returnedErrors.push({
|
||||
id: error.id,
|
||||
status: 404,
|
||||
message: "Resource not found",
|
||||
error: error.error.message,
|
||||
});
|
||||
} else {
|
||||
if (!errors.some((res) => res.id === error.id)) {
|
||||
returnedErrors.push({
|
||||
id: error.id,
|
||||
status: 500,
|
||||
message: "Error processing events",
|
||||
error: "Internal Server Error",
|
||||
});
|
||||
}
|
||||
returnedErrors.push({
|
||||
id: error.id,
|
||||
status: 500,
|
||||
message: "Error processing events",
|
||||
error: "Internal Server Error",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { verifyAuthHeaderAndReturnScope } from "@/src/features/public-api/server/apiAuth";
|
||||
import { cors, runMiddleware } from "@/src/features/public-api/server/cors";
|
||||
import { prisma } from "@/src/server/db";
|
||||
|
||||
import { type NextApiRequest, type NextApiResponse } from "next";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
await runMiddleware(req, res, cors);
|
||||
|
||||
// CHECK AUTH
|
||||
const authCheck = await verifyAuthHeaderAndReturnScope(
|
||||
req.headers.authorization,
|
||||
);
|
||||
if (!authCheck.validKey)
|
||||
return res.status(401).json({
|
||||
message: authCheck.error,
|
||||
});
|
||||
// END CHECK AUTH
|
||||
|
||||
if (req.method === "GET") {
|
||||
try {
|
||||
const projects = await prisma.project.findMany({
|
||||
where: {
|
||||
id: authCheck.scope.projectId,
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
data: projects.map((project) => ({
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return res.status(500).json({ message: "Internal server error" });
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
`Method not allowed for ${req.method} on /api/public/projects`,
|
||||
);
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
}
|
||||
+75
-52
@@ -21,6 +21,9 @@ import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { Divider } from "@tremor/react";
|
||||
import { CloudPrivacyNotice } from "@/src/features/auth/components/AuthCloudPrivacyNotice";
|
||||
import { CloudRegionSwitch } from "@/src/features/auth/components/AuthCloudRegionSwitch";
|
||||
|
||||
const credentialAuthForm = z.object({
|
||||
email: z.string().email(),
|
||||
@@ -29,7 +32,8 @@ const credentialAuthForm = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
type PageProps = {
|
||||
// Also used in src/pages/auth/sign-up.tsx
|
||||
export type PageProps = {
|
||||
authProviders: {
|
||||
credentials: boolean;
|
||||
google: boolean;
|
||||
@@ -37,6 +41,7 @@ type PageProps = {
|
||||
};
|
||||
};
|
||||
|
||||
// Also used in src/pages/auth/sign-up.tsx
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
export const getServerSideProps: GetServerSideProps<PageProps> = async () => {
|
||||
return {
|
||||
@@ -48,13 +53,60 @@ export const getServerSideProps: GetServerSideProps<PageProps> = async () => {
|
||||
github:
|
||||
env.AUTH_GITHUB_CLIENT_ID !== undefined &&
|
||||
env.AUTH_GITHUB_CLIENT_SECRET !== undefined,
|
||||
credentials: true,
|
||||
credentials: env.AUTH_DISABLE_USERNAME_PASSWORD !== "true",
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default function SignIn(props: PageProps) {
|
||||
// Also used in src/pages/auth/sign-up.tsx
|
||||
export function SSOButtons({
|
||||
authProviders,
|
||||
action = "Sign in",
|
||||
}: PageProps & { action?: string }) {
|
||||
const posthog = usePostHog();
|
||||
|
||||
return (
|
||||
// any authprovider from props is enanbles
|
||||
Object.entries(authProviders).some(
|
||||
([name, enabled]) => enabled && name !== "credentials",
|
||||
) ? (
|
||||
<div>
|
||||
{authProviders.credentials ? (
|
||||
<Divider className="text-gray-400" />
|
||||
) : null}
|
||||
<div className="flex flex-row flex-wrap items-center justify-center gap-4">
|
||||
{authProviders.google ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
posthog.capture("sign_in:google_button_click");
|
||||
void signIn("google");
|
||||
}}
|
||||
variant="secondary"
|
||||
>
|
||||
<FcGoogle className="mr-3" size={18} />
|
||||
{action} with Google
|
||||
</Button>
|
||||
) : null}
|
||||
{authProviders.github ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
posthog.capture("sign_in:github_button_click");
|
||||
void signIn("github");
|
||||
}}
|
||||
variant="secondary"
|
||||
>
|
||||
<FaGithub className="mr-3" size={18} />
|
||||
{action} with Github
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
);
|
||||
}
|
||||
|
||||
export default function SignIn({ authProviders }: PageProps) {
|
||||
const [credentialsFormError, setCredentialsFormError] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
@@ -98,12 +150,13 @@ export default function SignIn(props: PageProps) {
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mt-14 sm:mx-auto sm:w-full sm:max-w-[480px]">
|
||||
<div className="divide-y bg-white p-6 py-6 shadow sm:rounded-lg sm:px-12">
|
||||
{props.authProviders.credentials ? (
|
||||
<div className="mt-14 bg-white px-6 py-10 shadow sm:mx-auto sm:w-full sm:max-w-[480px] sm:rounded-lg sm:px-12">
|
||||
<div className="space-y-8">
|
||||
<CloudRegionSwitch />
|
||||
{authProviders.credentials ? (
|
||||
<Form {...credentialsForm}>
|
||||
<form
|
||||
className="space-y-6 py-6"
|
||||
className="space-y-6"
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
onSubmit={credentialsForm.handleSubmit(onCredentialsSubmit)}
|
||||
>
|
||||
@@ -150,53 +203,23 @@ export default function SignIn(props: PageProps) {
|
||||
</form>
|
||||
</Form>
|
||||
) : null}
|
||||
|
||||
{
|
||||
// any authprovider from props is enanbles
|
||||
Object.values(props.authProviders).some((enabled) => enabled) ? (
|
||||
<div className="flex flex-row flex-wrap items-center justify-center gap-4 py-6">
|
||||
{props.authProviders.google ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
posthog.capture("sign_in:google_button_click");
|
||||
void signIn("google");
|
||||
}}
|
||||
variant="secondary"
|
||||
>
|
||||
<FcGoogle className="mr-3" size={18} />
|
||||
Sign in with Google
|
||||
</Button>
|
||||
) : null}
|
||||
{props.authProviders.github ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
posthog.capture("sign_in:github_button_click");
|
||||
void signIn("github");
|
||||
}}
|
||||
variant="secondary"
|
||||
>
|
||||
<FaGithub className="mr-3" size={18} />
|
||||
Sign in with Github
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
<SSOButtons authProviders={authProviders} />
|
||||
</div>
|
||||
|
||||
{env.NEXT_PUBLIC_SIGN_UP_DISABLED !== "true" &&
|
||||
props.authProviders.credentials ? (
|
||||
<p className="mt-10 text-center text-sm text-gray-500">
|
||||
No account yet?{" "}
|
||||
<Link
|
||||
href="/auth/sign-up"
|
||||
className="font-semibold leading-6 text-indigo-600 hover:text-indigo-500"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
) : null}
|
||||
<CloudPrivacyNotice action="signing in" />
|
||||
</div>
|
||||
|
||||
{env.NEXT_PUBLIC_SIGN_UP_DISABLED !== "true" &&
|
||||
authProviders.credentials ? (
|
||||
<p className="mt-10 text-center text-sm text-gray-500">
|
||||
No account yet?{" "}
|
||||
<Link
|
||||
href="/auth/sign-up"
|
||||
className="font-semibold leading-6 text-indigo-600 hover:text-indigo-500"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
+98
-120
@@ -19,8 +19,14 @@ import { env } from "@/src/env.mjs";
|
||||
import { useState } from "react";
|
||||
import { LangfuseIcon } from "@/src/components/LangfuseLogo";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { CloudPrivacyNotice } from "@/src/features/auth/components/AuthCloudPrivacyNotice";
|
||||
import { CloudRegionSwitch } from "@/src/features/auth/components/AuthCloudRegionSwitch";
|
||||
import { SSOButtons, type PageProps } from "@/src/pages/auth/sign-in";
|
||||
|
||||
export default function SignIn() {
|
||||
// Use the same getServerSideProps function as src/pages/auth/sign-in.tsx
|
||||
export { getServerSideProps } from "@/src/pages/auth/sign-in";
|
||||
|
||||
export default function SignIn({ authProviders }: PageProps) {
|
||||
const posthog = usePostHog();
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const form = useForm<z.infer<typeof signupSchema>>({
|
||||
@@ -84,130 +90,102 @@ export default function SignIn() {
|
||||
</div>
|
||||
{env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== undefined ? (
|
||||
<div className="text-center sm:mx-auto sm:w-full sm:max-w-[480px]">
|
||||
No credit card required. All users have access to a demo project.
|
||||
No credit card required.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-14 sm:mx-auto sm:w-full sm:max-w-[480px]">
|
||||
<div className="bg-white px-6 py-12 shadow sm:rounded-lg sm:px-12">
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-6"
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Jane Doe" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="jsdoe@example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== undefined ? (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="referralSource"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Where did you hear about us?{" "}
|
||||
<span className="font-normal">(optional)</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="referralSource" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
Sign up
|
||||
</Button>
|
||||
{formError ? (
|
||||
<div className="text-center text-sm font-medium text-destructive">
|
||||
{formError}
|
||||
</div>
|
||||
) : null}
|
||||
</form>
|
||||
</Form>
|
||||
{env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== undefined ? (
|
||||
<div className="-mb-4 mt-8 text-center text-xs text-gray-500">
|
||||
By creating an account you are agreeing to our{" "}
|
||||
<a
|
||||
href="https://app.termly.io/document/terms-of-service/baf80a2e-dc67-46de-9ca8-2f7457179c32"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="italic"
|
||||
>
|
||||
Terms of Service
|
||||
</a>
|
||||
,{" "}
|
||||
<a
|
||||
href="https://app.termly.io/document/privacy-policy/47905712-56e1-4ad0-9bb7-8958f3263f90"
|
||||
rel="noopener noreferrer"
|
||||
className="italic"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
, and{" "}
|
||||
<a
|
||||
href="https://app.termly.io/document/cookie-policy/f97945a3-cb02-4db7-9370-c57023d92838"
|
||||
rel="noopener noreferrer"
|
||||
className="italic"
|
||||
>
|
||||
Cookie Policy
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p className="mt-10 text-center text-sm text-gray-500">
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
href="/auth/sign-in"
|
||||
className="font-semibold leading-6 text-indigo-600 hover:text-indigo-500"
|
||||
<div className="mt-14 bg-white px-6 py-10 shadow sm:mx-auto sm:w-full sm:max-w-[480px] sm:rounded-lg sm:px-12">
|
||||
<CloudRegionSwitch isSignUpPage />
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-6"
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Jane Doe" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="jsdoe@example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== undefined ? (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="referralSource"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Where did you hear about us?{" "}
|
||||
<span className="font-normal">(optional)</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="referralSource" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
Sign up
|
||||
</Button>
|
||||
{formError ? (
|
||||
<div className="text-center text-sm font-medium text-destructive">
|
||||
{formError}
|
||||
</div>
|
||||
) : null}
|
||||
</form>
|
||||
</Form>
|
||||
<SSOButtons authProviders={authProviders} action="Sign up" />
|
||||
<CloudPrivacyNotice action="creating an account" />
|
||||
</div>
|
||||
|
||||
<p className="mt-10 text-center text-sm text-gray-500">
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
href="/auth/sign-in"
|
||||
className="font-semibold leading-6 text-indigo-600 hover:text-indigo-500"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
+16
-29
@@ -9,47 +9,34 @@ import {
|
||||
import Link from "next/link";
|
||||
import { NewProjectButton } from "@/src/features/projects/components/NewProjectButton";
|
||||
import Header from "@/src/components/layouts/header";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState, useEffect } from "react";
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { Spinner } from "@/src/components/layouts/spinner";
|
||||
|
||||
export default function GetStartedPage() {
|
||||
const projects = api.projects.all.useQuery();
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// check url query for welcome = 1
|
||||
const getStarted = router.query.getStarted === "1";
|
||||
|
||||
useEffect(() => {
|
||||
if (projects.data) {
|
||||
if (
|
||||
projects.data.filter((p) => p.id !== env.NEXT_PUBLIC_DEMO_PROJECT_ID)
|
||||
.length > 0 &&
|
||||
loading === true &&
|
||||
!getStarted
|
||||
)
|
||||
void router.push(
|
||||
`/project/${
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
projects.data.filter(
|
||||
(p) => p.id !== env.NEXT_PUBLIC_DEMO_PROJECT_ID,
|
||||
)[0]!.id
|
||||
}`,
|
||||
);
|
||||
else setLoading(false);
|
||||
}
|
||||
}, [projects.data, router, loading, getStarted]);
|
||||
const session = useSession();
|
||||
const projects = session.data?.user?.projects;
|
||||
const redirectProject = projects?.filter(
|
||||
(p) => p.id !== env.NEXT_PUBLIC_DEMO_PROJECT_ID,
|
||||
)[0];
|
||||
|
||||
if (loading || projects.status === "loading") {
|
||||
return <div>Loading...</div>;
|
||||
if (session.status === "authenticated" && redirectProject && !getStarted) {
|
||||
void router.push(`/project/${redirectProject.id}`);
|
||||
return <Spinner message="Redirecting" />;
|
||||
}
|
||||
|
||||
if (session.status === "loading") {
|
||||
return <Spinner message="Loading" />;
|
||||
}
|
||||
|
||||
const demoProject =
|
||||
env.NEXT_PUBLIC_DEMO_PROJECT_ID !== undefined && projects.data?.length
|
||||
? projects.data.find(
|
||||
env.NEXT_PUBLIC_DEMO_PROJECT_ID !== undefined
|
||||
? projects?.find(
|
||||
(project) => project.id === env.NEXT_PUBLIC_DEMO_PROJECT_ID,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user