Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebabf0e100 | ||
|
|
e05d4693d2 | ||
|
|
77d49adc00 | ||
|
|
7dc45f0074 | ||
|
|
44d1da3c9c | ||
|
|
564ba4bd68 | ||
|
|
63df788908 | ||
|
|
fb463cbf98 | ||
|
|
cfd99efd48 | ||
|
|
3b2c5f8bf3 | ||
|
|
d08a276b67 | ||
|
|
23bc2cd39c | ||
|
|
a31eda8c68 | ||
|
|
d044ae51d1 | ||
|
|
552578afc3 | ||
|
|
00997db325 | ||
|
|
550464cf0b | ||
|
|
e43568a3d2 | ||
|
|
4869e8a093 | ||
|
|
d9b307ec87 | ||
|
|
3abfbef4c3 | ||
|
|
86aa1eb34f | ||
|
|
59edc9851a | ||
|
|
0e31dcef99 | ||
|
|
a67235ab8a | ||
|
|
eb88c9bdf8 | ||
|
|
77e4791cbe | ||
|
|
5d922dfae7 | ||
|
|
c704c84451 | ||
|
|
6f23488438 | ||
|
|
ca50abb290 | ||
|
|
15067114a2 | ||
|
|
645f14535b | ||
|
|
250a5b03d2 | ||
|
|
5ee060dfc6 | ||
|
|
f08502cde4 | ||
|
|
90ecb88249 | ||
|
|
16e60f74da | ||
|
|
0ab252cc7c | ||
|
|
ebadbaf30d | ||
|
|
7ce6b6817e | ||
|
|
6d4040d5ed | ||
|
|
4a7a4c0add | ||
|
|
883e53d8b5 | ||
|
|
a9e6b9ae5c | ||
|
|
a61ee1f29f | ||
|
|
eefdf60e31 | ||
|
|
19a633a756 | ||
|
|
e84c1a3ab8 | ||
|
|
4d41aa7e53 | ||
|
|
a4c28cc324 | ||
|
|
187f2e7b1d | ||
|
|
b355825295 | ||
|
|
33dc18356a | ||
|
|
c804d90f6a | ||
|
|
09aa547de1 | ||
|
|
57839eae5c | ||
|
|
a43f5f38a1 | ||
|
|
8220d07057 | ||
|
|
89ffe20363 | ||
|
|
9a3adf54b1 | ||
|
|
459129118e | ||
|
|
0862370ad6 | ||
|
|
1bddde99f0 | ||
|
|
fe2e7681da | ||
|
|
d209766f01 | ||
|
|
82bfe03dba | ||
|
|
15fc8e4c3c | ||
|
|
374dbf9716 | ||
|
|
6819988f90 | ||
|
|
e3079afb2f | ||
|
|
3b6689f44b | ||
|
|
fdf417e961 | ||
|
|
a751f1f0f1 | ||
|
|
5bf348b55e | ||
|
|
4ffadc8124 | ||
|
|
8069fb7ddd | ||
|
|
133fc90718 | ||
|
|
32a2a7956d | ||
|
|
01cc9832c1 | ||
|
|
6127ea6442 | ||
|
|
22fbc2a89d | ||
|
|
1ed2e230e0 | ||
|
|
6563716f21 | ||
|
|
487e0a77d6 | ||
|
|
c084d42b4a | ||
|
|
2d8cc2baa3 | ||
|
|
47630f7c5f | ||
|
|
082dfaf339 | ||
|
|
f8efbd51da | ||
|
|
22d98256ce | ||
|
|
00caa680f2 | ||
|
|
48d35eb637 | ||
|
|
61786fedfb | ||
|
|
1f3e3603be | ||
|
|
cee2c797c4 |
+7
-1
@@ -16,7 +16,7 @@ const config = {
|
||||
rules: {
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
"@typescript-eslint/no-confusing-void-expression": "off",
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
parser: "@typescript-eslint/parser",
|
||||
@@ -35,6 +35,12 @@ const config = {
|
||||
},
|
||||
],
|
||||
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
|
||||
"react/jsx-key": [
|
||||
"error",
|
||||
{
|
||||
warnOnDuplicates: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
contact_links:
|
||||
- name: 🤗 Get Help
|
||||
url: https://github.com/langfuse/langfuse/discussions/new?category=q-a
|
||||
about: If you can’t get something to work the way you expect, open a question in our discussion forums.
|
||||
- name: 💡 Feature Request
|
||||
url: https://github.com/langfuse/langfuse/discussions/new?category=ideas
|
||||
about: Suggest any ideas you have using our discussion forums.
|
||||
@@ -1,36 +0,0 @@
|
||||
# This template is heavily inspired by the Next.js's template:
|
||||
# See here: https://github.com/vercel/next.js/blob/canary/.github/ISSUE_TEMPLATE/3.feature_request.yml
|
||||
|
||||
name: 🛠 Feature Request
|
||||
description: Create a feature request for the core packages
|
||||
title: "feat: "
|
||||
labels: ["✨ enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for taking the time to file a feature request. Please fill out this form as completely as possible.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the feature you'd like to request
|
||||
description: Please describe the feature as clear and concise as possible. Remember to add context as to why you believe this feature is needed.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the solution you'd like to see
|
||||
description: Please describe the solution you would like to see. Adding example usage is a good way to provide context.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Add any other information related to the feature here. If your feature request is related to any issues or discussions, link them here.
|
||||
- type: checkboxes
|
||||
id: contribute
|
||||
attributes:
|
||||
label: Contribute
|
||||
description: Are you willing to contribute to the implementation of this feature?
|
||||
options:
|
||||
- label: Yes, I can implement this and raise a PR
|
||||
|
||||
@@ -30,6 +30,36 @@ jobs:
|
||||
- name: lint
|
||||
run: npm run lint
|
||||
|
||||
test-docker-build:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
|
||||
NEXTAUTH_SECRET: "secret"
|
||||
SALT: "salt"
|
||||
NEXTAUTH_URL: "http://localhost:3030"
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
steps:
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
build-args: |
|
||||
DATABASE_URL=${{ env.DATABASE_URL }}
|
||||
NEXTAUTH_SECRET=${{ env.NEXTAUTH_SECRET }}
|
||||
NEXTAUTH_URL=${{ env.NEXTAUTH_URL }}
|
||||
SALT=${{ env.SALT }}
|
||||
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
@@ -101,7 +131,7 @@ jobs:
|
||||
all-ci-passed:
|
||||
# This allows us to have a branch protection rule for tests and deploys with matrix
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, tests, e2e-tests]
|
||||
needs: [lint, tests, e2e-tests, test-docker-build]
|
||||
if: always()
|
||||
steps:
|
||||
- name: Successful deploy
|
||||
|
||||
@@ -79,6 +79,14 @@ flowchart TB
|
||||
Python --- API
|
||||
```
|
||||
|
||||
### Database Overview
|
||||
|
||||
The diagram below may not show all relationships if the foreign key is not defined in the database schema. For instance, `trace_id` in the `observation` table is not defined as a foreign key to the `trace` table to allow unordered ingestion of these objects, but it is still a foreign key in the application code.
|
||||
|
||||
Full database schema: [prisma/schema.prisma](prisma/schema.prisma)
|
||||
|
||||
<img src="./prisma/database.svg">
|
||||
|
||||
### Infrastructure & Network Overview
|
||||
|
||||
```mermaid
|
||||
|
||||
@@ -80,7 +80,6 @@ types:
|
||||
properties:
|
||||
id: string
|
||||
name: string
|
||||
status: DatasetStatus
|
||||
projectId: string
|
||||
createdAt: datetime
|
||||
updatedAt: datetime
|
||||
@@ -130,6 +129,7 @@ types:
|
||||
- optional<string>
|
||||
- optional<integer>
|
||||
- optional<boolean>
|
||||
- optional<list<string>>
|
||||
DatasetStatus:
|
||||
enum:
|
||||
- ACTIVE
|
||||
|
||||
@@ -26,6 +26,9 @@ service:
|
||||
limit: optional<integer>
|
||||
userId: optional<string>
|
||||
name: optional<string>
|
||||
orderBy:
|
||||
type: string
|
||||
docs: Format of the string sort_by=timestamp.asc (id, timestamp, name, userId, release, version, public, bookmarked, sessionId)
|
||||
tags:
|
||||
type: optional<string>
|
||||
allow-multiple: true
|
||||
@@ -37,3 +40,6 @@ types:
|
||||
properties:
|
||||
data: list<commons.TraceWithDetails>
|
||||
meta: pagination.MetaResponse
|
||||
Sort:
|
||||
properties:
|
||||
id: string
|
||||
|
||||
@@ -884,6 +884,14 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
nullable: true
|
||||
- name: orderBy
|
||||
in: query
|
||||
description: >-
|
||||
Format of the string sort_by=timestamp.asc (id, timestamp, name,
|
||||
userId, release, version, public, bookmarked, sessionId)
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: tags
|
||||
in: query
|
||||
description: Only traces that include all of these tags will be returned.
|
||||
@@ -1147,8 +1155,6 @@ components:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
status:
|
||||
$ref: '#/components/schemas/DatasetStatus'
|
||||
projectId:
|
||||
type: string
|
||||
createdAt:
|
||||
@@ -1168,7 +1174,6 @@ components:
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- status
|
||||
- projectId
|
||||
- createdAt
|
||||
- updatedAt
|
||||
@@ -1278,6 +1283,10 @@ components:
|
||||
nullable: true
|
||||
- type: boolean
|
||||
nullable: true
|
||||
- type: array
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
DatasetStatus:
|
||||
title: DatasetStatus
|
||||
type: string
|
||||
@@ -1991,6 +2000,14 @@ components:
|
||||
required:
|
||||
- data
|
||||
- meta
|
||||
Sort:
|
||||
title: Sort
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
utilsMetaResponse:
|
||||
title: utilsMetaResponse
|
||||
type: object
|
||||
|
||||
@@ -708,7 +708,7 @@
|
||||
"request": {
|
||||
"description": "Get list of traces",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/traces?page=&limit=&userId=&name=&tags=",
|
||||
"raw": "{{baseUrl}}/api/public/traces?page=&limit=&userId=&name=&orderBy=&tags=",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
@@ -738,6 +738,11 @@
|
||||
"value": "",
|
||||
"description": null
|
||||
},
|
||||
{
|
||||
"key": "orderBy",
|
||||
"value": "",
|
||||
"description": "Format of the string sort_by=timestamp.asc (id, timestamp, name, userId, release, version, public, bookmarked, sessionId)"
|
||||
},
|
||||
{
|
||||
"key": "tags",
|
||||
"value": "",
|
||||
|
||||
Generated
+1108
-639
File diff suppressed because it is too large
Load Diff
+20
-17
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "langfuse-core",
|
||||
"version": "1.30.0",
|
||||
"version": "1.34.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",
|
||||
@@ -16,7 +16,7 @@
|
||||
"test": "jest --runInBand",
|
||||
"test:watch": "jest --watch --runInBand",
|
||||
"test:e2e": "playwright test",
|
||||
"db:migrate": "npx prisma migrate dev",
|
||||
"db:migrate": "DISABLE_ERD=false npx prisma migrate dev",
|
||||
"db:reset": "npx prisma migrate reset",
|
||||
"db:seed": "npx prisma db seed",
|
||||
"db:seed:examples": "npx prisma db seed -- --environment examples",
|
||||
@@ -28,12 +28,12 @@
|
||||
"dependencies": {
|
||||
"@anthropic-ai/tokenizer": "^0.0.4",
|
||||
"@aws-sdk/client-s3": "^3.485.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.478.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.485.0",
|
||||
"@headlessui/react": "^1.7.17",
|
||||
"@heroicons/react": "^2.1.1",
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"@next-auth/prisma-adapter": "^1.0.7",
|
||||
"@prisma/client": "^5.7.1",
|
||||
"@prisma/client": "^5.8.1",
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.5",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
@@ -45,6 +45,7 @@
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-scroll-area": "^1.0.5",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-separator": "^1.0.3",
|
||||
"@radix-ui/react-slider": "^1.1.2",
|
||||
@@ -55,8 +56,8 @@
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@react-email/components": "^0.0.12",
|
||||
"@react-email/render": "^0.0.10",
|
||||
"@sentry/nextjs": "^7.92.0",
|
||||
"@sentry/profiling-node": "^1.3.2",
|
||||
"@sentry/nextjs": "^7.95.0",
|
||||
"@sentry/profiling-node": "^1.3.5",
|
||||
"@sentry/types": "^7.88.0",
|
||||
"@t3-oss/env-nextjs": "^0.7.1",
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
@@ -74,22 +75,22 @@
|
||||
"cmdk": "^0.2.0",
|
||||
"core-js": "^3.35.0",
|
||||
"cors": "^2.8.5",
|
||||
"date-fns": "^3.0.6",
|
||||
"date-fns": "^3.3.1",
|
||||
"decimal.js": "^10.4.3",
|
||||
"exponential-backoff": "^3.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.304.0",
|
||||
"next": "^14.0.4",
|
||||
"lucide-react": "^0.314.0",
|
||||
"next": "^14.1.0",
|
||||
"next-auth": "^4.24.5",
|
||||
"next-query-params": "^5.0.0",
|
||||
"nodemailer": "^6.9.8",
|
||||
"posthog-js": "^1.96.1",
|
||||
"posthog-js": "^1.100.0",
|
||||
"posthog-node": "^3.2.1",
|
||||
"react": "18.2.0",
|
||||
"react-day-picker": "^8.10.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "^7.49.2",
|
||||
"react-icons": "^4.12.0",
|
||||
"react-icons": "^5.0.1",
|
||||
"react-responsive": "^9.0.2",
|
||||
"react18-json-view": "^0.2.7-canary.2",
|
||||
"superjson": "2.2.1",
|
||||
@@ -102,6 +103,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jedmao/location": "^3.0.0",
|
||||
"@mermaid-js/mermaid-cli": "^10.6.1",
|
||||
"@playwright/test": "^1.40.1",
|
||||
"@release-it/bumper": "^6.0.1",
|
||||
"@testing-library/jest-dom": "^6.1.5",
|
||||
@@ -116,20 +118,21 @@
|
||||
"@types/react": "^18.2.46",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"@typescript-eslint/eslint-plugin": "^6.16.0",
|
||||
"@typescript-eslint/parser": "^6.16.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
||||
"@typescript-eslint/parser": "^6.19.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"dotenv-cli": "^7.3.0",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-next": "^14.0.4",
|
||||
"eslint-config-next": "^14.1.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"postcss": "^8.4.33",
|
||||
"prettier": "^3.1.1",
|
||||
"prettier-plugin-tailwindcss": "^0.5.10",
|
||||
"prisma": "^5.7.1",
|
||||
"prettier-plugin-tailwindcss": "^0.5.11",
|
||||
"prisma": "^5.8.1",
|
||||
"prisma-erd-generator": "^1.11.2",
|
||||
"release-it": "^17.0.1",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.3.3"
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 194 KiB |
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `status` on the `datasets` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "datasets" DROP COLUMN "status";
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Migration to add gpt-35 spelling
|
||||
|
||||
INSERT INTO pricings (
|
||||
id,
|
||||
model_name,
|
||||
pricing_unit,
|
||||
price,
|
||||
currency,
|
||||
token_type
|
||||
)
|
||||
VALUES
|
||||
('clqqpc2pr000008l3hvy63gxy1','gpt-35-turbo-1106', 'PER_1000_TOKENS', 0.001, 'USD', 'PROMPT'),
|
||||
('clqqpcb6d000208l3atrfbmou1','gpt-35-turbo-1106', 'PER_1000_TOKENS', 0.002, 'USD', 'COMPLETION'),
|
||||
('clqqpdh45000008lfgrnx76cv1','gpt-35-turbo-instruct', 'PER_1000_TOKENS', 0.0015, 'USD', 'PROMPT'),
|
||||
('clqqpdjya000108lf3s4b4c4m1','gpt-35-turbo-instruct', 'PER_1000_TOKENS', 0.002, 'USD', 'COMPLETION')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- DropIndex
|
||||
DROP INDEX "traces_project_id_external_id_key";
|
||||
@@ -13,6 +13,14 @@ datasource db {
|
||||
shadowDatabaseUrl = env("SHADOW_DATABASE_URL")
|
||||
}
|
||||
|
||||
generator erd {
|
||||
provider = "prisma-erd-generator"
|
||||
ignoreTables = ["_prisma_migrations", "Session", "Account", "Example"]
|
||||
disabled = true
|
||||
ignoreEnums = true
|
||||
output = "database.svg"
|
||||
}
|
||||
|
||||
model Example {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
@@ -190,7 +198,6 @@ model Trace {
|
||||
|
||||
scores Score[]
|
||||
|
||||
@@unique([projectId, externalId])
|
||||
@@index([projectId])
|
||||
@@index([sessionId])
|
||||
@@index([name])
|
||||
@@ -312,7 +319,6 @@ model CronJobs {
|
||||
model Dataset {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
status DatasetStatus @default(ACTIVE)
|
||||
projectId String @map("project_id")
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
+46
-39
@@ -127,10 +127,10 @@ async function main() {
|
||||
|
||||
for (let i = 0; i < TRACE_VOLUME; i++) {
|
||||
// print progress to console with a progress bar that refreshes every 10 iterations
|
||||
if (i % 10 === 0) {
|
||||
if ((i + 1) % 10 === 0 || i === TRACE_VOLUME - 1) {
|
||||
process.stdout.clearLine(0);
|
||||
process.stdout.cursorTo(0);
|
||||
process.stdout.write(`Seeding ${i} of ${TRACE_VOLUME}`);
|
||||
process.stdout.write(`Seeding ${i + 1} of ${TRACE_VOLUME}`);
|
||||
}
|
||||
// random date within last 90 days, with a linear bias towards more recent dates
|
||||
const traceTs = new Date(
|
||||
@@ -409,47 +409,54 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
const dataset = await prisma.dataset.create({
|
||||
data: {
|
||||
name: "demo-dataset",
|
||||
projectId: project2.id,
|
||||
},
|
||||
});
|
||||
|
||||
const datasetRun = await prisma.datasetRuns.create({
|
||||
data: {
|
||||
name: "demo-dataset-run",
|
||||
datasetId: dataset.id,
|
||||
},
|
||||
});
|
||||
|
||||
for (let runNumber = 0; runNumber < 10; runNumber++) {
|
||||
//pick randomly from existingSpanIds
|
||||
const sourceObservationId =
|
||||
generationIds[Math.floor(Math.random() * generationIds.length)];
|
||||
const runObservationId =
|
||||
generationIds[Math.floor(Math.random() * generationIds.length)];
|
||||
|
||||
const datasetItem = await prisma.datasetItem.create({
|
||||
for (let datasetNumber = 0; datasetNumber < 2; datasetNumber++) {
|
||||
const dataset = await prisma.dataset.create({
|
||||
data: {
|
||||
datasetId: dataset.id,
|
||||
sourceObservationId:
|
||||
Math.random() > 0.5 ? sourceObservationId : undefined,
|
||||
input: [
|
||||
{ role: "user", content: "How can i create a React component?" },
|
||||
],
|
||||
expectedOutput:
|
||||
"Creating a React component can be done in two ways: as a functional component or as a class component. Let's start with a basic example of both.",
|
||||
name: `demo-dataset-${datasetNumber}`,
|
||||
projectId: project2.id,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.datasetRunItems.create({
|
||||
data: {
|
||||
datasetItemId: datasetItem.id,
|
||||
observationId: runObservationId!,
|
||||
datasetRunId: datasetRun.id,
|
||||
},
|
||||
});
|
||||
for (let datasetRunNumber = 0; datasetRunNumber < 2; datasetRunNumber++) {
|
||||
const datasetRun = await prisma.datasetRuns.create({
|
||||
data: {
|
||||
name: `demo-dataset-run-${datasetRunNumber}`,
|
||||
datasetId: dataset.id,
|
||||
},
|
||||
});
|
||||
|
||||
for (let runNumber = 0; runNumber < 10; runNumber++) {
|
||||
//pick randomly from existingSpanIds
|
||||
const sourceObservationId =
|
||||
generationIds[Math.floor(Math.random() * generationIds.length)];
|
||||
const runObservationId =
|
||||
generationIds[Math.floor(Math.random() * generationIds.length)];
|
||||
|
||||
const datasetItem = await prisma.datasetItem.create({
|
||||
data: {
|
||||
datasetId: dataset.id,
|
||||
sourceObservationId:
|
||||
Math.random() > 0.5 ? sourceObservationId : undefined,
|
||||
input: [
|
||||
{
|
||||
role: "user",
|
||||
content: "How can i create a React component?",
|
||||
},
|
||||
],
|
||||
expectedOutput:
|
||||
"Creating a React component can be done in two ways: as a functional component or as a class component. Let's start with a basic example of both.",
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.datasetRunItems.create({
|
||||
data: {
|
||||
datasetItemId: datasetItem.id,
|
||||
observationId: runObservationId!,
|
||||
datasetRunId: datasetRun.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ if (process.env.NEXT_PUBLIC_SENTRY_DSN)
|
||||
// Set tracesSampleRate to 1.0 to capture 100%
|
||||
// of transactions for performance monitoring.
|
||||
// We recommend adjusting this value in production
|
||||
tracesSampleRate: 0.8,
|
||||
tracesSampleRate: 0.3,
|
||||
|
||||
// Capture Replay for 100% of all sessions,
|
||||
// plus for 100% of sessions with an error
|
||||
|
||||
@@ -7,7 +7,7 @@ if (process.env.NEXT_PUBLIC_SENTRY_DSN)
|
||||
// Set tracesSampleRate to 1.0 to capture 100%
|
||||
// of transactions for performance monitoring.
|
||||
// We recommend adjusting this value in production
|
||||
tracesSampleRate: 0.75,
|
||||
tracesSampleRate: 0.3,
|
||||
|
||||
// ...
|
||||
|
||||
|
||||
@@ -12,9 +12,9 @@ if (process.env.NEXT_PUBLIC_SENTRY_DSN)
|
||||
// Set tracesSampleRate to 1.0 to capture 100%
|
||||
// of transactions for performance monitoring.
|
||||
// We recommend adjusting this value in production
|
||||
tracesSampleRate: 0.5,
|
||||
tracesSampleRate: 0.3,
|
||||
|
||||
profilesSampleRate: 0.5, // Profiling sample rate is relative to tracesSampleRate
|
||||
profilesSampleRate: 0.3, // Profiling sample rate is relative to tracesSampleRate
|
||||
integrations: [
|
||||
// Add profiling integration to list of integrations
|
||||
new ProfilingIntegration(),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { test, expect } from "@playwright/test";
|
||||
|
||||
test("should redirect to sign-in if not signed in", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.waitForTimeout(2000);
|
||||
await expect(page).toHaveURL("/auth/sign-in");
|
||||
});
|
||||
|
||||
|
||||
@@ -3,28 +3,33 @@ import { test, expect } from "@playwright/test";
|
||||
test("should see new projects dialog open after clicking new project btn", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/auth/sign-in");
|
||||
await page.fill('input[name="email"]', "demo@langfuse.com");
|
||||
await page.goto("auth/sign-up");
|
||||
await page.fill('input[name="name"]', "demo user");
|
||||
await page.fill('input[name="email"]', randomEmailAddress());
|
||||
await page.fill('input[type="password"]', "password");
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForTimeout(2000);
|
||||
await page.waitForTimeout(2000);
|
||||
await page.isVisible("Create new project");
|
||||
expect(await page.innerHTML("data-testid=create-new-project-title")).toBe(
|
||||
"Create new project",
|
||||
);
|
||||
await page.click('[data-testid="create-project-btn"]');
|
||||
await page.waitForTimeout(2000);
|
||||
await page.isVisible('[data-testid="new-project-form"]');
|
||||
await expect(page.locator("data-testid=new-project-form")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Create a project with provided name", async ({ page }) => {
|
||||
test.setTimeout(60000);
|
||||
|
||||
await page.goto("/auth/sign-in");
|
||||
await page.fill('input[name="email"]', "demo@langfuse.com");
|
||||
await page.fill('input[type="password"]', "password");
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForTimeout(2000);
|
||||
await page.isVisible("Create new project");
|
||||
await page.isVisible('[data-testid="new-project-form"]');
|
||||
await page.click('[data-testid="create-project-btn"]');
|
||||
await page.waitForTimeout(2000);
|
||||
await page.isVisible('[data-testid="new-project-form"]');
|
||||
await expect(page.locator("data-testid=new-project-form")).toBeVisible();
|
||||
await page.fill(
|
||||
'[data-testid="new-project-name-input"]',
|
||||
"my e2e demo project",
|
||||
@@ -33,7 +38,11 @@ test("Create a project with provided name", async ({ page }) => {
|
||||
await page.waitForTimeout(2000);
|
||||
expect(page.url()).toContain("/project/");
|
||||
await page.waitForTimeout(2000);
|
||||
expect(await page.getByTestId("project-title-span-1").textContent()).toBe(
|
||||
expect(await page.getByTestId("project-name").textContent()).toContain(
|
||||
"my e2e demo project",
|
||||
);
|
||||
});
|
||||
|
||||
// random email address to be used in tests
|
||||
const randomEmailAddress = () =>
|
||||
Math.random().toString(36).substring(2, 11) + "@example.com";
|
||||
|
||||
@@ -300,6 +300,7 @@ describe("/api/public/ingestion API Endpoint", () => {
|
||||
id: generationId,
|
||||
traceId: traceId,
|
||||
parentObservationId: spanId,
|
||||
modelParameters: { someKey: ["user-1", "user-2"] },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -376,6 +377,9 @@ describe("/api/public/ingestion API Endpoint", () => {
|
||||
expect(dbGeneration?.traceId).toBe(traceId);
|
||||
expect(dbGeneration?.name).toBe("generation-name");
|
||||
expect(dbGeneration?.parentObservationId).toBe(spanId);
|
||||
expect(dbGeneration?.modelParameters).toEqual({
|
||||
someKey: ["user-1", "user-2"],
|
||||
});
|
||||
|
||||
const dbEvent = await prisma.observation.findUnique({
|
||||
where: {
|
||||
@@ -790,6 +794,66 @@ describe("/api/public/ingestion API Endpoint", () => {
|
||||
expect(dbTrace[0]?.version).toBe("2.0.0");
|
||||
});
|
||||
|
||||
it("should not override a trace from a different project", async () => {
|
||||
const traceId = v4();
|
||||
const newProjectId = v4();
|
||||
|
||||
await prisma.project.create({
|
||||
data: {
|
||||
id: newProjectId,
|
||||
name: "another-project",
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.trace.create({
|
||||
data: {
|
||||
id: traceId,
|
||||
project: { connect: { id: newProjectId } },
|
||||
},
|
||||
});
|
||||
|
||||
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",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(responseOne.status).toBe(207);
|
||||
|
||||
console.log(responseOne.body);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
||||
const errors = responseOne.body.errors;
|
||||
|
||||
expect(errors).toBeDefined();
|
||||
console.log(errors);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
expect(errors.length).toBe(1);
|
||||
|
||||
const dbTrace = await prisma.trace.findMany({
|
||||
where: {
|
||||
id: traceId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbTrace.length).toEqual(1);
|
||||
expect(dbTrace[0]?.name).toBeNull();
|
||||
expect(dbTrace[0]?.release).toBeNull();
|
||||
expect(dbTrace[0]?.metadata).toBeNull();
|
||||
expect(dbTrace[0]?.version).toBeNull();
|
||||
});
|
||||
|
||||
[
|
||||
{
|
||||
inputs: [{ a: "a" }, { b: "b" }],
|
||||
|
||||
@@ -53,7 +53,7 @@ describe("Traces TRPC Router", () => {
|
||||
orderBy: null,
|
||||
});
|
||||
expect(traces).toBeDefined();
|
||||
expect(traces).toMatchObject([trace]);
|
||||
expect(traces).toMatchObject({ traces: [trace] });
|
||||
});
|
||||
|
||||
test("traces.all RPC orders traces by userId", async () => {
|
||||
@@ -88,7 +88,7 @@ describe("Traces TRPC Router", () => {
|
||||
order: "ASC",
|
||||
},
|
||||
});
|
||||
expect(tracesASC).toMatchObject([trace1, trace2]);
|
||||
expect(tracesASC).toMatchObject({ traces: [trace1, trace2] });
|
||||
|
||||
const tracesDESC = await caller.traces.all({
|
||||
page: 0,
|
||||
@@ -102,6 +102,6 @@ describe("Traces TRPC Router", () => {
|
||||
order: "DESC",
|
||||
},
|
||||
});
|
||||
expect(tracesDESC).toMatchObject([trace2, trace1]);
|
||||
expect(tracesDESC).toMatchObject({ traces: [trace2, trace1] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,11 +4,13 @@ describe("Token Count Functions", () => {
|
||||
describe("token count for strings", () => {
|
||||
[
|
||||
{ model: "gpt-3.5", tokens: 114 },
|
||||
{ model: "gpt-35", tokens: 114 },
|
||||
{ model: "gpt-4-1106-preview", tokens: 114 },
|
||||
{ model: "gpt-4-vision-preview", tokens: 114 },
|
||||
{ model: "claude", tokens: 118 },
|
||||
{ model: "claude-instant-1.2", tokens: 118 },
|
||||
{ model: "gpt-3.5-turbo-1106", tokens: 114 },
|
||||
{ model: "gpt-35-turbo-1106", tokens: 114 },
|
||||
].forEach(({ model, tokens }) => {
|
||||
it(`should return token count ${tokens} for ${model}`, () => {
|
||||
const result = tokenCount({
|
||||
@@ -71,6 +73,7 @@ describe("Token Count Functions", () => {
|
||||
[
|
||||
{ model: "gpt-4", tokens: 44 },
|
||||
{ model: "gpt-3.5-turbo-16k-0613", tokens: 44 },
|
||||
{ model: "gpt-35-turbo-16k-0613", tokens: 44 },
|
||||
{ model: "claude-instant-1.2", tokens: 48 },
|
||||
].forEach(({ model, tokens }) => {
|
||||
it(`should return token count ${tokens} for ${model}`, () => {
|
||||
|
||||
@@ -4,13 +4,18 @@ import { useRouter } from "next/router";
|
||||
import { useSession } from "next-auth/react";
|
||||
import DocPopup from "@/src/components/layouts/doc-popup";
|
||||
import { type Status, StatusBadge } from "./status-badge";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
|
||||
export default function Header(props: {
|
||||
export default function Header({
|
||||
level = "h2",
|
||||
...props
|
||||
}: {
|
||||
title: string;
|
||||
breadcrumb?: { name: string; href?: string }[];
|
||||
status?: Status;
|
||||
help?: { description: string; href: string };
|
||||
actionButtons?: React.ReactNode;
|
||||
level?: "h2" | "h3";
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const session = useSession();
|
||||
@@ -35,7 +40,7 @@ export default function Header(props: {
|
||||
[...props.breadcrumb.map((i) => i.href).filter(Boolean)].pop();
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className={cn(level === "h2" ? "mb-8" : "mb-1")}>
|
||||
<div>
|
||||
{backHref ? (
|
||||
<nav className="sm:hidden" aria-label="Back">
|
||||
@@ -51,7 +56,7 @@ export default function Header(props: {
|
||||
</Link>
|
||||
</nav>
|
||||
) : null}
|
||||
{breadcrumb.length ? (
|
||||
{(level === "h2" || props.breadcrumb) && breadcrumb.length ? (
|
||||
<nav className="hidden sm:flex" aria-label="Breadcrumb">
|
||||
<ol role="list" className="flex items-center space-x-4">
|
||||
{breadcrumb.map(({ name, href }, index) => (
|
||||
@@ -85,9 +90,15 @@ export default function Header(props: {
|
||||
<div className="mt-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-3 md:gap-5">
|
||||
<div className="flex min-w-0 flex-row">
|
||||
<h2 className="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight">
|
||||
{props.title}
|
||||
</h2>
|
||||
{level === "h2" ? (
|
||||
<h2 className="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight">
|
||||
{props.title}
|
||||
</h2>
|
||||
) : (
|
||||
<h3 className="text-lg font-bold leading-7 text-gray-900 sm:truncate sm:text-xl sm:tracking-tight">
|
||||
{props.title}
|
||||
</h3>
|
||||
)}
|
||||
{props.help ? (
|
||||
<DocPopup
|
||||
description={props.help.description}
|
||||
|
||||
+208
-133
@@ -1,12 +1,12 @@
|
||||
import { ROUTES } from "@/src/components/layouts/routes";
|
||||
import { ROUTES, type Route } from "@/src/components/layouts/routes";
|
||||
import { Fragment, type PropsWithChildren, useState } from "react";
|
||||
import { Dialog, Menu, Transition } from "@headlessui/react";
|
||||
import { Dialog, Disclosure, Menu, Transition } from "@headlessui/react";
|
||||
import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import clsx from "clsx";
|
||||
import { Code, MessageSquarePlus, Info } from "lucide-react";
|
||||
import { Code, MessageSquarePlus, Info, ChevronRightIcon } from "lucide-react";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import { ChevronDownIcon } from "@heroicons/react/20/solid";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
@@ -35,9 +35,9 @@ const userNavigation = [
|
||||
},
|
||||
];
|
||||
|
||||
const pathsWithoutNavigation: string[] = [];
|
||||
const unauthenticatedPaths = ["/auth/sign-in", "/auth/sign-up"];
|
||||
const publishablePaths = [
|
||||
const pathsWithoutNavigation: string[] = ["/onboarding"];
|
||||
const unauthenticatedPaths: string[] = ["/auth/sign-in", "/auth/sign-up"];
|
||||
const publishablePaths: string[] = [
|
||||
"/project/[projectId]/sessions/[sessionId]",
|
||||
"/project/[projectId]/traces/[traceId]",
|
||||
];
|
||||
@@ -50,30 +50,47 @@ export default function Layout(props: PropsWithChildren) {
|
||||
api.environment.enableExperimentalFeatures.useQuery().data ?? false;
|
||||
|
||||
const projectId = router.query.projectId as string | undefined;
|
||||
const navigation = ROUTES.filter(
|
||||
({ pathname }) => projectId || !pathname.includes("[projectId]"),
|
||||
)
|
||||
.filter(
|
||||
({ featureFlag }) =>
|
||||
featureFlag === undefined ||
|
||||
|
||||
const mapNavigation = (route: Route): NavigationItem | null => {
|
||||
// Project-level routes
|
||||
if (!projectId && route.pathname?.includes("[projectId]")) return null;
|
||||
|
||||
// Feature Flags
|
||||
if (
|
||||
!(
|
||||
route.featureFlag === undefined ||
|
||||
enableExperimentalFeatures ||
|
||||
session.data?.user?.featureFlags[featureFlag],
|
||||
session.data?.user?.featureFlags[route.featureFlag]
|
||||
)
|
||||
)
|
||||
.filter(
|
||||
({ rbacScope }) =>
|
||||
rbacScope === undefined ||
|
||||
(projectId && hasAccess({ projectId, scope: rbacScope, session })),
|
||||
return null;
|
||||
|
||||
// RBAC
|
||||
if (
|
||||
route.rbacScope !== undefined &&
|
||||
(!projectId || !hasAccess({ projectId, scope: route.rbacScope, session }))
|
||||
)
|
||||
.map(({ pathname, ...rest }) => ({
|
||||
pathname,
|
||||
href: pathname.replace("[projectId]", projectId ?? ""),
|
||||
...rest,
|
||||
}))
|
||||
.map(({ pathname, ...rest }) => ({
|
||||
pathname,
|
||||
current: router.pathname === pathname,
|
||||
...rest,
|
||||
}));
|
||||
return null;
|
||||
|
||||
// apply to children as well
|
||||
const children: (NavigationItem | null)[] =
|
||||
route.children?.map((child) => mapNavigation(child)).filter(Boolean) ??
|
||||
[];
|
||||
return {
|
||||
...route,
|
||||
href: route.pathname?.replace("[projectId]", projectId ?? ""),
|
||||
current: router.pathname === route.pathname,
|
||||
children:
|
||||
children.length > 0
|
||||
? (children as NavigationItem[]) // does not include null due to filter
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const navigationMapped: (NavigationItem | null)[] = ROUTES.map((route) =>
|
||||
mapNavigation(route),
|
||||
).filter(Boolean);
|
||||
const navigation = navigationMapped.filter(Boolean) as NavigationItem[]; // does not include null due to filter
|
||||
|
||||
const currentPathName = navigation.find(({ current }) => current)?.name;
|
||||
|
||||
@@ -124,7 +141,6 @@ export default function Layout(props: PropsWithChildren) {
|
||||
{props.children}
|
||||
</main>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
@@ -207,58 +223,7 @@ export default function Layout(props: PropsWithChildren) {
|
||||
<LangfuseLogo version size="xl" />
|
||||
<nav className="flex flex-1 flex-col">
|
||||
<ul role="list" className="flex flex-1 flex-col gap-y-7">
|
||||
<li>
|
||||
<ul role="list" className="-mx-2 space-y-1">
|
||||
{navigation.map((item) => (
|
||||
<li
|
||||
key={item.name}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<Link
|
||||
href={item.href}
|
||||
className={clsx(
|
||||
item.current
|
||||
? "bg-gray-50 text-indigo-600"
|
||||
: "text-gray-700 hover:bg-gray-50 hover:text-indigo-600",
|
||||
"group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6",
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
className={clsx(
|
||||
item.current
|
||||
? "text-indigo-600"
|
||||
: "text-gray-400 group-hover:text-indigo-600",
|
||||
"h-6 w-6 shrink-0",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{item.name}
|
||||
{item.label && (
|
||||
<span
|
||||
className={cn(
|
||||
"self-center whitespace-nowrap break-keep rounded-sm border px-1 py-0.5 text-xs",
|
||||
item.current
|
||||
? "border-indigo-600 text-indigo-600"
|
||||
: "border-gray-200 text-gray-400 group-hover:border-indigo-600 group-hover:text-indigo-600",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
<FeedbackButtonWrapper className="w-full">
|
||||
<li className="group flex cursor-pointer gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:bg-gray-50 hover:text-indigo-600">
|
||||
<MessageSquarePlus
|
||||
className="h-6 w-6 shrink-0 text-gray-400 group-hover:text-indigo-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Feedback
|
||||
</li>
|
||||
</FeedbackButtonWrapper>
|
||||
</ul>
|
||||
</li>
|
||||
<MainNavigation nav={navigation} />
|
||||
<li>
|
||||
<div className="flex flex-row place-content-between items-center">
|
||||
<div className="text-xs font-semibold leading-6 text-gray-400">
|
||||
@@ -324,56 +289,7 @@ export default function Layout(props: PropsWithChildren) {
|
||||
<LangfuseLogo version 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) => (
|
||||
<li key={item.name}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className={clsx(
|
||||
item.current
|
||||
? "bg-gray-50 text-indigo-600"
|
||||
: "text-gray-700 hover:bg-gray-50 hover:text-indigo-600",
|
||||
"group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6",
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
className={clsx(
|
||||
item.current
|
||||
? "text-indigo-600"
|
||||
: "text-gray-400 group-hover:text-indigo-600",
|
||||
"h-6 w-6 shrink-0",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{item.name}
|
||||
{item.label && (
|
||||
<span
|
||||
className={cn(
|
||||
"self-center whitespace-nowrap break-keep rounded-sm border px-1 py-0.5 text-xs",
|
||||
item.current
|
||||
? "border-indigo-600 text-indigo-600"
|
||||
: "border-gray-200 text-gray-400 group-hover:border-indigo-600 group-hover:text-indigo-600",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
<FeedbackButtonWrapper className="w-full">
|
||||
<li className="group flex cursor-pointer gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:bg-gray-50 hover:text-indigo-600">
|
||||
<MessageSquarePlus
|
||||
className="h-6 w-6 shrink-0 text-gray-400 group-hover:text-indigo-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Feedback
|
||||
</li>
|
||||
</FeedbackButtonWrapper>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<MainNavigation nav={navigation} />
|
||||
<li className="mt-auto">
|
||||
<div className="flex flex-row place-content-between items-center">
|
||||
<div className="text-xs font-semibold leading-6 text-gray-400">
|
||||
@@ -569,11 +485,170 @@ export default function Layout(props: PropsWithChildren) {
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<main className="py-4">
|
||||
<div className="px-4">{props.children}</div>
|
||||
</main>
|
||||
<main className="p-4">{props.children}</main>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type NavigationItem = NestedNavigationItem & {
|
||||
children?: NestedNavigationItem[];
|
||||
};
|
||||
|
||||
type NestedNavigationItem = Omit<Route, "children"> & {
|
||||
href?: string;
|
||||
current: boolean;
|
||||
};
|
||||
|
||||
const MainNavigation: React.FC<{
|
||||
nav: NavigationItem[];
|
||||
onNavitemClick?: () => void;
|
||||
}> = ({ nav, onNavitemClick }) => {
|
||||
const STORAGE_KEY = "sidebar-tracing-default-open";
|
||||
const getDefaultOpen = () => {
|
||||
const savedState = localStorage.getItem(STORAGE_KEY);
|
||||
if (savedState !== null) {
|
||||
try {
|
||||
return JSON.parse(savedState) as boolean;
|
||||
} catch (e) {
|
||||
console.error("Error parsing saved state: ", e);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleDropDownClick = () => {
|
||||
const savedState = localStorage.getItem(STORAGE_KEY);
|
||||
const isOpen =
|
||||
savedState !== null ? (JSON.parse(savedState) as boolean) : false;
|
||||
const newState = !isOpen;
|
||||
localStorage.setItem(
|
||||
"sidebar-tracing-default-open",
|
||||
JSON.stringify(newState),
|
||||
);
|
||||
};
|
||||
return (
|
||||
<li>
|
||||
<ul role="list" className="-mx-2 space-y-1">
|
||||
{nav.map((item) => (
|
||||
<li key={item.name}>
|
||||
{(!item.children || item.children.length === 0) && item.href ? (
|
||||
<Link
|
||||
href={item.href}
|
||||
className={clsx(
|
||||
item.current
|
||||
? "bg-gray-50 text-indigo-600"
|
||||
: "text-gray-700 hover:bg-gray-50 hover:text-indigo-600",
|
||||
"group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6",
|
||||
)}
|
||||
onClick={onNavitemClick}
|
||||
>
|
||||
{item.icon && (
|
||||
<item.icon
|
||||
className={clsx(
|
||||
item.current
|
||||
? "text-indigo-600"
|
||||
: "text-gray-400 group-hover:text-indigo-600",
|
||||
"h-6 w-6 shrink-0",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{item.name}
|
||||
{item.label && (
|
||||
<span
|
||||
className={cn(
|
||||
"self-center whitespace-nowrap break-keep rounded-sm border px-1 py-0.5 text-xs",
|
||||
item.current
|
||||
? "border-indigo-600 text-indigo-600"
|
||||
: "border-gray-200 text-gray-400 group-hover:border-indigo-600 group-hover:text-indigo-600",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
) : item.children && item.children.length > 0 ? (
|
||||
<Disclosure
|
||||
as="div"
|
||||
defaultOpen={
|
||||
item.children.some((child) => child.current) ||
|
||||
getDefaultOpen()
|
||||
}
|
||||
>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Disclosure.Button
|
||||
className="group flex w-full items-center gap-x-3 rounded-md p-2 text-left text-sm font-semibold leading-6 hover:bg-gray-50 hover:text-indigo-600"
|
||||
onClick={handleDropDownClick}
|
||||
>
|
||||
{item.icon && (
|
||||
<item.icon
|
||||
className="h-6 w-6 shrink-0 text-gray-400 group-hover:text-indigo-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{item.name}
|
||||
{item.label && (
|
||||
<span
|
||||
className={cn(
|
||||
"self-center whitespace-nowrap break-keep rounded-sm border px-1 py-0.5 text-xs",
|
||||
item.current
|
||||
? "border-indigo-600 text-indigo-600"
|
||||
: "border-gray-200 text-gray-400 group-hover:border-indigo-600 group-hover:text-indigo-600",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
<ChevronRightIcon
|
||||
className={clsx(
|
||||
open ? "rotate-90 text-gray-500" : "text-gray-400",
|
||||
"ml-auto h-5 w-5 shrink-0",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Disclosure.Button>
|
||||
<Disclosure.Panel as="ul" className="mt-1 px-2">
|
||||
{item.children?.map((subItem) => (
|
||||
<li key={subItem.name}>
|
||||
{/* 44px */}
|
||||
<Link
|
||||
href={subItem.href ?? "#"}
|
||||
className={clsx(
|
||||
subItem.current
|
||||
? "bg-gray-50 text-indigo-600"
|
||||
: "text-gray-700 hover:bg-gray-50 hover:text-indigo-600",
|
||||
"flex w-full items-center gap-x-3 rounded-md py-2 pl-9 pr-2 text-sm leading-6",
|
||||
)}
|
||||
>
|
||||
{subItem.name}
|
||||
{subItem.label && (
|
||||
<span className="self-center whitespace-nowrap break-keep rounded-sm border border-gray-200 px-1 py-0.5 text-xs text-gray-400 group-hover:border-indigo-600 group-hover:text-indigo-600">
|
||||
{subItem.label}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</Disclosure.Panel>
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
<FeedbackButtonWrapper className="w-full">
|
||||
<li className="group flex cursor-pointer gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:bg-gray-50 hover:text-indigo-600">
|
||||
<MessageSquarePlus
|
||||
className="h-6 w-6 shrink-0 text-gray-400 group-hover:text-indigo-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Feedback
|
||||
</li>
|
||||
</FeedbackButtonWrapper>
|
||||
</ul>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,48 +4,50 @@ import {
|
||||
Database,
|
||||
LayoutDashboard,
|
||||
LifeBuoy,
|
||||
LineChart,
|
||||
ListTree,
|
||||
type LucideIcon,
|
||||
Settings,
|
||||
TextSelect,
|
||||
UsersIcon,
|
||||
Route,
|
||||
PenSquareIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
export const ROUTES: Array<{
|
||||
export type Route = {
|
||||
name: string;
|
||||
pathname: string;
|
||||
icon: LucideIcon;
|
||||
featureFlag?: Flag;
|
||||
label?: string;
|
||||
rbacScope?: Scope;
|
||||
}> = [
|
||||
icon?: LucideIcon; // ignored for nested routes
|
||||
pathname?: string; // link, ignored if children
|
||||
children?: Array<Route>; // folder
|
||||
};
|
||||
|
||||
export const ROUTES: Route[] = [
|
||||
{
|
||||
name: "Dashboard",
|
||||
pathname: `/project/[projectId]`,
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
{
|
||||
name: "Sessions",
|
||||
pathname: `/project/[projectId]/sessions`,
|
||||
icon: Route,
|
||||
},
|
||||
{
|
||||
name: "Traces",
|
||||
pathname: `/project/[projectId]/traces`,
|
||||
name: "Tracing",
|
||||
icon: ListTree,
|
||||
},
|
||||
{
|
||||
name: "Generations",
|
||||
pathname: `/project/[projectId]/generations`,
|
||||
icon: TextSelect,
|
||||
},
|
||||
{
|
||||
name: "Scores",
|
||||
pathname: `/project/[projectId]/scores`,
|
||||
icon: LineChart,
|
||||
children: [
|
||||
{
|
||||
name: "Traces",
|
||||
pathname: `/project/[projectId]/traces`,
|
||||
},
|
||||
{
|
||||
name: "Sessions",
|
||||
pathname: `/project/[projectId]/sessions`,
|
||||
},
|
||||
{
|
||||
name: "Generations",
|
||||
pathname: `/project/[projectId]/generations`,
|
||||
},
|
||||
{
|
||||
name: "Scores",
|
||||
pathname: `/project/[projectId]/scores`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Users",
|
||||
|
||||
@@ -68,15 +68,22 @@ export const SessionPage: React.FC<{
|
||||
/>,
|
||||
<DetailPageNav
|
||||
key="nav"
|
||||
currentId={sessionId}
|
||||
path={(id) => `/project/${projectId}/sessions/${id}`}
|
||||
currentId={encodeURIComponent(sessionId)}
|
||||
path={(id) =>
|
||||
`/project/${projectId}/sessions/${encodeURIComponent(id)}`
|
||||
}
|
||||
listKey="sessions"
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{session.data?.users.map((userId) => (
|
||||
<Link key={userId} href={`/project/${projectId}/users/${userId}`}>
|
||||
{session.data?.users.filter(Boolean).map((userId) => (
|
||||
<Link
|
||||
key={userId}
|
||||
href={`/project/${projectId}/users/${encodeURIComponent(
|
||||
userId ?? "",
|
||||
)}`}
|
||||
>
|
||||
<Badge>User ID: {userId}</Badge>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
+128
-22
@@ -1,50 +1,37 @@
|
||||
import { StarIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { useHasAccess } from "@/src/features/rbac/utils/checkAccess";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { type RouterOutput, type RouterInput } from "@/src/utils/types";
|
||||
import { useState } from "react";
|
||||
|
||||
export function StarToggle({
|
||||
value,
|
||||
disabled = false,
|
||||
onClick,
|
||||
size = "sm",
|
||||
isLoading,
|
||||
}: {
|
||||
value: boolean;
|
||||
disabled?: boolean;
|
||||
onClick: (value: boolean) => Promise<unknown>;
|
||||
size?: "sm" | "xs";
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const [cachedValue, setCachedValue] = useState<boolean | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const optimisticValue = cachedValue ?? value;
|
||||
|
||||
useEffect(() => {
|
||||
setCachedValue(null);
|
||||
}, [value]);
|
||||
|
||||
const handleBookmarkClick = async () => {
|
||||
if (disabled) return;
|
||||
setLoading(true);
|
||||
setCachedValue(!optimisticValue);
|
||||
await onClick(!optimisticValue);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={size}
|
||||
onClick={() => void handleBookmarkClick()}
|
||||
onClick={() => void onClick(!value)}
|
||||
disabled={disabled}
|
||||
loading={loading}
|
||||
loading={isLoading}
|
||||
>
|
||||
<StarIcon
|
||||
className={cn(
|
||||
"h-4 w-4",
|
||||
optimisticValue ? "fill-current text-yellow-500" : "text-gray-500",
|
||||
value ? "fill-current text-yellow-500" : "text-gray-500",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
@@ -52,6 +39,88 @@ export function StarToggle({
|
||||
}
|
||||
|
||||
export function StarTraceToggle({
|
||||
tracesFilter,
|
||||
projectId,
|
||||
traceId,
|
||||
value,
|
||||
size = "sm",
|
||||
}: {
|
||||
tracesFilter: RouterInput["traces"]["all"];
|
||||
projectId: string;
|
||||
traceId: string;
|
||||
value: boolean;
|
||||
size?: "sm" | "xs";
|
||||
}) {
|
||||
const utils = api.useUtils();
|
||||
const hasAccess = useHasAccess({ projectId, scope: "objects:bookmark" });
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const mutBookmarkTrace = api.traces.bookmark.useMutation({
|
||||
// Optimistic update
|
||||
// Tanstack docs: https://tanstack.com/query/v4/docs/react/guides/optimistic-updates
|
||||
|
||||
onMutate: async () => {
|
||||
// Cancel any outgoing refetches
|
||||
// (so they don't overwrite our optimistic update)
|
||||
await utils.traces.all.cancel();
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
// Snapshot the previous value
|
||||
const prev = utils.traces.all.getData(tracesFilter);
|
||||
|
||||
return { prev };
|
||||
},
|
||||
onError: (err, _newTodo, context) => {
|
||||
setIsLoading(false);
|
||||
// Rollback to the previous value if mutation fails
|
||||
console.log("error", err);
|
||||
utils.traces.all.setData(tracesFilter, context?.prev);
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsLoading(false);
|
||||
utils.traces.all.setData(
|
||||
tracesFilter,
|
||||
(oldQueryData: RouterOutput["traces"]["all"] | undefined) => {
|
||||
return {
|
||||
totalCount: oldQueryData?.totalCount,
|
||||
traces: oldQueryData?.traces
|
||||
? oldQueryData.traces.map((trace) => {
|
||||
return {
|
||||
...trace,
|
||||
bookmarked:
|
||||
trace.id === traceId
|
||||
? !trace.bookmarked
|
||||
: trace.bookmarked,
|
||||
};
|
||||
})
|
||||
: [],
|
||||
};
|
||||
},
|
||||
);
|
||||
void utils.traces.all.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<StarToggle
|
||||
value={value}
|
||||
size={size}
|
||||
disabled={!hasAccess}
|
||||
isLoading={isLoading}
|
||||
onClick={(value) =>
|
||||
mutBookmarkTrace.mutateAsync({
|
||||
projectId,
|
||||
traceId,
|
||||
bookmarked: value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function StarTraceDetailsToggle({
|
||||
projectId,
|
||||
traceId,
|
||||
value,
|
||||
@@ -64,9 +133,44 @@ export function StarTraceToggle({
|
||||
}) {
|
||||
const utils = api.useUtils();
|
||||
const hasAccess = useHasAccess({ projectId, scope: "objects:bookmark" });
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const mutBookmarkTrace = api.traces.bookmark.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.traces.invalidate();
|
||||
onMutate: async () => {
|
||||
// Cancel any outgoing refetches
|
||||
// (so they don't overwrite our optimistic update)
|
||||
await utils.traces.byId.cancel();
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
// Snapshot the previous value
|
||||
const prevData = utils.traces.byId.getData({ traceId });
|
||||
|
||||
return { prevData };
|
||||
},
|
||||
onError: (err, _newTodo, context) => {
|
||||
setIsLoading(false);
|
||||
console.log("error", err);
|
||||
// Rollback to the previous value if mutation fails
|
||||
utils.traces.byId.setData({ traceId }, context?.prevData);
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsLoading(false);
|
||||
|
||||
utils.traces.byId.setData(
|
||||
{ traceId },
|
||||
(oldQueryData: RouterOutput["traces"]["byId"] | undefined) => {
|
||||
return oldQueryData
|
||||
? {
|
||||
...oldQueryData,
|
||||
bookmarked: !oldQueryData.bookmarked,
|
||||
}
|
||||
: undefined;
|
||||
},
|
||||
);
|
||||
void utils.traces.byId.invalidate();
|
||||
void utils.traces.all.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -75,6 +179,7 @@ export function StarTraceToggle({
|
||||
value={value}
|
||||
size={size}
|
||||
disabled={!hasAccess}
|
||||
isLoading={isLoading}
|
||||
onClick={(value) =>
|
||||
mutBookmarkTrace.mutateAsync({
|
||||
projectId,
|
||||
@@ -109,6 +214,7 @@ export function StarSessionToggle({
|
||||
<StarToggle
|
||||
value={value}
|
||||
size={size}
|
||||
isLoading={mutBookmarkSession.isLoading}
|
||||
disabled={!hasAccess}
|
||||
onClick={(value) =>
|
||||
mutBookmarkSession.mutateAsync({
|
||||
|
||||
@@ -122,10 +122,14 @@ export function DataTable<TData extends object, TValue>({
|
||||
}
|
||||
|
||||
if (orderBy?.column === header.column.columnDef.id) {
|
||||
setOrderBy({
|
||||
column: header.column.columnDef.id,
|
||||
order: orderBy.order === "ASC" ? "DESC" : "ASC",
|
||||
});
|
||||
if (orderBy.order === "DESC") {
|
||||
setOrderBy({
|
||||
column: header.column.columnDef.id,
|
||||
order: "ASC",
|
||||
});
|
||||
} else {
|
||||
setOrderBy(null);
|
||||
}
|
||||
} else {
|
||||
setOrderBy({
|
||||
column: header.column.columnDef.id,
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
exportOptions,
|
||||
type ExportFileFormats,
|
||||
} from "@/src/server/api/interfaces/exportTypes";
|
||||
import { useOrderByState } from "@/src/features/orderBy/hooks/useOrderByState";
|
||||
|
||||
export type GenerationsTableRow = {
|
||||
id: string;
|
||||
@@ -82,14 +83,21 @@ export default function GenerationsTable({ projectId }: GenerationsTableProps) {
|
||||
},
|
||||
]);
|
||||
|
||||
const [orderByState, setOrderByState] = useOrderByState({
|
||||
column: "startTime",
|
||||
order: "DESC",
|
||||
});
|
||||
|
||||
const generations = api.generations.all.useQuery({
|
||||
page: paginationState.pageIndex,
|
||||
limit: paginationState.pageSize,
|
||||
projectId,
|
||||
filter: filterState,
|
||||
orderBy: orderByState,
|
||||
searchQuery,
|
||||
});
|
||||
const totalCount = generations.data?.slice(1)[0]?.totalCount ?? 0;
|
||||
|
||||
const totalCount = generations.data?.totalCount ?? 0;
|
||||
|
||||
const filterOptions = api.generations.filterOptions.useQuery({
|
||||
projectId,
|
||||
@@ -143,6 +151,7 @@ export default function GenerationsTable({ projectId }: GenerationsTableProps) {
|
||||
const columns: LangfuseColumnDef<GenerationsTableRow>[] = [
|
||||
{
|
||||
accessorKey: "id",
|
||||
id: "id",
|
||||
header: "ID",
|
||||
cell: ({ row }) => {
|
||||
const observationId = row.getValue("id");
|
||||
@@ -155,13 +164,17 @@ export default function GenerationsTable({ projectId }: GenerationsTableProps) {
|
||||
/>
|
||||
) : null;
|
||||
},
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
id: "name",
|
||||
header: "name",
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "traceId",
|
||||
id: "traceId",
|
||||
header: "Trace ID",
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue("traceId");
|
||||
@@ -172,19 +185,25 @@ export default function GenerationsTable({ projectId }: GenerationsTableProps) {
|
||||
/>
|
||||
) : undefined;
|
||||
},
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "traceName",
|
||||
id: "traceName",
|
||||
header: "Trace Name",
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "startTime",
|
||||
id: "startTime",
|
||||
header: "Start Time",
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "latency",
|
||||
id: "latency",
|
||||
header: "Latency",
|
||||
cell: ({ row }) => {
|
||||
const value: number | undefined = row.getValue("latency");
|
||||
@@ -193,6 +212,7 @@ export default function GenerationsTable({ projectId }: GenerationsTableProps) {
|
||||
) : undefined;
|
||||
},
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "cost",
|
||||
@@ -208,6 +228,7 @@ export default function GenerationsTable({ projectId }: GenerationsTableProps) {
|
||||
},
|
||||
{
|
||||
accessorKey: "level",
|
||||
id: "level",
|
||||
header: "Level",
|
||||
enableHiding: true,
|
||||
cell({ row }) {
|
||||
@@ -224,6 +245,7 @@ export default function GenerationsTable({ projectId }: GenerationsTableProps) {
|
||||
</span>
|
||||
) : undefined;
|
||||
},
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "statusMessage",
|
||||
@@ -233,8 +255,10 @@ export default function GenerationsTable({ projectId }: GenerationsTableProps) {
|
||||
},
|
||||
{
|
||||
accessorKey: "model",
|
||||
id: "model",
|
||||
header: "Model",
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "usage",
|
||||
@@ -288,8 +312,10 @@ export default function GenerationsTable({ projectId }: GenerationsTableProps) {
|
||||
},
|
||||
{
|
||||
accessorKey: "version",
|
||||
id: "version",
|
||||
header: "Version",
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
},
|
||||
];
|
||||
const [columnVisibility, setColumnVisibility] =
|
||||
@@ -299,7 +325,7 @@ export default function GenerationsTable({ projectId }: GenerationsTableProps) {
|
||||
);
|
||||
|
||||
const rows: GenerationsTableRow[] = generations.isSuccess
|
||||
? generations.data.map((generation) => {
|
||||
? generations.data.generations.map((generation) => {
|
||||
return {
|
||||
id: generation.id,
|
||||
traceId: generation.traceId,
|
||||
@@ -380,7 +406,7 @@ export default function GenerationsTable({ projectId }: GenerationsTableProps) {
|
||||
data={
|
||||
generations.isLoading
|
||||
? { isLoading: true, isError: false }
|
||||
: generations.isError
|
||||
: generations.error
|
||||
? {
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
@@ -397,6 +423,8 @@ export default function GenerationsTable({ projectId }: GenerationsTableProps) {
|
||||
onChange: setPaginationState,
|
||||
state: paginationState,
|
||||
}}
|
||||
setOrderBy={setOrderByState}
|
||||
orderBy={orderByState}
|
||||
columnVisibility={columnVisibility}
|
||||
onColumnVisibilityChange={setColumnVisibility}
|
||||
/>
|
||||
|
||||
@@ -7,6 +7,7 @@ import useColumnVisibility from "@/src/features/column-visibility/hooks/useColum
|
||||
import { useQueryFilterState } from "@/src/features/filters/hooks/useFilterState";
|
||||
import { type FilterState } from "@/src/features/filters/types";
|
||||
import { useDetailPageLists } from "@/src/features/navigate-detail-pages/context";
|
||||
import { useOrderByState } from "@/src/features/orderBy/hooks/useOrderByState";
|
||||
import { sessionsViewCols } from "@/src/server/api/definitions/sessionsView";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { formatInterval, utcDateOffsetByDays } from "@/src/utils/dates";
|
||||
@@ -63,12 +64,19 @@ export default function SessionsTable({
|
||||
pageSize: withDefault(NumberParam, 50),
|
||||
});
|
||||
|
||||
const [orderByState, setOrderByState] = useOrderByState({
|
||||
column: "createdAt",
|
||||
order: "DESC",
|
||||
});
|
||||
|
||||
const sessions = api.sessions.all.useQuery({
|
||||
page: paginationState.pageIndex,
|
||||
limit: paginationState.pageSize,
|
||||
projectId,
|
||||
filter: filterState,
|
||||
orderBy: orderByState,
|
||||
});
|
||||
|
||||
const totalCount = sessions.data?.slice(1)[0]?.totalCount ?? 0;
|
||||
useEffect(() => {
|
||||
if (sessions.isSuccess) {
|
||||
@@ -96,6 +104,7 @@ export default function SessionsTable({
|
||||
const columns: LangfuseColumnDef<SessionTableRow>[] = [
|
||||
{
|
||||
accessorKey: "bookmarked",
|
||||
id: "bookmarked",
|
||||
header: undefined,
|
||||
cell: ({ row }) => {
|
||||
const bookmarked = row.getValue("bookmarked");
|
||||
@@ -111,27 +120,33 @@ export default function SessionsTable({
|
||||
/>
|
||||
) : undefined;
|
||||
},
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "id",
|
||||
id: "id",
|
||||
header: "ID",
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue("id");
|
||||
return value && typeof value === "string" ? (
|
||||
<TableLink
|
||||
path={`/project/${projectId}/sessions/${value}`}
|
||||
path={`/project/${projectId}/sessions/${encodeURIComponent(value)}`}
|
||||
value={value}
|
||||
/>
|
||||
) : undefined;
|
||||
},
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
id: "createdAt",
|
||||
header: "Created At",
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "sessionDuration",
|
||||
id: "sessionDuration",
|
||||
header: "Duration",
|
||||
enableHiding: true,
|
||||
cell: ({ row }) => {
|
||||
@@ -140,6 +155,7 @@ export default function SessionsTable({
|
||||
? formatInterval(value)
|
||||
: undefined;
|
||||
},
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "userIds",
|
||||
@@ -153,7 +169,7 @@ export default function SessionsTable({
|
||||
{(value as string[]).map((user) => (
|
||||
<TableLink
|
||||
key={user}
|
||||
path={`/project/${projectId}/users/${user}`}
|
||||
path={`/project/${projectId}/users/${encodeURIComponent(user)}`}
|
||||
value={user}
|
||||
truncateAt={40}
|
||||
/>
|
||||
@@ -164,8 +180,10 @@ export default function SessionsTable({
|
||||
},
|
||||
{
|
||||
accessorKey: "countTraces",
|
||||
id: "countTraces",
|
||||
header: "Traces",
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -204,6 +222,8 @@ export default function SessionsTable({
|
||||
onChange: setPaginationState,
|
||||
state: paginationState,
|
||||
}}
|
||||
setOrderBy={setOrderByState}
|
||||
orderBy={orderByState}
|
||||
columnVisibility={columnVisibility}
|
||||
onColumnVisibilityChange={setColumnVisibility}
|
||||
help={{
|
||||
|
||||
@@ -6,6 +6,7 @@ import { TraceTableMultiSelectAction } from "@/src/components/table/data-table-m
|
||||
import { DataTableToolbar } from "@/src/components/table/data-table-toolbar";
|
||||
import TableLink from "@/src/components/table/table-link";
|
||||
import { type LangfuseColumnDef } from "@/src/components/table/types";
|
||||
import { TagTracePopver } from "@/src/features/tag/components/TagTracePopver";
|
||||
import { TokenUsageBadge } from "@/src/components/token-usage-badge";
|
||||
import { Checkbox } from "@/src/components/ui/checkbox";
|
||||
import { JSONView } from "@/src/components/ui/code";
|
||||
@@ -99,21 +100,22 @@ export default function TracesTable({
|
||||
pageIndex: withDefault(NumberParam, 0),
|
||||
pageSize: withDefault(NumberParam, 50),
|
||||
});
|
||||
|
||||
const traces = api.traces.all.useQuery({
|
||||
const tracesAllQueryFilter = {
|
||||
page: paginationState.pageIndex,
|
||||
limit: paginationState.pageSize,
|
||||
projectId,
|
||||
filter: filterState,
|
||||
searchQuery,
|
||||
orderBy: orderByState,
|
||||
});
|
||||
const totalCount = traces.data?.slice(1)[0]?.totalCount ?? 0;
|
||||
};
|
||||
const traces = api.traces.all.useQuery(tracesAllQueryFilter);
|
||||
|
||||
const totalCount = traces.data?.totalCount ?? 0;
|
||||
useEffect(() => {
|
||||
if (traces.isSuccess) {
|
||||
setDetailPageList(
|
||||
"traces",
|
||||
traces.data.map((t) => t.id),
|
||||
traces.data.traces.map((t) => t.id),
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -134,9 +136,8 @@ export default function TracesTable({
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const convertToTableRow = (
|
||||
trace: RouterOutput["traces"]["all"][0],
|
||||
trace: RouterOutput["traces"]["all"]["traces"][0],
|
||||
): TracesTableRow => {
|
||||
return {
|
||||
bookmarked: trace.bookmarked,
|
||||
@@ -197,10 +198,10 @@ export default function TracesTable({
|
||||
cell: ({ row }) => {
|
||||
const bookmarked = row.getValue("bookmarked");
|
||||
const traceId = row.getValue("id");
|
||||
|
||||
return typeof traceId === "string" &&
|
||||
typeof bookmarked === "boolean" ? (
|
||||
<StarTraceToggle
|
||||
tracesFilter={tracesAllQueryFilter}
|
||||
traceId={traceId}
|
||||
projectId={projectId}
|
||||
value={bookmarked}
|
||||
@@ -246,7 +247,7 @@ export default function TracesTable({
|
||||
const value = row.getValue("userId");
|
||||
return value && typeof value === "string" ? (
|
||||
<TableLink
|
||||
path={`/project/${projectId}/users/${value}`}
|
||||
path={`/project/${projectId}/users/${encodeURIComponent(value)}`}
|
||||
value={value}
|
||||
truncateAt={40}
|
||||
/>
|
||||
@@ -263,7 +264,7 @@ export default function TracesTable({
|
||||
const value = row.getValue("sessionId");
|
||||
return value && typeof value === "string" ? (
|
||||
<TableLink
|
||||
path={`/project/${projectId}/sessions/${value}`}
|
||||
path={`/project/${projectId}/sessions/${encodeURIComponent(value)}`}
|
||||
value={value}
|
||||
truncateAt={40}
|
||||
/>
|
||||
@@ -362,6 +363,21 @@ export default function TracesTable({
|
||||
accessorKey: "tags",
|
||||
id: "tags",
|
||||
header: "Tags",
|
||||
cell: ({ row }) => {
|
||||
const tags: string[] = row.getValue("tags");
|
||||
const traceId: string = row.getValue("id");
|
||||
const filterOptionTags = traceFilterOptions.data?.tags ?? [];
|
||||
const allTags = filterOptionTags.map((t) => t.value);
|
||||
return (
|
||||
<TagTracePopver
|
||||
tags={tags}
|
||||
availableTags={allTags}
|
||||
projectId={projectId}
|
||||
traceId={traceId}
|
||||
tracesFilter={tracesAllQueryFilter}
|
||||
/>
|
||||
);
|
||||
},
|
||||
enableHiding: true,
|
||||
},
|
||||
{
|
||||
@@ -401,7 +417,8 @@ export default function TracesTable({
|
||||
<TraceTableMultiSelectAction
|
||||
// Exclude traces that are not in the current page
|
||||
selectedTraceIds={Object.keys(selectedRows).filter(
|
||||
(traceId) => traces.data?.map((t) => t.id).includes(traceId),
|
||||
(traceId) =>
|
||||
traces.data?.traces.map((t) => t.id).includes(traceId),
|
||||
)}
|
||||
projectId={projectId}
|
||||
onDeleteSuccess={() => {
|
||||
@@ -426,11 +443,11 @@ export default function TracesTable({
|
||||
: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: traces.data.map((t) => convertToTableRow(t)),
|
||||
data: traces.data.traces.map((t) => convertToTableRow(t)),
|
||||
}
|
||||
}
|
||||
pagination={{
|
||||
pageCount: Math.ceil(totalCount / paginationState.pageSize),
|
||||
pageCount: Math.ceil(Number(totalCount) / paginationState.pageSize),
|
||||
onChange: setPaginationState,
|
||||
state: paginationState,
|
||||
}}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { api } from "@/src/utils/api";
|
||||
import { IOPreview } from "@/src/components/trace/IOPreview";
|
||||
import { formatInterval } from "@/src/utils/dates";
|
||||
import Link from "next/link";
|
||||
import { usdFormatter } from "@/src/utils/numbers";
|
||||
|
||||
export const ObservationPreview = (props: {
|
||||
observations: Array<ObservationReturnType>;
|
||||
@@ -61,8 +62,19 @@ export const ObservationPreview = (props: {
|
||||
projectId={preloadedObservation.projectId}
|
||||
/>
|
||||
) : undefined}
|
||||
{preloadedObservation.completionStartTime ? (
|
||||
<Badge variant="outline">
|
||||
Time to first token:{" "}
|
||||
{formatInterval(
|
||||
(preloadedObservation.completionStartTime.getTime() -
|
||||
preloadedObservation.startTime.getTime()) /
|
||||
1000,
|
||||
)}
|
||||
</Badge>
|
||||
) : null}
|
||||
{preloadedObservation.endTime ? (
|
||||
<Badge variant="outline">
|
||||
Latency:{" "}
|
||||
{formatInterval(
|
||||
(preloadedObservation.endTime.getTime() -
|
||||
preloadedObservation.startTime.getTime()) /
|
||||
@@ -87,7 +99,7 @@ export const ObservationPreview = (props: {
|
||||
) : null}
|
||||
{preloadedObservation.price ? (
|
||||
<Badge variant="outline">
|
||||
{preloadedObservation.price.toString()} USD
|
||||
{usdFormatter(preloadedObservation.price.toNumber())}
|
||||
</Badge>
|
||||
) : undefined}
|
||||
|
||||
|
||||
@@ -73,9 +73,6 @@ export const TracePreview = ({
|
||||
title="Metadata"
|
||||
json={trace.metadata}
|
||||
/>
|
||||
{trace.tags.length !== 0 && (
|
||||
<JSONView key={trace.id + "-tags"} title="Tags" json={trace.tags} />
|
||||
)}
|
||||
{scores.find((s) => s.observationId === null) ? (
|
||||
<div className="mt-5 flex flex-col gap-2">
|
||||
<h3>Scores</h3>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { TracePreview } from "./TracePreview";
|
||||
import Header from "@/src/components/layouts/header";
|
||||
import { Badge } from "@/src/components/ui/badge";
|
||||
import { TraceAggUsageBadge } from "@/src/components/token-usage-badge";
|
||||
import Decimal from "decimal.js";
|
||||
import { StringParam, useQueryParam } from "use-query-params";
|
||||
import { PublishTraceSwitch } from "@/src/components/publish-object-switch";
|
||||
import { DetailPageNav } from "@/src/features/navigate-detail-pages/DetailPageNav";
|
||||
@@ -14,12 +13,16 @@ 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 { StarTraceToggle } from "@/src/components/star-toggle";
|
||||
import { StarTraceDetailsToggle } from "@/src/components/star-toggle";
|
||||
import Link from "next/link";
|
||||
import { NoAccessError } from "@/src/components/no-access";
|
||||
import { TagTraceDetailsPopover } from "@/src/features/tag/components/TagTraceDetailsPopover";
|
||||
import useLocalStorage from "@/src/components/useLocalStorage";
|
||||
import { Toggle } from "@/src/components/ui/toggle";
|
||||
import { Award, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
|
||||
import { ScrollArea } from "@/src/components/ui/scroll-area";
|
||||
import { usdFormatter } from "@/src/utils/numbers";
|
||||
import type Decimal from "decimal.js";
|
||||
|
||||
export function Trace(props: {
|
||||
observations: Array<ObservationReturnType>;
|
||||
@@ -39,8 +42,8 @@ export function Trace(props: {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid h-full gap-4 md:grid-cols-3">
|
||||
<div className="md:col-span-2 md:h-full md:overflow-y-auto">
|
||||
<div className="grid gap-4 md:h-full md:grid-cols-3">
|
||||
<ScrollArea className="md:col-span-2 md:h-full">
|
||||
{currentObservationId === undefined ||
|
||||
currentObservationId === "" ||
|
||||
currentObservationId === null ? (
|
||||
@@ -58,9 +61,9 @@ export function Trace(props: {
|
||||
traceId={props.trace.id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="md:h-full md:overflow-hidden">
|
||||
<div className="mb-2 flex flex-row justify-end gap-2">
|
||||
</ScrollArea>
|
||||
<div className="md:flex md:h-full md:flex-col md:overflow-hidden">
|
||||
<div className="mb-2 flex flex-shrink-0 flex-row justify-end gap-2">
|
||||
<Toggle
|
||||
pressed={scoresOnObservationTree}
|
||||
onPressedChange={(e) => {
|
||||
@@ -86,16 +89,17 @@ export function Trace(props: {
|
||||
)}
|
||||
</Toggle>
|
||||
</div>
|
||||
<ObservationTree
|
||||
observations={props.observations}
|
||||
trace={props.trace}
|
||||
scores={props.scores}
|
||||
currentObservationId={currentObservationId ?? undefined}
|
||||
setCurrentObservationId={setCurrentObservationId}
|
||||
showMetrics={metricsOnObservationTree}
|
||||
showScores={scoresOnObservationTree}
|
||||
className="md:h-full md:overflow-y-auto"
|
||||
/>
|
||||
<ScrollArea className="flex flex-grow">
|
||||
<ObservationTree
|
||||
observations={props.observations}
|
||||
trace={props.trace}
|
||||
scores={props.scores}
|
||||
currentObservationId={currentObservationId ?? undefined}
|
||||
setCurrentObservationId={setCurrentObservationId}
|
||||
showMetrics={metricsOnObservationTree}
|
||||
showScores={scoresOnObservationTree}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -112,20 +116,36 @@ export function TracePage({ traceId }: { traceId: string }) {
|
||||
},
|
||||
},
|
||||
);
|
||||
const totalCost = trace.data?.observations.reduce(
|
||||
(acc, o) => {
|
||||
if (!o.price) return acc;
|
||||
|
||||
return acc ? acc.plus(o.price) : new Decimal(0).plus(o.price);
|
||||
const traceFilterOptions = api.traces.filterOptions.useQuery(
|
||||
{
|
||||
projectId: trace.data?.projectId ?? "",
|
||||
},
|
||||
{
|
||||
trpc: {
|
||||
context: {
|
||||
skipBatch: true,
|
||||
},
|
||||
},
|
||||
enabled: !!trace.data?.projectId && trace.isSuccess,
|
||||
},
|
||||
undefined as Decimal | undefined,
|
||||
);
|
||||
|
||||
const filterOptionTags = traceFilterOptions.data?.tags ?? [];
|
||||
const allTags = filterOptionTags.map((t) => t.value);
|
||||
|
||||
const totalCost: Decimal | undefined = trace.data?.observations.reduce(
|
||||
(prev: Decimal | undefined, curr: ObservationReturnType) => {
|
||||
if (!curr.price) return prev;
|
||||
|
||||
return prev ? prev.plus(curr.price) : curr.price;
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
if (trace.error?.data?.code === "UNAUTHORIZED") return <NoAccessError />;
|
||||
if (!trace.data) return <div>loading...</div>;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col overflow-hidden xl:container md:h-[calc(100vh-100px)] xl:h-[calc(100vh-40px)]">
|
||||
<div className="flex flex-col overflow-hidden xl:container md:h-[calc(100vh-2rem)]">
|
||||
<Header
|
||||
title="Trace Detail"
|
||||
breadcrumb={[
|
||||
@@ -137,7 +157,7 @@ export function TracePage({ traceId }: { traceId: string }) {
|
||||
]}
|
||||
actionButtons={
|
||||
<>
|
||||
<StarTraceToggle
|
||||
<StarTraceDetailsToggle
|
||||
traceId={trace.data.id}
|
||||
projectId={trace.data.projectId}
|
||||
value={trace.data.bookmarked}
|
||||
@@ -164,18 +184,18 @@ export function TracePage({ traceId }: { traceId: string }) {
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{trace.data.sessionId ? (
|
||||
<Link
|
||||
href={`/project/${router.query.projectId as string}/sessions/${
|
||||
trace.data.sessionId
|
||||
}`}
|
||||
href={`/project/${
|
||||
router.query.projectId as string
|
||||
}/sessions/${encodeURIComponent(trace.data.sessionId)}`}
|
||||
>
|
||||
<Badge>Session: {trace.data.sessionId}</Badge>
|
||||
</Link>
|
||||
) : null}
|
||||
{trace.data.userId ? (
|
||||
<Link
|
||||
href={`/project/${router.query.projectId as string}/users/${
|
||||
trace.data.userId
|
||||
}`}
|
||||
href={`/project/${
|
||||
router.query.projectId as string
|
||||
}/users/${encodeURIComponent(trace.data.userId)}`}
|
||||
>
|
||||
<Badge>User ID: {trace.data.userId}</Badge>
|
||||
</Link>
|
||||
@@ -183,10 +203,21 @@ export function TracePage({ traceId }: { traceId: string }) {
|
||||
<TraceAggUsageBadge observations={trace.data.observations} />
|
||||
{totalCost ? (
|
||||
<Badge variant="outline">
|
||||
Total cost: {totalCost.toString()} USD
|
||||
Total cost: {usdFormatter(totalCost.toNumber())}
|
||||
</Badge>
|
||||
) : undefined}
|
||||
</div>
|
||||
<div className="mt-5 rounded-lg border bg-card font-semibold text-card-foreground shadow-sm">
|
||||
<div className="flex flex-row items-center gap-3 p-2.5">
|
||||
Tags
|
||||
<TagTraceDetailsPopover
|
||||
tags={trace.data.tags}
|
||||
availableTags={allTags}
|
||||
traceId={trace.data.id}
|
||||
projectId={trace.data.projectId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex-1 overflow-hidden border-t pt-5">
|
||||
<Trace
|
||||
key={trace.data.id}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
));
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
));
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
@@ -1 +1 @@
|
||||
export const VERSION = "v1.30.0";
|
||||
export const VERSION = "v1.34.1";
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { api } from "@/src/utils/api";
|
||||
import { type FilterState } from "@/src/features/filters/types";
|
||||
import { TotalMetric } from "./TotalMetric";
|
||||
import { compactNumberFormatter, usdFormatter } from "@/src/utils/numbers";
|
||||
import { DashboardTable } from "@/src/features/dashboard/components/cards/DashboardTable";
|
||||
import DocPopup from "@/src/components/layouts/doc-popup";
|
||||
import { RightAlignedCell } from "@/src/features/dashboard/components/RightAlignedCell";
|
||||
import { DashboardCard } from "@/src/features/dashboard/components/cards/DashboardCard";
|
||||
import DocPopup from "@/src/components/layouts/doc-popup";
|
||||
import { DashboardTable } from "@/src/features/dashboard/components/cards/DashboardTable";
|
||||
import { type FilterState } from "@/src/features/filters/types";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { compactNumberFormatter, usdFormatter } from "@/src/utils/numbers";
|
||||
import { TotalMetric } from "./TotalMetric";
|
||||
|
||||
export const MetricTable = ({
|
||||
className,
|
||||
@@ -54,14 +54,14 @@ export const MetricTable = ({
|
||||
.filter((item) => item.model !== null)
|
||||
.map((item, i) => [
|
||||
item.model as string,
|
||||
<RightAlignedCell key={i}>
|
||||
<RightAlignedCell key={`${i}-tokens`}>
|
||||
{item.sumTotalTokens
|
||||
? compactNumberFormatter(item.sumTotalTokens as number)
|
||||
: "0"}
|
||||
</RightAlignedCell>,
|
||||
<RightAlignedCell key={i}>
|
||||
<RightAlignedCell key={`${i}-cost`}>
|
||||
{item.totalTokenCost
|
||||
? usdFormatter(item.totalTokenCost as number)
|
||||
? usdFormatter(item.totalTokenCost as number, 2, 2)
|
||||
: "$0"}
|
||||
</RightAlignedCell>,
|
||||
])
|
||||
@@ -76,14 +76,14 @@ export const MetricTable = ({
|
||||
<DashboardTable
|
||||
headers={[
|
||||
"Model",
|
||||
<RightAlignedCell key={0}>Tokens</RightAlignedCell>,
|
||||
<RightAlignedCell key={0}>USD</RightAlignedCell>,
|
||||
<RightAlignedCell key="tokens">Tokens</RightAlignedCell>,
|
||||
<RightAlignedCell key="cost">USD</RightAlignedCell>,
|
||||
]}
|
||||
rows={metricsData}
|
||||
collapse={{ collapsed: 5, expanded: 20 }}
|
||||
>
|
||||
<TotalMetric
|
||||
metric={totalTokens ? usdFormatter(totalTokens) : "$0"}
|
||||
metric={totalTokens ? usdFormatter(totalTokens, 2, 2) : "$0"}
|
||||
description="Total cost"
|
||||
>
|
||||
<DocPopup
|
||||
|
||||
@@ -94,13 +94,19 @@ export const ModelUsageChart = ({
|
||||
0,
|
||||
);
|
||||
|
||||
// had to add this function as tremor under the hodd adds more variables
|
||||
// to the function call which would break usdFormatter.
|
||||
const oneValueUsdFormatter = (value: number) => {
|
||||
return usdFormatter(value, 2, 2);
|
||||
};
|
||||
|
||||
const data = [
|
||||
{
|
||||
tabTitle: "Total cost",
|
||||
data: transformedModelCost,
|
||||
totalMetric: totalCost ? usdFormatter(totalCost) : usdFormatter(0),
|
||||
totalMetric: totalCost ? usdFormatter(totalCost, 2, 2) : usdFormatter(0),
|
||||
metricDescription: `Token cost`,
|
||||
formatter: usdFormatter,
|
||||
formatter: oneValueUsdFormatter,
|
||||
},
|
||||
{
|
||||
tabTitle: "Total tokens",
|
||||
@@ -115,7 +121,7 @@ export const ModelUsageChart = ({
|
||||
return (
|
||||
<DashboardCard
|
||||
className={className}
|
||||
title={"Model Usage"}
|
||||
title="Model Usage"
|
||||
isLoading={tokens.isLoading}
|
||||
>
|
||||
<TabComponent
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
import { type Key, type ReactNode } from "react";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
export const RightAlignedCell = ({
|
||||
children,
|
||||
className,
|
||||
key,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
key?: Key;
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn("text-right", className)} key={key}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}) => <div className={cn("text-right", className)}>{children}</div>;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { api } from "@/src/utils/api";
|
||||
import { type FilterState } from "@/src/features/filters/types";
|
||||
import { TotalMetric } from "./TotalMetric";
|
||||
import { compactNumberFormatter } from "@/src/utils/numbers";
|
||||
import { DashboardTable } from "@/src/features/dashboard/components/cards/DashboardTable";
|
||||
import { DashboardCard } from "@/src/features/dashboard/components/cards/DashboardCard";
|
||||
import { NoData } from "@/src/features/dashboard/components/NoData";
|
||||
import { RightAlignedCell } from "./RightAlignedCell";
|
||||
import DocPopup from "@/src/components/layouts/doc-popup";
|
||||
import { NoData } from "@/src/features/dashboard/components/NoData";
|
||||
import { DashboardCard } from "@/src/features/dashboard/components/cards/DashboardCard";
|
||||
import { DashboardTable } from "@/src/features/dashboard/components/cards/DashboardTable";
|
||||
import { type FilterState } from "@/src/features/filters/types";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { compactNumberFormatter } from "@/src/utils/numbers";
|
||||
import { RightAlignedCell } from "./RightAlignedCell";
|
||||
import { TotalMetric } from "./TotalMetric";
|
||||
|
||||
export const ScoresTable = ({
|
||||
className,
|
||||
@@ -128,23 +128,23 @@ export const ScoresTable = ({
|
||||
<DashboardTable
|
||||
headers={[
|
||||
"Name",
|
||||
<RightAlignedCell key={0}>#</RightAlignedCell>,
|
||||
<RightAlignedCell key={0}>Avg</RightAlignedCell>,
|
||||
<RightAlignedCell key={0}>0</RightAlignedCell>,
|
||||
<RightAlignedCell key={0}>1</RightAlignedCell>,
|
||||
<RightAlignedCell key="count">#</RightAlignedCell>,
|
||||
<RightAlignedCell key="average">Avg</RightAlignedCell>,
|
||||
<RightAlignedCell key="zero">0</RightAlignedCell>,
|
||||
<RightAlignedCell key="one">1</RightAlignedCell>,
|
||||
]}
|
||||
rows={data.map((item, i) => [
|
||||
item.scoreName,
|
||||
<RightAlignedCell key={i}>
|
||||
<RightAlignedCell key={`${i}-count`}>
|
||||
{compactNumberFormatter(item.countScoreId as number)}
|
||||
</RightAlignedCell>,
|
||||
<RightAlignedCell key={i}>
|
||||
<RightAlignedCell key={`${i}-average`}>
|
||||
{compactNumberFormatter(item.avgValue)}
|
||||
</RightAlignedCell>,
|
||||
<RightAlignedCell key={i}>
|
||||
<RightAlignedCell key={`${i}-zero`}>
|
||||
{compactNumberFormatter(item.zeroValueScore as number)}
|
||||
</RightAlignedCell>,
|
||||
<RightAlignedCell key={i}>
|
||||
<RightAlignedCell key={`${i}-one`}>
|
||||
{compactNumberFormatter(item.oneValueScore)}
|
||||
</RightAlignedCell>,
|
||||
])}
|
||||
|
||||
@@ -114,15 +114,17 @@ export const UserChart = ({
|
||||
|
||||
const maxNumberOfEntries = { collapsed: 5, expanded: 20 } as const;
|
||||
|
||||
const localUsdFormatter = (value: number) => usdFormatter(value, 2, 2);
|
||||
|
||||
const data = [
|
||||
{
|
||||
tabTitle: "Token cost",
|
||||
data: isExpanded
|
||||
? transformedCost.slice(0, maxNumberOfEntries.expanded)
|
||||
: transformedCost.slice(0, maxNumberOfEntries.collapsed),
|
||||
totalMetric: totalCost ? usdFormatter(totalCost) : usdFormatter(0),
|
||||
totalMetric: totalCost ? usdFormatter(totalCost, 2, 2) : usdFormatter(0),
|
||||
metricDescription: "Total cost",
|
||||
formatter: usdFormatter,
|
||||
formatter: localUsdFormatter,
|
||||
},
|
||||
{
|
||||
tabTitle: "Count of Traces",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ExpandListButton } from "@/src/features/dashboard/components/cards/ChevronButton";
|
||||
import { NoData } from "../NoData";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { NoData } from "../NoData";
|
||||
|
||||
type TableHeaders = ReactNode[];
|
||||
type TableRows = ReactNode[][];
|
||||
@@ -57,11 +57,11 @@ export const DashboardTable = ({
|
||||
: collapse.collapsed
|
||||
: undefined,
|
||||
)
|
||||
.map((row) => (
|
||||
<tr key={"1"}>
|
||||
{row.map((cell, i) => (
|
||||
.map((row, i) => (
|
||||
<tr key={i}>
|
||||
{row.map((cell, j) => (
|
||||
<td
|
||||
key={i}
|
||||
key={j}
|
||||
className="whitespace-nowrap py-2 pl-3 pr-2 text-xs text-gray-500 sm:pl-0"
|
||||
>
|
||||
{cell}
|
||||
|
||||
@@ -15,6 +15,8 @@ import { Button } from "@/src/components/ui/button";
|
||||
import { DatasetStatus, type DatasetItem } from "@prisma/client";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { type LangfuseColumnDef } from "@/src/components/table/types";
|
||||
import { useDetailPageLists } from "@/src/features/navigate-detail-pages/context";
|
||||
import { useEffect } from "react";
|
||||
|
||||
type RowData = {
|
||||
id: string;
|
||||
@@ -31,12 +33,23 @@ export function DatasetItemsTable({
|
||||
projectId: string;
|
||||
datasetId: string;
|
||||
}) {
|
||||
const { setDetailPageList } = useDetailPageLists();
|
||||
const utils = api.useUtils();
|
||||
const items = api.datasets.itemsByDatasetId.useQuery({
|
||||
projectId,
|
||||
datasetId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (items.isSuccess) {
|
||||
setDetailPageList(
|
||||
"datasetItems",
|
||||
items.data.map((t) => t.id),
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [items.isSuccess, items.data]);
|
||||
|
||||
const mutUpdate = api.datasets.updateDatasetItem.useMutation({
|
||||
onSuccess: () => utils.datasets.invalidate(),
|
||||
});
|
||||
|
||||
@@ -2,9 +2,11 @@ import { GroupedScoreBadges } from "@/src/components/grouped-score-badge";
|
||||
import { DataTable } from "@/src/components/table/data-table";
|
||||
import TableLink from "@/src/components/table/table-link";
|
||||
import { type LangfuseColumnDef } from "@/src/components/table/types";
|
||||
import { useDetailPageLists } from "@/src/features/navigate-detail-pages/context";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { formatInterval } from "@/src/utils/dates";
|
||||
import { type RouterOutput } from "@/src/utils/types";
|
||||
import { useEffect } from "react";
|
||||
|
||||
type RowData = {
|
||||
key: {
|
||||
@@ -25,7 +27,16 @@ export function DatasetRunsTable(props: {
|
||||
projectId: props.projectId,
|
||||
datasetId: props.datasetId,
|
||||
});
|
||||
|
||||
const { setDetailPageList } = useDetailPageLists();
|
||||
useEffect(() => {
|
||||
if (runs.isSuccess) {
|
||||
setDetailPageList(
|
||||
"datasetRuns",
|
||||
runs.data.map((t) => t.id),
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [runs.isSuccess, runs.data]);
|
||||
const columns: LangfuseColumnDef<RowData>[] = [
|
||||
{
|
||||
accessorKey: "key",
|
||||
|
||||
@@ -10,18 +10,17 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/src/components/ui/dropdown-menu";
|
||||
import { NewDatasetButton } from "@/src/features/datasets/components/NewDatasetButton";
|
||||
import { useDetailPageLists } from "@/src/features/navigate-detail-pages/context";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { type RouterOutput } from "@/src/utils/types";
|
||||
import { DatasetStatus } from "@prisma/client";
|
||||
import { Archive, MoreVertical } from "lucide-react";
|
||||
import { MoreVertical, Trash } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
type RowData = {
|
||||
key: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
status: DatasetStatus;
|
||||
createdAt: string;
|
||||
lastRunAt?: string;
|
||||
countItems: number;
|
||||
@@ -29,13 +28,23 @@ type RowData = {
|
||||
};
|
||||
|
||||
export function DatasetsTable(props: { projectId: string }) {
|
||||
const { setDetailPageList } = useDetailPageLists();
|
||||
const utils = api.useUtils();
|
||||
const datasets = api.datasets.allDatasets.useQuery({
|
||||
projectId: props.projectId,
|
||||
});
|
||||
const mutArchive = api.datasets.updateDataset.useMutation({
|
||||
const mutDelete = api.datasets.deleteDataset.useMutation({
|
||||
onSuccess: () => utils.datasets.invalidate(),
|
||||
});
|
||||
useEffect(() => {
|
||||
if (datasets.isSuccess) {
|
||||
setDetailPageList(
|
||||
"datasets",
|
||||
datasets.data.map((t) => t.id),
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [datasets.isSuccess, datasets.data]);
|
||||
|
||||
const columns: LangfuseColumnDef<RowData>[] = [
|
||||
{
|
||||
@@ -52,26 +61,6 @@ export function DatasetsTable(props: { projectId: string }) {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => {
|
||||
const status: DatasetStatus = row.getValue("status");
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full",
|
||||
status === DatasetStatus.ACTIVE
|
||||
? "bg-green-600"
|
||||
: "bg-yellow-600",
|
||||
)}
|
||||
/>
|
||||
<span>{status}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "countItems",
|
||||
header: "Items",
|
||||
@@ -92,7 +81,6 @@ export function DatasetsTable(props: { projectId: string }) {
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const key: RowData["key"] = row.getValue("key");
|
||||
const status: DatasetStatus = row.getValue("status");
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -105,18 +93,14 @@ export function DatasetsTable(props: { projectId: string }) {
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
mutArchive.mutate({
|
||||
mutDelete.mutate({
|
||||
projectId: props.projectId,
|
||||
datasetId: key.id,
|
||||
status:
|
||||
status === DatasetStatus.ARCHIVED
|
||||
? DatasetStatus.ACTIVE
|
||||
: DatasetStatus.ARCHIVED,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Archive className="mr-2 h-4 w-4" />
|
||||
{status === DatasetStatus.ARCHIVED ? "Unarchive" : "Archive"}
|
||||
<Trash className="mr-2 h-4 w-4" />
|
||||
Delete permanently
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -132,7 +116,6 @@ export function DatasetsTable(props: { projectId: string }) {
|
||||
key: { id: item.id, name: item.name },
|
||||
createdAt: item.createdAt.toISOString(),
|
||||
lastRunAt: item.lastRunAt?.toISOString() ?? "",
|
||||
status: item.status,
|
||||
countItems: item.countDatasetItems,
|
||||
countRuns: item.countDatasetRuns,
|
||||
};
|
||||
|
||||
@@ -29,7 +29,6 @@ export const datasetRouter = createTRPCRouter({
|
||||
d.name,
|
||||
d.created_at "createdAt",
|
||||
d.updated_at "updatedAt",
|
||||
d.status,
|
||||
count(distinct di.id)::int "countDatasetItems",
|
||||
count(distinct dr.id)::int "countDatasetRuns",
|
||||
max(dr.created_at) "lastRunAt"
|
||||
@@ -37,8 +36,8 @@ export const datasetRouter = createTRPCRouter({
|
||||
LEFT JOIN dataset_items di ON di.dataset_id = d.id
|
||||
LEFT JOIN dataset_runs dr ON dr.dataset_id = d.id
|
||||
WHERE d.project_id = ${input.projectId}
|
||||
GROUP BY 1,2,3,4,5
|
||||
ORDER BY d.status ASC, d.created_at DESC
|
||||
GROUP BY 1,2,3,4
|
||||
ORDER BY d.created_at DESC
|
||||
`);
|
||||
}),
|
||||
byId: protectedProjectProcedure
|
||||
@@ -243,30 +242,19 @@ export const datasetRouter = createTRPCRouter({
|
||||
},
|
||||
});
|
||||
}),
|
||||
updateDataset: protectedProjectProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
datasetId: z.string(),
|
||||
status: z.enum(["ACTIVE", "ARCHIVED"]).optional(),
|
||||
name: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
deleteDataset: protectedProjectProcedure
|
||||
.input(z.object({ projectId: z.string(), datasetId: z.string() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
throwIfNoAccess({
|
||||
session: ctx.session,
|
||||
projectId: input.projectId,
|
||||
scope: "datasets:CUD",
|
||||
});
|
||||
return ctx.prisma.dataset.update({
|
||||
return ctx.prisma.dataset.delete({
|
||||
where: {
|
||||
id: input.datasetId,
|
||||
projectId: input.projectId,
|
||||
},
|
||||
data: {
|
||||
status: input.status,
|
||||
name: input.name,
|
||||
},
|
||||
});
|
||||
}),
|
||||
createDatasetItem: protectedProjectProcedure
|
||||
|
||||
@@ -24,29 +24,30 @@ export function tokenCount(p: {
|
||||
model: string;
|
||||
text: unknown;
|
||||
}): number | undefined {
|
||||
const model = cleanModelString(p.model);
|
||||
if (
|
||||
p.text === null ||
|
||||
p.text === undefined ||
|
||||
(Array.isArray(p.text) && p.text.length === 0)
|
||||
) {
|
||||
return undefined;
|
||||
} else if (isOpenAiModel(p.model)) {
|
||||
} else if (isOpenAiModel(model)) {
|
||||
return isChatMessageArray(p.text)
|
||||
? openAiChatTokenCount({
|
||||
model: p.model,
|
||||
model: model,
|
||||
messages: p.text,
|
||||
})
|
||||
: isString(p.text)
|
||||
? openAiStringTokenCount({ model: p.model, text: p.text })
|
||||
? openAiStringTokenCount({ model: model, text: p.text })
|
||||
: openAiStringTokenCount({
|
||||
model: p.model,
|
||||
model: model,
|
||||
text: JSON.stringify(p.text),
|
||||
});
|
||||
} else if (isClaudeModel(p.model)) {
|
||||
} else if (isClaudeModel(model)) {
|
||||
return isString(p.text)
|
||||
? claudeStringTokenCount({ model: p.model, text: p.text })
|
||||
? claudeStringTokenCount({ model: model, text: p.text })
|
||||
: claudeStringTokenCount({
|
||||
model: p.model,
|
||||
model: model,
|
||||
text: JSON.stringify(p.text),
|
||||
});
|
||||
} else {
|
||||
@@ -56,13 +57,6 @@ export function tokenCount(p: {
|
||||
}
|
||||
|
||||
function openAiChatTokenCount(params: TokenCalculationParams) {
|
||||
let encoding: Tiktoken;
|
||||
try {
|
||||
encoding = encoding_for_model(params.model);
|
||||
} catch (KeyError) {
|
||||
console.log("Warning: model not found. Using cl100k_base encoding.");
|
||||
encoding = get_encoding("cl100k_base");
|
||||
}
|
||||
let tokens_per_message = 0;
|
||||
let tokens_per_name = 0;
|
||||
|
||||
@@ -99,7 +93,7 @@ function openAiChatTokenCount(params: TokenCalculationParams) {
|
||||
Object.keys(message).forEach((key) => {
|
||||
const value = message[key as keyof typeof message];
|
||||
if (value) {
|
||||
num_tokens += encoding.encode(value).length;
|
||||
num_tokens += getTokensByModel(params.model, value);
|
||||
}
|
||||
if (key === "name") {
|
||||
num_tokens += tokens_per_name;
|
||||
@@ -107,6 +101,7 @@ function openAiChatTokenCount(params: TokenCalculationParams) {
|
||||
});
|
||||
});
|
||||
num_tokens += 3; // every reply is primed with <| start |> assistant <| message |>
|
||||
|
||||
return num_tokens;
|
||||
}
|
||||
|
||||
@@ -131,10 +126,29 @@ const claudeStringTokenCount = (p: { model: string; text: string }) => {
|
||||
const getTokens = (name: TiktokenEncoding, text: string) => {
|
||||
const encoding = get_encoding(name);
|
||||
const tokens = encoding.encode(text);
|
||||
// https://github.com/dqbd/tiktoken/issues/72
|
||||
// we need to ensure to deallocate memory from the encoder
|
||||
encoding.free();
|
||||
return tokens.length;
|
||||
};
|
||||
|
||||
const getTokensByModel = (model: TiktokenModel, text: string) => {
|
||||
let encoding: Tiktoken;
|
||||
try {
|
||||
encoding = encoding_for_model(model);
|
||||
} catch (KeyError) {
|
||||
console.log("Warning: model not found. Using cl100k_base encoding.");
|
||||
encoding = get_encoding("cl100k_base");
|
||||
}
|
||||
|
||||
const length = encoding.encode(text).length;
|
||||
|
||||
// https://github.com/dqbd/tiktoken/issues/72
|
||||
// we need to ensure to deallocate memory from the encoder
|
||||
encoding.free();
|
||||
return length;
|
||||
};
|
||||
|
||||
function isString(value: unknown): value is string {
|
||||
return typeof value === "string";
|
||||
}
|
||||
@@ -223,7 +237,8 @@ export function calculateTokenCost(
|
||||
completionTokens: Decimal;
|
||||
},
|
||||
): Decimal | undefined {
|
||||
const pricing = pricingList.filter((p) => p.modelName === input.model);
|
||||
const model = cleanModelString(input.model);
|
||||
const pricing = pricingList.filter((p) => p.modelName === model);
|
||||
|
||||
if (pricing.length === 0) {
|
||||
console.log("no pricing found for model", input.model);
|
||||
@@ -298,3 +313,6 @@ const calculateValue = (
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const cleanModelString = (model: string) =>
|
||||
model.toLowerCase().replaceAll("gpt-35", "gpt-3.5");
|
||||
|
||||
@@ -50,7 +50,7 @@ export const DetailPageNav = (props: {
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [previousPageId, nextPageId, router, props]);
|
||||
|
||||
if (ids.length > 0)
|
||||
if (ids.length > 1)
|
||||
return (
|
||||
<div>
|
||||
<Tooltip>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
import Script from "next/script";
|
||||
|
||||
export function PricingPage(props: { className?: string }) {
|
||||
const session = useSession();
|
||||
return (
|
||||
<>
|
||||
<Script async src="https://js.stripe.com/v3/pricing-table.js" />
|
||||
<div className={props.className}>
|
||||
<stripe-pricing-table
|
||||
client-reference-id={session.data?.user?.id}
|
||||
pricing-table-id="prctbl_1OWhugAWilt2EAVVKHi8TEw7"
|
||||
publishable-key="pk_live_51MPW00AWilt2EAVVFWfPTQhgmLA0EeacLSzAs6e3vECCcMBvwcMse81XgXO6k1bdBHbPBdpOmrXE8P1gBrxE7yhH00RPHQ8SyG"
|
||||
></stripe-pricing-table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -69,7 +69,7 @@ export default function RenameProject(props: { projectId: string }) {
|
||||
<b>{form.watch().newName}</b>".
|
||||
</p>
|
||||
) : (
|
||||
<p className="mb-4 text-sm text-gray-700">
|
||||
<p className="mb-4 text-sm text-gray-700" data-testid="project-name">
|
||||
Your Project is currently named "<b>{projectName}</b>
|
||||
".
|
||||
</p>
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
} from "@/src/features/public-api/lib/apiKeys";
|
||||
import { type ApiAccessScope } from "@/src/features/public-api/server/types";
|
||||
import { prisma } from "@/src/server/db";
|
||||
import { instrumentAsync } from "@/src/utils/instrumentation";
|
||||
|
||||
export type AuthHeaderVerificationResult =
|
||||
| {
|
||||
@@ -20,93 +19,85 @@ export type AuthHeaderVerificationResult =
|
||||
export async function verifyAuthHeaderAndReturnScope(
|
||||
authHeader: string | undefined,
|
||||
): Promise<AuthHeaderVerificationResult> {
|
||||
return instrumentAsync(
|
||||
{ name: "verifyAuthHeaderAndReturnScope" },
|
||||
async () => {
|
||||
if (!authHeader) {
|
||||
console.error("No authorization header");
|
||||
return {
|
||||
validKey: false,
|
||||
error: "No authorization header",
|
||||
};
|
||||
if (!authHeader) {
|
||||
console.error("No authorization header");
|
||||
return {
|
||||
validKey: false,
|
||||
error: "No authorization header",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Basic auth, full scope, needs secret key and public key
|
||||
if (authHeader.startsWith("Basic ")) {
|
||||
const { username: publicKey, password: secretKey } =
|
||||
extractBasicAuthCredentials(authHeader);
|
||||
|
||||
const salt = env.SALT;
|
||||
const hashFromProvidedKey = createShaHash(secretKey, salt);
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { fastHashedSecretKey: hashFromProvidedKey },
|
||||
});
|
||||
let projectId = apiKey?.projectId;
|
||||
|
||||
if (!apiKey || !apiKey.fastHashedSecretKey) {
|
||||
const dbKey = await findDbKeyOrThrow(publicKey);
|
||||
const isValid = await verifySecretKey(secretKey, dbKey.hashedSecretKey);
|
||||
|
||||
if (!isValid) {
|
||||
console.log("Old key is invalid", publicKey);
|
||||
throw new Error("Invalid credentials");
|
||||
}
|
||||
|
||||
const shaKey = createShaHash(secretKey, salt);
|
||||
|
||||
await prisma.apiKey.update({
|
||||
where: { publicKey },
|
||||
data: {
|
||||
fastHashedSecretKey: shaKey,
|
||||
},
|
||||
});
|
||||
projectId = dbKey.projectId;
|
||||
}
|
||||
|
||||
try {
|
||||
// Basic auth, full scope, needs secret key and public key
|
||||
if (authHeader.startsWith("Basic ")) {
|
||||
const { username: publicKey, password: secretKey } =
|
||||
extractBasicAuthCredentials(authHeader);
|
||||
|
||||
const salt = env.SALT;
|
||||
const hashFromProvidedKey = createShaHash(secretKey, salt);
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { fastHashedSecretKey: hashFromProvidedKey },
|
||||
});
|
||||
let projectId = apiKey?.projectId;
|
||||
|
||||
if (!apiKey || !apiKey.fastHashedSecretKey) {
|
||||
const dbKey = await findDbKeyOrThrow(publicKey);
|
||||
const isValid = await verifySecretKey(
|
||||
secretKey,
|
||||
dbKey.hashedSecretKey,
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
console.log("Old key is invalid", publicKey);
|
||||
throw new Error("Invalid credentials");
|
||||
}
|
||||
|
||||
const shaKey = createShaHash(secretKey, salt);
|
||||
|
||||
await prisma.apiKey.update({
|
||||
where: { publicKey },
|
||||
data: {
|
||||
fastHashedSecretKey: shaKey,
|
||||
},
|
||||
});
|
||||
projectId = dbKey.projectId;
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
console.log("No project id found for key", publicKey);
|
||||
throw new Error("Invalid credentials");
|
||||
}
|
||||
|
||||
return {
|
||||
validKey: true,
|
||||
scope: {
|
||||
projectId: projectId,
|
||||
accessLevel: "all",
|
||||
},
|
||||
};
|
||||
}
|
||||
// Bearer auth, limited scope, only needs public key
|
||||
if (authHeader.startsWith("Bearer ")) {
|
||||
const publicKey = authHeader.replace("Bearer ", "");
|
||||
|
||||
const dbKey = await findDbKeyOrThrow(publicKey);
|
||||
|
||||
return {
|
||||
validKey: true,
|
||||
scope: {
|
||||
projectId: dbKey.projectId,
|
||||
accessLevel: "scores",
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error("Error verifying auth header: ", error);
|
||||
return {
|
||||
validKey: false,
|
||||
error: error instanceof Error ? error.message : "Authorization error",
|
||||
};
|
||||
if (!projectId) {
|
||||
console.log("No project id found for key", publicKey);
|
||||
throw new Error("Invalid credentials");
|
||||
}
|
||||
|
||||
return {
|
||||
validKey: false,
|
||||
error: "Invalid authorization header",
|
||||
validKey: true,
|
||||
scope: {
|
||||
projectId: projectId,
|
||||
accessLevel: "all",
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
// Bearer auth, limited scope, only needs public key
|
||||
if (authHeader.startsWith("Bearer ")) {
|
||||
const publicKey = authHeader.replace("Bearer ", "");
|
||||
|
||||
const dbKey = await findDbKeyOrThrow(publicKey);
|
||||
|
||||
return {
|
||||
validKey: true,
|
||||
scope: {
|
||||
projectId: dbKey.projectId,
|
||||
accessLevel: "scores",
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error("Error verifying auth header: ", error);
|
||||
return {
|
||||
validKey: false,
|
||||
error: error instanceof Error ? error.message : "Authorization error",
|
||||
};
|
||||
}
|
||||
return {
|
||||
validKey: false,
|
||||
error: "Invalid authorization header",
|
||||
};
|
||||
}
|
||||
|
||||
function extractBasicAuthCredentials(basicAuthHeader: string): {
|
||||
|
||||
@@ -96,7 +96,9 @@ export const CreateGenerationBody = CreateSpanBody.extend({
|
||||
modelParameters: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.union([z.string(), z.number(), z.boolean()]).nullish(),
|
||||
z
|
||||
.union([z.string(), z.number(), z.boolean(), z.array(z.string())])
|
||||
.nullish(),
|
||||
)
|
||||
.nullish(),
|
||||
usage: usage,
|
||||
@@ -116,7 +118,9 @@ export const UpdateGenerationBody = UpdateSpanBody.extend({
|
||||
modelParameters: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.union([z.string(), z.number(), z.boolean()]).nullish(),
|
||||
z
|
||||
.union([z.string(), z.number(), z.boolean(), z.array(z.string())])
|
||||
.nullish(),
|
||||
)
|
||||
.nullish(),
|
||||
usage: usage,
|
||||
|
||||
@@ -11,6 +11,7 @@ const scopes = [
|
||||
|
||||
"objects:publish",
|
||||
"objects:bookmark",
|
||||
"objects:tag",
|
||||
|
||||
"traces:delete",
|
||||
|
||||
@@ -39,6 +40,7 @@ export const roleAccessRights: Record<MembershipRole, Scope[]> = {
|
||||
"apiKeys:delete",
|
||||
"objects:publish",
|
||||
"objects:bookmark",
|
||||
"objects:tag",
|
||||
"traces:delete",
|
||||
"scores:CUD",
|
||||
"project:delete",
|
||||
@@ -58,6 +60,7 @@ export const roleAccessRights: Record<MembershipRole, Scope[]> = {
|
||||
"apiKeys:delete",
|
||||
"objects:publish",
|
||||
"objects:bookmark",
|
||||
"objects:tag",
|
||||
"traces:delete",
|
||||
"scores:CUD",
|
||||
"datasets:CUD",
|
||||
@@ -68,6 +71,7 @@ export const roleAccessRights: Record<MembershipRole, Scope[]> = {
|
||||
"members:read",
|
||||
"objects:publish",
|
||||
"objects:bookmark",
|
||||
"objects:tag",
|
||||
"scores:CUD",
|
||||
"datasets:CUD",
|
||||
"prompts:CUD",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import React from "react";
|
||||
|
||||
export const TagButton: React.FC<{ tag: string; loading: boolean }> =
|
||||
React.memo(({ tag, loading }) => (
|
||||
<Button
|
||||
key={tag}
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
className="text-xs font-semibold hover:bg-white"
|
||||
loading={loading}
|
||||
>
|
||||
{tag}
|
||||
</Button>
|
||||
));
|
||||
TagButton.displayName = "TagButton";
|
||||
@@ -0,0 +1,38 @@
|
||||
import { CommandItem } from "@/src/components/ui/command";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { Check } from "lucide-react";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
|
||||
type TagCommandItemProps = {
|
||||
value: string;
|
||||
selectedTags: string[];
|
||||
setSelectedTags: (value: string[]) => void;
|
||||
};
|
||||
|
||||
const TagCommandItem = ({
|
||||
value,
|
||||
selectedTags,
|
||||
setSelectedTags,
|
||||
}: TagCommandItemProps) => {
|
||||
return (
|
||||
<CommandItem
|
||||
key={value}
|
||||
onSelect={() => {
|
||||
setSelectedTags([...selectedTags, value]);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary opacity-50 [&_svg]:invisible",
|
||||
)}
|
||||
>
|
||||
<Check className={cn("h-4 w-4")} />
|
||||
</div>
|
||||
<Button variant="secondary" size="xs">
|
||||
{value}
|
||||
</Button>
|
||||
</CommandItem>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagCommandItem;
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from "react";
|
||||
import { CommandItem } from "cmdk";
|
||||
|
||||
type TagItemCreateProps = {
|
||||
inputValue: string;
|
||||
options: string[];
|
||||
onSelect: () => void;
|
||||
};
|
||||
|
||||
const TagItemCreate = ({
|
||||
inputValue,
|
||||
options,
|
||||
onSelect,
|
||||
}: TagItemCreateProps) => {
|
||||
const hasNoOption = !options
|
||||
.map((value) => value.toLowerCase())
|
||||
.includes(inputValue.toLowerCase());
|
||||
|
||||
const render = inputValue !== "" && hasNoOption;
|
||||
|
||||
if (!render) return null;
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={inputValue}
|
||||
value={inputValue}
|
||||
className="flex cursor-pointer items-center rounded-sm px-3 py-2 text-sm text-muted-foreground hover:bg-secondary/80"
|
||||
onSelect={onSelect}
|
||||
>
|
||||
Create new tag: "{inputValue}"
|
||||
</CommandItem>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagItemCreate;
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from "react";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { X } from "lucide-react";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
|
||||
type TagInputProps = React.ComponentPropsWithoutRef<
|
||||
typeof CommandPrimitive.Input
|
||||
> & {
|
||||
selectedTags: string[];
|
||||
setSelectedTags: (tags: string[]) => void;
|
||||
};
|
||||
|
||||
export const TagInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
TagInputProps
|
||||
>(({ className, selectedTags, setSelectedTags, ...props }, ref) => (
|
||||
<div
|
||||
className="flex flex-wrap items-center overflow-auto rounded-lg border px-2"
|
||||
cmdk-input-wrapper=""
|
||||
>
|
||||
{selectedTags.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 pt-2">
|
||||
{selectedTags.map((tag: string) => (
|
||||
<Button
|
||||
key={tag}
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
const newTags = selectedTags.filter((t) => t !== tag);
|
||||
setSelectedTags(newTags);
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
<X className="ml-1 h-3 w-3" />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-11 w-full rounded-md border-transparent bg-transparent px-1 text-sm outline-none placeholder:text-slate-500 focus:border-0 focus:border-none focus:border-transparent focus:ring-0 disabled:cursor-not-allowed disabled:opacity-50 dark:placeholder:text-slate-400 ",
|
||||
className,
|
||||
)}
|
||||
autoFocus
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
|
||||
TagInput.displayName = CommandPrimitive.Input.displayName;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { TagButton } from "@/src/features/tag/components/TagButton";
|
||||
|
||||
type TagListProps = {
|
||||
selectedTags: string[];
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
const TagList = ({ selectedTags, isLoading }: TagListProps) => {
|
||||
return selectedTags.length > 0 ? (
|
||||
selectedTags.map((tag) => (
|
||||
<TagButton key={tag} tag={tag} loading={isLoading} />
|
||||
))
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="text-xs font-bold opacity-0 hover:bg-white hover:opacity-100"
|
||||
>
|
||||
Add tag
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagList;
|
||||
@@ -0,0 +1,88 @@
|
||||
import TagCommandItem from "@/src/features/tag/components/TagCommandItem";
|
||||
import TagCreateItem from "@/src/features/tag/components/TagCreateItem";
|
||||
import { TagInput } from "@/src/features/tag/components/TagInput";
|
||||
import TagList from "@/src/features/tag/components/TagList";
|
||||
import useTagManager from "@/src/features/tag/hooks/useTagManager";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "@/src/components/ui/popover";
|
||||
import { Command, CommandList, CommandGroup } from "cmdk";
|
||||
|
||||
type TagManagerProps = {
|
||||
tags: string[];
|
||||
allTags: string[];
|
||||
hasAccess: boolean;
|
||||
isLoading: boolean;
|
||||
mutateTags: (value: string[]) => void;
|
||||
};
|
||||
|
||||
const TagManager = ({
|
||||
tags,
|
||||
allTags,
|
||||
hasAccess,
|
||||
isLoading,
|
||||
mutateTags,
|
||||
}: TagManagerProps) => {
|
||||
const {
|
||||
selectedTags,
|
||||
inputValue,
|
||||
availableTags,
|
||||
handleItemCreate,
|
||||
setInputValue,
|
||||
setSelectedTags,
|
||||
} = useTagManager({ initialTags: tags, allTags });
|
||||
|
||||
const handlePopoverChange = (open: boolean) => {
|
||||
if (!open && selectedTags !== tags) {
|
||||
setInputValue("");
|
||||
mutateTags(selectedTags);
|
||||
}
|
||||
};
|
||||
|
||||
if (!hasAccess) {
|
||||
return <TagList selectedTags={selectedTags} isLoading={isLoading} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={(open) => handlePopoverChange(open)}>
|
||||
<PopoverTrigger className="select-none" asChild>
|
||||
<div className="flex flex-wrap gap-x-2 gap-y-1">
|
||||
<TagList selectedTags={selectedTags} isLoading={isLoading} />
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<Command>
|
||||
<TagInput
|
||||
value={inputValue}
|
||||
onValueChange={setInputValue}
|
||||
selectedTags={selectedTags}
|
||||
setSelectedTags={setSelectedTags}
|
||||
/>
|
||||
<CommandList
|
||||
className={availableTags.length > 0 ? "mt-2" : undefined}
|
||||
>
|
||||
<CommandGroup>
|
||||
{availableTags.slice(0, 5).map((value: string) => (
|
||||
<TagCommandItem
|
||||
key={value}
|
||||
value={value}
|
||||
selectedTags={selectedTags}
|
||||
setSelectedTags={setSelectedTags}
|
||||
/>
|
||||
))}
|
||||
<TagCreateItem
|
||||
onSelect={handleItemCreate}
|
||||
inputValue={inputValue}
|
||||
options={allTags}
|
||||
/>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagManager;
|
||||
@@ -0,0 +1,74 @@
|
||||
import React, { useState } from "react";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { useHasAccess } from "@/src/features/rbac/utils/checkAccess";
|
||||
import { type RouterOutput } from "@/src/utils/types";
|
||||
import TagManager from "@/src/features/tag/components/TagMananger";
|
||||
|
||||
type TagTraceDetailsPopoverProps = {
|
||||
tags: string[];
|
||||
availableTags: string[];
|
||||
projectId: string;
|
||||
traceId: string;
|
||||
};
|
||||
|
||||
export function TagTraceDetailsPopover({
|
||||
tags,
|
||||
availableTags,
|
||||
projectId,
|
||||
traceId,
|
||||
}: TagTraceDetailsPopoverProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const hasAccess = useHasAccess({ projectId, scope: "objects:tag" });
|
||||
|
||||
const utils = api.useUtils();
|
||||
const mutTags = api.traces.updateTags.useMutation({
|
||||
onMutate: async () => {
|
||||
await utils.traces.byId.cancel();
|
||||
setIsLoading(true);
|
||||
// Snapshot the previous value
|
||||
const prev = utils.traces.byId.getData({ traceId });
|
||||
|
||||
return { prev };
|
||||
},
|
||||
onError: (err, _newTags, context) => {
|
||||
setIsLoading(false);
|
||||
// Rollback to the previous value if mutation fails
|
||||
utils.traces.byId.setData({ traceId }, context?.prev);
|
||||
},
|
||||
onSettled: (data, error, { traceId, tags }) => {
|
||||
setIsLoading(false);
|
||||
utils.traces.byId.setData(
|
||||
{ traceId },
|
||||
(oldQueryData: RouterOutput["traces"]["byId"] | undefined) => {
|
||||
return oldQueryData
|
||||
? {
|
||||
...oldQueryData,
|
||||
tags: tags,
|
||||
}
|
||||
: undefined;
|
||||
},
|
||||
);
|
||||
void utils.traces.all.invalidate();
|
||||
void utils.traces.byId.invalidate();
|
||||
void utils.traces.filterOptions.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
function mutateTags(newTags: string[]) {
|
||||
void mutTags.mutateAsync({
|
||||
projectId,
|
||||
traceId,
|
||||
tags: newTags,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<TagManager
|
||||
tags={tags}
|
||||
allTags={availableTags}
|
||||
hasAccess={hasAccess}
|
||||
isLoading={isLoading}
|
||||
mutateTags={mutateTags}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import React, { useState } from "react";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { useHasAccess } from "@/src/features/rbac/utils/checkAccess";
|
||||
import { type RouterOutput, type RouterInput } from "@/src/utils/types";
|
||||
import TagManager from "@/src/features/tag/components/TagMananger";
|
||||
|
||||
type TagTracePopverProps = {
|
||||
tags: string[];
|
||||
availableTags: string[];
|
||||
projectId: string;
|
||||
traceId: string;
|
||||
tracesFilter: RouterInput["traces"]["all"];
|
||||
};
|
||||
|
||||
export function TagTracePopver({
|
||||
tags,
|
||||
availableTags,
|
||||
projectId,
|
||||
traceId,
|
||||
tracesFilter,
|
||||
}: TagTracePopverProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const hasAccess = useHasAccess({ projectId, scope: "objects:tag" });
|
||||
|
||||
const utils = api.useUtils();
|
||||
const mutTags = api.traces.updateTags.useMutation({
|
||||
onMutate: async () => {
|
||||
await utils.traces.all.cancel();
|
||||
setIsLoading(true);
|
||||
const prevTrace = utils.traces.all.getData(tracesFilter);
|
||||
return { prevTrace };
|
||||
},
|
||||
onError: (err, _newTags, context) => {
|
||||
utils.traces.all.setData(tracesFilter, context?.prevTrace);
|
||||
console.log("error", err);
|
||||
setIsLoading(false);
|
||||
},
|
||||
onSettled: (data, error, { traceId, tags }) => {
|
||||
utils.traces.all.setData(
|
||||
tracesFilter,
|
||||
(oldQueryData: RouterOutput["traces"]["all"] | undefined) => {
|
||||
return oldQueryData
|
||||
? {
|
||||
totalCount: oldQueryData.totalCount,
|
||||
traces: oldQueryData.traces.map((trace) => {
|
||||
return trace.id === traceId ? { ...trace, tags } : trace;
|
||||
}),
|
||||
}
|
||||
: { totalCount: undefined, traces: [] };
|
||||
},
|
||||
);
|
||||
setIsLoading(false);
|
||||
},
|
||||
});
|
||||
|
||||
function mutateTags(newTags: string[]) {
|
||||
void mutTags.mutateAsync({
|
||||
projectId,
|
||||
traceId,
|
||||
tags: newTags,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<TagManager
|
||||
tags={tags}
|
||||
allTags={availableTags}
|
||||
hasAccess={hasAccess}
|
||||
isLoading={isLoading}
|
||||
mutateTags={mutateTags}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useState, useMemo } from "react";
|
||||
|
||||
type UseTagManagerProps = {
|
||||
initialTags: string[];
|
||||
allTags: string[];
|
||||
};
|
||||
|
||||
function useTagManager({ initialTags, allTags }: UseTagManagerProps) {
|
||||
const [selectedTags, setSelectedTags] = useState(initialTags);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
|
||||
const availableTags = useMemo(
|
||||
() => allTags.filter((value) => !selectedTags.includes(value)),
|
||||
[allTags, selectedTags],
|
||||
);
|
||||
const handleItemCreate = () => {
|
||||
setSelectedTags([...selectedTags, inputValue]);
|
||||
availableTags.push(inputValue);
|
||||
setInputValue("");
|
||||
};
|
||||
|
||||
return {
|
||||
selectedTags,
|
||||
inputValue,
|
||||
availableTags,
|
||||
handleItemCreate,
|
||||
setInputValue,
|
||||
setSelectedTags,
|
||||
};
|
||||
}
|
||||
|
||||
export default useTagManager;
|
||||
@@ -4,12 +4,16 @@ import { Button } from "@/src/components/ui/button";
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { Card, Flex, MarkerBar, Metric, Text } from "@tremor/react";
|
||||
import {
|
||||
chatAvailable,
|
||||
sendUserChatMessage,
|
||||
showAgentChatMessage,
|
||||
} from "@/src/features/support-chat/chat";
|
||||
import Link from "next/link";
|
||||
import { PricingPage } from "@/src/features/pricing-page/PricingPage";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTrigger,
|
||||
} from "@/src/components/ui/dialog";
|
||||
import Header from "@/src/components/layouts/header";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
|
||||
export const ProjectUsageChart: React.FC<{ projectId: string }> = ({
|
||||
projectId,
|
||||
@@ -17,6 +21,7 @@ export const ProjectUsageChart: React.FC<{ projectId: string }> = ({
|
||||
const usage = api.usageMetering.currentMonth.useQuery({
|
||||
projectId,
|
||||
});
|
||||
const posthog = usePostHog();
|
||||
const project = api.projects.byId.useQuery({ projectId });
|
||||
const planLimit =
|
||||
project.data?.cloudConfig?.monthlyObservationLimit ?? 100_000;
|
||||
@@ -56,28 +61,38 @@ export const ProjectUsageChart: React.FC<{ projectId: string }> = ({
|
||||
) : null}
|
||||
</Card>
|
||||
<div className="mt-4 flex flex-row items-center gap-2">
|
||||
{chatAvailable && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className=""
|
||||
onClick={() => {
|
||||
sendUserChatMessage(
|
||||
"I want to change my plan, project: " + projectId,
|
||||
);
|
||||
// wait for 2 seconds
|
||||
setTimeout(() => {
|
||||
showAgentChatMessage(
|
||||
"We're happy to help. Which plan would you like to change to? See https://langfuse.com/#pricing for details on available plans.",
|
||||
);
|
||||
}, 2000);
|
||||
}}
|
||||
>
|
||||
Request plan change
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="secondary" asChild>
|
||||
<Link href="https://langfuse.com/pricing">View plans</Link>
|
||||
</Button>
|
||||
<Dialog
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
posthog.capture("project_settings:pricing_dialog_opened");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="secondary">Change plans</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<Header
|
||||
title="Select plan"
|
||||
level="h3"
|
||||
actionButtons={
|
||||
<Button variant="secondary" asChild>
|
||||
<Link href="https://langfuse.com/pricing">
|
||||
Pricing page ↗
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</DialogHeader>
|
||||
<p>
|
||||
All plans offer a 7-day free trial. For more information about the
|
||||
plans, please visit our pricing page or reach out to us via the
|
||||
chat.
|
||||
</p>
|
||||
<PricingPage className="mb-5 mt-10 " />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<div className="inline-block text-sm text-gray-500">
|
||||
Currently: {plan}
|
||||
</div>
|
||||
|
||||
@@ -47,7 +47,6 @@ export default async function handler(
|
||||
where: {
|
||||
name: name,
|
||||
projectId: authCheck.scope.projectId,
|
||||
status: "ACTIVE",
|
||||
},
|
||||
include: {
|
||||
datasetItems: {
|
||||
@@ -65,7 +64,7 @@ export default async function handler(
|
||||
|
||||
if (!dataset) {
|
||||
return res.status(404).json({
|
||||
message: "Dataset not found or not active",
|
||||
message: "Dataset not found",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,15 @@ import { ScoreProcessor } from "../../../server/api/services/EventProcessor";
|
||||
import { isNotNullOrUndefined } from "@/src/utils/types";
|
||||
import { telemetry } from "@/src/features/telemetry";
|
||||
import { jsonSchema } from "@/src/utils/zod";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: {
|
||||
sizeLimit: "3mb",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
@@ -298,6 +307,9 @@ export const handleBatchResult = (
|
||||
error: error.error.message,
|
||||
});
|
||||
} else {
|
||||
if (process.env.NEXT_PUBLIC_SENTRY_DSN) {
|
||||
Sentry.captureException(error.error);
|
||||
}
|
||||
returnedErrors.push({
|
||||
id: error.id,
|
||||
status: 500,
|
||||
|
||||
@@ -15,12 +15,24 @@ import {
|
||||
} from "@/src/features/public-api/server/ingestion-api-schema";
|
||||
import { v4 } from "uuid";
|
||||
import { telemetry } from "@/src/features/telemetry";
|
||||
import { orderByToPrismaSql } from "@/src/features/orderBy/server/orderByToPrisma";
|
||||
import { tracesTableCols } from "@/src/server/api/definitions/tracesTable";
|
||||
import { orderBy } from "@/src/server/api/interfaces/orderBy";
|
||||
|
||||
const GetTracesSchema = z.object({
|
||||
...paginationZod,
|
||||
userId: z.string().nullish(),
|
||||
name: z.string().nullish(),
|
||||
tags: z.union([z.array(z.string()), z.string()]).nullish(),
|
||||
orderBy: z
|
||||
.string() // orderBy=timestamp.asc
|
||||
.nullish()
|
||||
.transform((v) => {
|
||||
if (!v) return null;
|
||||
const [column, order] = v.split(".");
|
||||
return { column, order: order?.toUpperCase() };
|
||||
})
|
||||
.pipe(orderBy.nullish()),
|
||||
});
|
||||
|
||||
export default async function handler(
|
||||
@@ -92,6 +104,11 @@ export default async function handler(
|
||||
)}] <@ t."tags"`
|
||||
: Prisma.empty;
|
||||
|
||||
const orderByCondition = orderByToPrismaSql(
|
||||
obj.orderBy ?? null,
|
||||
tracesTableCols,
|
||||
);
|
||||
|
||||
const traces = await prisma.$queryRaw<
|
||||
Array<Trace & { observations: string[]; scores: string[] }>
|
||||
>(Prisma.sql`
|
||||
@@ -116,7 +133,7 @@ export default async function handler(
|
||||
${nameCondition}
|
||||
${tagsCondition}
|
||||
GROUP BY t.id
|
||||
ORDER BY t."timestamp" DESC
|
||||
${orderByCondition}
|
||||
LIMIT ${obj.limit} OFFSET ${skipValue}
|
||||
`);
|
||||
const totalItems = await prisma.trace.count({
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { verifyAuthHeaderAndReturnScope } from "@/src/features/public-api/server/apiAuth";
|
||||
import { cors, runMiddleware } from "@/src/features/public-api/server/cors";
|
||||
import { mapUsageOutput } from "@/src/features/public-api/server/outputSchemaConversion";
|
||||
import { prisma } from "@/src/server/db";
|
||||
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";
|
||||
import { mapUsageOutput } from "@/src/features/public-api/server/outputSchemaConversion";
|
||||
|
||||
const GetTraceSchema = z.object({
|
||||
traceId: z.string(),
|
||||
@@ -53,6 +53,13 @@ export default async function handler(
|
||||
scores: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!trace) {
|
||||
return res.status(404).json({
|
||||
message: "Trace not found within authorized project",
|
||||
});
|
||||
}
|
||||
|
||||
const observations = await prisma.observation.findMany({
|
||||
where: {
|
||||
traceId: traceId,
|
||||
@@ -60,11 +67,6 @@ export default async function handler(
|
||||
},
|
||||
});
|
||||
|
||||
if (!trace) {
|
||||
return res.status(404).json({
|
||||
message: "Trace not found within authorized project",
|
||||
});
|
||||
}
|
||||
return res
|
||||
.status(200)
|
||||
.json({ ...trace, observations: observations.map(mapUsageOutput) });
|
||||
|
||||
@@ -18,7 +18,6 @@ import type * as z from "zod";
|
||||
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";
|
||||
@@ -28,7 +27,6 @@ import { PasswordInput } from "@/src/components/ui/password-input";
|
||||
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>>({
|
||||
resolver: zodResolver(signupSchema),
|
||||
@@ -36,20 +34,12 @@ export default function SignIn({ authProviders }: PageProps) {
|
||||
name: "",
|
||||
email: "",
|
||||
password: "",
|
||||
referralSource: "",
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(values: z.infer<typeof signupSchema>) {
|
||||
try {
|
||||
setFormError(null);
|
||||
if (values.referralSource !== "") {
|
||||
posthog.capture("survey sent", {
|
||||
$survey_id: "018ade05-4d8c-0000-36b7-fc390b221590",
|
||||
$survey_name: "Referral source",
|
||||
$survey_response: values.referralSource,
|
||||
});
|
||||
}
|
||||
const res = await fetch("/api/auth/signup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -65,7 +55,9 @@ export default function SignIn({ authProviders }: PageProps) {
|
||||
await signIn<"credentials">("credentials", {
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
callbackUrl: "/?getStarted=1",
|
||||
callbackUrl: env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION
|
||||
? "/onboarding"
|
||||
: "/?getStarted=1",
|
||||
});
|
||||
} catch (err) {
|
||||
setFormError("An error occurred. Please try again.");
|
||||
@@ -142,24 +134,6 @@ export default function SignIn({ authProviders }: PageProps) {
|
||||
</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"
|
||||
|
||||
+3
-1
@@ -54,7 +54,9 @@ export default function GetStartedPage() {
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
|
||||
<Card className="flex-1">
|
||||
<CardHeader>
|
||||
<CardTitle>Create new project</CardTitle>
|
||||
<CardTitle data-testid="create-new-project-title">
|
||||
Create new project
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// This page is currently only shown to Langfuse cloud users.
|
||||
// It might be expanded to everyone in the future when it does not only ask for the referral source.
|
||||
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/src/components/ui/form";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { useRouter } from "next/router";
|
||||
import { LangfuseIcon } from "@/src/components/LangfuseLogo";
|
||||
import { Textarea } from "@/src/components/ui/textarea";
|
||||
|
||||
const referralSourceSchema = z.object({
|
||||
referralSource: z.string().optional(),
|
||||
});
|
||||
|
||||
export default function ReferralSource() {
|
||||
const posthog = usePostHog();
|
||||
const router = useRouter();
|
||||
const form = useForm<z.infer<typeof referralSourceSchema>>({
|
||||
resolver: zodResolver(referralSourceSchema),
|
||||
defaultValues: {
|
||||
referralSource: "",
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(values: z.infer<typeof referralSourceSchema>) {
|
||||
if (values.referralSource && values.referralSource !== "") {
|
||||
posthog.capture("survey sent", {
|
||||
$survey_id: "018ade05-4d8c-0000-36b7-fc390b221590",
|
||||
$survey_name: "Referral source",
|
||||
$survey_response: values.referralSource,
|
||||
});
|
||||
}
|
||||
void router.push("/?getStarted=1");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col py-6 sm:min-h-full sm:justify-center sm:px-6 sm:py-12 lg:px-8">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<LangfuseIcon className="mx-auto" />
|
||||
<h2 className="mt-4 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">
|
||||
Welcome to Langfuse
|
||||
</h2>
|
||||
</div>
|
||||
<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">
|
||||
<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="referralSource"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Where did you hear about us?{" "}
|
||||
<span className="font-normal">(optional)</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="We're curious to know how you discovered the project! Thanks for sharing."
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant={form.formState.isDirty ? "default" : "secondary"}
|
||||
className="w-full"
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
{form.formState.isDirty ? "Continue" : "Skip"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { api } from "@/src/utils/api";
|
||||
import { useRouter } from "next/router";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/src/components/ui/tabs";
|
||||
import Link from "next/link";
|
||||
import { DetailPageNav } from "@/src/features/navigate-detail-pages/DetailPageNav";
|
||||
|
||||
export default function Dataset() {
|
||||
const router = useRouter();
|
||||
@@ -23,6 +24,13 @@ export default function Dataset() {
|
||||
{ name: "Datasets", href: `/project/${projectId}/datasets` },
|
||||
{ name: dataset.data?.name ?? datasetId },
|
||||
]}
|
||||
actionButtons={
|
||||
<DetailPageNav
|
||||
currentId={datasetId}
|
||||
path={(id) => `/project/${projectId}/datasets/${id}`}
|
||||
listKey="datasets"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Tabs value="runs" className="mb-3">
|
||||
<TabsList>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRouter } from "next/router";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/src/components/ui/tabs";
|
||||
import Link from "next/link";
|
||||
import { DatasetItemsTable } from "@/src/features/datasets/components/DatasetItemsTable";
|
||||
import { DetailPageNav } from "@/src/features/navigate-detail-pages/DetailPageNav";
|
||||
|
||||
export default function DatasetItems() {
|
||||
const router = useRouter();
|
||||
@@ -23,6 +24,13 @@ export default function DatasetItems() {
|
||||
{ name: "Datasets", href: `/project/${projectId}/datasets` },
|
||||
{ name: dataset.data?.name ?? datasetId },
|
||||
]}
|
||||
actionButtons={
|
||||
<DetailPageNav
|
||||
currentId={datasetId}
|
||||
path={(id) => `/project/${projectId}/datasets/${id}/items/`}
|
||||
listKey="datasets"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Tabs value="items" className="mb-3">
|
||||
<TabsList>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Header from "@/src/components/layouts/header";
|
||||
import { DatasetRunItemsTable } from "@/src/features/datasets/components/DatasetRunItemsTable";
|
||||
import { EditDatasetItem } from "@/src/features/datasets/components/EditDatasetItem";
|
||||
import { DetailPageNav } from "@/src/features/navigate-detail-pages/DetailPageNav";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
@@ -27,6 +28,15 @@ export default function Dataset() {
|
||||
},
|
||||
{ name: "Item: " + itemId },
|
||||
]}
|
||||
actionButtons={
|
||||
<DetailPageNav
|
||||
currentId={itemId}
|
||||
path={(id) =>
|
||||
`/project/${projectId}/datasets/${datasetId}/items/${id}`
|
||||
}
|
||||
listKey="datasetItems"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<EditDatasetItem
|
||||
projectId={projectId}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Header from "@/src/components/layouts/header";
|
||||
import { DatasetRunItemsTable } from "@/src/features/datasets/components/DatasetRunItemsTable";
|
||||
import { DetailPageNav } from "@/src/features/navigate-detail-pages/DetailPageNav";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
@@ -26,6 +27,15 @@ export default function Dataset() {
|
||||
},
|
||||
{ name: "Run: " + runId },
|
||||
]}
|
||||
actionButtons={
|
||||
<DetailPageNav
|
||||
currentId={runId}
|
||||
path={(id) =>
|
||||
`/project/${projectId}/datasets/${datasetId}/runs/${id}`
|
||||
}
|
||||
listKey="datasetRuns"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<DatasetRunItemsTable
|
||||
projectId={projectId}
|
||||
|
||||
@@ -105,6 +105,7 @@ export default function Start() {
|
||||
projectId={projectId}
|
||||
globalFilterState={globalFilterState}
|
||||
/>
|
||||
|
||||
<MetricTable
|
||||
className="col-span-1 xl:col-span-2"
|
||||
projectId={projectId}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { SessionPage } from "@/src/components/session";
|
||||
|
||||
export default function Trace() {
|
||||
const router = useRouter();
|
||||
const sessionId = router.query.sessionId as string;
|
||||
const sessionId = decodeURIComponent(router.query.sessionId as string);
|
||||
const projectId = router.query.projectId as string;
|
||||
|
||||
return <SessionPage sessionId={sessionId} projectId={projectId} />;
|
||||
|
||||
@@ -47,7 +47,7 @@ export default function UsersPage() {
|
||||
console.log("setting detail page list");
|
||||
setDetailPageList(
|
||||
"users",
|
||||
users.data.map((u) => u.userId),
|
||||
users.data.map((u) => encodeURIComponent(u.userId)),
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -63,7 +63,7 @@ export default function UsersPage() {
|
||||
return typeof value === "string" ? (
|
||||
<>
|
||||
<TableLink
|
||||
path={`/project/${projectId}/users/${value}`}
|
||||
path={`/project/${projectId}/users/${encodeURIComponent(value)}`}
|
||||
value={value}
|
||||
truncateAt={40}
|
||||
/>
|
||||
|
||||
@@ -12,9 +12,9 @@ import SessionsTable from "@/src/components/table/use-cases/sessions";
|
||||
|
||||
const tabs = ["Details", "Sessions", "Traces", "Scores"] as const;
|
||||
|
||||
export default function TracePage() {
|
||||
export default function UserPage() {
|
||||
const router = useRouter();
|
||||
const userId = router.query.userId as string;
|
||||
const userId = decodeURIComponent(router.query.userId as string);
|
||||
const projectId = router.query.projectId as string;
|
||||
|
||||
const [currentTab, setCurrentTab] = useQueryParam(
|
||||
@@ -51,8 +51,10 @@ export default function TracePage() {
|
||||
]}
|
||||
actionButtons={
|
||||
<DetailPageNav
|
||||
currentId={userId}
|
||||
path={(id) => `/project/${projectId}/users/${id}`}
|
||||
currentId={encodeURIComponent(userId)}
|
||||
path={(id) =>
|
||||
`/project/${projectId}/users/${encodeURIComponent(id)}`
|
||||
}
|
||||
listKey="users"
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -8,62 +8,73 @@ import { ObservationLevel } from "@prisma/client";
|
||||
export const observationsTableCols: ColumnDefinition[] = [
|
||||
{
|
||||
name: "id",
|
||||
id: "id",
|
||||
type: "string",
|
||||
internal: 'o."id"',
|
||||
},
|
||||
{
|
||||
name: "name",
|
||||
id: "name",
|
||||
type: "stringOptions",
|
||||
internal: 'o."name"',
|
||||
options: [], // to be added at runtime
|
||||
},
|
||||
{ name: "traceId", type: "string", internal: 't."id"' },
|
||||
{ name: "traceId", id: "traceId", type: "string", internal: 't."id"' },
|
||||
{
|
||||
name: "traceName",
|
||||
id: "traceName",
|
||||
type: "stringOptions",
|
||||
internal: 't."name"',
|
||||
options: [], // to be added at runtime
|
||||
},
|
||||
{ name: "userId", type: "string", internal: 't."user_id"' },
|
||||
{ name: "userId", id: "userId", type: "string", internal: 't."user_id"' },
|
||||
{
|
||||
name: "start_time",
|
||||
id: "startTime",
|
||||
type: "datetime",
|
||||
internal: 'o."start_time"',
|
||||
},
|
||||
{
|
||||
name: "end_time",
|
||||
id: "endTime",
|
||||
type: "datetime",
|
||||
internal: 'o."end_time"',
|
||||
},
|
||||
{
|
||||
name: "latency (s)",
|
||||
id: "latency",
|
||||
type: "number",
|
||||
internal: '"latency"',
|
||||
},
|
||||
{
|
||||
name: "level",
|
||||
id: "level",
|
||||
type: "stringOptions",
|
||||
internal: 'o."level"::text',
|
||||
options: Object.values(ObservationLevel).map((value) => ({ value })),
|
||||
},
|
||||
{
|
||||
name: "Status Message",
|
||||
id: "statusMessage",
|
||||
type: "string",
|
||||
internal: 'o."status_message"',
|
||||
},
|
||||
{
|
||||
name: "model",
|
||||
id: "model",
|
||||
type: "stringOptions",
|
||||
internal: 'o."model"',
|
||||
options: [], // to be added at runtime
|
||||
},
|
||||
{
|
||||
name: "metadata",
|
||||
id: "metadata",
|
||||
type: "stringObject",
|
||||
internal: 't."metadata"',
|
||||
internal: 'o."metadata"',
|
||||
},
|
||||
{
|
||||
name: "version",
|
||||
id: "version",
|
||||
type: "string",
|
||||
internal: 'o."version"',
|
||||
},
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { type ColumnDefinition } from "@/src/server/api/interfaces/tableDefinition";
|
||||
|
||||
export const sessionsViewCols: ColumnDefinition[] = [
|
||||
{ name: "⭐️", type: "boolean", internal: "s.bookmarked" },
|
||||
{ name: "⭐️", id: "bookmarked", type: "boolean", internal: "s.bookmarked" },
|
||||
{
|
||||
name: "id",
|
||||
id: "id",
|
||||
type: "string",
|
||||
internal: 's."id"',
|
||||
},
|
||||
{
|
||||
name: "userId",
|
||||
type: "string",
|
||||
@@ -9,12 +15,20 @@ export const sessionsViewCols: ColumnDefinition[] = [
|
||||
},
|
||||
{
|
||||
name: "Session duration (s)",
|
||||
id: "sessionDuration",
|
||||
type: "number",
|
||||
internal: 'o."sessionDuration"',
|
||||
},
|
||||
{
|
||||
name: "createdAt",
|
||||
id: "createdAt",
|
||||
type: "datetime",
|
||||
internal: 's."created_at"',
|
||||
},
|
||||
{
|
||||
name: "countTraces",
|
||||
id: "countTraces",
|
||||
type: "number",
|
||||
internal: 't."countTraces"',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from "@/src/server/api/trpc";
|
||||
|
||||
import { type Observation, Prisma } from "@prisma/client";
|
||||
import { paginationZod } from "@/src/utils/zod";
|
||||
import { jsonSchema, paginationZod } from "@/src/utils/zod";
|
||||
import { singleFilter } from "@/src/server/api/interfaces/filters";
|
||||
import {
|
||||
datetimeFilterToPrismaSql,
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
exportFileFormats,
|
||||
exportOptions,
|
||||
} from "@/src/server/api/interfaces/exportTypes";
|
||||
import { orderBy } from "@/src/server/api/interfaces/orderBy";
|
||||
import { orderByToPrismaSql } from "@/src/features/orderBy/server/orderByToPrisma";
|
||||
|
||||
const GenerationFilterOptions = z.object({
|
||||
projectId: z.string(), // Required for protectedProjectProcedure
|
||||
@@ -38,6 +40,7 @@ const GenerationFilterOptions = z.object({
|
||||
});
|
||||
|
||||
const ListInputs = GenerationFilterOptions.extend({
|
||||
orderBy: orderBy,
|
||||
...paginationZod,
|
||||
});
|
||||
|
||||
@@ -64,6 +67,11 @@ export const generationsRouter = createTRPCRouter({
|
||||
observationsTableCols,
|
||||
);
|
||||
|
||||
const orderByCondition = orderByToPrismaSql(
|
||||
input.orderBy,
|
||||
observationsTableCols,
|
||||
);
|
||||
|
||||
// to improve query performance, add timeseries filter to observation queries as well
|
||||
const startTimeFilter = input.filter.find(
|
||||
(f) => f.column === "start_time" && f.type === "datetime",
|
||||
@@ -115,39 +123,65 @@ export const generationsRouter = createTRPCRouter({
|
||||
o.total_tokens as "totalTokens",
|
||||
o.level,
|
||||
o.status_message as "statusMessage",
|
||||
o.version,
|
||||
(count(*) OVER())::int AS "totalCount"
|
||||
o.version
|
||||
FROM observations_with_latency o
|
||||
JOIN traces t ON t.id = o.trace_id
|
||||
WHERE
|
||||
t.project_id = ${input.projectId}
|
||||
${searchCondition}
|
||||
${filterCondition}
|
||||
ORDER BY o.start_time DESC
|
||||
${orderByCondition}
|
||||
LIMIT ${input.limit}
|
||||
OFFSET ${input.page * input.limit}
|
||||
`,
|
||||
);
|
||||
|
||||
const pricings = await ctx.prisma.pricing.findMany();
|
||||
const totalGenerations = await ctx.prisma.$queryRaw<
|
||||
Array<{ count: bigint }>
|
||||
>(
|
||||
Prisma.sql`
|
||||
WITH observations_with_latency AS (
|
||||
SELECT
|
||||
o.*,
|
||||
CASE WHEN o.end_time IS NULL THEN NULL ELSE (EXTRACT(EPOCH FROM o."end_time") - EXTRACT(EPOCH FROM o."start_time"))::double precision END AS "latency"
|
||||
FROM observations o
|
||||
WHERE o.type = 'GENERATION'
|
||||
AND o.project_id = ${input.projectId}
|
||||
${datetimeFilter}
|
||||
)
|
||||
SELECT
|
||||
count(*)
|
||||
FROM observations_with_latency o
|
||||
JOIN traces t ON t.id = o.trace_id
|
||||
WHERE
|
||||
t.project_id = ${input.projectId}
|
||||
${searchCondition}
|
||||
${filterCondition}
|
||||
`,
|
||||
);
|
||||
|
||||
return generations.map(({ input, output, ...rest }) => {
|
||||
return {
|
||||
...rest,
|
||||
input,
|
||||
output,
|
||||
cost: rest.model
|
||||
? calculateTokenCost(pricings, {
|
||||
model: rest.model,
|
||||
totalTokens: new Decimal(rest.totalTokens),
|
||||
promptTokens: new Decimal(rest.promptTokens),
|
||||
completionTokens: new Decimal(rest.completionTokens),
|
||||
input: input,
|
||||
output: output,
|
||||
})
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
const pricings = await ctx.prisma.pricing.findMany();
|
||||
const count = totalGenerations[0]?.count;
|
||||
return {
|
||||
totalCount: count ? Number(count) : undefined,
|
||||
generations: generations.map(({ input, output, ...rest }) => {
|
||||
return {
|
||||
...rest,
|
||||
input,
|
||||
output,
|
||||
cost: rest.model
|
||||
? calculateTokenCost(pricings, {
|
||||
model: rest.model,
|
||||
totalTokens: new Decimal(rest.totalTokens),
|
||||
promptTokens: new Decimal(rest.promptTokens),
|
||||
completionTokens: new Decimal(rest.completionTokens),
|
||||
input: input,
|
||||
output: output,
|
||||
})
|
||||
: undefined,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
|
||||
export: protectedProjectProcedure
|
||||
@@ -253,7 +287,7 @@ export const generationsRouter = createTRPCRouter({
|
||||
generation.startTime.toISOString(),
|
||||
generation.endTime?.toISOString() ?? "",
|
||||
generation.cost
|
||||
? usdFormatter(generation.cost.toNumber())
|
||||
? usdFormatter(generation.cost.toNumber(), 2, 8)
|
||||
: "",
|
||||
JSON.stringify(generation.input),
|
||||
JSON.stringify(generation.output),
|
||||
@@ -277,9 +311,11 @@ export const generationsRouter = createTRPCRouter({
|
||||
content: z.string(),
|
||||
}),
|
||||
);
|
||||
const outputSchema = z.object({
|
||||
completion: z.string(),
|
||||
});
|
||||
const outputSchema = z
|
||||
.object({
|
||||
completion: jsonSchema,
|
||||
})
|
||||
.or(jsonSchema);
|
||||
output = enrichedGenerations
|
||||
.map((generation) => ({
|
||||
parsedInput: inputSchemaOpenAI.safeParse(generation.input),
|
||||
@@ -293,7 +329,14 @@ export const generationsRouter = createTRPCRouter({
|
||||
? [
|
||||
{
|
||||
role: "assistant",
|
||||
content: generation.parsedOutput.data.completion,
|
||||
content:
|
||||
typeof generation.parsedOutput.data ===
|
||||
"object" &&
|
||||
"completion" in generation.parsedOutput.data
|
||||
? JSON.stringify(
|
||||
generation.parsedOutput.data.completion,
|
||||
)
|
||||
: JSON.stringify(generation.parsedOutput.data),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
@@ -303,6 +346,7 @@ export const generationsRouter = createTRPCRouter({
|
||||
// to jsonl
|
||||
.map((row) => JSON.stringify(row))
|
||||
.join("\n");
|
||||
console.log(output);
|
||||
break;
|
||||
default:
|
||||
throw new Error("Invalid export file format");
|
||||
|
||||
@@ -20,7 +20,6 @@ export const observationsRouter = createTRPCRouter({
|
||||
traceId: input.traceId,
|
||||
},
|
||||
});
|
||||
|
||||
const scores = generation.traceId
|
||||
? await ctx.prisma.score.findMany({
|
||||
where: {
|
||||
|
||||
@@ -12,10 +12,13 @@ import { singleFilter } from "@/src/server/api/interfaces/filters";
|
||||
import { paginationZod } from "@/src/utils/zod";
|
||||
import { throwIfNoAccess } from "@/src/features/rbac/utils/checkAccess";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { orderBy } from "@/src/server/api/interfaces/orderBy";
|
||||
import { orderByToPrismaSql } from "@/src/features/orderBy/server/orderByToPrisma";
|
||||
|
||||
const SessionFilterOptions = z.object({
|
||||
projectId: z.string(), // Required for protectedProjectProcedure
|
||||
filter: z.array(singleFilter).nullable(),
|
||||
orderBy: orderBy,
|
||||
...paginationZod,
|
||||
});
|
||||
|
||||
@@ -28,6 +31,10 @@ export const sessionRouter = createTRPCRouter({
|
||||
input.filter ?? [],
|
||||
sessionsViewCols,
|
||||
);
|
||||
const orderByCondition = orderByToPrismaSql(
|
||||
input.orderBy,
|
||||
sessionsViewCols,
|
||||
);
|
||||
|
||||
const sessions = await ctx.prisma.$queryRaw<
|
||||
Array<{
|
||||
@@ -79,7 +86,7 @@ export const sessionRouter = createTRPCRouter({
|
||||
WHERE
|
||||
s."project_id" = ${input.projectId}
|
||||
${filterCondition}
|
||||
ORDER BY 2 desc
|
||||
${orderByCondition}
|
||||
LIMIT ${input.limit}
|
||||
OFFSET ${input.page * input.limit}
|
||||
`);
|
||||
|
||||
@@ -22,6 +22,7 @@ import { throwIfNoAccess } from "@/src/features/rbac/utils/checkAccess";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { orderBy } from "@/src/server/api/interfaces/orderBy";
|
||||
import { orderByToPrismaSql } from "@/src/features/orderBy/server/orderByToPrisma";
|
||||
import { type Sql } from "@prisma/client/runtime/library";
|
||||
|
||||
const TraceFilterOptions = z.object({
|
||||
projectId: z.string(), // Required for protectedProjectProcedure
|
||||
@@ -70,58 +71,8 @@ export const traceRouter = createTRPCRouter({
|
||||
)`
|
||||
: Prisma.empty;
|
||||
|
||||
const query = Prisma.sql`
|
||||
WITH usage AS (
|
||||
SELECT
|
||||
trace_id,
|
||||
sum(prompt_tokens) AS "promptTokens",
|
||||
sum(completion_tokens) AS "completionTokens",
|
||||
sum(total_tokens) AS "totalTokens"
|
||||
FROM
|
||||
"observations"
|
||||
WHERE
|
||||
"trace_id" IS NOT NULL
|
||||
AND "type" = 'GENERATION'
|
||||
AND "project_id" = ${input.projectId}
|
||||
${observationTimeseriesFilter}
|
||||
GROUP BY
|
||||
trace_id
|
||||
),
|
||||
trace_latency AS (
|
||||
SELECT
|
||||
trace_id,
|
||||
EXTRACT(EPOCH FROM COALESCE(MAX("end_time"), MAX("start_time"))) - EXTRACT(EPOCH FROM MIN("start_time"))::double precision AS "latency"
|
||||
FROM
|
||||
"observations"
|
||||
WHERE
|
||||
"trace_id" IS NOT NULL
|
||||
AND "project_id" = ${input.projectId}
|
||||
${observationTimeseriesFilter}
|
||||
GROUP BY
|
||||
trace_id
|
||||
),
|
||||
-- used for filtering
|
||||
scores_avg AS (
|
||||
SELECT
|
||||
trace_id,
|
||||
jsonb_object_agg(name::text, avg_value::double precision) AS scores_avg
|
||||
FROM (
|
||||
SELECT
|
||||
trace_id,
|
||||
name,
|
||||
avg(value) avg_value
|
||||
FROM
|
||||
scores
|
||||
GROUP BY
|
||||
1,
|
||||
2
|
||||
ORDER BY
|
||||
1) tmp
|
||||
GROUP BY
|
||||
1
|
||||
)
|
||||
SELECT
|
||||
t.*,
|
||||
const tracesQuery = createTracesQuery(
|
||||
Prisma.sql`t.*,
|
||||
t."user_id" AS "userId",
|
||||
t."metadata" AS "metadata",
|
||||
t.session_id AS "sessionId",
|
||||
@@ -129,22 +80,15 @@ export const traceRouter = createTRPCRouter({
|
||||
COALESCE(u."promptTokens", 0)::int AS "promptTokens",
|
||||
COALESCE(u."completionTokens", 0)::int AS "completionTokens",
|
||||
COALESCE(u."totalTokens", 0)::int AS "totalTokens",
|
||||
tl.latency AS "latency",
|
||||
(count(*) OVER ())::int AS "totalCount"
|
||||
FROM
|
||||
"traces" AS t
|
||||
LEFT JOIN usage AS u ON u.trace_id = t.id
|
||||
-- used for filtering
|
||||
LEFT JOIN scores_avg AS s_avg ON s_avg.trace_id = t.id
|
||||
LEFT JOIN trace_latency AS tl ON tl.trace_id = t.id
|
||||
WHERE
|
||||
t."project_id" = ${input.projectId}
|
||||
${searchCondition}
|
||||
${filterCondition}
|
||||
${orderByCondition}
|
||||
LIMIT ${input.limit}
|
||||
OFFSET ${input.page * input.limit}
|
||||
`;
|
||||
tl.latency AS "latency"`,
|
||||
input.projectId,
|
||||
observationTimeseriesFilter,
|
||||
input.page,
|
||||
input.limit,
|
||||
searchCondition,
|
||||
filterCondition,
|
||||
orderByCondition,
|
||||
);
|
||||
|
||||
const traces = await ctx.prisma.$queryRaw<
|
||||
Array<
|
||||
@@ -156,7 +100,21 @@ export const traceRouter = createTRPCRouter({
|
||||
latency: number | null;
|
||||
}
|
||||
>
|
||||
>(query);
|
||||
>(tracesQuery);
|
||||
|
||||
const countQyery = createTracesQuery(
|
||||
Prisma.sql`count(*)`,
|
||||
input.projectId,
|
||||
observationTimeseriesFilter,
|
||||
0,
|
||||
1,
|
||||
searchCondition,
|
||||
filterCondition,
|
||||
Prisma.empty,
|
||||
);
|
||||
|
||||
const totalTraces =
|
||||
await ctx.prisma.$queryRaw<Array<{ count: bigint }>>(countQyery);
|
||||
|
||||
// get scores for each trace individually to increase
|
||||
// performance of the query above
|
||||
@@ -170,11 +128,14 @@ export const traceRouter = createTRPCRouter({
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return traces.map((trace) => {
|
||||
const filteredScores = scores.filter((s) => s.traceId === trace.id);
|
||||
return { ...trace, scores: filteredScores };
|
||||
});
|
||||
const totalTraceCount = totalTraces[0]?.count;
|
||||
return {
|
||||
traces: traces.map((trace) => {
|
||||
const filteredScores = scores.filter((s) => s.traceId === trace.id);
|
||||
return { ...trace, scores: filteredScores };
|
||||
}),
|
||||
totalCount: totalTraceCount ? Number(totalTraceCount) : undefined,
|
||||
};
|
||||
}),
|
||||
filterOptions: protectedProjectProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
@@ -404,4 +365,113 @@ export const traceRouter = createTRPCRouter({
|
||||
}
|
||||
}
|
||||
}),
|
||||
updateTags: protectedProjectProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
traceId: z.string(),
|
||||
tags: z.array(z.string()),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
throwIfNoAccess({
|
||||
session: ctx.session,
|
||||
projectId: input.projectId,
|
||||
scope: "objects:tag",
|
||||
});
|
||||
try {
|
||||
const trace = await ctx.prisma.trace.update({
|
||||
where: {
|
||||
id: input.traceId,
|
||||
projectId: input.projectId,
|
||||
},
|
||||
data: {
|
||||
tags: {
|
||||
set: input.tags,
|
||||
},
|
||||
},
|
||||
});
|
||||
return trace;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
function createTracesQuery(
|
||||
select: Sql,
|
||||
projectId: string,
|
||||
observationTimeseriesFilter: Sql,
|
||||
page: number,
|
||||
limit: number,
|
||||
searchCondition: Sql,
|
||||
filterCondition: Sql,
|
||||
orderByCondition: Sql,
|
||||
) {
|
||||
return Prisma.sql`
|
||||
WITH usage AS (
|
||||
SELECT
|
||||
trace_id,
|
||||
sum(prompt_tokens) AS "promptTokens",
|
||||
sum(completion_tokens) AS "completionTokens",
|
||||
sum(total_tokens) AS "totalTokens"
|
||||
FROM
|
||||
"observations"
|
||||
WHERE
|
||||
"trace_id" IS NOT NULL
|
||||
AND "type" = 'GENERATION'
|
||||
AND "project_id" = ${projectId}
|
||||
${observationTimeseriesFilter}
|
||||
GROUP BY
|
||||
trace_id
|
||||
),
|
||||
trace_latency AS (
|
||||
SELECT
|
||||
trace_id,
|
||||
EXTRACT(EPOCH FROM COALESCE(MAX("end_time"), MAX("start_time"))) - EXTRACT(EPOCH FROM MIN("start_time"))::double precision AS "latency"
|
||||
FROM
|
||||
"observations"
|
||||
WHERE
|
||||
"trace_id" IS NOT NULL
|
||||
AND "project_id" = ${projectId}
|
||||
${observationTimeseriesFilter}
|
||||
GROUP BY
|
||||
trace_id
|
||||
),
|
||||
-- used for filtering
|
||||
scores_avg AS (
|
||||
SELECT
|
||||
trace_id,
|
||||
jsonb_object_agg(name::text, avg_value::double precision) AS scores_avg
|
||||
FROM (
|
||||
SELECT
|
||||
trace_id,
|
||||
name,
|
||||
avg(value) avg_value
|
||||
FROM
|
||||
scores
|
||||
GROUP BY
|
||||
1,
|
||||
2
|
||||
ORDER BY
|
||||
1) tmp
|
||||
GROUP BY
|
||||
1
|
||||
)
|
||||
SELECT
|
||||
${select}
|
||||
FROM
|
||||
"traces" AS t
|
||||
LEFT JOIN usage AS u ON u.trace_id = t.id
|
||||
-- used for filtering
|
||||
LEFT JOIN scores_avg AS s_avg ON s_avg.trace_id = t.id
|
||||
LEFT JOIN trace_latency AS tl ON tl.trace_id = t.id
|
||||
WHERE
|
||||
t."project_id" = ${projectId}
|
||||
${searchCondition}
|
||||
${filterCondition}
|
||||
${orderByCondition}
|
||||
LIMIT ${limit}
|
||||
OFFSET ${page * limit}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -57,10 +57,13 @@ export class ObservationProcessor implements EventProcessor {
|
||||
this.event = event;
|
||||
}
|
||||
|
||||
async convertToObservation(apiScope: ApiAccessScope): Promise<{
|
||||
async convertToObservation(
|
||||
apiScope: ApiAccessScope,
|
||||
existingObservation: Observation | null,
|
||||
): Promise<{
|
||||
id: string;
|
||||
create: Prisma.ObservationCreateInput;
|
||||
update: Prisma.ObservationUpdateInput;
|
||||
create: Prisma.ObservationUncheckedCreateInput;
|
||||
update: Prisma.ObservationUncheckedUpdateInput;
|
||||
}> {
|
||||
const { body } = this.event;
|
||||
|
||||
@@ -85,12 +88,6 @@ export class ObservationProcessor implements EventProcessor {
|
||||
|
||||
const { id, traceId, name, startTime, metadata } = body;
|
||||
|
||||
const existingObservation = id
|
||||
? await prisma.observation.findUnique({
|
||||
where: { id, projectId: apiScope.projectId },
|
||||
})
|
||||
: null;
|
||||
|
||||
if (
|
||||
this.event.type === eventTypes.OBSERVATION_UPDATE &&
|
||||
!existingObservation
|
||||
@@ -124,20 +121,26 @@ export class ObservationProcessor implements EventProcessor {
|
||||
metadata ?? undefined,
|
||||
);
|
||||
|
||||
const prompts =
|
||||
const prompt =
|
||||
"promptName" in this.event.body &&
|
||||
typeof this.event.body.promptName === "string" &&
|
||||
"promptVersion" in this.event.body &&
|
||||
typeof this.event.body.promptVersion === "number"
|
||||
? await prisma.prompt.findMany({
|
||||
? await prisma.prompt.findUnique({
|
||||
where: {
|
||||
projectId: apiScope.projectId,
|
||||
name: this.event.body.promptName,
|
||||
version: this.event.body.promptVersion,
|
||||
projectId_name_version: {
|
||||
projectId: apiScope.projectId,
|
||||
name: this.event.body.promptName,
|
||||
version: this.event.body.promptVersion,
|
||||
},
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
|
||||
// Only null if promptName and promptVersion are set but prompt is not found
|
||||
if (prompt === null)
|
||||
console.warn("Prompt not found for observation", this.event.body);
|
||||
|
||||
const observationId = id ?? v4();
|
||||
return {
|
||||
id: observationId,
|
||||
@@ -174,10 +177,8 @@ export class ObservationProcessor implements EventProcessor {
|
||||
statusMessage: body.statusMessage ?? undefined,
|
||||
parentObservationId: body.parentObservationId ?? undefined,
|
||||
version: body.version ?? undefined,
|
||||
project: { connect: { id: apiScope.projectId } },
|
||||
...(prompts && prompts.length === 1
|
||||
? { prompt: { connect: { id: prompts[0]?.id } } }
|
||||
: undefined),
|
||||
projectId: apiScope.projectId,
|
||||
promptId: prompt ? prompt.id : undefined,
|
||||
},
|
||||
update: {
|
||||
name,
|
||||
@@ -209,9 +210,7 @@ export class ObservationProcessor implements EventProcessor {
|
||||
statusMessage: body.statusMessage ?? undefined,
|
||||
parentObservationId: body.parentObservationId ?? undefined,
|
||||
version: body.version ?? undefined,
|
||||
...(prompts && prompts.length === 1
|
||||
? { prompt: { connect: { id: prompts[0]?.id } } }
|
||||
: undefined),
|
||||
promptId: prompt ? prompt.id : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -250,14 +249,28 @@ export class ObservationProcessor implements EventProcessor {
|
||||
if (apiScope.accessLevel !== "all")
|
||||
throw new AuthenticationError("Access denied for observation creation");
|
||||
|
||||
const obs = await this.convertToObservation(apiScope);
|
||||
const existingObservation = this.event.body.id
|
||||
? await prisma.observation.findFirst({
|
||||
where: { id: this.event.body.id },
|
||||
})
|
||||
: null;
|
||||
|
||||
if (
|
||||
existingObservation &&
|
||||
existingObservation.projectId !== apiScope.projectId
|
||||
) {
|
||||
throw new AuthenticationError(
|
||||
`Access denied for observation creation ${existingObservation.projectId} `,
|
||||
);
|
||||
}
|
||||
|
||||
const obs = await this.convertToObservation(apiScope, existingObservation);
|
||||
|
||||
// Do not use nested upserts or multiple where conditions as this should be a single native database upsert
|
||||
// https://www.prisma.io/docs/orm/reference/prisma-client-reference#database-upserts
|
||||
return await prisma.observation.upsert({
|
||||
where: {
|
||||
id_projectId: {
|
||||
id: obs.id,
|
||||
projectId: apiScope.projectId,
|
||||
},
|
||||
id: obs.id,
|
||||
},
|
||||
create: obs.create,
|
||||
update: obs.update,
|
||||
@@ -277,7 +290,9 @@ export class TraceProcessor implements EventProcessor {
|
||||
const { body } = this.event;
|
||||
|
||||
if (apiScope.accessLevel !== "all")
|
||||
throw new AuthenticationError("Access denied for trace creation");
|
||||
throw new AuthenticationError(
|
||||
`Access denied for trace creation, ${apiScope.accessLevel}`,
|
||||
);
|
||||
|
||||
const internalId = body.id ?? v4();
|
||||
|
||||
@@ -288,12 +303,18 @@ export class TraceProcessor implements EventProcessor {
|
||||
body,
|
||||
);
|
||||
|
||||
const existingTrace = await prisma.trace.findUnique({
|
||||
const existingTrace = await prisma.trace.findFirst({
|
||||
where: {
|
||||
id: internalId,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingTrace && existingTrace.projectId !== apiScope.projectId) {
|
||||
throw new AuthenticationError(
|
||||
`Access denied for trace creation ${existingTrace.projectId} `,
|
||||
);
|
||||
}
|
||||
|
||||
const mergedMetadata = mergeJson(
|
||||
existingTrace?.metadata
|
||||
? jsonSchema.parse(existingTrace.metadata)
|
||||
@@ -301,10 +322,27 @@ export class TraceProcessor implements EventProcessor {
|
||||
body.metadata ?? undefined,
|
||||
);
|
||||
|
||||
if (body.sessionId) {
|
||||
await prisma.traceSession.upsert({
|
||||
where: {
|
||||
id_projectId: {
|
||||
id: body.sessionId,
|
||||
projectId: apiScope.projectId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
id: body.sessionId,
|
||||
projectId: apiScope.projectId,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
// Do not use nested upserts or multiple where conditions as this should be a single native database upsert
|
||||
// https://www.prisma.io/docs/orm/reference/prisma-client-reference#database-upserts
|
||||
const upsertedTrace = await prisma.trace.upsert({
|
||||
where: {
|
||||
id: internalId,
|
||||
projectId: apiScope.projectId,
|
||||
},
|
||||
create: {
|
||||
id: internalId,
|
||||
@@ -315,16 +353,9 @@ export class TraceProcessor implements EventProcessor {
|
||||
metadata: mergedMetadata ?? body.metadata ?? undefined,
|
||||
release: body.release ?? undefined,
|
||||
version: body.version ?? undefined,
|
||||
session: body.sessionId
|
||||
? {
|
||||
connectOrCreate: {
|
||||
where: { id: body.sessionId, projectId: apiScope.projectId },
|
||||
create: { id: body.sessionId, projectId: apiScope.projectId },
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
sessionId: body.sessionId ?? undefined,
|
||||
public: body.public ?? undefined,
|
||||
project: { connect: { id: apiScope.projectId } },
|
||||
projectId: apiScope.projectId,
|
||||
tags: body.tags ?? undefined,
|
||||
},
|
||||
update: {
|
||||
@@ -335,14 +366,7 @@ export class TraceProcessor implements EventProcessor {
|
||||
metadata: mergedMetadata ?? body.metadata ?? undefined,
|
||||
release: body.release ?? undefined,
|
||||
version: body.version ?? undefined,
|
||||
session: body.sessionId
|
||||
? {
|
||||
connectOrCreate: {
|
||||
where: { id: body.sessionId, projectId: apiScope.projectId },
|
||||
create: { id: body.sessionId, projectId: apiScope.projectId },
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
sessionId: body.sessionId ?? undefined,
|
||||
public: body.public ?? undefined,
|
||||
tags: body.tags ?? undefined,
|
||||
},
|
||||
|
||||
@@ -70,6 +70,9 @@ export const createTRPCContext = async (opts: CreateNextContextOptions) => {
|
||||
import { initTRPC, TRPCError } from "@trpc/server";
|
||||
import superjson from "superjson";
|
||||
import { ZodError } from "zod";
|
||||
import { setUpSuperjson } from "@/src/utils/superjson";
|
||||
|
||||
setUpSuperjson();
|
||||
|
||||
const t = initTRPC.context<typeof createTRPCContext>().create({
|
||||
transformer: superjson,
|
||||
|
||||
@@ -211,6 +211,11 @@ export const authOptions: NextAuthOptions = {
|
||||
providers,
|
||||
pages: {
|
||||
signIn: "/auth/sign-in",
|
||||
...(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION
|
||||
? {
|
||||
newUser: "/onboarding",
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
cookies: {
|
||||
sessionToken: {
|
||||
|
||||
@@ -16,6 +16,9 @@ import { type inferRouterInputs, type inferRouterOutputs } from "@trpc/server";
|
||||
import superjson from "superjson";
|
||||
|
||||
import { type AppRouter } from "@/src/server/api/root";
|
||||
import { setUpSuperjson } from "@/src/utils/superjson";
|
||||
|
||||
setUpSuperjson();
|
||||
|
||||
const getBaseUrl = () => {
|
||||
if (typeof window !== "undefined") return ""; // browser should use relative url
|
||||
|
||||
+11
-5
@@ -14,12 +14,18 @@ export const numberFormatter = (number: number) => {
|
||||
}).format(number);
|
||||
};
|
||||
|
||||
export const usdFormatter = (number: number) =>
|
||||
new Intl.NumberFormat("en-US", {
|
||||
export const usdFormatter = (
|
||||
number: number,
|
||||
minimumFractionDigits: number = 2,
|
||||
maximumFractionDigits: number = 4,
|
||||
) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
|
||||
// These options are needed to round to whole numbers if that's what you want.
|
||||
//minimumFractionDigits: 0, // (this suffices for whole numbers, but will print 2500.10 as $2,500.1)
|
||||
//maximumFractionDigits: 0, // (causes 2500.99 to be printed as $2,501)
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#minimumfractiondigits
|
||||
minimumFractionDigits,
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#maximumfractiondigits
|
||||
maximumFractionDigits,
|
||||
}).format(number);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import Decimal from "decimal.js";
|
||||
import superjson from "superjson";
|
||||
|
||||
export const setUpSuperjson = () => {
|
||||
superjson.registerCustom<Decimal, string>(
|
||||
{
|
||||
isApplicable: (v): v is Decimal => Decimal.isDecimal(v),
|
||||
serialize: (v) => v.toJSON(),
|
||||
deserialize: (v) => new Decimal(v),
|
||||
},
|
||||
"decimal.js",
|
||||
);
|
||||
};
|
||||
+3
-1
@@ -16,6 +16,7 @@
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noErrorTruncation": true,
|
||||
"baseUrl": ".",
|
||||
"types": ["jest", "node"],
|
||||
"paths": {
|
||||
@@ -29,7 +30,8 @@
|
||||
"**/*.tsx",
|
||||
"**/*.cjs",
|
||||
"**/*.mjs",
|
||||
"src/features/navigate-detail-pages"
|
||||
"src/features/navigate-detail-pages",
|
||||
"types/global.d.ts"
|
||||
],
|
||||
"exclude": ["node_modules", "sdk"]
|
||||
}
|
||||
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
declare namespace JSX {
|
||||
interface IntrinsicElements {
|
||||
"stripe-pricing-table": React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLElement>,
|
||||
HTMLElement
|
||||
>;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user