Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c02bb556ca | ||
|
|
1b792d844f | ||
|
|
52a42812b4 | ||
|
|
3b84af8cbf | ||
|
|
1521445939 | ||
|
|
a1e961de09 | ||
|
|
c360cf10d4 | ||
|
|
3e2f3f3d28 | ||
|
|
11e5da2cd8 | ||
|
|
8205595f2c | ||
|
|
101277cab8 | ||
|
|
954e31ca4a | ||
|
|
43c3258135 | ||
|
|
eb8ae94a5d | ||
|
|
33abe53acc | ||
|
|
5e8ec0bd13 | ||
|
|
177f370914 | ||
|
|
5538242649 | ||
|
|
8c8e58e2b8 | ||
|
|
a2c367c1b6 | ||
|
|
da0ac73603 | ||
|
|
e60e74478c | ||
|
|
329f83e844 | ||
|
|
28c035da76 | ||
|
|
4dfeceb9d6 | ||
|
|
df802184a6 | ||
|
|
407d169095 | ||
|
|
e1958e73d4 | ||
|
|
2a08c3395c | ||
|
|
5c405f4ed8 | ||
|
|
25cc29f353 | ||
|
|
b5e1b9916f | ||
|
|
8075996a1c | ||
|
|
80cc85d756 | ||
|
|
45c611bc6d | ||
|
|
1478aa9c2b | ||
|
|
4944408b62 | ||
|
|
f6e81f818b | ||
|
|
b2d5839dcd | ||
|
|
2608878d20 | ||
|
|
62a2857619 | ||
|
|
0c1942c2b3 | ||
|
|
b95c93cdb1 | ||
|
|
3691099c74 | ||
|
|
3f1ae71eec | ||
|
|
668cf3f6c2 | ||
|
|
6221be155d | ||
|
|
3e13d191db | ||
|
|
61297826f4 | ||
|
|
3268ef6488 | ||
|
|
4aba13ec35 | ||
|
|
0f5d5cd9a0 | ||
|
|
78df145e20 | ||
|
|
b35618f1c9 | ||
|
|
a139ada85e | ||
|
|
cdfb0c6b7c | ||
|
|
c0bbf39b29 | ||
|
|
a6900ad3c4 | ||
|
|
21940a0464 | ||
|
|
fe642f8e9e | ||
|
|
c803b0d9c2 | ||
|
|
e7086cf044 | ||
|
|
9d49bbe987 |
+5
-1
@@ -29,4 +29,8 @@ S3_ENDPOINT=
|
||||
S3_ACCESS_KEY_ID=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
S3_BUCKET_NAME=
|
||||
S3_REGION=
|
||||
S3_REGION=
|
||||
|
||||
# Set during docker build of application
|
||||
# Used to disable environment verification at build time
|
||||
# DOCKER_BUILD=1
|
||||
@@ -57,6 +57,9 @@ SALT="salt"
|
||||
# S3_BUCKET_NAME=
|
||||
# S3_REGION=
|
||||
|
||||
# Exports are streamed to S3 in pages to avoid memory issues
|
||||
# The page size can be adjusted if needed to optimize performance
|
||||
# DB_EXPORT_PAGE_SIZE=1000
|
||||
|
||||
|
||||
|
||||
@@ -81,6 +84,9 @@ SALT="salt"
|
||||
# NEXT_SENTRY_PROJECT=
|
||||
# SENTRY_AUTH_TOKEN=
|
||||
|
||||
# Betterstack
|
||||
# LANGFUSE_TEAM_BETTERSTACK_TOKEN=
|
||||
|
||||
# Demo project that users can use to try the platform
|
||||
# NEXT_PUBLIC_DEMO_PROJECT_ID=
|
||||
|
||||
|
||||
@@ -9,10 +9,3 @@ body:
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Add any other information related to the change here. If your idea is related to any issues or discussions, link them here.
|
||||
- type: checkboxes
|
||||
id: contribute
|
||||
attributes:
|
||||
label: Contribute
|
||||
description: Are you willing to contribute to the implementation of this idea?
|
||||
options:
|
||||
- label: Yes, I can implement this and raise a PR
|
||||
|
||||
@@ -33,10 +33,6 @@ jobs:
|
||||
test-docker-build:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
|
||||
NEXTAUTH_SECRET: "secret"
|
||||
SALT: "salt"
|
||||
NEXTAUTH_URL: "http://localhost:3030"
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
@@ -54,11 +50,6 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
build-args: |
|
||||
DATABASE_URL=${{ env.DATABASE_URL }}
|
||||
NEXTAUTH_SECRET=${{ env.NEXTAUTH_SECRET }}
|
||||
NEXTAUTH_URL=${{ env.NEXTAUTH_URL }}
|
||||
SALT=${{ env.SALT }}
|
||||
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -152,10 +143,6 @@ jobs:
|
||||
environment: "protected branches"
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
|
||||
NEXTAUTH_SECRET: "secret"
|
||||
SALT: "salt"
|
||||
NEXTAUTH_URL: "http://localhost:3030"
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
permissions:
|
||||
@@ -198,8 +185,3 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
DATABASE_URL=${{ env.DATABASE_URL }}
|
||||
NEXTAUTH_SECRET=${{ env.NEXTAUTH_SECRET }}
|
||||
NEXTAUTH_URL=${{ env.NEXTAUTH_URL }}
|
||||
SALT=${{ env.SALT }}
|
||||
|
||||
+5
-1
@@ -48,4 +48,8 @@ yarn-error.log*
|
||||
/generated/typescript-server
|
||||
|
||||
# openapi spec that is copied during build
|
||||
/public/openapi*.yml
|
||||
/public/openapi*.yml
|
||||
|
||||
|
||||
# vscode
|
||||
.devcontainer
|
||||
+5
-16
@@ -1,19 +1,11 @@
|
||||
# Base image
|
||||
FROM node:20-alpine AS base
|
||||
ARG DATABASE_URL
|
||||
ARG NEXTAUTH_SECRET
|
||||
ARG NEXTAUTH_URL
|
||||
ARG SALT
|
||||
|
||||
# It's important to update the index before installing packages to ensure you're getting the latest versions.
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
RUN apk update && apk upgrade --no-cache libcrypto3 libssl3 libc6-compat
|
||||
|
||||
FROM base AS deps
|
||||
ARG DATABASE_URL
|
||||
ARG NEXTAUTH_SECRET
|
||||
ARG NEXTAUTH_URL
|
||||
ARG SALT
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -29,10 +21,6 @@ RUN \
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
ARG DATABASE_URL
|
||||
ARG NEXTAUTH_SECRET
|
||||
ARG NEXTAUTH_URL
|
||||
ARG SALT
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
@@ -46,6 +34,9 @@ RUN rm -f ./src/middleware.ts
|
||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
# Disable validation of environment variables during build
|
||||
ENV DOCKER_BUILD 1
|
||||
|
||||
# Generate prisma client
|
||||
RUN npx prisma generate
|
||||
|
||||
@@ -54,10 +45,6 @@ RUN npm run build
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
ARG DATABASE_URL
|
||||
ARG NEXTAUTH_SECRET
|
||||
ARG NEXTAUTH_URL
|
||||
ARG SALT
|
||||
|
||||
RUN apk add --no-cache dumb-init
|
||||
|
||||
@@ -66,6 +53,8 @@ WORKDIR /app
|
||||
ENV NODE_ENV production
|
||||
# Uncomment the following line in case you want to disable telemetry during runtime.
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
# Needed to re-enable validation of environment variables during runtime
|
||||
ENV DOCKER_BUILD 0
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
@@ -4,17 +4,11 @@ services:
|
||||
langfuse-server:
|
||||
build:
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- DATABASE_URL=postgresql://postgres:postgres@db:5432/postgres
|
||||
- NEXTAUTH_SECRET=mysecret
|
||||
- SALT=mysalt
|
||||
- NEXTAUTH_URL=http://localhost:3000
|
||||
depends_on:
|
||||
- db
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=postgresql://postgres:postgres@db:5432/postgres
|
||||
- NEXTAUTH_SECRET=mysecret
|
||||
- SALT=mysalt
|
||||
|
||||
@@ -8,7 +8,6 @@ services:
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=postgresql://postgres:postgres@db:5432/postgres
|
||||
- NEXTAUTH_SECRET=mysecret
|
||||
- SALT=mysalt
|
||||
|
||||
@@ -18,18 +18,33 @@ types:
|
||||
public:
|
||||
type: optional<boolean>
|
||||
docs: Public traces are accessible via url without login
|
||||
TraceWithDetails:
|
||||
TraceWithDetails: # GET /traces
|
||||
extends: Trace
|
||||
properties:
|
||||
htmlPath:
|
||||
type: string
|
||||
docs: Path of trace in Langfuse UI
|
||||
latency:
|
||||
type: double
|
||||
docs: Latency of trace in seconds
|
||||
totalCost:
|
||||
type: double
|
||||
docs: Cost of trace in USD
|
||||
observations:
|
||||
type: list<string>
|
||||
docs: List of observation ids
|
||||
scores:
|
||||
type: list<string>
|
||||
docs: List of score ids
|
||||
TraceWithFullDetails:
|
||||
TraceWithFullDetails: # GET traces/[traceID]
|
||||
extends: Trace
|
||||
properties:
|
||||
htmlPath:
|
||||
type: string
|
||||
docs: Path of trace in Langfuse UI
|
||||
totalCost:
|
||||
type: double
|
||||
docs: Cost of trace in USD
|
||||
observations: list<ObservationsView>
|
||||
scores: list<Score>
|
||||
Session:
|
||||
@@ -72,6 +87,7 @@ types:
|
||||
calculatedInputCost: optional<double>
|
||||
calculatedOutputCost: optional<double>
|
||||
calculatedTotalCost: optional<double>
|
||||
latency: optional<double>
|
||||
|
||||
Usage:
|
||||
properties:
|
||||
|
||||
@@ -128,6 +128,7 @@ types:
|
||||
TraceBody:
|
||||
properties:
|
||||
id: optional<string>
|
||||
timestamp: optional<datetime>
|
||||
name: optional<string>
|
||||
userId: optional<string>
|
||||
input: optional<unknown>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/fern-api/fern/main/fern.schema.json
|
||||
imports:
|
||||
pagination: ./utils/pagination.yml
|
||||
commons: ./commons.yml
|
||||
service:
|
||||
auth: true
|
||||
base-path: /api/public
|
||||
endpoints:
|
||||
daily:
|
||||
docs: Get daily metrics of the Langfuse project
|
||||
method: GET
|
||||
path: /metrics/daily
|
||||
request:
|
||||
name: GetDailyMetricsRequest
|
||||
query-parameters:
|
||||
page: optional<integer>
|
||||
limit: optional<integer>
|
||||
traceName:
|
||||
type: optional<string>
|
||||
docs: Optional filter by the name of the trace
|
||||
userId:
|
||||
type: optional<string>
|
||||
docs: Optional filter by the userId associated with the trace
|
||||
tags:
|
||||
type: optional<string>
|
||||
allow-multiple: true
|
||||
docs: Optional filter for metrics where traces include all of these tags
|
||||
response: DailyMetrics
|
||||
types:
|
||||
DailyMetrics:
|
||||
properties:
|
||||
data:
|
||||
type: list<DailyMetricsDetails>
|
||||
docs: A list of daily metrics, only days with ingested data are included.
|
||||
meta: pagination.MetaResponse
|
||||
DailyMetricsDetails:
|
||||
properties:
|
||||
date: date
|
||||
countTraces: integer
|
||||
totalCost: double
|
||||
usage: list<UsageByModel>
|
||||
UsageByModel:
|
||||
docs: Daily usage of a given model. Usage corresponds to the unit set for the specific model (e.g. tokens).
|
||||
properties:
|
||||
model: string
|
||||
inputUsage: integer
|
||||
outputUsage: integer
|
||||
totalUsage: integer
|
||||
@@ -29,8 +29,11 @@ types:
|
||||
name: string
|
||||
isActive: boolean
|
||||
prompt: string
|
||||
config: optional<unknown>
|
||||
|
||||
Prompt:
|
||||
properties:
|
||||
name: string
|
||||
version: integer
|
||||
prompt: string
|
||||
config: unknown
|
||||
|
||||
@@ -24,6 +24,14 @@ service:
|
||||
userId: optional<string>
|
||||
name: optional<string>
|
||||
response: Scores
|
||||
delete:
|
||||
docs: Delete a score
|
||||
method: DELETE
|
||||
path: /scores/{scoreId}
|
||||
path-parameters:
|
||||
scoreId:
|
||||
type: string
|
||||
docs: The unique langfuse identifier of a score
|
||||
types:
|
||||
CreateScoreRequest:
|
||||
properties:
|
||||
|
||||
@@ -387,6 +387,81 @@ paths:
|
||||
$ref: '#/components/schemas/IngestionEvent'
|
||||
required:
|
||||
- batch
|
||||
/api/public/metrics/daily:
|
||||
get:
|
||||
description: Get daily metrics of the Langfuse project
|
||||
operationId: metrics_daily
|
||||
tags:
|
||||
- Metrics
|
||||
parameters:
|
||||
- name: page
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
nullable: true
|
||||
- name: limit
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
nullable: true
|
||||
- name: traceName
|
||||
in: query
|
||||
description: Optional filter by the name of the trace
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
nullable: true
|
||||
- name: userId
|
||||
in: query
|
||||
description: Optional filter by the userId associated with the trace
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
nullable: true
|
||||
- name: tags
|
||||
in: query
|
||||
description: Optional filter for metrics where traces include all of these tags
|
||||
required: false
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DailyMetrics'
|
||||
'400':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'401':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'403':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'404':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'405':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
/api/public/observations/{observationId}:
|
||||
get:
|
||||
description: Get a specific observation
|
||||
@@ -761,6 +836,48 @@ paths:
|
||||
application/json:
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
/api/public/scores/{scoreId}:
|
||||
delete:
|
||||
description: Delete a score
|
||||
operationId: score_delete
|
||||
tags:
|
||||
- Score
|
||||
parameters:
|
||||
- name: scoreId
|
||||
in: path
|
||||
description: The unique langfuse identifier of a score
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'204':
|
||||
description: ''
|
||||
'400':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'401':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'403':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'404':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'405':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
/api/public/sessions/{sessionId}:
|
||||
get:
|
||||
description: Get a session
|
||||
@@ -983,6 +1100,17 @@ components:
|
||||
title: TraceWithDetails
|
||||
type: object
|
||||
properties:
|
||||
htmlPath:
|
||||
type: string
|
||||
description: Path of trace in Langfuse UI
|
||||
latency:
|
||||
type: number
|
||||
format: double
|
||||
description: Latency of trace in seconds
|
||||
totalCost:
|
||||
type: number
|
||||
format: double
|
||||
description: Cost of trace in USD
|
||||
observations:
|
||||
type: array
|
||||
items:
|
||||
@@ -994,6 +1122,9 @@ components:
|
||||
type: string
|
||||
description: List of score ids
|
||||
required:
|
||||
- htmlPath
|
||||
- latency
|
||||
- totalCost
|
||||
- observations
|
||||
- scores
|
||||
allOf:
|
||||
@@ -1002,6 +1133,13 @@ components:
|
||||
title: TraceWithFullDetails
|
||||
type: object
|
||||
properties:
|
||||
htmlPath:
|
||||
type: string
|
||||
description: Path of trace in Langfuse UI
|
||||
totalCost:
|
||||
type: number
|
||||
format: double
|
||||
description: Cost of trace in USD
|
||||
observations:
|
||||
type: array
|
||||
items:
|
||||
@@ -1011,6 +1149,8 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/Score'
|
||||
required:
|
||||
- htmlPath
|
||||
- totalCost
|
||||
- observations
|
||||
- scores
|
||||
allOf:
|
||||
@@ -1134,6 +1274,10 @@ components:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
latency:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Observation'
|
||||
Usage:
|
||||
@@ -1720,6 +1864,10 @@ components:
|
||||
id:
|
||||
type: string
|
||||
nullable: true
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
name:
|
||||
type: string
|
||||
nullable: true
|
||||
@@ -1936,6 +2084,60 @@ components:
|
||||
required:
|
||||
- successes
|
||||
- errors
|
||||
DailyMetrics:
|
||||
title: DailyMetrics
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/DailyMetricsDetails'
|
||||
description: A list of daily metrics, only days with ingested data are included.
|
||||
meta:
|
||||
$ref: '#/components/schemas/utilsMetaResponse'
|
||||
required:
|
||||
- data
|
||||
- meta
|
||||
DailyMetricsDetails:
|
||||
title: DailyMetricsDetails
|
||||
type: object
|
||||
properties:
|
||||
date:
|
||||
type: string
|
||||
countTraces:
|
||||
type: integer
|
||||
totalCost:
|
||||
type: number
|
||||
format: double
|
||||
usage:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/UsageByModel'
|
||||
required:
|
||||
- date
|
||||
- countTraces
|
||||
- totalCost
|
||||
- usage
|
||||
UsageByModel:
|
||||
title: UsageByModel
|
||||
type: object
|
||||
description: >-
|
||||
Daily usage of a given model. Usage corresponds to the unit set for the
|
||||
specific model (e.g. tokens).
|
||||
properties:
|
||||
model:
|
||||
type: string
|
||||
inputUsage:
|
||||
type: integer
|
||||
outputUsage:
|
||||
type: integer
|
||||
totalUsage:
|
||||
type: integer
|
||||
required:
|
||||
- model
|
||||
- inputUsage
|
||||
- outputUsage
|
||||
- totalUsage
|
||||
Observations:
|
||||
title: Observations
|
||||
type: object
|
||||
@@ -1993,6 +2195,8 @@ components:
|
||||
type: boolean
|
||||
prompt:
|
||||
type: string
|
||||
config:
|
||||
nullable: true
|
||||
required:
|
||||
- name
|
||||
- isActive
|
||||
@@ -2007,10 +2211,12 @@ components:
|
||||
type: integer
|
||||
prompt:
|
||||
type: string
|
||||
config: {}
|
||||
required:
|
||||
- name
|
||||
- version
|
||||
- prompt
|
||||
- config
|
||||
CreateScoreRequest:
|
||||
title: CreateScoreRequest
|
||||
type: object
|
||||
|
||||
@@ -320,7 +320,7 @@
|
||||
"auth": null,
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"batch\": [\n {\n \"type\": \"trace-create\",\n \"body\": {\n \"id\": \"example\",\n \"name\": \"example\",\n \"userId\": \"example\",\n \"input\": \"UNKNOWN\",\n \"output\": \"UNKNOWN\",\n \"sessionId\": \"example\",\n \"release\": \"example\",\n \"version\": \"example\",\n \"metadata\": \"UNKNOWN\",\n \"tags\": [\n \"example\"\n ],\n \"public\": true\n },\n \"id\": \"example\",\n \"timestamp\": \"example\",\n \"metadata\": \"UNKNOWN\"\n }\n ]\n}",
|
||||
"raw": "{\n \"batch\": [\n {\n \"type\": \"trace-create\",\n \"body\": {\n \"id\": \"example\",\n \"timestamp\": \"1994-11-05T13:15:30Z\",\n \"name\": \"example\",\n \"userId\": \"example\",\n \"input\": \"UNKNOWN\",\n \"output\": \"UNKNOWN\",\n \"sessionId\": \"example\",\n \"release\": \"example\",\n \"version\": \"example\",\n \"metadata\": \"UNKNOWN\",\n \"tags\": [\n \"example\"\n ],\n \"public\": true\n },\n \"id\": \"example\",\n \"timestamp\": \"example\",\n \"metadata\": \"UNKNOWN\"\n }\n ]\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
@@ -332,6 +332,65 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "container",
|
||||
"description": null,
|
||||
"name": "Metrics",
|
||||
"item": [
|
||||
{
|
||||
"_type": "endpoint",
|
||||
"name": "Daily",
|
||||
"request": {
|
||||
"description": "Get daily metrics of the Langfuse project",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/metrics/daily?page=&limit=&traceName=&userId=&tags=",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"public",
|
||||
"metrics",
|
||||
"daily"
|
||||
],
|
||||
"query": [
|
||||
{
|
||||
"key": "page",
|
||||
"value": "",
|
||||
"description": null
|
||||
},
|
||||
{
|
||||
"key": "limit",
|
||||
"value": "",
|
||||
"description": null
|
||||
},
|
||||
{
|
||||
"key": "traceName",
|
||||
"value": "",
|
||||
"description": "Optional filter by the name of the trace"
|
||||
},
|
||||
{
|
||||
"key": "userId",
|
||||
"value": "",
|
||||
"description": "Optional filter by the userId associated with the trace"
|
||||
},
|
||||
{
|
||||
"key": "tags",
|
||||
"value": "",
|
||||
"description": "Optional filter for metrics where traces include all of these tags"
|
||||
}
|
||||
],
|
||||
"variable": []
|
||||
},
|
||||
"header": [],
|
||||
"method": "GET",
|
||||
"auth": null,
|
||||
"body": null
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "container",
|
||||
"description": null,
|
||||
@@ -528,7 +587,7 @@
|
||||
"auth": null,
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"name\": \"example\",\n \"isActive\": true,\n \"prompt\": \"example\"\n}",
|
||||
"raw": "{\n \"name\": \"example\",\n \"isActive\": true,\n \"prompt\": \"example\",\n \"config\": \"UNKNOWN\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
@@ -623,6 +682,38 @@
|
||||
"body": null
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"_type": "endpoint",
|
||||
"name": "Delete",
|
||||
"request": {
|
||||
"description": "Delete a score",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/scores/:scoreId",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"public",
|
||||
"scores",
|
||||
":scoreId"
|
||||
],
|
||||
"query": [],
|
||||
"variable": [
|
||||
{
|
||||
"key": "scoreId",
|
||||
"value": "",
|
||||
"description": "The unique langfuse identifier of a score"
|
||||
}
|
||||
]
|
||||
},
|
||||
"header": [],
|
||||
"method": "DELETE",
|
||||
"auth": null,
|
||||
"body": null
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
Generated
+1716
-580
File diff suppressed because it is too large
Load Diff
+16
-14
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "langfuse-core",
|
||||
"version": "2.3.0",
|
||||
"version": "2.7.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"prebuild": "cp generated/openapi-client/openapi.yml public/openapi-client.yml && cp generated/openapi-server/openapi.yml public/openapi-server.yml",
|
||||
@@ -31,6 +31,7 @@
|
||||
"dependencies": {
|
||||
"@anthropic-ai/tokenizer": "^0.0.4",
|
||||
"@aws-sdk/client-s3": "^3.507.0",
|
||||
"@aws-sdk/lib-storage": "^3.511.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.507.0",
|
||||
"@headlessui/react": "^1.7.18",
|
||||
"@heroicons/react": "^2.1.1",
|
||||
@@ -59,8 +60,8 @@
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@react-email/components": "^0.0.14",
|
||||
"@react-email/render": "^0.0.12",
|
||||
"@sentry/nextjs": "^7.99.0",
|
||||
"@sentry/profiling-node": "^1.3.5",
|
||||
"@sentry/nextjs": "^7.101.1",
|
||||
"@sentry/profiling-node": "^7.101.1",
|
||||
"@sentry/types": "^7.88.0",
|
||||
"@t3-oss/env-nextjs": "^0.8.0",
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
@@ -71,25 +72,26 @@
|
||||
"@trpc/next": "^10.45.0",
|
||||
"@trpc/react-query": "^10.45.0",
|
||||
"@trpc/server": "^10.45.0",
|
||||
"@vercel/edge-config": "^0.4.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"cmdk": "^0.2.1",
|
||||
"core-js": "^3.35.1",
|
||||
"core-js": "^3.36.0",
|
||||
"cors": "^2.8.5",
|
||||
"date-fns": "^3.3.1",
|
||||
"decimal.js": "^10.4.3",
|
||||
"exponential-backoff": "^3.1.1",
|
||||
"js-tiktoken": "^1.0.10",
|
||||
"kysely": "^0.27.2",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.323.0",
|
||||
"lucide-react": "^0.330.0",
|
||||
"next": "^14.1.0",
|
||||
"next-auth": "^4.24.5",
|
||||
"next-auth": "^4.24.6",
|
||||
"next-query-params": "^5.0.0",
|
||||
"nodemailer": "^6.9.9",
|
||||
"posthog-js": "^1.104.4",
|
||||
"posthog-node": "^3.6.2",
|
||||
"posthog-js": "^1.105.9",
|
||||
"posthog-node": "^3.6.3",
|
||||
"prisma-kysely": "^1.8.0",
|
||||
"react": "18.2.0",
|
||||
"react-day-picker": "^8.10.0",
|
||||
"react-dom": "18.2.0",
|
||||
@@ -120,9 +122,9 @@
|
||||
"@types/node": "20.10.5",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"@types/react": "^18.2.55",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@types/react-dom": "^18.2.19",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@typescript-eslint/eslint-plugin": "^6.20.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"dotenv-cli": "^7.3.0",
|
||||
@@ -130,16 +132,16 @@
|
||||
"eslint-config-next": "^14.1.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"postcss": "^8.4.34",
|
||||
"postcss": "^8.4.35",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-tailwindcss": "^0.5.11",
|
||||
"prisma": "^5.9.1",
|
||||
"prisma-erd-generator": "^1.11.2",
|
||||
"release-it": "^17.0.3",
|
||||
"release-it": "^17.0.5",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.7.0",
|
||||
"tsx": "^4.7.1",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"ct3aMetadata": {
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
import type { ColumnType } from "kysely";
|
||||
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
|
||||
? ColumnType<S, I | undefined, U>
|
||||
: ColumnType<T, T | undefined, T>;
|
||||
export type Timestamp = ColumnType<Date, Date | string, Date | string>;
|
||||
|
||||
export const MembershipRole = {
|
||||
OWNER: "OWNER",
|
||||
ADMIN: "ADMIN",
|
||||
MEMBER: "MEMBER",
|
||||
VIEWER: "VIEWER"
|
||||
} as const;
|
||||
export type MembershipRole = (typeof MembershipRole)[keyof typeof MembershipRole];
|
||||
export const ObservationType = {
|
||||
SPAN: "SPAN",
|
||||
EVENT: "EVENT",
|
||||
GENERATION: "GENERATION"
|
||||
} as const;
|
||||
export type ObservationType = (typeof ObservationType)[keyof typeof ObservationType];
|
||||
export const ObservationLevel = {
|
||||
DEBUG: "DEBUG",
|
||||
DEFAULT: "DEFAULT",
|
||||
WARNING: "WARNING",
|
||||
ERROR: "ERROR"
|
||||
} as const;
|
||||
export type ObservationLevel = (typeof ObservationLevel)[keyof typeof ObservationLevel];
|
||||
export const PricingUnit = {
|
||||
PER_1000_TOKENS: "PER_1000_TOKENS",
|
||||
PER_1000_CHARS: "PER_1000_CHARS"
|
||||
} as const;
|
||||
export type PricingUnit = (typeof PricingUnit)[keyof typeof PricingUnit];
|
||||
export const TokenType = {
|
||||
PROMPT: "PROMPT",
|
||||
COMPLETION: "COMPLETION",
|
||||
TOTAL: "TOTAL"
|
||||
} as const;
|
||||
export type TokenType = (typeof TokenType)[keyof typeof TokenType];
|
||||
export const DatasetStatus = {
|
||||
ACTIVE: "ACTIVE",
|
||||
ARCHIVED: "ARCHIVED"
|
||||
} as const;
|
||||
export type DatasetStatus = (typeof DatasetStatus)[keyof typeof DatasetStatus];
|
||||
export type Account = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
type: string;
|
||||
provider: string;
|
||||
providerAccountId: string;
|
||||
refresh_token: string | null;
|
||||
access_token: string | null;
|
||||
expires_at: number | null;
|
||||
expires_in: number | null;
|
||||
ext_expires_in: number | null;
|
||||
token_type: string | null;
|
||||
scope: string | null;
|
||||
id_token: string | null;
|
||||
session_state: string | null;
|
||||
};
|
||||
export type ApiKey = {
|
||||
id: string;
|
||||
created_at: Generated<Timestamp>;
|
||||
note: string | null;
|
||||
public_key: string;
|
||||
hashed_secret_key: string;
|
||||
fast_hashed_secret_key: string | null;
|
||||
display_secret_key: string;
|
||||
last_used_at: Timestamp | null;
|
||||
expires_at: Timestamp | null;
|
||||
project_id: string;
|
||||
};
|
||||
export type AuditLog = {
|
||||
id: string;
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
user_id: string;
|
||||
project_id: string;
|
||||
user_project_role: MembershipRole;
|
||||
resource_type: string;
|
||||
resource_id: string;
|
||||
action: string;
|
||||
before: string | null;
|
||||
after: string | null;
|
||||
};
|
||||
export type CronJobs = {
|
||||
name: string;
|
||||
last_run: Timestamp | null;
|
||||
job_started_at: Timestamp | null;
|
||||
state: string | null;
|
||||
};
|
||||
export type Dataset = {
|
||||
id: string;
|
||||
name: string;
|
||||
project_id: string;
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
};
|
||||
export type DatasetItem = {
|
||||
id: string;
|
||||
status: Generated<DatasetStatus>;
|
||||
input: unknown;
|
||||
expected_output: unknown | null;
|
||||
source_observation_id: string | null;
|
||||
dataset_id: string;
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
};
|
||||
export type DatasetRunItems = {
|
||||
id: string;
|
||||
dataset_run_id: string;
|
||||
dataset_item_id: string;
|
||||
observation_id: string;
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
};
|
||||
export type DatasetRuns = {
|
||||
id: string;
|
||||
name: string;
|
||||
dataset_id: string;
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
};
|
||||
export type Events = {
|
||||
id: string;
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
project_id: string;
|
||||
data: unknown;
|
||||
headers: Generated<unknown>;
|
||||
url: string | null;
|
||||
method: string | null;
|
||||
};
|
||||
export type Example = {
|
||||
id: string;
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Timestamp;
|
||||
};
|
||||
export type Membership = {
|
||||
project_id: string;
|
||||
user_id: string;
|
||||
role: MembershipRole;
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
};
|
||||
export type MembershipInvitation = {
|
||||
id: string;
|
||||
email: string;
|
||||
role: MembershipRole;
|
||||
project_id: string;
|
||||
sender_id: string | null;
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
};
|
||||
export type Model = {
|
||||
id: string;
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
project_id: string | null;
|
||||
model_name: string;
|
||||
match_pattern: string;
|
||||
start_date: Timestamp | null;
|
||||
input_price: string | null;
|
||||
output_price: string | null;
|
||||
total_price: string | null;
|
||||
unit: string;
|
||||
tokenizer_id: string | null;
|
||||
tokenizer_config: unknown | null;
|
||||
};
|
||||
export type Observation = {
|
||||
id: string;
|
||||
trace_id: string | null;
|
||||
project_id: string;
|
||||
type: ObservationType;
|
||||
start_time: Generated<Timestamp>;
|
||||
end_time: Timestamp | null;
|
||||
name: string | null;
|
||||
metadata: unknown | null;
|
||||
parent_observation_id: string | null;
|
||||
level: Generated<ObservationLevel>;
|
||||
status_message: string | null;
|
||||
version: string | null;
|
||||
created_at: Generated<Timestamp>;
|
||||
model: string | null;
|
||||
internal_model: string | null;
|
||||
modelParameters: unknown | null;
|
||||
input: unknown | null;
|
||||
output: unknown | null;
|
||||
prompt_tokens: Generated<number>;
|
||||
completion_tokens: Generated<number>;
|
||||
total_tokens: Generated<number>;
|
||||
unit: string | null;
|
||||
input_cost: string | null;
|
||||
output_cost: string | null;
|
||||
total_cost: string | null;
|
||||
completion_start_time: Timestamp | null;
|
||||
prompt_id: string | null;
|
||||
};
|
||||
export type ObservationView = {
|
||||
id: string;
|
||||
trace_id: string | null;
|
||||
project_id: string;
|
||||
type: ObservationType;
|
||||
start_time: Generated<Timestamp>;
|
||||
end_time: Timestamp | null;
|
||||
name: string | null;
|
||||
metadata: unknown | null;
|
||||
parent_observation_id: string | null;
|
||||
level: Generated<ObservationLevel>;
|
||||
status_message: string | null;
|
||||
version: string | null;
|
||||
created_at: Generated<Timestamp>;
|
||||
model: string | null;
|
||||
modelParameters: unknown | null;
|
||||
input: unknown | null;
|
||||
output: unknown | null;
|
||||
prompt_tokens: Generated<number>;
|
||||
completion_tokens: Generated<number>;
|
||||
total_tokens: Generated<number>;
|
||||
unit: string | null;
|
||||
completion_start_time: Timestamp | null;
|
||||
prompt_id: string | null;
|
||||
model_id: string | null;
|
||||
input_price: string | null;
|
||||
output_price: string | null;
|
||||
total_price: string | null;
|
||||
calculated_input_cost: string | null;
|
||||
calculated_output_cost: string | null;
|
||||
calculated_total_cost: string | null;
|
||||
latency: string | null;
|
||||
};
|
||||
export type Pricing = {
|
||||
id: string;
|
||||
model_name: string;
|
||||
pricing_unit: Generated<PricingUnit>;
|
||||
price: string;
|
||||
currency: Generated<string>;
|
||||
token_type: TokenType;
|
||||
};
|
||||
export type Project = {
|
||||
id: string;
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
name: string;
|
||||
cloud_config: unknown | null;
|
||||
};
|
||||
export type Prompt = {
|
||||
id: string;
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
project_id: string;
|
||||
created_by: string;
|
||||
prompt: string;
|
||||
name: string;
|
||||
version: number;
|
||||
is_active: boolean;
|
||||
config: Generated<unknown>;
|
||||
};
|
||||
export type Score = {
|
||||
id: string;
|
||||
timestamp: Generated<Timestamp>;
|
||||
name: string;
|
||||
value: number;
|
||||
comment: string | null;
|
||||
trace_id: string;
|
||||
observation_id: string | null;
|
||||
};
|
||||
export type Session = {
|
||||
id: string;
|
||||
session_token: string;
|
||||
user_id: string;
|
||||
expires: Timestamp;
|
||||
};
|
||||
export type Trace = {
|
||||
id: string;
|
||||
external_id: string | null;
|
||||
timestamp: Generated<Timestamp>;
|
||||
name: string | null;
|
||||
user_id: string | null;
|
||||
metadata: unknown | null;
|
||||
release: string | null;
|
||||
version: string | null;
|
||||
project_id: string;
|
||||
public: Generated<boolean>;
|
||||
bookmarked: Generated<boolean>;
|
||||
tags: Generated<string[]>;
|
||||
input: unknown | null;
|
||||
output: unknown | null;
|
||||
session_id: string | null;
|
||||
};
|
||||
export type TraceSession = {
|
||||
id: string;
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
project_id: string;
|
||||
bookmarked: Generated<boolean>;
|
||||
public: Generated<boolean>;
|
||||
};
|
||||
export type User = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
email_verified: Timestamp | null;
|
||||
password: string | null;
|
||||
image: string | null;
|
||||
admin: Generated<boolean>;
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
feature_flags: Generated<string[]>;
|
||||
};
|
||||
export type VerificationToken = {
|
||||
identifier: string;
|
||||
token: string;
|
||||
expires: Timestamp;
|
||||
};
|
||||
export type DB = {
|
||||
Account: Account;
|
||||
api_keys: ApiKey;
|
||||
audit_logs: AuditLog;
|
||||
cron_jobs: CronJobs;
|
||||
dataset_items: DatasetItem;
|
||||
dataset_run_items: DatasetRunItems;
|
||||
dataset_runs: DatasetRuns;
|
||||
datasets: Dataset;
|
||||
events: Events;
|
||||
Example: Example;
|
||||
membership_invitations: MembershipInvitation;
|
||||
memberships: Membership;
|
||||
models: Model;
|
||||
observations: Observation;
|
||||
observations_view: ObservationView;
|
||||
pricings: Pricing;
|
||||
projects: Project;
|
||||
prompts: Prompt;
|
||||
scores: Score;
|
||||
Session: Session;
|
||||
trace_sessions: TraceSession;
|
||||
traces: Trace;
|
||||
users: User;
|
||||
verification_tokens: VerificationToken;
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "audit_logs" (
|
||||
"id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"project_id" TEXT NOT NULL,
|
||||
"user_project_role" "MembershipRole" NOT NULL,
|
||||
"resource_type" TEXT NOT NULL,
|
||||
"resource_id" TEXT NOT NULL,
|
||||
"action" TEXT NOT NULL,
|
||||
"before" TEXT,
|
||||
"after" TEXT,
|
||||
|
||||
CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audit_logs_project_id_idx" ON "audit_logs"("project_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audit_logs_created_at_idx" ON "audit_logs"("created_at");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- This is an empty migration.
|
||||
|
||||
DELETE FROM models
|
||||
WHERE id in ('clruwnahl00040al78f1lb0at');
|
||||
|
||||
|
||||
|
||||
INSERT INTO models (
|
||||
id,
|
||||
project_id,
|
||||
model_name,
|
||||
match_pattern,
|
||||
start_date,
|
||||
input_price,
|
||||
output_price,
|
||||
total_price,
|
||||
unit,
|
||||
tokenizer_id,
|
||||
tokenizer_config
|
||||
)
|
||||
VALUES
|
||||
-- according to email, gpt-3.5-turbo and gpt-3.5-turbo-16k will point to 0125 models as of 2024-02-16
|
||||
-- gpt-3.5-turbo-0125 now supports 16k token length. 16k model will point to regular 3.5 turbo model according to mail.
|
||||
('clruwnahl00040al78f1lb0at', NULL, 'gpt-3.5-turbo', '(?i)^(gpt-)(35|3.5)(-turbo)$', '2024-02-16', 0.0000005, 0.0000015, NULL, 'TOKENS', 'openai', '{ "tokensPerMessage": 3, "tokensPerName": 1, "tokenizerModel": "gpt-3.5-turbo" }'),
|
||||
('clsk9lntu000008jwfc51bbqv', NULL, 'gpt-3.5-turbo-16k', '(?i)^(gpt-)(35|3.5)(-turbo-16k)$', '2024-02-16', 0.0000005, 0.0000015, NULL, 'TOKENS', 'openai', '{ "tokensPerMessage": 3, "tokensPerName": 1, "tokenizerModel": "gpt-3.5-turbo-16k" }')
|
||||
@@ -0,0 +1,5 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX "prompts_project_id_id_idx" ON "prompts"("project_id", "id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "prompts_project_id_idx" ON "prompts"("project_id");
|
||||
@@ -0,0 +1,17 @@
|
||||
-- This is an empty migration.
|
||||
|
||||
INSERT INTO models (
|
||||
id,
|
||||
project_id,
|
||||
model_name,
|
||||
match_pattern,
|
||||
start_date,
|
||||
input_price,
|
||||
output_price,
|
||||
total_price,
|
||||
unit,
|
||||
tokenizer_id,
|
||||
tokenizer_config
|
||||
)
|
||||
VALUES
|
||||
('clsnq07bn000008l4e46v1ll8', NULL, 'gpt-4-turbo-preview', '(?i)^(gpt-4-turbo-preview)$', '2023-11-06', 0.00001, 0.00003, NULL, 'TOKENS', 'openai', '{ "tokensPerMessage": 3, "tokensPerName": 1, "tokenizerModel": "gpt-4" }')
|
||||
@@ -0,0 +1,50 @@
|
||||
CREATE OR REPLACE VIEW "observations_view" AS
|
||||
SELECT
|
||||
o.*,
|
||||
m.id AS "model_id",
|
||||
m.start_date AS "model_start_date",
|
||||
m.input_price,
|
||||
m.output_price,
|
||||
m.total_price,
|
||||
m.tokenizer_config AS "tokenizer_config",
|
||||
CASE
|
||||
WHEN o.input_cost IS NULL AND o.output_cost IS NULL AND o.total_cost IS NULL THEN
|
||||
o.prompt_tokens::decimal * m.input_price
|
||||
ELSE
|
||||
o.input_cost
|
||||
END AS "calculated_input_cost",
|
||||
CASE
|
||||
WHEN o.input_cost IS NULL AND o.output_cost IS NULL AND o.total_cost IS NULL THEN
|
||||
o.completion_tokens::decimal * m.output_price
|
||||
ELSE
|
||||
o.output_cost
|
||||
END AS "calculated_output_cost",
|
||||
CASE
|
||||
WHEN o.input_cost IS NULL AND o.output_cost IS NULL AND o.total_cost IS NULL THEN
|
||||
CASE
|
||||
WHEN m.total_price IS NOT NULL AND o.total_tokens IS NOT NULL THEN
|
||||
m.total_price * o.total_tokens
|
||||
ELSE
|
||||
o.prompt_tokens::decimal * m.input_price +
|
||||
o.completion_tokens::decimal * m.output_price
|
||||
END
|
||||
ELSE
|
||||
o.total_cost
|
||||
END AS "calculated_total_cost",
|
||||
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
|
||||
LEFT JOIN models m ON m.id = (
|
||||
SELECT
|
||||
id
|
||||
FROM
|
||||
models
|
||||
WHERE (project_id = o.project_id OR project_id IS NULL)
|
||||
AND model_name = o.internal_model
|
||||
AND (start_date < o.start_time OR start_date is NULL)
|
||||
AND o.unit::TEXT = unit
|
||||
ORDER BY
|
||||
project_id ASC, -- in postgres, NULLs are sorted last when ordering ASC
|
||||
start_date DESC NULLS LAST -- now, NULLs are sorted last when ordering DESC as well
|
||||
LIMIT 1
|
||||
)
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "prompts" ADD COLUMN "config" JSONB NOT NULL DEFAULT '{}';
|
||||
@@ -21,6 +21,17 @@ generator erd {
|
||||
output = "database.svg"
|
||||
}
|
||||
|
||||
generator kysely {
|
||||
provider = "prisma-kysely"
|
||||
|
||||
// Optionally provide a destination directory for the generated file
|
||||
// and a filename of your choice
|
||||
// output = "../src/db"
|
||||
// fileName = "types.ts"
|
||||
// Optionally generate runtime enums to a separate file
|
||||
// enumFileName = "enums.ts"
|
||||
}
|
||||
|
||||
model Example {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
@@ -72,6 +83,7 @@ model User {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
featureFlags String[] @default([]) @map("feature_flags")
|
||||
AuditLog AuditLog[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -101,6 +113,7 @@ model Project {
|
||||
sessions TraceSession[]
|
||||
Prompt Prompt[]
|
||||
Model Model[]
|
||||
AuditLog AuditLog[]
|
||||
|
||||
@@map("projects")
|
||||
}
|
||||
@@ -419,10 +432,13 @@ model Prompt {
|
||||
name String
|
||||
version Int
|
||||
isActive Boolean @map("is_active")
|
||||
config Json @default("{}")
|
||||
Observation Observation[]
|
||||
|
||||
@@unique([projectId, name, version])
|
||||
@@index([projectId, name, version])
|
||||
@@index([projectId, id])
|
||||
@@index([projectId])
|
||||
@@map("prompts")
|
||||
}
|
||||
|
||||
@@ -489,6 +505,27 @@ view ObservationView {
|
||||
calculatedInputCost Decimal? @map("calculated_input_cost")
|
||||
calculatedOutputCost Decimal? @map("calculated_output_cost")
|
||||
calculatedTotalCost Decimal? @map("calculated_total_cost")
|
||||
latency Decimal? @map("latency")
|
||||
|
||||
@@map("observations_view")
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
userId String @map("user_id")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
projectId String @map("project_id")
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
userProjectRole MembershipRole @map("user_project_role")
|
||||
resourceType String @map("resource_type")
|
||||
resourceId String @map("resource_id")
|
||||
action String
|
||||
before String? //stringified JSON
|
||||
after String? // stringified JSON
|
||||
|
||||
@@index([projectId])
|
||||
@@index([createdAt])
|
||||
@@map("audit_logs")
|
||||
}
|
||||
|
||||
+18
-4
@@ -176,7 +176,10 @@ async function main() {
|
||||
projectId: project2.id,
|
||||
createdBy: "user-1",
|
||||
prompt: "Prompt 4 version 1 content with {{variable}}",
|
||||
name: "Prompt 4 with variable",
|
||||
name: "Prompt 4 with variable and config",
|
||||
config: {
|
||||
temperature: 0.7,
|
||||
},
|
||||
version: 1,
|
||||
isActive: false,
|
||||
},
|
||||
@@ -185,7 +188,11 @@ async function main() {
|
||||
projectId: project2.id,
|
||||
createdBy: "user-1",
|
||||
prompt: "Prompt 4 version 2 content with {{variable}}",
|
||||
name: "Prompt 4 with variable",
|
||||
name: "Prompt 4 with variable and config",
|
||||
config: {
|
||||
temperature: 0.7,
|
||||
topP: 0.9,
|
||||
},
|
||||
version: 2,
|
||||
isActive: true,
|
||||
},
|
||||
@@ -194,7 +201,12 @@ async function main() {
|
||||
projectId: project2.id,
|
||||
createdBy: "user-1",
|
||||
prompt: "Prompt 4 version 3 content with {{variable}}",
|
||||
name: "Prompt 4 with variable",
|
||||
name: "Prompt 4 with variable and config",
|
||||
config: {
|
||||
temperature: 0.7,
|
||||
topP: 0.9,
|
||||
frequencyPenalty: 0.5,
|
||||
},
|
||||
version: 3,
|
||||
isActive: false,
|
||||
},
|
||||
@@ -208,13 +220,14 @@ async function main() {
|
||||
createdBy: version.createdBy,
|
||||
prompt: version.prompt,
|
||||
name: version.name,
|
||||
config: version.config,
|
||||
version: version.version,
|
||||
isActive: version.isActive,
|
||||
},
|
||||
});
|
||||
promptIds.push(version.id);
|
||||
}
|
||||
const promptName = "Prompt with Longer Name";
|
||||
const promptName = "Prompt with many versions";
|
||||
const projectId = project2.id;
|
||||
const createdBy = "user-1";
|
||||
|
||||
@@ -267,6 +280,7 @@ async function main() {
|
||||
] as string,
|
||||
metadata: {
|
||||
user: `user-${i}@langfuse.com`,
|
||||
more: "1,2,3;4?6",
|
||||
},
|
||||
tags: tags as string[],
|
||||
project: {
|
||||
|
||||
@@ -239,8 +239,23 @@ describe("cost retrieval tests", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it(`should prioritize latest models`, async () => {
|
||||
await pruneDatabase();
|
||||
await prisma.model.create({
|
||||
data: {
|
||||
id: "model-0",
|
||||
modelName: "gpt-3.5-turbo",
|
||||
inputPrice: "0.0000000",
|
||||
outputPrice: "0.0000000",
|
||||
totalPrice: "0.1",
|
||||
matchPattern: "(.*)(gpt-)(35|3.5)(-turbo)?(.*)",
|
||||
projectId: null,
|
||||
startDate: null,
|
||||
tokenizerConfig: { tokensPerMessage: 3, tokensPerName: 1 },
|
||||
unit: ModelUsageUnit.Tokens,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.model.create({
|
||||
data: {
|
||||
@@ -305,6 +320,70 @@ describe("cost retrieval tests", () => {
|
||||
expect(view?.calculatedTotalCost?.toString()).toBe("0.0124");
|
||||
});
|
||||
|
||||
it(`should prioritize own models`, async () => {
|
||||
await pruneDatabase();
|
||||
await prisma.model.create({
|
||||
data: {
|
||||
id: "model-0",
|
||||
modelName: "gpt-3.5-turbo",
|
||||
inputPrice: "0.0000000",
|
||||
outputPrice: "0.0000000",
|
||||
totalPrice: "0.1",
|
||||
matchPattern: "(.*)(gpt-)(35|3.5)(-turbo)?(.*)",
|
||||
projectId: null,
|
||||
startDate: null,
|
||||
tokenizerConfig: { tokensPerMessage: 3, tokensPerName: 1 },
|
||||
unit: ModelUsageUnit.Tokens,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.model.create({
|
||||
data: {
|
||||
id: "model-1",
|
||||
modelName: "gpt-3.5-turbo",
|
||||
inputPrice: "0.0000010",
|
||||
outputPrice: "0.0000020",
|
||||
totalPrice: "0.1",
|
||||
matchPattern: "(.*)(gpt-)(35|3.5)(-turbo)?(.*)",
|
||||
startDate: null,
|
||||
project: { connect: { id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a" } },
|
||||
tokenizerConfig: { tokensPerMessage: 3, tokensPerName: 1 },
|
||||
unit: ModelUsageUnit.Tokens,
|
||||
},
|
||||
});
|
||||
|
||||
const dbTrace = await prisma.trace.create({
|
||||
data: {
|
||||
name: "trace-name",
|
||||
project: { connect: { id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a" } },
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.observation.create({
|
||||
data: {
|
||||
traceId: dbTrace.id,
|
||||
type: "GENERATION",
|
||||
project: { connect: { id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a" } },
|
||||
model: "gpt-3.5-turbo",
|
||||
internalModel: "gpt-3.5-turbo",
|
||||
startTime: new Date("2024-01-01T00:00:00.000Z"),
|
||||
unit: ModelUsageUnit.Tokens,
|
||||
promptTokens: 200,
|
||||
completionTokens: 3000,
|
||||
totalTokens: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const view = await prisma.observationView.findFirst({
|
||||
where: { traceId: dbTrace.id },
|
||||
});
|
||||
|
||||
console.log(view);
|
||||
|
||||
// calculated cost fields
|
||||
expect(view?.modelId).toBe("model-1");
|
||||
});
|
||||
|
||||
it(`should prioritize old model if the latest model is not own one`, async () => {
|
||||
await pruneDatabase();
|
||||
|
||||
@@ -371,6 +450,72 @@ describe("cost retrieval tests", () => {
|
||||
expect(view?.calculatedTotalCost?.toString()).toBe("0.0124");
|
||||
});
|
||||
|
||||
it(`should prioritize new model if the latest model is own one`, async () => {
|
||||
await pruneDatabase();
|
||||
|
||||
await prisma.model.create({
|
||||
data: {
|
||||
id: "model-1",
|
||||
modelName: "gpt-3.5-turbo",
|
||||
inputPrice: "0.0010",
|
||||
outputPrice: "0.0020",
|
||||
totalPrice: "0.1",
|
||||
matchPattern: "(.*)(gpt-)(35|3.5)(-turbo)?(.*)",
|
||||
startDate: null,
|
||||
tokenizerConfig: { tokensPerMessage: 3, tokensPerName: 1 },
|
||||
unit: ModelUsageUnit.Tokens,
|
||||
},
|
||||
});
|
||||
await prisma.model.create({
|
||||
data: {
|
||||
id: "model-2",
|
||||
modelName: "gpt-3.5-turbo",
|
||||
inputPrice: "0.0000020",
|
||||
outputPrice: "0.0000040",
|
||||
totalPrice: undefined,
|
||||
matchPattern: "(.*)(gpt-)(35|3.5)(-turbo)?(.*)",
|
||||
startDate: new Date("2023-12-01"),
|
||||
tokenizerConfig: { tokensPerMessage: 3, tokensPerName: 1 },
|
||||
project: { connect: { id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a" } },
|
||||
unit: ModelUsageUnit.Tokens,
|
||||
},
|
||||
});
|
||||
|
||||
const dbTrace = await prisma.trace.create({
|
||||
data: {
|
||||
name: "trace-name",
|
||||
project: { connect: { id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a" } },
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.observation.create({
|
||||
data: {
|
||||
traceId: dbTrace.id,
|
||||
type: "GENERATION",
|
||||
project: { connect: { id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a" } },
|
||||
model: "gpt-3.5-turbo",
|
||||
internalModel: "gpt-3.5-turbo",
|
||||
startTime: new Date("2024-01-01T00:00:00.000Z"),
|
||||
unit: ModelUsageUnit.Tokens,
|
||||
promptTokens: 200,
|
||||
completionTokens: 3000,
|
||||
totalTokens: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const view = await prisma.observationView.findFirst({
|
||||
where: { traceId: dbTrace.id },
|
||||
});
|
||||
|
||||
console.log(view);
|
||||
|
||||
// calculated cost fields
|
||||
expect(view?.modelId).toBe("model-2");
|
||||
expect(view?.calculatedInputCost?.toString()).toBe("0.0004");
|
||||
expect(view?.calculatedOutputCost?.toString()).toBe("0.012");
|
||||
expect(view?.calculatedTotalCost?.toString()).toBe("0.0124");
|
||||
});
|
||||
|
||||
it(`should prioritize user provided cost`, async () => {
|
||||
await pruneDatabase();
|
||||
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
/** @jest-environment node */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
|
||||
import { pruneDatabase } from "@/src/__tests__/test-utils";
|
||||
import { ModelUsageUnit } from "@/src/constants";
|
||||
import { appRouter } from "@/src/server/api/root";
|
||||
import { createInnerTRPCContext } from "@/src/server/api/trpc";
|
||||
import { prisma } from "@/src/server/db";
|
||||
import type { Session } from "next-auth";
|
||||
|
||||
describe("observations.export RPC", () => {
|
||||
const numberOfGenerations = 5;
|
||||
const projectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
|
||||
|
||||
beforeAll(async () => {
|
||||
// Disable S3 upload
|
||||
process.env.S3_ENDPOINT = "";
|
||||
|
||||
await pruneDatabase();
|
||||
const traceId = "trace-1";
|
||||
|
||||
await prisma.trace.create({
|
||||
data: {
|
||||
id: traceId,
|
||||
name: "trace-name",
|
||||
userId: "user-1",
|
||||
projectId,
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "2.0.0",
|
||||
},
|
||||
});
|
||||
|
||||
for (let i = 1; i <= numberOfGenerations; i++) {
|
||||
await prisma.observation.create({
|
||||
data: {
|
||||
type: "GENERATION",
|
||||
id: `generation-${i}`,
|
||||
name: `generation-${i}`,
|
||||
model: "gpt-3.5-turbo",
|
||||
totalCost: 1,
|
||||
startTime: new Date("2021-01-01T00:00:00.000Z"),
|
||||
endTime: new Date("2021-01-01T00:00:05.000Z"),
|
||||
project: { connect: { id: projectId } },
|
||||
traceId,
|
||||
input: [
|
||||
{
|
||||
role: "system",
|
||||
content: "Be a helpful assistant",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: "How can i create a React component?",
|
||||
},
|
||||
],
|
||||
output: {
|
||||
completion: `Creating a React component can be done in two ways.`,
|
||||
},
|
||||
metadata: {
|
||||
user: `user-@langfuse.com`,
|
||||
},
|
||||
unit: ModelUsageUnit.Tokens,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => await pruneDatabase());
|
||||
|
||||
const session: Session = {
|
||||
expires: "1",
|
||||
user: {
|
||||
id: "clgb17vnp000008jjere5g15i",
|
||||
name: "John Doe",
|
||||
projects: [
|
||||
{
|
||||
id: projectId,
|
||||
role: "ADMIN",
|
||||
name: "test",
|
||||
},
|
||||
],
|
||||
featureFlags: {
|
||||
templateFlag: true,
|
||||
},
|
||||
admin: true,
|
||||
},
|
||||
};
|
||||
|
||||
const ctx = createInnerTRPCContext({ session });
|
||||
const caller = appRouter.createCaller({ ...ctx, prisma });
|
||||
|
||||
it("should return a CSV file", async () => {
|
||||
const result = await caller.generations.export({
|
||||
fileFormat: "CSV",
|
||||
orderBy: { column: "id", order: "ASC" },
|
||||
filter: [
|
||||
{
|
||||
column: "start_time",
|
||||
type: "datetime",
|
||||
operator: ">",
|
||||
value: new Date("1990-01-01"),
|
||||
},
|
||||
],
|
||||
projectId,
|
||||
searchQuery: null,
|
||||
});
|
||||
|
||||
if (result.type !== "data")
|
||||
throw new Error("No data returned. Is S3 accidentally enabled?");
|
||||
const { data, fileName } = result;
|
||||
|
||||
const fileExtension = fileName.split(".").pop();
|
||||
expect(fileName).toContain(`lf-export-${projectId}`);
|
||||
expect(fileExtension).toBe("csv");
|
||||
expect(data.split("\n").filter(Boolean).length).toBe(
|
||||
numberOfGenerations + 1,
|
||||
);
|
||||
});
|
||||
|
||||
it("should return a JSON file", async () => {
|
||||
const result = await caller.generations.export({
|
||||
fileFormat: "JSON",
|
||||
orderBy: { column: "id", order: "ASC" },
|
||||
filter: [
|
||||
{
|
||||
column: "start_time",
|
||||
type: "datetime",
|
||||
operator: ">",
|
||||
value: new Date("1990-01-01"),
|
||||
},
|
||||
],
|
||||
projectId,
|
||||
searchQuery: null,
|
||||
});
|
||||
|
||||
if (result.type !== "data")
|
||||
throw new Error("No data returned. Is S3 accidentally enabled?");
|
||||
const { data, fileName } = result;
|
||||
|
||||
const fileExtension = fileName.split(".").pop();
|
||||
expect(fileName).toContain(`lf-export-${projectId}`);
|
||||
expect(fileExtension).toBe("json");
|
||||
|
||||
expect(JSON.parse(data).length).toBe(numberOfGenerations);
|
||||
});
|
||||
|
||||
it("should return a OPENAI-JSONL file", async () => {
|
||||
const result = await caller.generations.export({
|
||||
fileFormat: "OPENAI-JSONL",
|
||||
orderBy: { column: "id", order: "ASC" },
|
||||
filter: [
|
||||
{
|
||||
column: "start_time",
|
||||
type: "datetime",
|
||||
operator: ">",
|
||||
value: new Date("1990-01-01"),
|
||||
},
|
||||
],
|
||||
projectId,
|
||||
searchQuery: null,
|
||||
});
|
||||
|
||||
if (result.type !== "data")
|
||||
throw new Error("No data returned. Is S3 accidentally enabled?");
|
||||
const { data, fileName } = result;
|
||||
|
||||
const fileExtension = fileName.split(".").pop();
|
||||
expect(fileName).toContain(`lf-export-${projectId}`);
|
||||
expect(fileExtension).toBe("jsonl");
|
||||
|
||||
expect(data.split("\n").filter(Boolean).length).toBe(numberOfGenerations);
|
||||
});
|
||||
|
||||
it("should throw on unsupported file formats", async () => {
|
||||
const unsupportedFileFormat = "XLSX";
|
||||
|
||||
const call = caller.generations.export({
|
||||
fileFormat: unsupportedFileFormat as unknown as "JSON",
|
||||
orderBy: { column: "id", order: "ASC" },
|
||||
filter: [
|
||||
{
|
||||
column: "start_time",
|
||||
type: "datetime",
|
||||
operator: ">",
|
||||
value: new Date("1990-01-01"),
|
||||
},
|
||||
],
|
||||
projectId,
|
||||
searchQuery: null,
|
||||
});
|
||||
|
||||
await expect(call).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -207,8 +207,6 @@ describe("/api/public/ingestion API Endpoint", () => {
|
||||
|
||||
expect(response.status).toBe(207);
|
||||
|
||||
console.log("response body", response.body);
|
||||
|
||||
const dbTrace = await prisma.trace.findMany({
|
||||
where: {
|
||||
name: "trace-name",
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { makeAPICall, pruneDatabase } from "@/src/__tests__/test-utils";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
describe("/api/public/metrics/daily API Endpoint", () => {
|
||||
beforeEach(async () => await pruneDatabase());
|
||||
afterEach(async () => await pruneDatabase());
|
||||
|
||||
it("should handle daily metrics correctly", async () => {
|
||||
await pruneDatabase();
|
||||
|
||||
// Create traces with observations on different days
|
||||
const traceId1 = uuidv4();
|
||||
const traceId2 = uuidv4();
|
||||
await makeAPICall("POST", "/api/public/traces", {
|
||||
id: traceId1,
|
||||
timestamp: "2021-01-01T00:00:00.000Z",
|
||||
name: "trace-day-1",
|
||||
userId: "user-daily-metrics",
|
||||
projectId: "project-daily-metrics",
|
||||
});
|
||||
await makeAPICall("POST", "/api/public/traces", {
|
||||
id: traceId2,
|
||||
timestamp: "2021-01-02T00:00:00.000Z",
|
||||
name: "trace-day-2",
|
||||
userId: "user-daily-metrics",
|
||||
projectId: "project-daily-metrics",
|
||||
});
|
||||
|
||||
// Simulate observations with usage metrics on different days
|
||||
await makeAPICall("POST", "/api/public/generations", {
|
||||
traceId: traceId1,
|
||||
model: "modelA",
|
||||
usage: { input: 100, output: 200, total: 300 },
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:01:00.000Z",
|
||||
});
|
||||
await makeAPICall("POST", "/api/public/generations", {
|
||||
traceId: traceId2,
|
||||
model: "modelB",
|
||||
usage: { input: 333 },
|
||||
startTime: "2021-01-02T00:00:00.000Z",
|
||||
endTime: "2021-01-02T00:02:00.000Z",
|
||||
});
|
||||
await makeAPICall("POST", "/api/public/generations", {
|
||||
traceId: traceId2,
|
||||
model: "modelC",
|
||||
usage: { input: 666, output: 777, totalCost: 1024.22 },
|
||||
startTime: "2021-01-02T00:00:00.000Z",
|
||||
endTime: "2021-01-02T00:04:00.000Z",
|
||||
});
|
||||
|
||||
// Retrieve the daily metrics
|
||||
const dailyMetricsResponse = await makeAPICall<{
|
||||
data: Array<Record<string, unknown>>;
|
||||
}>("GET", `/api/public/metrics/daily`);
|
||||
const dailyMetricsData = dailyMetricsResponse.body.data;
|
||||
|
||||
// Check if the daily metrics are calculated correctly
|
||||
expect(dailyMetricsData).toHaveLength(2); // Two days of data
|
||||
if (!dailyMetricsData[0])
|
||||
throw new Error("dailyMetricsData[0] is undefined");
|
||||
expect(dailyMetricsData[0].date).toBe("2021-01-02"); // Latest date first
|
||||
expect(dailyMetricsData[0].countTraces).toBe(1);
|
||||
expect(dailyMetricsData[0].totalCost).toEqual(1024.22);
|
||||
expect(dailyMetricsData[0].usage).toEqual([
|
||||
{
|
||||
model: "modelB",
|
||||
inputUsage: 333,
|
||||
outputUsage: 0,
|
||||
totalUsage: 333,
|
||||
},
|
||||
{
|
||||
model: "modelC",
|
||||
inputUsage: 666,
|
||||
outputUsage: 777,
|
||||
totalUsage: 1443,
|
||||
},
|
||||
]);
|
||||
|
||||
if (!dailyMetricsData[1])
|
||||
throw new Error("dailyMetricsData[1] is undefined");
|
||||
expect(dailyMetricsData[1].date).toBe("2021-01-01");
|
||||
expect(dailyMetricsData[1].countTraces).toBe(1);
|
||||
expect(dailyMetricsData[1].totalCost).toEqual(0);
|
||||
expect(dailyMetricsData[1].usage).toEqual([
|
||||
{
|
||||
model: "modelA",
|
||||
inputUsage: 100,
|
||||
outputUsage: 200,
|
||||
totalUsage: 300,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -92,5 +92,9 @@ describe("model match", () => {
|
||||
expect(observation.promptTokens).toBeGreaterThan(0);
|
||||
expect(observation.completionTokens).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// temporary fix: wait for 5 additional seconds to ensure that the model match is complete
|
||||
// had issue with the test failing because the model match was not complete and logged to console
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
@@ -18,6 +18,9 @@ describe("/api/public/prompts API Endpoint", () => {
|
||||
prompt: "prompt",
|
||||
isActive: true,
|
||||
version: 1,
|
||||
config: {
|
||||
temperature: 0.1,
|
||||
},
|
||||
project: {
|
||||
connect: { id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a" },
|
||||
},
|
||||
@@ -43,6 +46,7 @@ describe("/api/public/prompts API Endpoint", () => {
|
||||
expect(fetchedObservations.body.version).toBe(1);
|
||||
expect(fetchedObservations.body.isActive).toBe(true);
|
||||
expect(fetchedObservations.body.createdBy).toBe("user-1");
|
||||
expect(fetchedObservations.body.config).toEqual({ temperature: 0.1 });
|
||||
});
|
||||
|
||||
it("should fetch active prompt only if no prompt version is given", async () => {
|
||||
@@ -55,6 +59,9 @@ describe("/api/public/prompts API Endpoint", () => {
|
||||
prompt: "prompt",
|
||||
isActive: false,
|
||||
version: 1,
|
||||
config: {
|
||||
temperature: 0.1,
|
||||
},
|
||||
project: {
|
||||
connect: { id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a" },
|
||||
},
|
||||
@@ -82,6 +89,9 @@ describe("/api/public/prompts API Endpoint", () => {
|
||||
prompt: "prompt-one",
|
||||
isActive: false,
|
||||
version: 1,
|
||||
config: {
|
||||
temperature: 0.1,
|
||||
},
|
||||
project: {
|
||||
connect: { id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a" },
|
||||
},
|
||||
@@ -96,6 +106,9 @@ describe("/api/public/prompts API Endpoint", () => {
|
||||
prompt: "prompt",
|
||||
isActive: true,
|
||||
version: 2,
|
||||
config: {
|
||||
temperature: 0.2,
|
||||
},
|
||||
project: {
|
||||
connect: { id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a" },
|
||||
},
|
||||
@@ -121,6 +134,7 @@ describe("/api/public/prompts API Endpoint", () => {
|
||||
expect(fetchedObservations.body.version).toBe(1);
|
||||
expect(fetchedObservations.body.isActive).toBe(false);
|
||||
expect(fetchedObservations.body.createdBy).toBe("user-1");
|
||||
expect(fetchedObservations.body.config).toEqual({ temperature: 0.1 });
|
||||
});
|
||||
|
||||
it("should fetch active prompt when multiple exist", async () => {
|
||||
@@ -134,6 +148,9 @@ describe("/api/public/prompts API Endpoint", () => {
|
||||
prompt: "prompt",
|
||||
isActive: false,
|
||||
version: 1,
|
||||
config: {
|
||||
temperature: 0.1,
|
||||
},
|
||||
project: {
|
||||
connect: { id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a" },
|
||||
},
|
||||
@@ -148,6 +165,9 @@ describe("/api/public/prompts API Endpoint", () => {
|
||||
prompt: "prompt",
|
||||
isActive: true,
|
||||
version: 2,
|
||||
config: {
|
||||
temperature: 0.2,
|
||||
},
|
||||
project: {
|
||||
connect: { id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a" },
|
||||
},
|
||||
@@ -173,6 +193,7 @@ describe("/api/public/prompts API Endpoint", () => {
|
||||
expect(fetchedObservations.body.version).toBe(2);
|
||||
expect(fetchedObservations.body.isActive).toBe(true);
|
||||
expect(fetchedObservations.body.createdBy).toBe("user-1");
|
||||
expect(fetchedObservations.body.config).toEqual({ temperature: 0.2 });
|
||||
});
|
||||
|
||||
it("should create and fetch a prompt", async () => {
|
||||
@@ -181,6 +202,9 @@ describe("/api/public/prompts API Endpoint", () => {
|
||||
prompt: "prompt",
|
||||
isActive: true,
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
config: {
|
||||
temperature: 0.1,
|
||||
},
|
||||
});
|
||||
|
||||
const fetchedObservations = await makeAPICall(
|
||||
@@ -200,6 +224,7 @@ describe("/api/public/prompts API Endpoint", () => {
|
||||
expect(fetchedObservations.body.version).toBe(1);
|
||||
expect(fetchedObservations.body.isActive).toBe(true);
|
||||
expect(fetchedObservations.body.createdBy).toBe("API");
|
||||
expect(fetchedObservations.body.config).toEqual({ temperature: 0.1 });
|
||||
});
|
||||
|
||||
it("should relate generation to prompt", async () => {
|
||||
@@ -327,6 +352,34 @@ describe("/api/public/prompts API Endpoint", () => {
|
||||
|
||||
expect(dbGeneration).toBeNull();
|
||||
});
|
||||
|
||||
it("should create empty object if no config is provided", async () => {
|
||||
await makeAPICall("POST", "/api/public/prompts", {
|
||||
name: "prompt-name",
|
||||
prompt: "prompt",
|
||||
isActive: true,
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
});
|
||||
|
||||
const fetchedObservations = await makeAPICall(
|
||||
"GET",
|
||||
"/api/public/prompts?name=prompt-name&version=1",
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(fetchedObservations.status).toBe(200);
|
||||
|
||||
if (!isPrompt(fetchedObservations.body)) {
|
||||
throw new Error("Expected body to be an array of observations");
|
||||
}
|
||||
|
||||
expect(fetchedObservations.body.name).toBe("prompt-name");
|
||||
expect(fetchedObservations.body.prompt).toBe("prompt");
|
||||
expect(fetchedObservations.body.version).toBe(1);
|
||||
expect(fetchedObservations.body.isActive).toBe(true);
|
||||
expect(fetchedObservations.body.createdBy).toBe("API");
|
||||
expect(fetchedObservations.body.config).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
const isPrompt = (x: unknown): x is Prompt => {
|
||||
@@ -339,6 +392,7 @@ const isPrompt = (x: unknown): x is Prompt => {
|
||||
typeof prompt.prompt === "string" &&
|
||||
typeof prompt.isActive === "boolean" &&
|
||||
typeof prompt.projectId === "string" &&
|
||||
typeof prompt.createdBy === "string"
|
||||
typeof prompt.createdBy === "string" &&
|
||||
typeof prompt.config === "object"
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,28 +16,28 @@ describe("Build valid SQL queries", () => {
|
||||
{
|
||||
table: "traces",
|
||||
values: ["project-id"],
|
||||
strings: [' FROM traces t WHERE t."project_id" = ', " ;"],
|
||||
strings: [' FROM traces t WHERE t."project_id" = ', ";"],
|
||||
} as const,
|
||||
{
|
||||
table: "traces_observations",
|
||||
values: ["project-id", "project-id"],
|
||||
strings: [
|
||||
' FROM traces t LEFT JOIN observations_view o ON t.id = o.trace_id WHERE t."project_id" = ',
|
||||
' AND o."project_id" = ',
|
||||
" ;",
|
||||
' AND o."project_id" = ',
|
||||
";",
|
||||
],
|
||||
} as const,
|
||||
{
|
||||
table: "observations",
|
||||
values: ["project-id"],
|
||||
strings: [' FROM observations_view o WHERE o."project_id" = ', " ;"],
|
||||
strings: [' FROM observations_view o WHERE o."project_id" = ', ";"],
|
||||
} as const,
|
||||
{
|
||||
table: "traces_scores",
|
||||
values: ["project-id"],
|
||||
strings: [
|
||||
' FROM traces t JOIN scores s ON t.id = s.trace_id WHERE t."project_id" = ',
|
||||
" ;",
|
||||
";",
|
||||
],
|
||||
} as const,
|
||||
].forEach((prop) => {
|
||||
@@ -133,7 +133,7 @@ describe("Build valid SQL queries", () => {
|
||||
],
|
||||
select: [],
|
||||
}),
|
||||
).toThrow("Column unknown not found");
|
||||
).toThrow("Invalid filter column: unknown");
|
||||
});
|
||||
|
||||
it("should not select an unknown column", () => {
|
||||
|
||||
@@ -231,4 +231,43 @@ describe("/api/public/scores API Endpoint", () => {
|
||||
expect(dbScore?.comment).toBe("comment-updated");
|
||||
expect(dbScore?.observationId).toBe(dbGeneration[0]!.id);
|
||||
});
|
||||
|
||||
it("should delete a score", async () => {
|
||||
await pruneDatabase();
|
||||
|
||||
const traceId = uuidv4();
|
||||
|
||||
await makeAPICall("POST", "/api/public/traces", {
|
||||
id: traceId,
|
||||
});
|
||||
|
||||
const scoreId = uuidv4();
|
||||
const createScore = await makeAPICall("POST", "/api/public/scores", {
|
||||
id: scoreId,
|
||||
name: "score-name",
|
||||
value: 100.5,
|
||||
traceId: traceId,
|
||||
comment: "comment",
|
||||
});
|
||||
|
||||
expect(createScore.status).toBe(200);
|
||||
const dbScore = await prisma.score.findUnique({
|
||||
where: {
|
||||
id: scoreId,
|
||||
},
|
||||
});
|
||||
expect(dbScore?.id).toBe(scoreId);
|
||||
|
||||
const deleteScore = await makeAPICall(
|
||||
"DELETE",
|
||||
`/api/public/scores/${scoreId}`,
|
||||
);
|
||||
expect(deleteScore.status).toBe(200);
|
||||
const deletedScore = await prisma.score.findUnique({
|
||||
where: {
|
||||
id: scoreId,
|
||||
},
|
||||
});
|
||||
expect(deletedScore).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,12 +44,12 @@ export type ErrorIngestion = {
|
||||
error: string;
|
||||
};
|
||||
|
||||
export async function makeAPICall(
|
||||
export async function makeAPICall<T = IngestionAPIResponse>(
|
||||
method: "POST" | "GET" | "PUT" | "DELETE" | "PATCH",
|
||||
url: string,
|
||||
body?: unknown,
|
||||
auth?: string,
|
||||
) {
|
||||
): Promise<{ body: T; status: number }> {
|
||||
const finalUrl = `http://localhost:3000/${url}`;
|
||||
const authorization =
|
||||
auth || createBasicAuthHeader("pk-lf-1234567890", "sk-lf-1234567890");
|
||||
@@ -60,14 +60,12 @@ export async function makeAPICall(
|
||||
"Content-Type": "application/json;charset=UTF-8",
|
||||
Authorization: authorization,
|
||||
},
|
||||
// Conditionally include the body property if the method is not "GET"
|
||||
...(method !== "GET" &&
|
||||
body !== undefined && { body: JSON.stringify(body) }),
|
||||
};
|
||||
const a = await fetch(finalUrl, options);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
return { body: (await a.json()) as IngestionAPIResponse, status: a.status };
|
||||
const response = await fetch(finalUrl, options);
|
||||
const responseBody = (await response.json()) as T;
|
||||
return { body: responseBody, status: response.status };
|
||||
}
|
||||
|
||||
export const setupUserAndProject = async () => {
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
import { makeAPICall, pruneDatabase } from "@/src/__tests__/test-utils";
|
||||
import { prisma } from "@/src/server/db";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
interface GetTracesAPIResponse {
|
||||
data: Array<{
|
||||
id: string;
|
||||
[key: string]: unknown;
|
||||
}>;
|
||||
}
|
||||
|
||||
describe("/api/public/traces API Endpoint", () => {
|
||||
beforeEach(async () => await pruneDatabase());
|
||||
@@ -65,6 +73,7 @@ describe("/api/public/traces API Endpoint", () => {
|
||||
await makeAPICall("POST", "/api/public/traces", {
|
||||
id: "trace-id",
|
||||
metadata: { key: "value" },
|
||||
timestamp: "2021-01-01T00:00:00.000Z",
|
||||
release: "1.0.0",
|
||||
version: "5.0.0",
|
||||
public: false,
|
||||
@@ -84,6 +93,7 @@ describe("/api/public/traces API Endpoint", () => {
|
||||
version: "5.0.0",
|
||||
public: false,
|
||||
userId: "user-1",
|
||||
timestamp: new Date("2021-01-01T00:00:00.000Z"),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,34 +116,100 @@ describe("/api/public/traces API Endpoint", () => {
|
||||
});
|
||||
|
||||
// multiple tags
|
||||
const traces = await makeAPICall(
|
||||
const traces = await makeAPICall<GetTracesAPIResponse>(
|
||||
"GET",
|
||||
"/api/public/traces?tags=tag-2&tags=tag-3",
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||
const traceIds = traces.body.data.map((t: { id: string }) => t.id);
|
||||
const traceIds = traces.body.data.map((t) => t.id);
|
||||
// check for equality ok as ordered by timestamp
|
||||
expect(traceIds).toEqual(["trace-3", "trace-1"]);
|
||||
|
||||
// single tag
|
||||
const traces2 = await makeAPICall("GET", "/api/public/traces?tags=tag-1");
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||
const traceIds2 = traces2.body.data.map((t: { id: string }) => t.id);
|
||||
const traces2 = await makeAPICall<GetTracesAPIResponse>(
|
||||
"GET",
|
||||
"/api/public/traces?tags=tag-1",
|
||||
);
|
||||
const traceIds2 = traces2.body.data.map((t) => t.id);
|
||||
// check for equality ok as ordered by timestamp
|
||||
expect(traceIds2).toEqual(["trace-2", "trace-1"]);
|
||||
|
||||
// wrong tag
|
||||
const traces3 = await makeAPICall("GET", "/api/public/traces?tags=tag-10");
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||
const traceIds3 = traces3.body.data.map((t: { id: string }) => t.id);
|
||||
const traces3 = await makeAPICall<GetTracesAPIResponse>(
|
||||
"GET",
|
||||
"/api/public/traces?tags=tag-10",
|
||||
);
|
||||
const traceIds3 = traces3.body.data.map((t) => t.id);
|
||||
// check for equality ok as ordered by timestamp
|
||||
expect(traceIds3).toEqual([]);
|
||||
|
||||
// no tag
|
||||
const traces4 = await makeAPICall("GET", "/api/public/traces?tags=");
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||
const traceIds4 = traces4.body.data.map((t: { id: string }) => t.id);
|
||||
const traces4 = await makeAPICall<GetTracesAPIResponse>(
|
||||
"GET",
|
||||
"/api/public/traces?tags=",
|
||||
);
|
||||
const traceIds4 = traces4.body.data.map((t) => t.id);
|
||||
// check for equality ok as ordered by timestamp
|
||||
expect(traceIds4).toEqual(["trace-3", "trace-2", "trace-1"]);
|
||||
});
|
||||
|
||||
it("should handle metrics correctly on GET traces and GET trace", async () => {
|
||||
await pruneDatabase();
|
||||
|
||||
// Create a trace with some observations that have costs and latencies
|
||||
const traceId = uuidv4();
|
||||
await makeAPICall("POST", "/api/public/traces", {
|
||||
id: traceId,
|
||||
name: "trace-with-costs",
|
||||
userId: "user-costs",
|
||||
projectId: "project-costs",
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "2.0.0",
|
||||
});
|
||||
console.log(traceId);
|
||||
|
||||
// Simulate observations with costs and latencies
|
||||
await makeAPICall("POST", "/api/public/generations", {
|
||||
traceId: traceId,
|
||||
usage: { totalCost: 10.5 },
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:10:00.000Z",
|
||||
});
|
||||
await makeAPICall("POST", "/api/public/generations", {
|
||||
traceId: traceId,
|
||||
usage: { totalCost: 5.25 },
|
||||
startTime: "2021-01-01T00:10:00.000Z",
|
||||
endTime: "2021-01-01T00:20:00.000Z",
|
||||
});
|
||||
|
||||
// GET traces
|
||||
// Retrieve the trace with totalCost and latency
|
||||
const traces = await makeAPICall<GetTracesAPIResponse>(
|
||||
"GET",
|
||||
`/api/public/traces`,
|
||||
);
|
||||
const traceData = traces.body.data[0];
|
||||
if (!traceData) throw new Error("traceData is undefined");
|
||||
|
||||
// Check if the totalCost and latency are calculated correctly
|
||||
expect(traceData.totalCost).toBeCloseTo(15.75); // Sum of costs
|
||||
expect(traceData.latency).toBeCloseTo(1200); // Difference in seconds between min startTime and max endTime
|
||||
expect(traceData.id).toBe(traceId);
|
||||
expect(traceData.htmlPath).toContain(`/traces/${traceId}`);
|
||||
expect(traceData.htmlPath).toContain(`/project/`); // do not know the projectId
|
||||
|
||||
// GET trace
|
||||
// Retrieve the trace with total
|
||||
const trace = await makeAPICall<{
|
||||
id: string;
|
||||
totalCost: number;
|
||||
htmlPath: string;
|
||||
}>("GET", `/api/public/traces/${traceId}`);
|
||||
console.log(trace.body);
|
||||
expect(trace.body.totalCost).toBeCloseTo(15.75);
|
||||
expect(trace.body.id).toBe(traceId);
|
||||
expect(trace.body.id).toBe(traceId);
|
||||
expect(trace.body.htmlPath).toContain(`/traces/${traceId}`);
|
||||
expect(trace.body.htmlPath).toContain(`/project/`); // do not know the projectId
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,7 +31,7 @@ import { type DashboardDateRange } from "@/src/pages/project/[projectId]";
|
||||
import { isValidOption } from "@/src/utils/types";
|
||||
import { setBeginningOfDay, setEndOfDay } from "@/src/utils/dates";
|
||||
|
||||
export const DEFAULT_DATE_RANGE_SELECTION = "Select date range" as const;
|
||||
export const DEFAULT_DATE_RANGE_SELECTION = "Date range" as const;
|
||||
export type AvailableDateRangeSelections =
|
||||
| typeof DEFAULT_DATE_RANGE_SELECTION
|
||||
| DateTimeAggregationOption;
|
||||
@@ -163,7 +163,7 @@ export function DatePickerWithRange({
|
||||
id="date"
|
||||
variant={"outline"}
|
||||
className={cn(
|
||||
"w-[350px] justify-start text-left font-normal",
|
||||
"w-[330px] justify-start text-left font-normal",
|
||||
!internalDateRange && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -171,8 +171,8 @@ export function DatePickerWithRange({
|
||||
{internalDateRange?.from ? (
|
||||
internalDateRange.to ? (
|
||||
<>
|
||||
{format(internalDateRange.from, "LLL dd, y : hh:mm")} -{" "}
|
||||
{format(internalDateRange.to, "LLL dd, y : hh:mm")}
|
||||
{format(internalDateRange.from, "LLL dd, yy : hh:mm")} -{" "}
|
||||
{format(internalDateRange.to, "LLL dd, yy : hh:mm")}
|
||||
</>
|
||||
) : (
|
||||
format(internalDateRange.from, "LLL dd, y")
|
||||
@@ -194,7 +194,7 @@ export function DatePickerWithRange({
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Select value={selectedOption} onValueChange={onDropDownSelection}>
|
||||
<SelectTrigger className="w-40 hover:bg-accent hover:text-accent-foreground focus:ring-0 focus:ring-offset-0">
|
||||
<SelectTrigger className="w-[120px] hover:bg-accent hover:text-accent-foreground focus:ring-0 focus:ring-offset-0">
|
||||
<SelectValue placeholder="Select" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" defaultValue={60}>
|
||||
|
||||
@@ -30,12 +30,9 @@ export function DeleteTrace({
|
||||
const mutDeleteTraces = api.traces.deleteMany.useMutation({
|
||||
onSuccess: () => {
|
||||
setIsDeleted(true);
|
||||
void utils.traces.invalidate();
|
||||
if (!isTableAction) {
|
||||
void router
|
||||
.push(`/project/${projectId}/traces`)
|
||||
.then(() => utils.traces.invalidate());
|
||||
} else {
|
||||
void utils.traces.invalidate();
|
||||
void router.push(`/project/${projectId}/traces`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function Header({
|
||||
[...props.breadcrumb.map((i) => i.href).filter(Boolean)].pop();
|
||||
|
||||
return (
|
||||
<div className={cn(level === "h2" ? "mb-8" : "mb-1")}>
|
||||
<div className={cn(level === "h2" ? "mb-4" : "mb-1")}>
|
||||
<div>
|
||||
{backHref ? (
|
||||
<nav className="sm:hidden" aria-label="Back">
|
||||
|
||||
@@ -77,7 +77,12 @@ export default function Layout(props: PropsWithChildren) {
|
||||
// RBAC
|
||||
if (
|
||||
route.rbacScope !== undefined &&
|
||||
(!projectId || !hasAccess({ projectId, scope: route.rbacScope, session }))
|
||||
(!projectId ||
|
||||
!hasAccess({
|
||||
projectId,
|
||||
scope: route.rbacScope,
|
||||
session: session.data,
|
||||
}))
|
||||
)
|
||||
return null;
|
||||
|
||||
@@ -492,21 +497,21 @@ export default function Layout(props: PropsWithChildren) {
|
||||
<Info className="h-4 w-4" />
|
||||
<span className="font-semibold">DEMO (view-only)</span>
|
||||
</div>
|
||||
<div>Live data from the Langfuse Q&A Chatbot.</div>
|
||||
<div>Use demo RAG chat to see live data in this project.</div>
|
||||
</div>
|
||||
|
||||
<Button size="sm" asChild className="ml-2">
|
||||
<Link
|
||||
href={
|
||||
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "EU"
|
||||
? "https://langfuse.com/docs/qa-chatbot"
|
||||
: "https://docs-staging.langfuse.com/docs/qa-chatbot"
|
||||
? "https://langfuse.com/docs/demo"
|
||||
: "https://docs-staging.langfuse.com/docs/demo"
|
||||
}
|
||||
target="_blank"
|
||||
>
|
||||
{env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "EU"
|
||||
? "Q&A Chatbot ↗"
|
||||
: "Q&A Chatbot (staging) ↗"}
|
||||
? "Use Chat ↗"
|
||||
: "Use Chat (staging) ↗"}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ManualScoreButton } from "@/src/features/manual-scoring/components/Manu
|
||||
import { DetailPageNav } from "@/src/features/navigate-detail-pages/DetailPageNav";
|
||||
import { useDetailPageLists } from "@/src/features/navigate-detail-pages/context";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { usdFormatter } from "@/src/utils/numbers";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
|
||||
@@ -88,6 +89,11 @@ export const SessionPage: React.FC<{
|
||||
</Link>
|
||||
))}
|
||||
<Badge variant="outline">Traces: {session.data?.traces.length}</Badge>
|
||||
{session.data && (
|
||||
<Badge variant="outline">
|
||||
Total cost: {usdFormatter(session.data.totalCost, 2, 2)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-5 flex flex-col gap-2 border-t pt-5">
|
||||
{session.data?.traces.map((trace) => (
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function TableLink({
|
||||
href={path}
|
||||
title={value}
|
||||
>
|
||||
{value.length > truncateAt
|
||||
{value.length - truncateAt > 3
|
||||
? `...${value.substring(value.length - truncateAt)}`
|
||||
: value}
|
||||
</Link>
|
||||
|
||||
@@ -4,6 +4,7 @@ import TableLink from "@/src/components/table/table-link";
|
||||
import { type LangfuseColumnDef } from "@/src/components/table/types";
|
||||
import useColumnVisibility from "@/src/features/column-visibility/hooks/useColumnVisibility";
|
||||
import { useQueryFilterState } from "@/src/features/filters/hooks/useFilterState";
|
||||
import { useOrderByState } from "@/src/features/orderBy/hooks/useOrderByState";
|
||||
import { scoresTableColsWithOptions } from "@/src/server/api/definitions/scoresTable";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { type RouterInput } from "@/src/utils/types";
|
||||
@@ -49,11 +50,17 @@ export default function ScoresTable({
|
||||
])
|
||||
: userFilterState;
|
||||
|
||||
const [orderByState, setOrderByState] = useOrderByState({
|
||||
column: "timestamp",
|
||||
order: "DESC",
|
||||
});
|
||||
|
||||
const scores = api.scores.all.useQuery({
|
||||
page: paginationState.pageIndex,
|
||||
limit: paginationState.pageSize,
|
||||
projectId,
|
||||
filter: filterState,
|
||||
orderBy: orderByState,
|
||||
});
|
||||
const totalCount = scores.data?.slice(1)[0]?.totalCount ?? 0;
|
||||
|
||||
@@ -162,6 +169,8 @@ export default function ScoresTable({
|
||||
onChange: setPaginationState,
|
||||
state: paginationState,
|
||||
}}
|
||||
orderBy={orderByState}
|
||||
setOrderBy={setOrderByState}
|
||||
columnVisibility={columnVisibility}
|
||||
onColumnVisibilityChange={setColumnVisibility}
|
||||
/>
|
||||
|
||||
@@ -11,6 +11,7 @@ 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";
|
||||
import { usdFormatter } from "@/src/utils/numbers";
|
||||
import { type RouterOutput } from "@/src/utils/types";
|
||||
import { useEffect } from "react";
|
||||
import { NumberParam, useQueryParams, withDefault } from "use-query-params";
|
||||
@@ -22,6 +23,7 @@ export type SessionTableRow = {
|
||||
countTraces: number;
|
||||
bookmarked: boolean;
|
||||
sessionDuration: number | null;
|
||||
totalCost: number;
|
||||
};
|
||||
|
||||
export type SessionTableProps = {
|
||||
@@ -98,6 +100,7 @@ export default function SessionsTable({
|
||||
countTraces: session.countTraces,
|
||||
bookmarked: session.bookmarked,
|
||||
sessionDuration: session.sessionDuration,
|
||||
totalCost: session.totalCost,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -132,6 +135,7 @@ export default function SessionsTable({
|
||||
<TableLink
|
||||
path={`/project/${projectId}/sessions/${encodeURIComponent(value)}`}
|
||||
value={value}
|
||||
truncateAt={40}
|
||||
/>
|
||||
) : undefined;
|
||||
},
|
||||
@@ -185,6 +189,20 @@ export default function SessionsTable({
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "totalCost",
|
||||
id: "totalCost",
|
||||
header: "Total Cost",
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const value: number | undefined = row.getValue("totalCost");
|
||||
|
||||
return value !== undefined ? (
|
||||
<span>{usdFormatter(value, 2, 2)}</span>
|
||||
) : undefined;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const [columnVisibility, setColumnVisibility] =
|
||||
|
||||
@@ -185,6 +185,7 @@ export default function TracesTable({
|
||||
}
|
||||
}}
|
||||
aria-label="Select all"
|
||||
className="opacity-60"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
@@ -192,6 +193,7 @@ export default function TracesTable({
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
className="opacity-60"
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -439,9 +441,8 @@ export default function TracesTable({
|
||||
actionButtons={
|
||||
<TraceTableMultiSelectAction
|
||||
// Exclude traces that are not in the current page
|
||||
selectedTraceIds={Object.keys(selectedRows).filter(
|
||||
(traceId) =>
|
||||
traces.data?.traces.map((t) => t.id).includes(traceId),
|
||||
selectedTraceIds={Object.keys(selectedRows).filter((traceId) =>
|
||||
traces.data?.traces.map((t) => t.id).includes(traceId),
|
||||
)}
|
||||
projectId={projectId}
|
||||
onDeleteSuccess={() => {
|
||||
|
||||
@@ -35,11 +35,27 @@ export const IOPreview: React.FC<{
|
||||
if (!inOpenAiMessageArray.success) {
|
||||
// check if input is an array of length 1 including an array of OpenAiMessageSchema
|
||||
// this is the case for some integrations
|
||||
// e.g. [[OpenAiMessageSchema, ...]]
|
||||
const inputArray = z.array(OpenAiMessageArraySchema).safeParse(input);
|
||||
if (inputArray.success && inputArray.data.length === 1) {
|
||||
inOpenAiMessageArray = OpenAiMessageArraySchema.safeParse(
|
||||
inputArray.data[0],
|
||||
);
|
||||
} else {
|
||||
// check if input is an object with a messages key
|
||||
// this is the case for some integrations
|
||||
// e.g. { messages: [OpenAiMessageSchema, ...] }
|
||||
const inputObject = z
|
||||
.object({
|
||||
messages: OpenAiMessageArraySchema,
|
||||
})
|
||||
.safeParse(input);
|
||||
|
||||
if (inputObject.success) {
|
||||
inOpenAiMessageArray = OpenAiMessageArraySchema.safeParse(
|
||||
inputObject.data.messages,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const outOpenAiMessage = OpenAiMessageSchema.safeParse(output);
|
||||
@@ -102,9 +118,15 @@ export const IOPreview: React.FC<{
|
||||
|
||||
const OpenAiMessageSchema = z
|
||||
.object({
|
||||
role: z.enum(["system", "user", "assistant"]).optional(),
|
||||
role: z.enum(["system", "user", "assistant", "function"]).optional(),
|
||||
name: z.string().optional(),
|
||||
content: z.union([z.record(z.any()), z.string()]).nullable(),
|
||||
content: z.union([z.record(z.any()).array(), z.string()]).nullable(),
|
||||
function_call: z
|
||||
.object({
|
||||
name: z.string(),
|
||||
arguments: z.record(z.any()),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.strict() // no additional properties
|
||||
.refine((value) => value.content !== null || value.role !== undefined);
|
||||
@@ -131,7 +153,7 @@ const OpenAiMessageView: React.FC<{
|
||||
<Fragment key={index}>
|
||||
<JSONView
|
||||
title={message.name ?? message.role}
|
||||
json={message.content}
|
||||
json={message.function_call ?? message.content}
|
||||
className={cn(
|
||||
message.role === "system" && "bg-gray-100",
|
||||
message.role === "assistant" && "bg-green-50",
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { type NestedObservation } from "@/src/utils/types";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { type Trace, type Score } from "@prisma/client";
|
||||
import { type Trace, type Score, $Enums } from "@prisma/client";
|
||||
import { GroupedScoreBadges } from "@/src/components/grouped-score-badge";
|
||||
import { Fragment } from "react";
|
||||
import { type ObservationReturnType } from "@/src/server/api/routers/traces";
|
||||
import { LevelColors } from "@/src/components/level-colors";
|
||||
import { formatInterval } from "@/src/utils/dates";
|
||||
import { MinusCircle, MinusIcon, PlusCircleIcon, PlusIcon } from "lucide-react";
|
||||
import { Toggle } from "@/src/components/ui/toggle";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
|
||||
export const ObservationTree = (props: {
|
||||
observations: ObservationReturnType[];
|
||||
collapsedObservations: string[];
|
||||
toggleCollapsedObservation: (id: string) => void;
|
||||
collapseAll: () => void;
|
||||
expandAll: () => void;
|
||||
trace: Trace;
|
||||
scores: Score[];
|
||||
currentObservationId: string | undefined;
|
||||
@@ -21,6 +28,8 @@ export const ObservationTree = (props: {
|
||||
return (
|
||||
<div className={props.className}>
|
||||
<ObservationTreeTraceNode
|
||||
expandAll={props.expandAll}
|
||||
collapseAll={props.collapseAll}
|
||||
trace={props.trace}
|
||||
scores={props.scores}
|
||||
currentObservationId={props.currentObservationId}
|
||||
@@ -30,6 +39,8 @@ export const ObservationTree = (props: {
|
||||
/>
|
||||
<ObservationTreeNode
|
||||
observations={nestedObservations}
|
||||
collapsedObservations={props.collapsedObservations}
|
||||
toggleCollapsedObservation={props.toggleCollapsedObservation}
|
||||
scores={props.scores}
|
||||
indentationLevel={1}
|
||||
currentObservationId={props.currentObservationId}
|
||||
@@ -40,8 +51,11 @@ export const ObservationTree = (props: {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ObservationTreeTraceNode = (props: {
|
||||
trace: Trace & { latency?: number };
|
||||
expandAll: () => void;
|
||||
collapseAll: () => void;
|
||||
scores: Score[];
|
||||
currentObservationId: string | undefined;
|
||||
setCurrentObservationId: (id: string | undefined) => void;
|
||||
@@ -50,7 +64,7 @@ const ObservationTreeTraceNode = (props: {
|
||||
}) => (
|
||||
<div
|
||||
className={cn(
|
||||
"group mb-0.5 flex cursor-pointer flex-col gap-1 rounded-sm p-1.5",
|
||||
"group mb-0.5 flex cursor-pointer flex-col gap-1 rounded-sm p-1",
|
||||
props.currentObservationId === undefined ||
|
||||
props.currentObservationId === ""
|
||||
? "bg-gray-100"
|
||||
@@ -60,7 +74,23 @@ const ObservationTreeTraceNode = (props: {
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<span className={cn("rounded-sm bg-gray-200 p-1 text-xs")}>TRACE</span>
|
||||
<span className="text-sm">{props.trace.name}</span>
|
||||
<span className="flex-1 text-sm">{props.trace.name}</span>
|
||||
<Button
|
||||
onClick={(ev) => (ev.stopPropagation(), props.expandAll())}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
title="Expand all"
|
||||
>
|
||||
<PlusCircleIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={(ev) => (ev.stopPropagation(), props.collapseAll())}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
title="Collapse all"
|
||||
>
|
||||
<MinusCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{props.showMetrics && props.trace.latency ? (
|
||||
@@ -79,8 +109,11 @@ const ObservationTreeTraceNode = (props: {
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
const ObservationTreeNode = (props: {
|
||||
observations: NestedObservation[];
|
||||
collapsedObservations: string[];
|
||||
toggleCollapsedObservation: (id: string) => void;
|
||||
scores: Score[];
|
||||
indentationLevel: number;
|
||||
currentObservationId: string | undefined;
|
||||
@@ -91,96 +124,144 @@ const ObservationTreeNode = (props: {
|
||||
<>
|
||||
{props.observations
|
||||
.sort((a, b) => a.startTime.getTime() - b.startTime.getTime())
|
||||
.map((observation) => (
|
||||
<Fragment key={observation.id}>
|
||||
<div className="flex">
|
||||
{Array.from({ length: props.indentationLevel }, (_, i) => (
|
||||
<div className="mx-2 border-r" key={i} />
|
||||
))}
|
||||
<div
|
||||
className={cn(
|
||||
"group my-0.5 flex flex-1 cursor-pointer flex-col gap-1 rounded-sm p-1.5",
|
||||
props.currentObservationId === observation.id
|
||||
? "bg-gray-100"
|
||||
: "hover:bg-gray-50",
|
||||
)}
|
||||
onClick={() => props.setCurrentObservationId(observation.id)}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"self-start rounded-sm bg-gray-200 p-1 text-xs",
|
||||
)}
|
||||
>
|
||||
{observation.type}
|
||||
</span>
|
||||
<span className="line-clamp-1 text-sm">{observation.name}</span>
|
||||
</div>
|
||||
{props.showMetrics &&
|
||||
(observation.promptTokens ||
|
||||
observation.completionTokens ||
|
||||
observation.totalTokens ||
|
||||
observation.endTime) && (
|
||||
<div className="flex gap-2">
|
||||
{observation.endTime ? (
|
||||
<span className="text-xs text-gray-500">
|
||||
{formatInterval(
|
||||
(observation.endTime.getTime() -
|
||||
observation.startTime.getTime()) /
|
||||
1000,
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
{observation.promptTokens ||
|
||||
observation.completionTokens ||
|
||||
observation.totalTokens ? (
|
||||
<span className="text-xs text-gray-500">
|
||||
{observation.promptTokens} →{" "}
|
||||
{observation.completionTokens} (∑{" "}
|
||||
{observation.totalTokens})
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
.map((observation) => {
|
||||
const collapsed = props.collapsedObservations.includes(observation.id);
|
||||
|
||||
return (
|
||||
<Fragment key={observation.id}>
|
||||
<div className="flex">
|
||||
{Array.from({ length: props.indentationLevel }, (_, i) => (
|
||||
<div className="mx-2 border-r" key={i} />
|
||||
))}
|
||||
<div
|
||||
className={cn(
|
||||
"group my-0.5 flex flex-1 cursor-pointer flex-col gap-1 rounded-sm p-1",
|
||||
props.currentObservationId === observation.id
|
||||
? "bg-gray-100"
|
||||
: "hover:bg-gray-50",
|
||||
)}
|
||||
{observation.level !== "DEFAULT" ? (
|
||||
<div className="flex">
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-sm p-0.5 text-xs",
|
||||
LevelColors[observation.level].bg,
|
||||
LevelColors[observation.level].text,
|
||||
)}
|
||||
>
|
||||
{observation.level}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{props.showScores &&
|
||||
props.scores.find((s) => s.observationId === observation.id) ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<GroupedScoreBadges
|
||||
scores={props.scores.filter(
|
||||
(s) => s.observationId === observation.id,
|
||||
)}
|
||||
onClick={() => props.setCurrentObservationId(observation.id)}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<ColorCodedObservationType
|
||||
observationType={observation.type}
|
||||
/>
|
||||
<span className="line-clamp-1 flex-1 text-sm">
|
||||
{observation.name}
|
||||
</span>
|
||||
{observation.children.length === 0 ? null : (
|
||||
<Toggle
|
||||
onClick={(ev) => (
|
||||
ev.stopPropagation(),
|
||||
props.toggleCollapsedObservation(observation.id)
|
||||
)}
|
||||
variant="default"
|
||||
pressed={collapsed}
|
||||
size="xs"
|
||||
className="w-7"
|
||||
title={
|
||||
collapsed ? "Expand children" : "Collapse children"
|
||||
}
|
||||
>
|
||||
{collapsed ? (
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<MinusIcon className="h-4 w-4" />
|
||||
)}
|
||||
</Toggle>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{props.showMetrics &&
|
||||
(observation.promptTokens ||
|
||||
observation.completionTokens ||
|
||||
observation.totalTokens ||
|
||||
observation.endTime) && (
|
||||
<div className="flex gap-2">
|
||||
{observation.endTime ? (
|
||||
<span className="text-xs text-gray-500">
|
||||
{formatInterval(
|
||||
(observation.endTime.getTime() -
|
||||
observation.startTime.getTime()) /
|
||||
1000,
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
{observation.promptTokens ||
|
||||
observation.completionTokens ||
|
||||
observation.totalTokens ? (
|
||||
<span className="text-xs text-gray-500">
|
||||
{observation.promptTokens} →{" "}
|
||||
{observation.completionTokens} (∑{" "}
|
||||
{observation.totalTokens})
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{observation.level !== "DEFAULT" ? (
|
||||
<div className="flex">
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-sm p-0.5 text-xs",
|
||||
LevelColors[observation.level].bg,
|
||||
LevelColors[observation.level].text,
|
||||
)}
|
||||
>
|
||||
{observation.level}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{props.showScores &&
|
||||
props.scores.find((s) => s.observationId === observation.id) ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<GroupedScoreBadges
|
||||
scores={props.scores.filter(
|
||||
(s) => s.observationId === observation.id,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ObservationTreeNode
|
||||
observations={observation.children}
|
||||
scores={props.scores}
|
||||
indentationLevel={props.indentationLevel + 1}
|
||||
currentObservationId={props.currentObservationId}
|
||||
setCurrentObservationId={props.setCurrentObservationId}
|
||||
showMetrics={props.showMetrics}
|
||||
showScores={props.showScores}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
{!collapsed && (
|
||||
<ObservationTreeNode
|
||||
observations={observation.children}
|
||||
collapsedObservations={props.collapsedObservations}
|
||||
toggleCollapsedObservation={props.toggleCollapsedObservation}
|
||||
scores={props.scores}
|
||||
indentationLevel={props.indentationLevel + 1}
|
||||
currentObservationId={props.currentObservationId}
|
||||
setCurrentObservationId={props.setCurrentObservationId}
|
||||
showMetrics={props.showMetrics}
|
||||
showScores={props.showScores}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
||||
const ColorCodedObservationType = (props: {
|
||||
observationType: $Enums.ObservationType;
|
||||
}) => {
|
||||
const colors: Record<$Enums.ObservationType, string> = {
|
||||
[$Enums.ObservationType.SPAN]: "bg-blue-100",
|
||||
[$Enums.ObservationType.GENERATION]: "bg-orange-100",
|
||||
[$Enums.ObservationType.EVENT]: "bg-green-100",
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"self-start rounded-sm p-1 text-xs",
|
||||
colors[props.observationType],
|
||||
)}
|
||||
>
|
||||
{props.observationType}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export function nestObservations(
|
||||
list: ObservationReturnType[],
|
||||
): NestedObservation[] {
|
||||
|
||||
@@ -20,9 +20,9 @@ import { TagTraceDetailsPopover } from "@/src/features/tag/components/TagTraceDe
|
||||
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 Decimal from "decimal.js";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
export function Trace(props: {
|
||||
observations: Array<ObservationReturnType>;
|
||||
@@ -41,9 +41,57 @@ export function Trace(props: {
|
||||
true,
|
||||
);
|
||||
|
||||
const [collapsedObservations, setCollapsedObservations] = useState<string[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
const toggleCollapsedObservation = useCallback(
|
||||
(id: string) => {
|
||||
if (collapsedObservations.includes(id)) {
|
||||
setCollapsedObservations(collapsedObservations.filter((i) => i !== id));
|
||||
} else {
|
||||
setCollapsedObservations([...collapsedObservations, id]);
|
||||
}
|
||||
},
|
||||
[collapsedObservations],
|
||||
);
|
||||
|
||||
const collapseAll = useCallback(() => {
|
||||
// exclude all parents of the current observation
|
||||
let excludeParentObservations = new Set<string>();
|
||||
let newExcludeParentObservations = new Set<string>();
|
||||
do {
|
||||
excludeParentObservations = new Set<string>([
|
||||
...excludeParentObservations,
|
||||
...newExcludeParentObservations,
|
||||
]);
|
||||
newExcludeParentObservations = new Set<string>(
|
||||
props.observations
|
||||
.filter(
|
||||
(o) =>
|
||||
o.parentObservationId !== null &&
|
||||
(o.id === currentObservationId ||
|
||||
excludeParentObservations.has(o.id)),
|
||||
)
|
||||
.map((o) => o.parentObservationId as string)
|
||||
.filter((id) => !excludeParentObservations.has(id)),
|
||||
);
|
||||
} while (newExcludeParentObservations.size > 0);
|
||||
|
||||
setCollapsedObservations(
|
||||
props.observations
|
||||
.map((o) => o.id)
|
||||
.filter((id) => !excludeParentObservations.has(id)),
|
||||
);
|
||||
}, [props.observations, currentObservationId]);
|
||||
|
||||
const expandAll = useCallback(() => {
|
||||
setCollapsedObservations([]);
|
||||
}, [setCollapsedObservations]);
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:h-full md:grid-cols-3">
|
||||
<ScrollArea className="md:col-span-2 md:h-full">
|
||||
<div className="grid gap-4 md:h-full md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7">
|
||||
<div className="overflow-y-auto md:col-span-3 md:h-full lg:col-span-4 xl:col-span-5">
|
||||
{currentObservationId === undefined ||
|
||||
currentObservationId === "" ||
|
||||
currentObservationId === null ? (
|
||||
@@ -61,8 +109,8 @@ export function Trace(props: {
|
||||
traceId={props.trace.id}
|
||||
/>
|
||||
)}
|
||||
</ScrollArea>
|
||||
<div className="md:flex md:h-full md:flex-col md:overflow-hidden">
|
||||
</div>
|
||||
<div className="md:col-span-2 md:flex md:h-full md:flex-col md:overflow-hidden">
|
||||
<div className="mb-2 flex flex-shrink-0 flex-row justify-end gap-2">
|
||||
<Toggle
|
||||
pressed={scoresOnObservationTree}
|
||||
@@ -89,17 +137,21 @@ export function Trace(props: {
|
||||
)}
|
||||
</Toggle>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<ObservationTree
|
||||
observations={props.observations}
|
||||
collapsedObservations={collapsedObservations}
|
||||
toggleCollapsedObservation={toggleCollapsedObservation}
|
||||
collapseAll={collapseAll}
|
||||
expandAll={expandAll}
|
||||
trace={props.trace}
|
||||
scores={props.scores}
|
||||
currentObservationId={currentObservationId ?? undefined}
|
||||
setCurrentObservationId={setCurrentObservationId}
|
||||
showMetrics={metricsOnObservationTree}
|
||||
showScores={scoresOnObservationTree}
|
||||
className="flex w-full flex-col overflow-y-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -139,7 +191,7 @@ export function TracePage({ traceId }: { traceId: string }) {
|
||||
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-2rem)]">
|
||||
<div className="flex flex-col overflow-hidden 2xl:container md:h-[calc(100vh-2rem)]">
|
||||
<Header
|
||||
title="Trace Detail"
|
||||
breadcrumb={[
|
||||
|
||||
@@ -16,13 +16,13 @@ export function JSONView(props: {
|
||||
const parsedJson = deepParseJson(props.json);
|
||||
|
||||
return (
|
||||
<div className={cn("max-w-full rounded-md border ", props.className)}>
|
||||
<div className={cn("rounded-md border", props.className)}>
|
||||
{props.title ? (
|
||||
<div className="border-b px-3 py-1 text-xs font-medium">
|
||||
{props.title}
|
||||
</div>
|
||||
) : undefined}
|
||||
<div className="flex gap-2 whitespace-pre-wrap p-3 text-xs">
|
||||
<div className="flex gap-2 whitespace-pre-wrap break-words p-3 text-xs">
|
||||
{props.isLoading ? (
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
) : (
|
||||
@@ -34,6 +34,7 @@ export function JSONView(props: {
|
||||
displaySize={"collapsed"}
|
||||
matchesURL={true}
|
||||
customizeCopy={(node) => stringifyJsonNode(node)}
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,7 @@ const toggleVariants = cva(
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-3",
|
||||
xs: "h-6 px-1.5",
|
||||
sm: "h-9 px-2.5",
|
||||
lg: "h-11 px-5",
|
||||
},
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const VERSION = "v2.3.0";
|
||||
export const VERSION = "v2.7.0";
|
||||
|
||||
@@ -58,6 +58,8 @@ export const env = createEnv({
|
||||
S3_SECRET_ACCESS_KEY: z.string().optional(),
|
||||
S3_BUCKET_NAME: z.string().optional(),
|
||||
S3_REGION: z.string().optional(),
|
||||
// Database exports
|
||||
DB_EXPORT_PAGE_SIZE: z.number().optional(),
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -120,5 +122,10 @@ export const env = createEnv({
|
||||
S3_SECRET_ACCESS_KEY: process.env.S3_SECRET_ACCESS_KEY,
|
||||
S3_BUCKET_NAME: process.env.S3_BUCKET_NAME,
|
||||
S3_REGION: process.env.S3_REGION,
|
||||
// Database exports
|
||||
DB_EXPORT_PAGE_SIZE: process.env.DB_EXPORT_PAGE_SIZE,
|
||||
},
|
||||
// Skip validation in Docker builds
|
||||
// DOCKER_BUILD is set in Dockerfile
|
||||
skipValidation: process.env.DOCKER_BUILD === "1",
|
||||
});
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { prisma as _prisma } from "@/src/server/db";
|
||||
import { type MembershipRole } from "@prisma/client";
|
||||
|
||||
export type AuditableResource =
|
||||
| "membership"
|
||||
| "membershipInvitation"
|
||||
| "datasetItem"
|
||||
| "dataset"
|
||||
| "trace"
|
||||
| "project"
|
||||
| "observation"
|
||||
| "score"
|
||||
| "model"
|
||||
| "prompt"
|
||||
| "session"
|
||||
| "apiKey";
|
||||
|
||||
type AuditLog = {
|
||||
resourceType: AuditableResource;
|
||||
resourceId: string;
|
||||
action: string;
|
||||
before?: unknown;
|
||||
after?: unknown;
|
||||
} & (
|
||||
| {
|
||||
projectId: string;
|
||||
userId: string;
|
||||
userProjectRole: MembershipRole;
|
||||
}
|
||||
| {
|
||||
session: {
|
||||
user: {
|
||||
id: string;
|
||||
};
|
||||
projectRole: MembershipRole;
|
||||
projectId: string;
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export async function auditLog(log: AuditLog, prisma?: typeof _prisma) {
|
||||
await (prisma ?? _prisma).auditLog.create({
|
||||
data: {
|
||||
projectId: "projectId" in log ? log.projectId : log.session.projectId,
|
||||
userId: "userId" in log ? log.userId : log.session.user.id,
|
||||
userProjectRole:
|
||||
"userProjectRole" in log
|
||||
? log.userProjectRole
|
||||
: log.session.projectRole,
|
||||
resourceType: log.resourceType,
|
||||
resourceId: log.resourceId,
|
||||
action: log.action,
|
||||
before: log.before ? JSON.stringify(log.before) : undefined,
|
||||
after: log.after ? JSON.stringify(log.after) : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "@/src/features/dashboard/components/hooks";
|
||||
import { NoData } from "@/src/features/dashboard/components/NoData";
|
||||
import DocPopup from "@/src/components/layouts/doc-popup";
|
||||
import { createTracesTimeFilter } from "@/src/features/dashboard/lib/dashboard-utils";
|
||||
|
||||
export function ChartScores(props: {
|
||||
className?: string;
|
||||
@@ -25,9 +26,7 @@ export function ChartScores(props: {
|
||||
projectId: props.projectId,
|
||||
from: "traces_scores",
|
||||
select: [{ column: "scoreName" }, { column: "value", agg: "AVG" }],
|
||||
filter: props.globalFilterState.map((f) =>
|
||||
f.type === "datetime" ? { ...f, column: "timestamp" } : f,
|
||||
),
|
||||
filter: createTracesTimeFilter(props.globalFilterState),
|
||||
groupBy: [
|
||||
{
|
||||
type: "datetime",
|
||||
|
||||
@@ -30,9 +30,10 @@ export const LatencyChart = ({
|
||||
const latencies = api.dashboard.chart.useQuery(
|
||||
{
|
||||
projectId,
|
||||
from: "observations",
|
||||
from: "traces_observations",
|
||||
select: [
|
||||
{ column: "duration", agg: "50thPercentile" },
|
||||
{ column: "duration", agg: "75thPercentile" },
|
||||
{ column: "duration", agg: "90thPercentile" },
|
||||
{ column: "duration", agg: "95thPercentile" },
|
||||
{ column: "duration", agg: "99thPercentile" },
|
||||
@@ -83,6 +84,10 @@ export const LatencyChart = ({
|
||||
tabTitle: "50th Percentile",
|
||||
data: getData("percentile50Duration"),
|
||||
},
|
||||
{
|
||||
tabTitle: "75th Percentile",
|
||||
data: getData("percentile75Duration"),
|
||||
},
|
||||
{
|
||||
tabTitle: "90th Percentile",
|
||||
data: getData("percentile90Duration"),
|
||||
|
||||
@@ -19,7 +19,7 @@ export const MetricTable = ({
|
||||
const metrics = api.dashboard.chart.useQuery(
|
||||
{
|
||||
projectId,
|
||||
from: "observations",
|
||||
from: "traces_observations",
|
||||
select: [
|
||||
{ column: "calculatedTotalCost", agg: "SUM" },
|
||||
{ column: "totalTokens", agg: "SUM" },
|
||||
|
||||
@@ -33,7 +33,7 @@ export const ModelUsageChart = ({
|
||||
const tokens = api.dashboard.chart.useQuery(
|
||||
{
|
||||
projectId,
|
||||
from: "observations",
|
||||
from: "traces_observations",
|
||||
select: [
|
||||
{ column: "totalTokens", agg: "SUM" },
|
||||
{ column: "calculatedTotalCost", agg: "SUM" },
|
||||
|
||||
@@ -7,6 +7,7 @@ import { api } from "@/src/utils/api";
|
||||
import { compactNumberFormatter } from "@/src/utils/numbers";
|
||||
import { RightAlignedCell } from "./RightAlignedCell";
|
||||
import { TotalMetric } from "./TotalMetric";
|
||||
import { createTracesTimeFilter } from "@/src/features/dashboard/lib/dashboard-utils";
|
||||
|
||||
export const ScoresTable = ({
|
||||
className,
|
||||
@@ -17,11 +18,7 @@ export const ScoresTable = ({
|
||||
projectId: string;
|
||||
globalFilterState: FilterState;
|
||||
}) => {
|
||||
const localFilters = globalFilterState.map((f) => ({
|
||||
...f,
|
||||
column: "timestamp",
|
||||
}));
|
||||
|
||||
const localFilters = createTracesTimeFilter(globalFilterState);
|
||||
const metrics = api.dashboard.chart.useQuery(
|
||||
{
|
||||
projectId,
|
||||
|
||||
@@ -21,6 +21,7 @@ export const TabComponent = ({ tabs }: TabComponentProps) => {
|
||||
name="tabs"
|
||||
className="block w-full rounded-md border-gray-300 py-2 pl-3 pr-10 text-base focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm"
|
||||
defaultValue={0}
|
||||
onChange={(e) => setSelectedIndex(Number(e.target.selectedIndex))}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<option key={tab.tabTitle}>{tab.tabTitle}</option>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ExpandListButton } from "@/src/features/dashboard/components/cards/Chev
|
||||
import { useState } from "react";
|
||||
import DocPopup from "@/src/components/layouts/doc-popup";
|
||||
import { NoData } from "@/src/features/dashboard/components/NoData";
|
||||
import { createTracesTimeFilter } from "@/src/features/dashboard/lib/dashboard-utils";
|
||||
|
||||
type BarChartDataPoint = {
|
||||
name: string;
|
||||
@@ -61,10 +62,7 @@ export const UserChart = ({
|
||||
projectId,
|
||||
from: "traces",
|
||||
select: [{ column: "user" }, { column: "traceId", agg: "COUNT" }],
|
||||
filter: globalFilterState.map((f) => ({
|
||||
...f,
|
||||
column: "timestamp",
|
||||
})),
|
||||
filter: createTracesTimeFilter(globalFilterState),
|
||||
groupBy: [
|
||||
{
|
||||
type: "string",
|
||||
|
||||
@@ -10,7 +10,7 @@ export const getAllModels = (
|
||||
const allModels = api.dashboard.chart.useQuery(
|
||||
{
|
||||
projectId,
|
||||
from: "observations",
|
||||
from: "traces_observations",
|
||||
select: [{ column: "model" }],
|
||||
filter: [
|
||||
...globalFilterState,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { type FilterState } from "@/src/features/filters/types";
|
||||
|
||||
// traces do not have a startTime or endTime column, so we need to map these to the timestamp column
|
||||
export const createTracesTimeFilter = (filters: FilterState) => {
|
||||
return filters.map((f) => {
|
||||
if (f.column === "startTime" || f.column === "endTime") {
|
||||
return {
|
||||
...f,
|
||||
column: "timestamp",
|
||||
};
|
||||
} else {
|
||||
return f;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,7 +1,3 @@
|
||||
import {
|
||||
dateTimeAggregationOptions,
|
||||
dateTimeAggregationSettings,
|
||||
} from "@/src/features/dashboard/lib/timeseries-aggregation";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
@@ -9,80 +5,20 @@ import {
|
||||
protectedProjectProcedure,
|
||||
} from "@/src/server/api/trpc";
|
||||
import { executeQuery } from "@/src/server/api/services/query-builder";
|
||||
import { sqlInterface } from "@/src/server/api/services/sqlInterface";
|
||||
import {
|
||||
filterInterface,
|
||||
sqlInterface,
|
||||
} from "@/src/server/api/services/sqlInterface";
|
||||
|
||||
export const dashboardRouter = createTRPCRouter({
|
||||
chart: protectedProjectProcedure
|
||||
.input(sqlInterface.extend({ projectId: z.string() }))
|
||||
.query(async ({ input, ctx }) => {
|
||||
return await executeQuery(ctx.prisma, input.projectId, input);
|
||||
}),
|
||||
|
||||
scores: protectedProjectProcedure
|
||||
.input(
|
||||
z.object({
|
||||
sqlInterface.extend({
|
||||
projectId: z.string(),
|
||||
agg: z.enum(dateTimeAggregationOptions),
|
||||
filter: filterInterface.optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
// queryRawUnsafe to add input.agg to the WHERE clause
|
||||
const output = await ctx.prisma.$queryRawUnsafe<
|
||||
{
|
||||
date_trunc: Date;
|
||||
values: {
|
||||
[key: string]: number;
|
||||
} | null;
|
||||
}[]
|
||||
>(`
|
||||
WITH timeseries AS (
|
||||
SELECT
|
||||
date_trunc('${
|
||||
dateTimeAggregationSettings[input.agg].date_trunc
|
||||
}', dt) as date_trunc
|
||||
FROM generate_series(
|
||||
NOW() - INTERVAL '${input.agg}', NOW(), INTERVAL '1 minute'
|
||||
) as dt
|
||||
WHERE dt > NOW() - INTERVAL '${input.agg}'
|
||||
GROUP BY 1
|
||||
),
|
||||
metrics AS (
|
||||
SELECT
|
||||
date_trunc('${
|
||||
dateTimeAggregationSettings[input.agg].date_trunc
|
||||
}', scores.timestamp) as date_trunc,
|
||||
scores.name as metric_name,
|
||||
AVG(value) as avg_value
|
||||
FROM scores
|
||||
LEFT JOIN traces ON scores.trace_id = traces.id
|
||||
WHERE scores.timestamp > NOW() - INTERVAL '${input.agg}'
|
||||
AND traces.project_id = '${input.projectId}'
|
||||
GROUP BY 1,2
|
||||
),
|
||||
json_metrics AS (
|
||||
SELECT
|
||||
date_trunc,
|
||||
jsonb_object_agg(metric_name, avg_value) as values
|
||||
FROM metrics
|
||||
GROUP BY 1
|
||||
)
|
||||
SELECT
|
||||
timeseries.date_trunc,
|
||||
json_metrics.values as values
|
||||
FROM timeseries
|
||||
LEFT JOIN json_metrics ON timeseries.date_trunc = json_metrics.date_trunc
|
||||
ORDER BY 1
|
||||
`);
|
||||
|
||||
return output.map((row) => ({
|
||||
...row,
|
||||
values: row.values
|
||||
? Object.entries(row.values).map(([label, value]) => ({
|
||||
label: "avg_" + label,
|
||||
value,
|
||||
}))
|
||||
: [],
|
||||
ts: row.date_trunc.getTime(),
|
||||
}));
|
||||
return await executeQuery(ctx.prisma, input.projectId, input);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
} from "@/src/server/api/trpc";
|
||||
import { type DatasetRuns, Prisma, type Dataset } from "@prisma/client";
|
||||
import { throwIfNoAccess } from "@/src/features/rbac/utils/checkAccess";
|
||||
import { auditLog } from "@/src/features/audit-logs/auditLog";
|
||||
import { DB } from "@/src/server/db";
|
||||
|
||||
export const datasetRouter = createTRPCRouter({
|
||||
allDatasets: protectedProjectProcedure
|
||||
@@ -15,7 +17,30 @@ export const datasetRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
return ctx.prisma.$queryRaw<
|
||||
const query = DB.selectFrom("datasets")
|
||||
.leftJoin("dataset_items", "datasets.id", "dataset_items.dataset_id")
|
||||
.leftJoin("dataset_runs", "datasets.id", "dataset_runs.dataset_id")
|
||||
.select(({ eb }) => [
|
||||
"datasets.id",
|
||||
"datasets.name",
|
||||
"datasets.created_at as createdAt",
|
||||
"datasets.updated_at as updatedAt",
|
||||
eb.fn.count("dataset_items.id").distinct().as("countDatasetItems"),
|
||||
eb.fn.count("dataset_runs.id").distinct().as("countDatasetRuns"),
|
||||
eb.fn.max("dataset_runs.created_at").as("lastRunAt"),
|
||||
])
|
||||
.where("datasets.project_id", "=", input.projectId)
|
||||
.groupBy([
|
||||
"datasets.id",
|
||||
"datasets.name",
|
||||
"datasets.created_at",
|
||||
"datasets.updated_at",
|
||||
])
|
||||
.orderBy("datasets.created_at", "desc");
|
||||
|
||||
const compiledQuery = query.compile();
|
||||
|
||||
return await ctx.prisma.$queryRawUnsafe<
|
||||
Array<
|
||||
Dataset & {
|
||||
countDatasetItems: number;
|
||||
@@ -23,22 +48,7 @@ export const datasetRouter = createTRPCRouter({
|
||||
lastRunAt: Date | null;
|
||||
}
|
||||
>
|
||||
>(Prisma.sql`
|
||||
SELECT
|
||||
d.id,
|
||||
d.name,
|
||||
d.created_at "createdAt",
|
||||
d.updated_at "updatedAt",
|
||||
count(distinct di.id)::int "countDatasetItems",
|
||||
count(distinct dr.id)::int "countDatasetRuns",
|
||||
max(dr.created_at) "lastRunAt"
|
||||
FROM datasets d
|
||||
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
|
||||
ORDER BY d.created_at DESC
|
||||
`);
|
||||
>(compiledQuery.sql, ...compiledQuery.parameters);
|
||||
}),
|
||||
byId: protectedProjectProcedure
|
||||
.input(
|
||||
@@ -203,7 +213,7 @@ export const datasetRouter = createTRPCRouter({
|
||||
projectId: input.projectId,
|
||||
scope: "datasets:CUD",
|
||||
});
|
||||
return ctx.prisma.datasetItem.update({
|
||||
const datasetItem = await ctx.prisma.datasetItem.update({
|
||||
where: {
|
||||
id: input.datasetItemId,
|
||||
datasetId: input.datasetId,
|
||||
@@ -226,6 +236,15 @@ export const datasetRouter = createTRPCRouter({
|
||||
status: input.status,
|
||||
},
|
||||
});
|
||||
await auditLog({
|
||||
session: ctx.session,
|
||||
resourceType: "datasetItem",
|
||||
resourceId: input.datasetItemId,
|
||||
projectId: input.projectId,
|
||||
action: "update",
|
||||
after: datasetItem,
|
||||
});
|
||||
return datasetItem;
|
||||
}),
|
||||
createDataset: protectedProjectProcedure
|
||||
.input(z.object({ projectId: z.string(), name: z.string() }))
|
||||
@@ -235,12 +254,23 @@ export const datasetRouter = createTRPCRouter({
|
||||
projectId: input.projectId,
|
||||
scope: "datasets:CUD",
|
||||
});
|
||||
return ctx.prisma.dataset.create({
|
||||
const dataset = await ctx.prisma.dataset.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
projectId: input.projectId,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
session: ctx.session,
|
||||
resourceType: "dataset",
|
||||
resourceId: dataset.id,
|
||||
projectId: input.projectId,
|
||||
action: "create",
|
||||
after: dataset,
|
||||
});
|
||||
|
||||
return dataset;
|
||||
}),
|
||||
deleteDataset: protectedProjectProcedure
|
||||
.input(z.object({ projectId: z.string(), datasetId: z.string() }))
|
||||
@@ -250,12 +280,21 @@ export const datasetRouter = createTRPCRouter({
|
||||
projectId: input.projectId,
|
||||
scope: "datasets:CUD",
|
||||
});
|
||||
return ctx.prisma.dataset.delete({
|
||||
const deletedDataset = await ctx.prisma.dataset.delete({
|
||||
where: {
|
||||
id: input.datasetId,
|
||||
projectId: input.projectId,
|
||||
},
|
||||
});
|
||||
await auditLog({
|
||||
session: ctx.session,
|
||||
resourceType: "dataset",
|
||||
resourceId: deletedDataset.id,
|
||||
projectId: input.projectId,
|
||||
action: "delete",
|
||||
before: deletedDataset,
|
||||
});
|
||||
return deletedDataset;
|
||||
}),
|
||||
createDatasetItem: protectedProjectProcedure
|
||||
.input(
|
||||
@@ -283,7 +322,7 @@ export const datasetRouter = createTRPCRouter({
|
||||
throw new Error("Dataset not found");
|
||||
}
|
||||
|
||||
return ctx.prisma.datasetItem.create({
|
||||
const datasetItem = await ctx.prisma.datasetItem.create({
|
||||
data: {
|
||||
input: JSON.parse(input.input) as Prisma.InputJsonObject,
|
||||
expectedOutput:
|
||||
@@ -296,6 +335,15 @@ export const datasetRouter = createTRPCRouter({
|
||||
sourceObservationId: input.sourceObservationId,
|
||||
},
|
||||
});
|
||||
await auditLog({
|
||||
session: ctx.session,
|
||||
resourceType: "datasetItem",
|
||||
resourceId: datasetItem.id,
|
||||
projectId: input.projectId,
|
||||
action: "create",
|
||||
after: datasetItem,
|
||||
});
|
||||
return datasetItem;
|
||||
}),
|
||||
runitemsByRunIdOrItemId: protectedProjectProcedure
|
||||
.input(
|
||||
|
||||
@@ -90,13 +90,13 @@ export function FeedbackButtonWrapper({
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="mb-5">{title}</DialogTitle>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-8"
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@@ -10,7 +10,7 @@ export default async function feedbackApiHandler(
|
||||
await runFeedbackCorsMiddleware(req, res);
|
||||
|
||||
try {
|
||||
const slackResponse = await sendToSlack(req);
|
||||
const slackResponse = await sendToSlack(req.body);
|
||||
if (slackResponse.status === 200) {
|
||||
res.status(200).json({ status: "OK" });
|
||||
} else {
|
||||
|
||||
@@ -97,7 +97,9 @@ export function FilterBuilder({
|
||||
? new Date(filter.value).toLocaleDateString()
|
||||
: filter.type === "stringOptions" ||
|
||||
filter.type === "arrayOptions"
|
||||
? filter.value.join(", ")
|
||||
? filter.value.length > 2
|
||||
? `${filter.value.length} selected`
|
||||
: filter.value.join(", ")
|
||||
: filter.type === "number" ||
|
||||
filter.type === "numberObject"
|
||||
? filter.value
|
||||
|
||||
@@ -19,15 +19,15 @@ const CommaArrayParam = {
|
||||
value.map((f) => {
|
||||
const stringified = `${f.column};${f.type};${
|
||||
f.type === "numberObject" || f.type === "stringObject" ? f.key : ""
|
||||
};${f.operator};${
|
||||
};${f.operator};${encodeURIComponent(
|
||||
f.type === "datetime"
|
||||
? f.value.toISOString()
|
||||
: f.type === "stringOptions"
|
||||
? f.value.join("|")
|
||||
: f.type === "arrayOptions"
|
||||
? f.value.join("|")
|
||||
: f.value
|
||||
}`;
|
||||
: f.value,
|
||||
)}`;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (DEBUG_QUERY_STATE) console.log("stringified", stringified);
|
||||
return stringified;
|
||||
@@ -43,20 +43,21 @@ const CommaArrayParam = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (DEBUG_QUERY_STATE)
|
||||
console.log("values", [column, type, key, operator, value]);
|
||||
const decodedValue = value ? decodeURIComponent(value) : undefined;
|
||||
const parsedValue =
|
||||
value === undefined || type === undefined
|
||||
decodedValue === undefined || type === undefined
|
||||
? undefined
|
||||
: type === "datetime"
|
||||
? new Date(value)
|
||||
? new Date(decodedValue)
|
||||
: type === "number" || type === "numberObject"
|
||||
? Number(value)
|
||||
? Number(decodedValue)
|
||||
: type === "stringOptions"
|
||||
? value.split("|")
|
||||
? decodedValue.split("|")
|
||||
: type === "arrayOptions"
|
||||
? value.split("|")
|
||||
? decodedValue.split("|")
|
||||
: type === "boolean"
|
||||
? value === "true"
|
||||
: value;
|
||||
? decodedValue === "true"
|
||||
: decodedValue;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (DEBUG_QUERY_STATE) console.log("parsedValue", parsedValue);
|
||||
const parsed = singleFilter.safeParse({
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { type FilterState } from "@/src/features/filters/types";
|
||||
import { filterOperators } from "@/src/server/api/interfaces/filters";
|
||||
import { type ColumnDefinition } from "@/src/server/api/interfaces/tableDefinition";
|
||||
import {
|
||||
type TableNames as TableName,
|
||||
type ColumnDefinition,
|
||||
} from "@/src/server/api/interfaces/tableDefinition";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
const operatorReplacements = {
|
||||
@@ -18,11 +21,24 @@ const arrayOperatorReplacements = {
|
||||
"none of": "&&",
|
||||
};
|
||||
|
||||
export function filterToPrismaSql(
|
||||
export function tableColumnsToSqlFilterAndPrefix(
|
||||
filters: FilterState,
|
||||
tableColumns: ColumnDefinition[],
|
||||
table: TableName,
|
||||
): Prisma.Sql {
|
||||
const statements = filters.map((filter) => {
|
||||
const sql = tableColumnsToSqlFilter(filters, tableColumns, table);
|
||||
if (sql === Prisma.empty) {
|
||||
return Prisma.empty;
|
||||
}
|
||||
return Prisma.join([Prisma.raw("AND "), sql], "");
|
||||
}
|
||||
|
||||
export function tableColumnsToSqlFilter(
|
||||
filters: FilterState,
|
||||
tableColumns: ColumnDefinition[],
|
||||
table: TableName,
|
||||
): Prisma.Sql {
|
||||
const internalFilters = filters.map((filter) => {
|
||||
// Get column definition to map column to internal name, e.g. "t.id"
|
||||
const col = tableColumns.find(
|
||||
(c) =>
|
||||
@@ -33,8 +49,17 @@ export function filterToPrismaSql(
|
||||
console.error("Invalid filter column", filter.column);
|
||||
throw new Error("Invalid filter column: " + filter.column);
|
||||
}
|
||||
|
||||
const colPrisma = Prisma.raw(col.internal);
|
||||
return {
|
||||
condition: filter,
|
||||
internalColumn: colPrisma,
|
||||
column: col,
|
||||
table: table,
|
||||
};
|
||||
});
|
||||
|
||||
const statements = internalFilters.map((filterAndColumn) => {
|
||||
const filter = filterAndColumn.condition;
|
||||
const operatorPrisma =
|
||||
filter.type === "arrayOptions"
|
||||
? Prisma.raw(
|
||||
@@ -108,18 +133,27 @@ export function filterToPrismaSql(
|
||||
? [Prisma.raw("NOT ("), Prisma.raw(")")]
|
||||
: [Prisma.empty, Prisma.empty];
|
||||
|
||||
return Prisma.sql`${funcPrisma1}${cast1}${colPrisma}${jsonKeyPrisma}${cast2} ${operatorPrisma} ${valuePrefix}${valuePrisma}${valueSuffix}${funcPrisma2}`;
|
||||
return Prisma.sql`${funcPrisma1}${cast1}${filterAndColumn.internalColumn}${jsonKeyPrisma}${cast2} ${operatorPrisma} ${valuePrefix}${valuePrisma}${castValueToPostgresTypes(filterAndColumn.column, filterAndColumn.table)}${valueSuffix}${funcPrisma2}`;
|
||||
});
|
||||
if (statements.length === 0) {
|
||||
return Prisma.empty;
|
||||
}
|
||||
|
||||
return Prisma.join(
|
||||
[Prisma.raw("AND "), Prisma.join(statements, " AND ")],
|
||||
"",
|
||||
);
|
||||
return Prisma.join(statements, " AND ");
|
||||
}
|
||||
|
||||
const castValueToPostgresTypes = (
|
||||
column: ColumnDefinition,
|
||||
table: TableName,
|
||||
) => {
|
||||
return column.name === "type" &&
|
||||
(table === "observations" ||
|
||||
table === "traces_observations" ||
|
||||
table === "traces_parent_observation_scores")
|
||||
? Prisma.sql`::"ObservationType"`
|
||||
: Prisma.empty;
|
||||
};
|
||||
|
||||
const dateOperators = filterOperators["datetime"];
|
||||
|
||||
export const datetimeFilterToPrismaSql = (
|
||||
|
||||
@@ -14,7 +14,7 @@ export function orderByToPrismaSql(
|
||||
tableColumns: ColumnDefinition[],
|
||||
): Prisma.Sql {
|
||||
if (!orderBy) {
|
||||
return Prisma.sql`ORDER BY t.timestamp DESC`;
|
||||
return Prisma.sql`ORDER BY t.timestamp DESC NULLS LAST`;
|
||||
}
|
||||
// Get column definition to map column to internal name, e.g. "t.id"
|
||||
const col = tableColumns.find(
|
||||
@@ -37,5 +37,7 @@ export function orderByToPrismaSql(
|
||||
}
|
||||
|
||||
// Both column and order are safe, can use raw SQL
|
||||
return Prisma.raw(`ORDER BY ${col.internal} ${order.data}`);
|
||||
return Prisma.raw(
|
||||
`ORDER BY ${col.internal} ${order.data} ${orderBy.order === "DESC" ? "NULLS LAST" : "NULLS FIRST"}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export function PricingPage(props: { className?: string }) {
|
||||
<div className={props.className}>
|
||||
<stripe-pricing-table
|
||||
client-reference-id={session.data?.user?.id}
|
||||
pricing-table-id="prctbl_1OWhugAWilt2EAVVKHi8TEw7"
|
||||
pricing-table-id="prctbl_1OjTUyAWilt2EAVVMCMAMDgB"
|
||||
publishable-key="pk_live_51MPW00AWilt2EAVVFWfPTQhgmLA0EeacLSzAs6e3vECCcMBvwcMse81XgXO6k1bdBHbPBdpOmrXE8P1gBrxE7yhH00RPHQ8SyG"
|
||||
></stripe-pricing-table>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { createTRPCRouter, protectedProcedure } from "@/src/server/api/trpc";
|
||||
import {
|
||||
createTRPCRouter,
|
||||
protectedProcedure,
|
||||
protectedProjectProcedure,
|
||||
} from "@/src/server/api/trpc";
|
||||
import * as z from "zod";
|
||||
import { throwIfNoAccess } from "@/src/features/rbac/utils/checkAccess";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { projectNameSchema } from "@/src/features/auth/lib/projectNameSchema";
|
||||
import { auditLog } from "@/src/features/audit-logs/auditLog";
|
||||
|
||||
export const projectsRouter = createTRPCRouter({
|
||||
all: protectedProcedure.query(async ({ ctx }) => {
|
||||
@@ -66,6 +71,15 @@ export const projectsRouter = createTRPCRouter({
|
||||
},
|
||||
},
|
||||
});
|
||||
await auditLog({
|
||||
resourceType: "project",
|
||||
resourceId: project.id,
|
||||
action: "create",
|
||||
userId: ctx.session.user.id,
|
||||
projectId: project.id,
|
||||
userProjectRole: "OWNER",
|
||||
after: project,
|
||||
});
|
||||
|
||||
return {
|
||||
id: project.id,
|
||||
@@ -74,7 +88,7 @@ export const projectsRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
|
||||
update: protectedProcedure
|
||||
update: protectedProjectProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
@@ -88,7 +102,7 @@ export const projectsRouter = createTRPCRouter({
|
||||
scope: "project:update",
|
||||
});
|
||||
|
||||
await ctx.prisma.project.update({
|
||||
const project = await ctx.prisma.project.update({
|
||||
where: {
|
||||
id: input.projectId,
|
||||
},
|
||||
@@ -96,10 +110,17 @@ export const projectsRouter = createTRPCRouter({
|
||||
name: input.newName,
|
||||
},
|
||||
});
|
||||
await auditLog({
|
||||
session: ctx.session,
|
||||
resourceType: "project",
|
||||
resourceId: input.projectId,
|
||||
action: "update",
|
||||
after: project,
|
||||
});
|
||||
return true;
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
delete: protectedProjectProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
@@ -111,6 +132,12 @@ export const projectsRouter = createTRPCRouter({
|
||||
projectId: input.projectId,
|
||||
scope: "project:delete",
|
||||
});
|
||||
await auditLog({
|
||||
session: ctx.session,
|
||||
resourceType: "project",
|
||||
resourceId: input.projectId,
|
||||
action: "delete",
|
||||
});
|
||||
|
||||
await ctx.prisma.project.delete({
|
||||
where: {
|
||||
@@ -121,7 +148,7 @@ export const projectsRouter = createTRPCRouter({
|
||||
return true;
|
||||
}),
|
||||
|
||||
transfer: protectedProcedure
|
||||
transfer: protectedProjectProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
@@ -145,6 +172,14 @@ export const projectsRouter = createTRPCRouter({
|
||||
if (newOwner.id === ctx.session.user.id)
|
||||
throw new Error("You cannot transfer project to yourself");
|
||||
|
||||
await auditLog({
|
||||
session: ctx.session,
|
||||
resourceType: "project",
|
||||
resourceId: input.projectId,
|
||||
action: "transfer",
|
||||
after: { ownerId: newOwner.id },
|
||||
});
|
||||
|
||||
return ctx.prisma.$transaction([
|
||||
// Add new owner, upsert to update role if already exists
|
||||
ctx.prisma.membership.upsert({
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { useHasAccess } from "@/src/features/rbac/utils/checkAccess";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/src/components/ui/popover";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
export function DeletePromptVersion({
|
||||
promptVersionId,
|
||||
projectId,
|
||||
version,
|
||||
countVersions,
|
||||
}: {
|
||||
promptVersionId: string;
|
||||
projectId: string;
|
||||
version: number;
|
||||
countVersions: number;
|
||||
}) {
|
||||
const utils = api.useUtils();
|
||||
const router = useRouter();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const hasAccess = useHasAccess({ projectId, scope: "prompts:CUD" });
|
||||
|
||||
const mutDeletePromptVersion = api.prompts.deleteVersion.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.prompts.invalidate();
|
||||
if (countVersions > 1) {
|
||||
void router.replace(
|
||||
{
|
||||
pathname: router.pathname,
|
||||
query: { ...router.query, version: undefined },
|
||||
},
|
||||
undefined,
|
||||
{ shallow: true },
|
||||
);
|
||||
} else {
|
||||
void router.push(`/project/${projectId}/prompts`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (!hasAccess) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
key={promptVersionId}
|
||||
open={isOpen}
|
||||
onOpenChange={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" type="button" size="icon">
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<h2 className="text-md mb-3 font-semibold">Please confirm</h2>
|
||||
<p className="mb-3 text-sm">
|
||||
This action deletes the prompt version. Requests of version{" "}
|
||||
<code className="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold">
|
||||
{version}
|
||||
</code>
|
||||
of this prompt will return an error.
|
||||
</p>
|
||||
<div className="flex justify-end space-x-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
loading={mutDeletePromptVersion.isLoading}
|
||||
onClick={() => {
|
||||
void mutDeletePromptVersion.mutateAsync({
|
||||
promptVersionId,
|
||||
projectId,
|
||||
});
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
Delete Prompt Version
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { useHasAccess } from "@/src/features/rbac/utils/checkAccess";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/src/components/ui/popover";
|
||||
|
||||
export function DeletePrompt({
|
||||
projectId,
|
||||
promptName,
|
||||
}: {
|
||||
projectId: string;
|
||||
promptName: string;
|
||||
}) {
|
||||
const utils = api.useUtils();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const hasAccess = useHasAccess({ projectId, scope: "prompts:CUD" });
|
||||
|
||||
const mutDeletePrompt = api.prompts.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.prompts.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
if (!hasAccess) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={() => setIsOpen(!isOpen)}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="xs">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<h2 className="text-md mb-3 font-semibold">Please confirm</h2>
|
||||
<p className="mb-3 text-sm">
|
||||
This action permanently deletes this prompt. All requests to fetch
|
||||
prompt{" "}
|
||||
<code className="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold">
|
||||
{promptName}
|
||||
</code>{" "}
|
||||
will error.
|
||||
</p>
|
||||
<div className="flex justify-end space-x-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
loading={mutDeletePrompt.isLoading}
|
||||
onClick={() => {
|
||||
void mutDeletePrompt.mutateAsync({
|
||||
projectId,
|
||||
promptName,
|
||||
});
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
Delete Prompt
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
FormControl,
|
||||
FormMessage,
|
||||
Form,
|
||||
FormDescription,
|
||||
} from "@/src/components/ui/form";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
@@ -29,6 +30,8 @@ import { Badge } from "@/src/components/ui/badge";
|
||||
import router from "next/router";
|
||||
import { AutoComplete } from "@/src/features/prompts/components/auto-complete";
|
||||
import { type AutoCompleteOption } from "@/src/features/prompts/components/auto-complete";
|
||||
import JsonView from "react18-json-view";
|
||||
import { jsonSchema } from "@/src/utils/zod";
|
||||
|
||||
export const CreatePromptDialog = (props: {
|
||||
projectId: string;
|
||||
@@ -36,6 +39,7 @@ export const CreatePromptDialog = (props: {
|
||||
promptName?: string;
|
||||
promptText?: string;
|
||||
subtitle?: string;
|
||||
promptConfig?: z.infer<typeof jsonSchema>;
|
||||
children?: React.ReactNode;
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -48,7 +52,7 @@ export const CreatePromptDialog = (props: {
|
||||
return (
|
||||
<Dialog open={hasAccess && open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{props.children}</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-3xl">
|
||||
<DialogContent className="max-h-screen overflow-auto sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="mb-5">
|
||||
{props.title}
|
||||
@@ -61,6 +65,7 @@ export const CreatePromptDialog = (props: {
|
||||
projectId={props.projectId}
|
||||
promptName={props.promptName}
|
||||
promptText={props.promptText}
|
||||
promptConfig={props.promptConfig}
|
||||
onFormSuccess={() => setOpen(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
@@ -77,16 +82,30 @@ const formSchema = z.object({
|
||||
const variables = extractVariables(val);
|
||||
const matches = variables.map((variable) => {
|
||||
// check regex here
|
||||
if (variable.match(/^[A-Za-z]+$/)) {
|
||||
if (variable.match(/^[A-Za-z_]+$/)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return !matches.includes(false);
|
||||
}, "Variables must only contain letters"),
|
||||
}, "Variables must only contain letters and underscores (_)"),
|
||||
isActive: z.boolean({
|
||||
required_error: "Enter whether the prompt should go live",
|
||||
}),
|
||||
// string as we keep the state in string to avoid recursive zod parsing issues
|
||||
config: z.string().refine(
|
||||
(value) => {
|
||||
try {
|
||||
JSON.parse(value);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{
|
||||
message: "Config needs to be valid JSON",
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
export const NewPromptForm = (props: {
|
||||
@@ -94,6 +113,7 @@ export const NewPromptForm = (props: {
|
||||
onFormSuccess?: () => void;
|
||||
promptName?: string;
|
||||
promptText?: string;
|
||||
promptConfig?: z.infer<typeof jsonSchema>;
|
||||
}) => {
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
@@ -105,6 +125,7 @@ export const NewPromptForm = (props: {
|
||||
isActive: false,
|
||||
name: props.promptName ?? "",
|
||||
prompt: props.promptText ?? "",
|
||||
config: props.promptConfig ? JSON.stringify(props.promptConfig) : "{}",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -148,16 +169,17 @@ export const NewPromptForm = (props: {
|
||||
name: values.name,
|
||||
prompt: values.prompt,
|
||||
isActive: values.isActive,
|
||||
// we keep the config in state as string. need to convert it to JSON before sending it to the API
|
||||
// zod parsing necessary to align with TRPC schema
|
||||
config: jsonSchema.parse(JSON.parse(values.config)),
|
||||
})
|
||||
.then((newPrompt) => {
|
||||
props.onFormSuccess?.();
|
||||
form.reset();
|
||||
// go to the following page after creating the prompt
|
||||
if (newPrompt) {
|
||||
void router.push(
|
||||
`/project/${props.projectId}/prompts/${newPrompt.name}`,
|
||||
);
|
||||
}
|
||||
void router.push(
|
||||
`/project/${props.projectId}/prompts/${newPrompt.name}`,
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
@@ -231,6 +253,30 @@ export const NewPromptForm = (props: {
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Config</FormLabel>
|
||||
<JsonView
|
||||
// need to convert string in state to JSON for the JSONView component
|
||||
src={jsonSchema.parse(JSON.parse(field.value))}
|
||||
onEdit={(edit) => {
|
||||
// need to put string back into the state
|
||||
field.onChange(JSON.stringify(edit.src));
|
||||
}}
|
||||
editable
|
||||
className="rounded-md border border-gray-200 p-2 text-sm"
|
||||
/>
|
||||
<FormDescription>
|
||||
Track configs for LLM API calls such as function definitions or
|
||||
LLM parameters.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isActive"
|
||||
|
||||
@@ -14,6 +14,9 @@ import { PromotePrompt } from "@/src/features/prompts/components/promote-prompt"
|
||||
import { ScrollArea } from "@radix-ui/react-scroll-area";
|
||||
import { useQueryParam, NumberParam } from "use-query-params";
|
||||
import router from "next/router";
|
||||
import { JSONView } from "@/src/components/ui/code";
|
||||
import { DeletePromptVersion } from "@/src/features/prompts/components/delete-prompt-version";
|
||||
import { jsonSchema } from "@/src/utils/zod";
|
||||
|
||||
export type PromptDetailProps = {
|
||||
projectId: string;
|
||||
@@ -72,11 +75,18 @@ export const PromptDetail = (props: PromptDetailProps) => {
|
||||
subtitle="We do not update prompts, instead we create a new version of the prompt."
|
||||
promptName={prompt.name}
|
||||
promptText={prompt.prompt}
|
||||
promptConfig={jsonSchema.parse(prompt.config)}
|
||||
>
|
||||
<Button variant="outline" size="icon">
|
||||
<Pencil className="h-5 w-5" />
|
||||
</Button>
|
||||
</CreatePromptDialog>
|
||||
<DeletePromptVersion
|
||||
projectId={props.projectId}
|
||||
promptVersionId={prompt.id}
|
||||
version={prompt.version}
|
||||
countVersions={promptHistory.data.length}
|
||||
/>
|
||||
<DetailPageNav
|
||||
key="nav"
|
||||
currentId={prompt.name}
|
||||
@@ -89,7 +99,7 @@ export const PromptDetail = (props: PromptDetailProps) => {
|
||||
</div>
|
||||
<div className="col-span-2 md:h-full">
|
||||
<CodeView content={prompt.prompt} title="Prompt" />
|
||||
<div className="mx-auto mt-5 w-full rounded-lg border text-base leading-7 text-gray-700">
|
||||
<div className="mx-auto mt-5 w-full rounded-lg border text-base leading-7">
|
||||
<div className="border-b px-3 py-1 text-xs font-medium">
|
||||
Variables
|
||||
</div>
|
||||
@@ -105,6 +115,10 @@ export const PromptDetail = (props: PromptDetailProps) => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{prompt.config && JSON.stringify(prompt.config) !== "{}" && (
|
||||
<JSONView className="mt-5" json={prompt.config} title="Config" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex h-screen flex-col">
|
||||
<div className="text-m px-3 font-medium">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button } from "@/src/components/ui/button";
|
||||
import { useDetailPageLists } from "@/src/features/navigate-detail-pages/context";
|
||||
import { CreatePromptDialog } from "@/src/features/prompts/components/new-prompt-button";
|
||||
import { useHasAccess } from "@/src/features/rbac/utils/checkAccess";
|
||||
import { DeletePrompt } from "@/src/features/prompts/components/delete-prompt";
|
||||
|
||||
import { api } from "@/src/utils/api";
|
||||
import { type RouterOutput } from "@/src/utils/types";
|
||||
@@ -71,6 +72,18 @@ export function PromptTable(props: { projectId: string }) {
|
||||
return createdAt.toLocaleString();
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "actions",
|
||||
header: "Actions",
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<DeletePrompt
|
||||
projectId={props.projectId}
|
||||
promptName={row.getValue("name")}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const convertToTableRow = (
|
||||
|
||||
@@ -6,12 +6,15 @@ import {
|
||||
} from "@/src/server/api/trpc";
|
||||
import { throwIfNoAccess } from "@/src/features/rbac/utils/checkAccess";
|
||||
import { type Prompt, type PrismaClient } from "@prisma/client";
|
||||
import { jsonSchema } from "@/src/utils/zod";
|
||||
import { auditLog } from "@/src/features/audit-logs/auditLog";
|
||||
|
||||
export const CreatePrompt = z.object({
|
||||
projectId: z.string(),
|
||||
name: z.string(),
|
||||
isActive: z.boolean(),
|
||||
prompt: z.string(),
|
||||
config: jsonSchema,
|
||||
});
|
||||
|
||||
export const promptRouter = createTRPCRouter({
|
||||
@@ -70,14 +73,125 @@ export const promptRouter = createTRPCRouter({
|
||||
scope: "prompts:CUD",
|
||||
});
|
||||
|
||||
return await createPrompt({
|
||||
const prompt = await createPrompt({
|
||||
projectId: input.projectId,
|
||||
name: input.name,
|
||||
prompt: input.prompt,
|
||||
isActive: input.isActive,
|
||||
createdBy: ctx.session.user.id,
|
||||
config: jsonSchema.parse(input.config),
|
||||
prisma: ctx.prisma,
|
||||
});
|
||||
|
||||
if (!prompt) {
|
||||
throw new Error("Failed to create prompt");
|
||||
}
|
||||
|
||||
await auditLog(
|
||||
{
|
||||
session: ctx.session,
|
||||
resourceType: "prompt",
|
||||
resourceId: prompt.id,
|
||||
action: "create",
|
||||
after: prompt,
|
||||
},
|
||||
ctx.prisma,
|
||||
);
|
||||
|
||||
return prompt;
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
throw e;
|
||||
}
|
||||
}),
|
||||
delete: protectedProjectProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
promptName: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
throwIfNoAccess({
|
||||
session: ctx.session,
|
||||
projectId: input.projectId,
|
||||
scope: "prompts:CUD",
|
||||
});
|
||||
|
||||
// fetch prompts before deletion to enable audit logging
|
||||
const prompts = await ctx.prisma.prompt.findMany({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
name: input.promptName,
|
||||
},
|
||||
});
|
||||
|
||||
for (const prompt of prompts) {
|
||||
await auditLog(
|
||||
{
|
||||
session: ctx.session,
|
||||
resourceType: "prompt",
|
||||
resourceId: prompt.id,
|
||||
action: "delete",
|
||||
before: prompt,
|
||||
},
|
||||
ctx.prisma,
|
||||
);
|
||||
}
|
||||
|
||||
await ctx.prisma.prompt.deleteMany({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
id: {
|
||||
in: prompts.map((p) => p.id),
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
throw e;
|
||||
}
|
||||
}),
|
||||
deleteVersion: protectedProjectProcedure
|
||||
.input(
|
||||
z.object({
|
||||
promptVersionId: z.string(),
|
||||
projectId: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
throwIfNoAccess({
|
||||
session: ctx.session,
|
||||
projectId: input.projectId,
|
||||
scope: "prompts:CUD",
|
||||
});
|
||||
|
||||
const promptVersion = await ctx.prisma.prompt.findFirstOrThrow({
|
||||
where: {
|
||||
id: input.promptVersionId,
|
||||
projectId: input.projectId,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog(
|
||||
{
|
||||
session: ctx.session,
|
||||
resourceType: "prompt",
|
||||
resourceId: input.promptVersionId,
|
||||
action: "delete",
|
||||
before: promptVersion,
|
||||
},
|
||||
ctx.prisma,
|
||||
);
|
||||
|
||||
await ctx.prisma.prompt.delete({
|
||||
where: {
|
||||
id: input.promptVersionId,
|
||||
projectId: input.projectId,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
throw e;
|
||||
@@ -99,6 +213,20 @@ export const promptRouter = createTRPCRouter({
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog(
|
||||
{
|
||||
session: ctx.session,
|
||||
resourceType: "prompt",
|
||||
resourceId: toBePromotedPrompt.id,
|
||||
action: "promote",
|
||||
after: {
|
||||
...toBePromotedPrompt,
|
||||
isActive: true,
|
||||
},
|
||||
},
|
||||
ctx.prisma,
|
||||
);
|
||||
|
||||
const latestActivePrompt = await ctx.prisma.prompt.findFirst({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
@@ -190,6 +318,7 @@ export const createPrompt = async ({
|
||||
prompt,
|
||||
isActive = true,
|
||||
createdBy,
|
||||
config,
|
||||
prisma,
|
||||
}: {
|
||||
projectId: string;
|
||||
@@ -197,6 +326,7 @@ export const createPrompt = async ({
|
||||
prompt: string;
|
||||
isActive?: boolean;
|
||||
createdBy: string;
|
||||
config: z.infer<typeof jsonSchema>;
|
||||
prisma: PrismaClient;
|
||||
}) => {
|
||||
const latestPrompt = await prisma.prompt.findFirst({
|
||||
@@ -225,6 +355,7 @@ export const createPrompt = async ({
|
||||
isActive: isActive,
|
||||
project: { connect: { id: projectId } },
|
||||
createdBy: createdBy,
|
||||
config: jsonSchema.parse(config),
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { auditLog } from "@/src/features/audit-logs/auditLog";
|
||||
import { generateKeySet } from "@/src/features/public-api/lib/apiKeys";
|
||||
import { throwIfNoAccess } from "@/src/features/rbac/utils/checkAccess";
|
||||
import {
|
||||
@@ -64,6 +65,13 @@ export const apiKeysRouter = createTRPCRouter({
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
session: ctx.session,
|
||||
resourceType: "apiKey",
|
||||
resourceId: apiKey.id,
|
||||
action: "create",
|
||||
});
|
||||
|
||||
return {
|
||||
id: apiKey.id,
|
||||
createdAt: apiKey.createdAt,
|
||||
@@ -86,6 +94,12 @@ export const apiKeysRouter = createTRPCRouter({
|
||||
projectId: input.projectId,
|
||||
scope: "apiKeys:delete",
|
||||
});
|
||||
await auditLog({
|
||||
session: ctx.session,
|
||||
resourceType: "apiKey",
|
||||
resourceId: input.id,
|
||||
action: "delete",
|
||||
});
|
||||
|
||||
// Make sure the API key exists and belongs to the project the user has access to
|
||||
const apiKey = await ctx.prisma.apiKey.findFirstOrThrow({
|
||||
|
||||
@@ -45,22 +45,20 @@ export const usage = MixedUsage.nullish()
|
||||
unit: ModelUsageUnit.Tokens,
|
||||
};
|
||||
}
|
||||
// if we get the new generic format, we do not set a default
|
||||
if ("input" in v || "output" in v || "total" in v || "unit" in v) {
|
||||
const unit = v.unit;
|
||||
return { ...v, unit };
|
||||
}
|
||||
|
||||
// if the object is empty, we return undefined
|
||||
if (lodash.isEmpty(v)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return v;
|
||||
})
|
||||
// ensure output is always of new usage model
|
||||
.pipe(Usage.nullish());
|
||||
|
||||
export const TraceBody = z.object({
|
||||
id: z.string().nullish(),
|
||||
timestamp: stringDate,
|
||||
name: z.string().nullish(),
|
||||
externalId: z.string().nullish(),
|
||||
input: jsonSchema.nullish(),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { auditLog } from "@/src/features/audit-logs/auditLog";
|
||||
import { sendProjectInvitation } from "@/src/features/email/lib/project-invitation";
|
||||
import { throwIfNoAccess } from "@/src/features/rbac/utils/checkAccess";
|
||||
import {
|
||||
@@ -5,6 +6,7 @@ import {
|
||||
protectedProjectProcedure,
|
||||
} from "@/src/server/api/trpc";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import * as z from "zod";
|
||||
|
||||
export const projectMembersRouter = createTRPCRouter({
|
||||
@@ -75,8 +77,7 @@ export const projectMembersRouter = createTRPCRouter({
|
||||
if (input.userId === ctx.session.user.id)
|
||||
throw new Error("You cannot remove yourself from a project");
|
||||
|
||||
// use deleteMany to protect against deleting owner with where clause
|
||||
return ctx.prisma.membership.deleteMany({
|
||||
const membership = await ctx.prisma.membership.findFirst({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
userId: input.userId,
|
||||
@@ -85,6 +86,26 @@ export const projectMembersRouter = createTRPCRouter({
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
|
||||
await auditLog({
|
||||
session: ctx.session,
|
||||
resourceType: "membership",
|
||||
resourceId: membership.projectId + "--" + membership.userId,
|
||||
action: "delete",
|
||||
before: membership,
|
||||
});
|
||||
|
||||
// use ids from membership to make sure owners cannot delete themselves
|
||||
return await ctx.prisma.membership.delete({
|
||||
where: {
|
||||
projectId_userId: {
|
||||
projectId: membership.projectId,
|
||||
userId: membership.userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
deleteInvitation: protectedProjectProcedure
|
||||
.input(
|
||||
@@ -100,6 +121,13 @@ export const projectMembersRouter = createTRPCRouter({
|
||||
scope: "members:delete",
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
session: ctx.session,
|
||||
resourceType: "membershipInvitation",
|
||||
resourceId: input.id,
|
||||
action: "delete",
|
||||
});
|
||||
|
||||
return await ctx.prisma.membershipInvitation.delete({
|
||||
where: {
|
||||
id: input.id,
|
||||
@@ -132,13 +160,21 @@ export const projectMembersRouter = createTRPCRouter({
|
||||
},
|
||||
});
|
||||
if (user) {
|
||||
return await ctx.prisma.membership.create({
|
||||
const membership = await ctx.prisma.membership.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
projectId: input.projectId,
|
||||
role: input.role,
|
||||
},
|
||||
});
|
||||
await auditLog({
|
||||
session: ctx.session,
|
||||
resourceType: "membership",
|
||||
resourceId: input.projectId + "--" + user.id,
|
||||
action: "create",
|
||||
after: membership,
|
||||
});
|
||||
return membership;
|
||||
} else {
|
||||
const invitation = await ctx.prisma.membershipInvitation.create({
|
||||
data: {
|
||||
@@ -148,6 +184,13 @@ export const projectMembersRouter = createTRPCRouter({
|
||||
senderId: ctx.session.user.id,
|
||||
},
|
||||
});
|
||||
await auditLog({
|
||||
session: ctx.session,
|
||||
resourceType: "membershipInvitation",
|
||||
resourceId: invitation.id,
|
||||
action: "create",
|
||||
after: invitation,
|
||||
});
|
||||
|
||||
const project = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
|
||||
@@ -5,20 +5,24 @@ import {
|
||||
import { type MembershipRole } from "@prisma/client";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { type Session } from "next-auth";
|
||||
import { useSession, type SessionContextValue } from "next-auth/react";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
type HasAccessParams =
|
||||
| {
|
||||
role: MembershipRole;
|
||||
scope: Scope;
|
||||
admin?: boolean; // prop user.admin
|
||||
}
|
||||
| {
|
||||
session: SessionContextValue | Session;
|
||||
session: null | Session;
|
||||
projectId: string;
|
||||
scope: Scope;
|
||||
};
|
||||
|
||||
// For use in TRPC routes
|
||||
/**
|
||||
* Check if user has access to the given scope, for use in TRPC resolvers
|
||||
* @throws TRPCError("UNAUTHORIZED") if user does not have access
|
||||
*/
|
||||
export const throwIfNoAccess = (p: HasAccessParams) => {
|
||||
if (!hasAccess(p))
|
||||
throw new TRPCError({
|
||||
@@ -28,27 +32,26 @@ export const throwIfNoAccess = (p: HasAccessParams) => {
|
||||
});
|
||||
};
|
||||
|
||||
// For use in UI components as react hook
|
||||
/**
|
||||
* React hook to check if user has access to the given scope
|
||||
* @returns true if user has access, false otherwise or while loading
|
||||
*/
|
||||
export const useHasAccess = (p: { projectId: string; scope: Scope }) => {
|
||||
const session = useSession();
|
||||
return hasAccess({ session, ...p });
|
||||
return hasAccess({ session: session.data, ...p });
|
||||
};
|
||||
|
||||
// For use in UI components as function, if session is already available
|
||||
export function hasAccess(p: HasAccessParams): boolean {
|
||||
const role: MembershipRole | undefined =
|
||||
"role" in p
|
||||
? // MembershipRole
|
||||
p.role
|
||||
: "data" in p.session
|
||||
? // SessionContextValue
|
||||
p.session.data?.user?.projects.find(
|
||||
(project) => project.id === p.projectId,
|
||||
)?.role
|
||||
: // Session
|
||||
p.session.user?.projects.find((project) => project.id === p.projectId)
|
||||
?.role;
|
||||
if (role === undefined) return false;
|
||||
const isAdmin = "role" in p ? p.admin : p.session?.user?.admin;
|
||||
if (isAdmin && p.scope.endsWith(":read")) return true;
|
||||
|
||||
return roleAccessRights[role].includes(p.scope);
|
||||
const projectRole: MembershipRole | undefined =
|
||||
"role" in p
|
||||
? p.role
|
||||
: p.session?.user?.projects.find((project) => project.id === p.projectId)
|
||||
?.role;
|
||||
if (projectRole === undefined) return false;
|
||||
|
||||
return roleAccessRights[projectRole].includes(p.scope);
|
||||
}
|
||||
|
||||
@@ -18,13 +18,13 @@ import { usePostHog } from "posthog-js/react";
|
||||
export const ProjectUsageChart: React.FC<{ projectId: string }> = ({
|
||||
projectId,
|
||||
}) => {
|
||||
const usage = api.usageMetering.currentMonth.useQuery({
|
||||
const usage = api.usageMetering.last30d.useQuery({
|
||||
projectId,
|
||||
});
|
||||
const posthog = usePostHog();
|
||||
const project = api.projects.byId.useQuery({ projectId });
|
||||
const planLimit =
|
||||
project.data?.cloudConfig?.monthlyObservationLimit ?? 100_000;
|
||||
project.data?.cloudConfig?.monthlyObservationLimit ?? 50_000;
|
||||
const plan = project.data?.cloudConfig?.plan ?? "Hobby";
|
||||
const currentMonth = new Date().toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
@@ -38,63 +38,70 @@ export const ProjectUsageChart: React.FC<{ projectId: string }> = ({
|
||||
Usage
|
||||
</h2>
|
||||
<Card className="p-4 lg:w-1/2">
|
||||
{usage.data !== undefined ? (
|
||||
{usage.data !== undefined && (
|
||||
<>
|
||||
<Text>Observations / month</Text>
|
||||
<Metric>{usage.data}</Metric>
|
||||
<Flex className="mt-4">
|
||||
<Text>
|
||||
{`${currentMonth}: ${usage.data} (${(
|
||||
(usage.data / planLimit) *
|
||||
100
|
||||
).toLocaleString(undefined, {
|
||||
maximumFractionDigits: 2,
|
||||
})}%)`}
|
||||
</Text>
|
||||
<Text>Plan limit: {simplifyNumber(planLimit)}</Text>
|
||||
</Flex>
|
||||
<MarkerBar
|
||||
value={Math.min((usage.data / planLimit) * 100, 100)}
|
||||
className="mt-3"
|
||||
/>
|
||||
{plan === "Hobby" && (
|
||||
<>
|
||||
<Flex className="mt-4">
|
||||
<Text>
|
||||
{`${currentMonth}: ${usage.data} (${(
|
||||
(usage.data / planLimit) *
|
||||
100
|
||||
).toLocaleString(undefined, {
|
||||
maximumFractionDigits: 2,
|
||||
})}%)`}
|
||||
</Text>
|
||||
<Text>Plan limit: {simplifyNumber(planLimit)}</Text>
|
||||
</Flex>
|
||||
<MarkerBar
|
||||
value={Math.min((usage.data / planLimit) * 100, 100)}
|
||||
className="mt-3"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
)}
|
||||
</Card>
|
||||
<div className="mt-4 flex flex-row items-center gap-2">
|
||||
<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>
|
||||
{plan === "Hobby" ? (
|
||||
<Dialog
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
posthog.capture("project_settings:pricing_dialog_opened");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="secondary">Change plan</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>
|
||||
<PricingPage className="mb-5 mt-5" />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
<Button variant="secondary">
|
||||
<Link href="https://billing.stripe.com/p/login/6oE9BXd4u8PR2aYaEE">
|
||||
Billing settings
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<div className="inline-block text-sm text-gray-500">
|
||||
Currently: {plan}
|
||||
Current plan: {plan}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,23 +5,23 @@ import {
|
||||
import * as z from "zod";
|
||||
|
||||
export const usageMeteringRouter = createTRPCRouter({
|
||||
currentMonth: protectedProjectProcedure
|
||||
last30d: protectedProjectProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const startOfThisMonth = new Date();
|
||||
startOfThisMonth.setDate(1);
|
||||
startOfThisMonth.setHours(0, 0, 0, 0);
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
thirtyDaysAgo.setHours(0, 0, 0, 0);
|
||||
|
||||
const usage = await ctx.prisma.observation.count({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
|
||||
startTime: {
|
||||
gte: startOfThisMonth,
|
||||
gte: thirtyDaysAgo,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
// Middleware to block requests from certain IPs on Langfuse Cloud
|
||||
// Not included in the self-host build, removed in Dockerfile
|
||||
|
||||
// import { type NextApiRequest } from "next";
|
||||
// import { type NextRequest } from "next/server";
|
||||
// import { get } from "@vercel/edge-config";
|
||||
|
||||
// export async function middleware(req: NextRequest) {
|
||||
// try {
|
||||
// if (process.env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== undefined) {
|
||||
// const config = await get("blockedIps");
|
||||
// const blockedIps = Array.isArray(config) ? config : [config];
|
||||
|
||||
// const ip = getIP(req);
|
||||
// if (ip && blockedIps.includes(ip)) {
|
||||
// console.log("Blocked request by ip: ", ip);
|
||||
// return new Response("Access denied", { status: 403 });
|
||||
// }
|
||||
// }
|
||||
|
||||
// return;
|
||||
// } catch (e) {
|
||||
// console.error("Server side error in middleware: ", e);
|
||||
// return new Response("Internal server error", { status: 500 });
|
||||
// }
|
||||
// }
|
||||
|
||||
// export default function getIP(request: Request | NextApiRequest) {
|
||||
// const xff =
|
||||
// request instanceof Request
|
||||
// ? request.headers.get("x-forwarded-for")
|
||||
// : request.headers["x-forwarded-for"];
|
||||
|
||||
// return xff ? (Array.isArray(xff) ? xff[0] : xff.split(",")[0]) : "127.0.0.1";
|
||||
// }
|
||||
@@ -0,0 +1,159 @@
|
||||
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 { Prisma } from "@prisma/client";
|
||||
import { paginationZod } from "@/src/utils/zod";
|
||||
|
||||
const GetUsageSchema = z.object({
|
||||
...paginationZod,
|
||||
traceName: z.string().nullish(),
|
||||
userId: z.string().nullish(),
|
||||
tags: z.union([z.array(z.string()), z.string()]).nullish(),
|
||||
});
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
await runMiddleware(req, res, cors);
|
||||
|
||||
// CHECK AUTH
|
||||
const authCheck = await verifyAuthHeaderAndReturnScope(
|
||||
req.headers.authorization,
|
||||
);
|
||||
if (!authCheck.validKey)
|
||||
return res.status(401).json({
|
||||
message: authCheck.error,
|
||||
});
|
||||
// END CHECK AUTH
|
||||
|
||||
try {
|
||||
if (req.method === "GET") {
|
||||
if (authCheck.scope.accessLevel !== "all") {
|
||||
return res.status(401).json({
|
||||
message:
|
||||
"Access denied - need to use basic auth with secret key to GET scores",
|
||||
});
|
||||
}
|
||||
const obj = GetUsageSchema.parse(req.query); // uses query and not body
|
||||
|
||||
const traceNameCondition = obj.traceName
|
||||
? Prisma.sql`AND t.name = ${obj.traceName}`
|
||||
: Prisma.empty;
|
||||
const userCondition = obj.userId
|
||||
? Prisma.sql`AND t."user_id" = ${obj.userId}`
|
||||
: Prisma.empty;
|
||||
const tagsCondition = obj.tags
|
||||
? Prisma.sql`AND ARRAY[${Prisma.join(
|
||||
(Array.isArray(obj.tags) ? obj.tags : [obj.tags]).map(
|
||||
(v) => Prisma.sql`${v}`,
|
||||
),
|
||||
", ",
|
||||
)}] <@ t."tags"`
|
||||
: Prisma.empty;
|
||||
|
||||
const usage = await prisma.$queryRaw`
|
||||
WITH model_usage AS (
|
||||
SELECT
|
||||
DATE_TRUNC('DAY',
|
||||
o.start_time) "date",
|
||||
o.model,
|
||||
SUM(o.prompt_tokens) inputUsage,
|
||||
SUM(o.completion_tokens) outputUsage,
|
||||
SUM(o.total_tokens) totalUsage
|
||||
FROM
|
||||
traces t
|
||||
LEFT JOIN observations o ON o.trace_id = t.id AND o.project_id = t.project_id
|
||||
WHERE o.start_time IS NOT NULL
|
||||
AND t.project_id = ${authCheck.scope.projectId}
|
||||
${traceNameCondition}
|
||||
${userCondition}
|
||||
${tagsCondition}
|
||||
GROUP BY
|
||||
1,
|
||||
2
|
||||
ORDER BY
|
||||
1,
|
||||
2
|
||||
),
|
||||
daily_model_usage AS (
|
||||
SELECT
|
||||
"date",
|
||||
json_agg(json_build_object('model',
|
||||
model,
|
||||
'inputUsage',
|
||||
inputUsage,
|
||||
'outputUsage',
|
||||
outputUsage,
|
||||
'totalUsage',
|
||||
totalUsage)) daily_usage_json
|
||||
FROM
|
||||
model_usage
|
||||
GROUP BY
|
||||
1
|
||||
),
|
||||
daily_stats AS (
|
||||
SELECT
|
||||
DATE_TRUNC('DAY', t.timestamp) "date",
|
||||
count(distinct t.id)::integer count_traces,
|
||||
SUM(o.calculated_total_cost)::DOUBLE PRECISION total_cost
|
||||
FROM traces t
|
||||
LEFT JOIN observations_view o ON o.project_id = t.project_id AND t.id = o.trace_id
|
||||
WHERE t.project_id = ${authCheck.scope.projectId}
|
||||
${traceNameCondition}
|
||||
${userCondition}
|
||||
${tagsCondition}
|
||||
GROUP BY 1
|
||||
)
|
||||
SELECT
|
||||
TO_CHAR(COALESCE(ds.date, daily_model_usage.date), 'YYYY-MM-DD') AS "date",
|
||||
COALESCE(count_traces, 0) "countTraces",
|
||||
COALESCE(total_cost, 0) "totalCost",
|
||||
COALESCE(daily_usage_json, '[]'::JSON) usage
|
||||
FROM
|
||||
daily_stats ds
|
||||
FULL OUTER JOIN
|
||||
daily_model_usage ON daily_model_usage.date = ds.date
|
||||
ORDER BY
|
||||
1 DESC
|
||||
LIMIT ${obj.limit} OFFSET ${(obj.page - 1) * obj.limit}
|
||||
`;
|
||||
|
||||
const totalItemsRes = await prisma.$queryRaw<{ count: number }[]>`
|
||||
SELECT
|
||||
COUNT(DISTINCT DATE_TRUNC('DAY', t.timestamp))::integer
|
||||
FROM traces t
|
||||
WHERE t.project_id = ${authCheck.scope.projectId}
|
||||
${traceNameCondition}
|
||||
${userCondition}
|
||||
${tagsCondition}
|
||||
`;
|
||||
|
||||
const totalItems =
|
||||
totalItemsRes[0] !== undefined ? totalItemsRes[0].count : 0;
|
||||
|
||||
return res.status(200).json({
|
||||
data: usage,
|
||||
meta: {
|
||||
page: obj.page,
|
||||
limit: obj.limit,
|
||||
totalItems,
|
||||
totalPages: Math.ceil(totalItems / obj.limit),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
console.error(req.method, req.body);
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "An unknown error occurred";
|
||||
res.status(400).json({
|
||||
message: "Invalid request data",
|
||||
error: errorMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
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 { Prisma } from "@prisma/client";
|
||||
import { paginationZod } from "@/src/utils/zod";
|
||||
|
||||
const GetUsageSchema = z.object({
|
||||
...paginationZod,
|
||||
group_by: z.enum(["trace_name"]).nullish(),
|
||||
trace_name: z.string().nullish(),
|
||||
});
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
await runMiddleware(req, res, cors);
|
||||
|
||||
// CHECK AUTH
|
||||
const authCheck = await verifyAuthHeaderAndReturnScope(
|
||||
req.headers.authorization,
|
||||
);
|
||||
if (!authCheck.validKey)
|
||||
return res.status(401).json({
|
||||
message: authCheck.error,
|
||||
});
|
||||
// END CHECK AUTH
|
||||
|
||||
try {
|
||||
if (req.method === "GET") {
|
||||
if (authCheck.scope.accessLevel !== "all") {
|
||||
return res.status(401).json({
|
||||
message:
|
||||
"Access denied - need to use basic auth with secret key to GET scores",
|
||||
});
|
||||
}
|
||||
const obj = GetUsageSchema.parse(req.query); // uses query and not body
|
||||
|
||||
const traceNameCondition = obj.trace_name
|
||||
? Prisma.sql`AND t.name = ${obj.trace_name}`
|
||||
: Prisma.empty;
|
||||
|
||||
if (obj.group_by === undefined) {
|
||||
const usage = await prisma.$queryRaw`
|
||||
WITH model_usage AS (
|
||||
SELECT
|
||||
DATE_TRUNC('DAY',
|
||||
o.start_time) observation_day,
|
||||
o.model,
|
||||
SUM(o.prompt_tokens) prompt_tokens,
|
||||
SUM(o.completion_tokens) completion_tokens,
|
||||
SUM(o.total_tokens) total_tokens
|
||||
FROM
|
||||
traces t
|
||||
LEFT JOIN observations o ON o.trace_id = t.id
|
||||
WHERE o.start_time IS NOT NULL
|
||||
AND o.project_id = ${authCheck.scope.projectId}
|
||||
AND t.project_id = ${authCheck.scope.projectId}
|
||||
${traceNameCondition}
|
||||
GROUP BY 1,2
|
||||
order by 1,2
|
||||
),
|
||||
daily_usage AS (
|
||||
SELECT
|
||||
observation_day,
|
||||
json_agg(json_build_object('model',
|
||||
model,
|
||||
'prompt_tokens',
|
||||
prompt_tokens,
|
||||
'completion_tokens',
|
||||
completion_tokens,
|
||||
'total_tokens',
|
||||
total_tokens)) daily_usage_json
|
||||
FROM model_usage
|
||||
group by 1
|
||||
)
|
||||
SELECT
|
||||
observation_day "date",
|
||||
daily_usage_json usage
|
||||
FROM daily_usage
|
||||
ORDER BY 1 desc
|
||||
LIMIT ${obj.limit} OFFSET ${(obj.page - 1) * obj.limit}
|
||||
`;
|
||||
const totalItemsRes = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||
SELECT
|
||||
count(DISTINCT DATE_TRUNC('DAY', observations.start_time))
|
||||
FROM
|
||||
observations
|
||||
JOIN traces ON observations.trace_id = traces.id
|
||||
WHERE traces.project_id = ${authCheck.scope.projectId}
|
||||
`;
|
||||
|
||||
const totalItems =
|
||||
totalItemsRes[0] !== undefined ? Number(totalItemsRes[0].count) : 0;
|
||||
|
||||
return res.status(200).json({
|
||||
data: usage,
|
||||
meta: {
|
||||
page: obj.page,
|
||||
limit: obj.limit,
|
||||
totalItems,
|
||||
totalPages: Math.ceil(totalItems / obj.limit),
|
||||
},
|
||||
});
|
||||
} else if (obj.group_by === "trace_name") {
|
||||
const usage = await prisma.$queryRaw`
|
||||
WITH model_usage AS (
|
||||
SELECT
|
||||
t."name" trace_name,
|
||||
DATE_TRUNC('DAY',
|
||||
o.start_time) observation_day,
|
||||
o.model,
|
||||
SUM(o.prompt_tokens) prompt_tokens,
|
||||
SUM(o.completion_tokens) completion_tokens,
|
||||
SUM(o.total_tokens) total_tokens
|
||||
FROM
|
||||
traces t
|
||||
LEFT JOIN observations o ON o.trace_id = t.id
|
||||
WHERE o.start_time IS NOT NULL
|
||||
AND t.project_id = ${authCheck.scope.projectId}
|
||||
AND o.project_id = ${authCheck.scope.projectId}
|
||||
${traceNameCondition}
|
||||
GROUP BY 1,2,3
|
||||
order by 1,2,3
|
||||
),
|
||||
daily_usage AS (
|
||||
SELECT
|
||||
trace_name,
|
||||
observation_day,
|
||||
json_agg(json_build_object('model',
|
||||
model,
|
||||
'prompt_tokens',
|
||||
prompt_tokens,
|
||||
'completion_tokens',
|
||||
completion_tokens,
|
||||
'total_tokens',
|
||||
total_tokens)) daily_usage_json
|
||||
FROM model_usage
|
||||
WHERE prompt_tokens > 0
|
||||
OR completion_tokens > 0
|
||||
OR total_tokens > 0
|
||||
group by 1,2
|
||||
order by 1,2 desc
|
||||
),
|
||||
all_trace_names AS (
|
||||
SELECT t."name" trace_name
|
||||
FROM traces t
|
||||
WHERE t.project_id = ${authCheck.scope.projectId}
|
||||
${traceNameCondition}
|
||||
GROUP BY 1
|
||||
)
|
||||
SELECT
|
||||
all_trace_names.trace_name,
|
||||
json_agg(json_build_object(
|
||||
'date',
|
||||
observation_day,
|
||||
'usage',
|
||||
daily_usage_json
|
||||
)) metrics
|
||||
FROM all_trace_names
|
||||
LEFT JOIN daily_usage ON all_trace_names.trace_name = daily_usage.trace_name
|
||||
group by 1
|
||||
ORDER BY 1
|
||||
LIMIT ${obj.limit} OFFSET ${(obj.page - 1) * obj.limit}
|
||||
`;
|
||||
const totalItemsRes = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||
SELECT
|
||||
count(DISTINCT CASE WHEN "name" IS NULL THEN 'COUNT_NULL' ELSE "name" END)
|
||||
FROM
|
||||
traces
|
||||
WHERE project_id = ${authCheck.scope.projectId}
|
||||
`;
|
||||
|
||||
const totalItems =
|
||||
totalItemsRes[0] !== undefined ? Number(totalItemsRes[0].count) : 0;
|
||||
|
||||
return res.status(200).json({
|
||||
data: usage,
|
||||
meta: {
|
||||
page: obj.page,
|
||||
limit: obj.limit,
|
||||
totalItems,
|
||||
totalPages: Math.ceil(totalItems / obj.limit),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
return res.status(400).json({
|
||||
message: "Invalid group_by value",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.error(req.method, req.body);
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "An unknown error occurred";
|
||||
res.status(400).json({
|
||||
message: "Invalid request data",
|
||||
error: errorMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -138,8 +138,9 @@ const getObservation = async (
|
||||
o."total_price" as "totalPrice",
|
||||
o."calculated_input_cost" as "calculatedInputCost",
|
||||
o."calculated_output_cost" as "calculatedOutputCost",
|
||||
o."calculated_total_cost" as "calculatedTotalCost"
|
||||
FROM observations_view o LEFT JOIN traces ON o."trace_id" = traces."id"
|
||||
o."calculated_total_cost" as "calculatedTotalCost",
|
||||
o."latency"
|
||||
FROM observations_view o LEFT JOIN traces ON o."trace_id" = traces."id" AND traces."project_id" = o."project_id"
|
||||
WHERE o."project_id" = ${authenticatedProjectId}
|
||||
${nameCondition}
|
||||
${userIdCondition}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createPrompt } from "@/src/features/prompts/server/prompt-router";
|
||||
import { verifyAuthHeaderAndReturnScope } from "@/src/features/public-api/server/apiAuth";
|
||||
import { cors, runMiddleware } from "@/src/features/public-api/server/cors";
|
||||
import { prisma } from "@/src/server/db";
|
||||
import { jsonSchema } from "@/src/utils/zod";
|
||||
import { type NextApiRequest, type NextApiResponse } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -14,6 +15,7 @@ const PromptCreateSchema = z.object({
|
||||
name: z.string(),
|
||||
prompt: z.string(),
|
||||
isActive: z.boolean(),
|
||||
config: jsonSchema.nullable().default({}),
|
||||
});
|
||||
|
||||
export default async function handler(
|
||||
@@ -56,7 +58,7 @@ export default async function handler(
|
||||
name: searchParams.name,
|
||||
version: searchParams.version ?? undefined,
|
||||
// if no version is given, we take the latest active prompt
|
||||
// if no prompt is active, there will no prompt be available
|
||||
// if no prompt is active, there will be no prompt available
|
||||
isActive: !searchParams.version ? true : undefined,
|
||||
},
|
||||
});
|
||||
@@ -111,8 +113,10 @@ export default async function handler(
|
||||
prompt: input.prompt,
|
||||
isActive: input.isActive,
|
||||
createdBy: "API",
|
||||
config: input.config ?? {},
|
||||
prisma: prisma,
|
||||
});
|
||||
console.log("created prompt", prompt);
|
||||
return res.status(200).json(prompt);
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
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 { verifyAuthHeaderAndReturnScope } from "@/src/features/public-api/server/apiAuth";
|
||||
|
||||
const ScoreDeleteSchema = z.object({
|
||||
scoreId: z.string(),
|
||||
});
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
await runMiddleware(req, res, cors);
|
||||
|
||||
// CHECK AUTH
|
||||
const authCheck = await verifyAuthHeaderAndReturnScope(
|
||||
req.headers.authorization,
|
||||
);
|
||||
if (!authCheck.validKey)
|
||||
return res.status(401).json({
|
||||
message: authCheck.error,
|
||||
});
|
||||
// END CHECK AUTH
|
||||
|
||||
if (req.method === "DELETE") {
|
||||
try {
|
||||
if (authCheck.scope.accessLevel !== "all") {
|
||||
return res.status(401).json({
|
||||
message:
|
||||
"Access denied - need to use basic auth with secret key to DELETE scores",
|
||||
});
|
||||
}
|
||||
|
||||
const { scoreId } = ScoreDeleteSchema.parse(req.query); // uses query and not body
|
||||
|
||||
const score = await prisma.score.findUnique({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
id: scoreId,
|
||||
trace: {
|
||||
projectId: authCheck.scope.projectId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!score) {
|
||||
return res.status(404).json({
|
||||
message: "Score not found within authorized project",
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.score.delete({
|
||||
where: {
|
||||
id: scoreId,
|
||||
trace: {
|
||||
projectId: authCheck.scope.projectId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(200).json({ message: "Score deleted successfully" });
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "An unknown error occurred";
|
||||
res.status(400).json({
|
||||
message: "Invalid request data",
|
||||
error: errorMessage,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,7 @@ export default async function handler(
|
||||
>(Prisma.sql`
|
||||
SELECT
|
||||
t.id,
|
||||
CONCAT('/project/', t.project_id,'/traces/',t.id) as "htmlPath",
|
||||
t.timestamp,
|
||||
t.name,
|
||||
t.project_id as "projectId",
|
||||
@@ -123,10 +124,12 @@ export default async function handler(
|
||||
t.release,
|
||||
t.version,
|
||||
t.tags,
|
||||
COALESCE(SUM(o.calculated_total_cost), 0)::DOUBLE PRECISION AS "totalCost",
|
||||
COALESCE(EXTRACT(EPOCH FROM COALESCE(MAX(o."end_time"), MAX(o."start_time"))) - EXTRACT(EPOCH FROM MIN(o."start_time")), 0)::double precision AS "latency",
|
||||
array_remove(array_agg(o.id), NULL) AS "observations",
|
||||
array_remove(array_agg(s.id), NULL) AS "scores"
|
||||
FROM "traces" AS t
|
||||
LEFT JOIN "observations" AS o ON t.id = o.trace_id AND o.project_id = ${authCheck.scope.projectId}
|
||||
LEFT JOIN "observations_view" AS o ON t.id = o.trace_id AND o.project_id = ${authCheck.scope.projectId}
|
||||
LEFT JOIN "scores" AS s ON t.id = s.trace_id
|
||||
WHERE t.project_id = ${authCheck.scope.projectId}
|
||||
${userCondition}
|
||||
|
||||
@@ -67,9 +67,17 @@ export default async function handler(
|
||||
},
|
||||
});
|
||||
|
||||
return res
|
||||
.status(200)
|
||||
.json({ ...trace, observations: observations.map(mapUsageOutput) });
|
||||
const outObservations = observations.map(mapUsageOutput);
|
||||
|
||||
return res.status(200).json({
|
||||
...trace,
|
||||
htmlPath: `/project/${authCheck.scope.projectId}/traces/${traceId}`,
|
||||
totalCost: outObservations.reduce(
|
||||
(acc, obs) => acc + (obs.calculatedTotalCost ?? 0),
|
||||
0,
|
||||
),
|
||||
observations: outObservations,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
const errorMessage =
|
||||
|
||||
@@ -28,6 +28,10 @@ import { usePostHog } from "posthog-js/react";
|
||||
import { FeedbackButtonWrapper } from "@/src/features/feedback/component/FeedbackButton";
|
||||
import { BarChart2 } from "lucide-react";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { FilterBuilder } from "@/src/features/filters/components/filter-builder";
|
||||
import { type FilterState } from "@/src/features/filters/types";
|
||||
import { type ColumnDefinition } from "@/src/server/api/interfaces/tableDefinition";
|
||||
import { useQueryFilterState } from "@/src/features/filters/hooks/useFilterState";
|
||||
|
||||
export type DashboardDateRange = {
|
||||
from: Date;
|
||||
@@ -75,7 +79,32 @@ export default function Start() {
|
||||
});
|
||||
};
|
||||
|
||||
const globalFilterState = dateRange
|
||||
const traceFilterOptions = api.traces.filterOptions.useQuery(
|
||||
{
|
||||
projectId,
|
||||
},
|
||||
{
|
||||
trpc: {
|
||||
context: {
|
||||
skipBatch: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
const values = traceFilterOptions.data?.name || [];
|
||||
|
||||
const traceName: ColumnDefinition[] = [
|
||||
{
|
||||
name: "traceName",
|
||||
type: "stringOptions" as const,
|
||||
options: values,
|
||||
internal: "internalValue",
|
||||
},
|
||||
];
|
||||
|
||||
const [userFilterState, setUserFilterState] = useQueryFilterState([]);
|
||||
|
||||
const timeFilter = dateRange
|
||||
? [
|
||||
{
|
||||
type: "datetime" as const,
|
||||
@@ -92,22 +121,31 @@ export default function Start() {
|
||||
]
|
||||
: [];
|
||||
|
||||
const mergedFilterState: FilterState = [...userFilterState, ...timeFilter];
|
||||
|
||||
return (
|
||||
<div className="md:container">
|
||||
<Header title={project?.name ?? "Dashboard"} />
|
||||
<div className="flex items-center justify-between">
|
||||
<DatePickerWithRange
|
||||
dateRange={dateRange}
|
||||
setAgg={setAgg}
|
||||
setDateRangeAndOption={setDateRangeAndOption}
|
||||
selectedOption={selectedOption}
|
||||
className=" max-w-full overflow-x-auto"
|
||||
/>
|
||||
<div className="my-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className=" flex flex-col gap-2 lg:flex-row">
|
||||
<DatePickerWithRange
|
||||
dateRange={dateRange}
|
||||
setAgg={setAgg}
|
||||
setDateRangeAndOption={setDateRangeAndOption}
|
||||
selectedOption={selectedOption}
|
||||
className="my-0 max-w-full overflow-x-auto"
|
||||
/>
|
||||
<FilterBuilder
|
||||
columns={traceName}
|
||||
filterState={userFilterState}
|
||||
onChange={setUserFilterState}
|
||||
/>
|
||||
</div>
|
||||
<FeedbackButtonWrapper
|
||||
className="border-box"
|
||||
title="Request Chart"
|
||||
description="Your feedback matters! Let us know what additional data or metrics you'd like to see in your dashboard."
|
||||
description="Your feedback matters! Let the Langfuse team know what additional data or metrics you'd like to see in your dashboard."
|
||||
type="dashboard"
|
||||
className="hidden lg:flex"
|
||||
>
|
||||
<Button
|
||||
id="date"
|
||||
@@ -117,7 +155,7 @@ export default function Start() {
|
||||
}
|
||||
>
|
||||
<BarChart2
|
||||
className="h-6 w-6 shrink-0 text-gray-700 group-hover:text-indigo-600"
|
||||
className="hidden h-6 w-6 shrink-0 text-gray-700 group-hover:text-indigo-600 lg:block"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Request Chart
|
||||
@@ -128,48 +166,47 @@ export default function Start() {
|
||||
<TracesBarListChart
|
||||
className="col-span-1 xl:col-span-2 "
|
||||
projectId={projectId}
|
||||
globalFilterState={globalFilterState}
|
||||
globalFilterState={mergedFilterState}
|
||||
/>
|
||||
|
||||
<MetricTable
|
||||
className="col-span-1 xl:col-span-2"
|
||||
projectId={projectId}
|
||||
globalFilterState={globalFilterState}
|
||||
globalFilterState={mergedFilterState}
|
||||
/>
|
||||
<ScoresTable
|
||||
className="col-span-1 xl:col-span-2"
|
||||
projectId={projectId}
|
||||
globalFilterState={globalFilterState}
|
||||
globalFilterState={mergedFilterState}
|
||||
/>
|
||||
<TracesTimeSeriesChart
|
||||
className="col-span-1 xl:col-span-3"
|
||||
projectId={projectId}
|
||||
globalFilterState={globalFilterState}
|
||||
globalFilterState={mergedFilterState}
|
||||
agg={agg}
|
||||
/>
|
||||
<ModelUsageChart
|
||||
className="col-span-1 min-h-24 xl:col-span-3"
|
||||
projectId={projectId}
|
||||
globalFilterState={globalFilterState}
|
||||
globalFilterState={mergedFilterState}
|
||||
agg={agg}
|
||||
/>
|
||||
<UserChart
|
||||
className="col-span-1 xl:col-span-3"
|
||||
projectId={projectId}
|
||||
globalFilterState={globalFilterState}
|
||||
globalFilterState={mergedFilterState}
|
||||
agg={agg}
|
||||
/>
|
||||
<ChartScores
|
||||
className="col-span-1 xl:col-span-3"
|
||||
agg={agg}
|
||||
projectId={projectId}
|
||||
globalFilterState={globalFilterState}
|
||||
globalFilterState={mergedFilterState}
|
||||
/>
|
||||
<LatencyChart
|
||||
className="col-span-1 flex-auto justify-between xl:col-span-full"
|
||||
projectId={projectId}
|
||||
agg={agg}
|
||||
globalFilterState={globalFilterState}
|
||||
globalFilterState={mergedFilterState}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useEffect, useState } from "react";
|
||||
import TableLink from "@/src/components/table/table-link";
|
||||
import { DataTable } from "@/src/components/table/data-table";
|
||||
import { useRouter } from "next/router";
|
||||
import { compactNumberFormatter } from "@/src/utils/numbers";
|
||||
import { compactNumberFormatter, usdFormatter } from "@/src/utils/numbers";
|
||||
import { GroupedScoreBadges } from "@/src/components/grouped-score-badge";
|
||||
import { type Score } from "@prisma/client";
|
||||
import { useQueryParams, withDefault, NumberParam } from "use-query-params";
|
||||
@@ -75,6 +75,10 @@ export default function UsersPage() {
|
||||
accessorKey: "firstEvent",
|
||||
header: "First Event",
|
||||
},
|
||||
{
|
||||
accessorKey: "totalCost",
|
||||
header: "Total Cost",
|
||||
},
|
||||
{
|
||||
accessorKey: "lastEvent",
|
||||
header: "Last Event",
|
||||
@@ -151,6 +155,7 @@ export default function UsersPage() {
|
||||
),
|
||||
totalTokens: compactNumberFormatter(t.totalTokens),
|
||||
lastScore: t.lastScore,
|
||||
totalCost: usdFormatter(t.sumCalculatedTotalCost, 2, 2),
|
||||
};
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import Header from "@/src/components/layouts/header";
|
||||
import { api } from "@/src/utils/api";
|
||||
import TracesTable from "@/src/components/table/use-cases/traces";
|
||||
import ScoresTable from "@/src/components/table/use-cases/scores";
|
||||
import { compactNumberFormatter } from "@/src/utils/numbers";
|
||||
import { compactNumberFormatter, usdFormatter } from "@/src/utils/numbers";
|
||||
import { GroupedScoreBadges } from "@/src/components/grouped-score-badge";
|
||||
import TableLink from "@/src/components/table/table-link";
|
||||
import { StringParam, useQueryParam, withDefault } from "use-query-params";
|
||||
@@ -137,6 +137,10 @@ function DetailsTab({ userId, projectId }: TabProps) {
|
||||
label: "Total Tokens",
|
||||
value: compactNumberFormatter(user.data.totalTokens),
|
||||
},
|
||||
{
|
||||
label: "Total Cost",
|
||||
value: usdFormatter(user.data.sumCalculatedTotalCost, 2, 2),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
@@ -174,6 +178,7 @@ function DetailsTab({ userId, projectId }: TabProps) {
|
||||
: `/project/${projectId}/traces/${user.data.lastScore.traceId}`
|
||||
}
|
||||
value={user.data.lastScore.traceId}
|
||||
truncateAt={40}
|
||||
/>
|
||||
<GroupedScoreBadges scores={[user.data.lastScore]} />
|
||||
</div>
|
||||
|
||||
@@ -32,6 +32,17 @@ export type ColumnDefinition =
|
||||
keyOptions?: Array<string>;
|
||||
};
|
||||
|
||||
export const tableNames = [
|
||||
"traces",
|
||||
"traces_observations",
|
||||
"observations",
|
||||
"traces_scores",
|
||||
"traces_parent_observation_scores",
|
||||
"sessions",
|
||||
] as const;
|
||||
|
||||
export type TableNames = (typeof tableNames)[number];
|
||||
|
||||
export type TableDefinitions = {
|
||||
[tableName: string]: {
|
||||
table: string;
|
||||
|
||||
@@ -1,573 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
createTRPCRouter,
|
||||
protectedProjectProcedure,
|
||||
} from "@/src/server/api/trpc";
|
||||
|
||||
import { Prisma, type ObservationView } from "@prisma/client";
|
||||
import { jsonSchema, paginationZod } from "@/src/utils/zod";
|
||||
import { singleFilter } from "@/src/server/api/interfaces/filters";
|
||||
import {
|
||||
datetimeFilterToPrismaSql,
|
||||
filterToPrismaSql,
|
||||
} from "@/src/features/filters/server/filterToPrisma";
|
||||
import {
|
||||
type ObservationOptions,
|
||||
observationsTableCols,
|
||||
} from "@/src/server/api/definitions/observationsTable";
|
||||
import { usdFormatter } from "@/src/utils/numbers";
|
||||
import { env } from "@/src/env.mjs";
|
||||
import {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
GetObjectCommand,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
import {
|
||||
exportFileFormats,
|
||||
exportOptions,
|
||||
} from "@/src/server/api/interfaces/exportTypes";
|
||||
import { orderBy } from "@/src/server/api/interfaces/orderBy";
|
||||
import { orderByToPrismaSql } from "@/src/features/orderBy/server/orderByToPrisma";
|
||||
|
||||
const GenerationTableOptions = z.object({
|
||||
projectId: z.string(), // Required for protectedProjectProcedure
|
||||
filter: z.array(singleFilter),
|
||||
searchQuery: z.string().nullable(),
|
||||
orderBy: orderBy,
|
||||
});
|
||||
|
||||
const ListInputs = GenerationTableOptions.extend({
|
||||
...paginationZod,
|
||||
});
|
||||
|
||||
// extend generationfilteroptions with export options
|
||||
const ExportInputs = GenerationTableOptions.extend({
|
||||
fileFormat: z.enum(exportFileFormats),
|
||||
});
|
||||
|
||||
export const generationsRouter = createTRPCRouter({
|
||||
all: protectedProjectProcedure
|
||||
.input(ListInputs)
|
||||
.query(async ({ input, ctx }) => {
|
||||
// ATTENTION: When making changes to this query, make sure to also update the export query
|
||||
const searchCondition = input.searchQuery
|
||||
? Prisma.sql`AND (
|
||||
o."id" ILIKE ${`%${input.searchQuery}%`} OR
|
||||
o."name" ILIKE ${`%${input.searchQuery}%`} OR
|
||||
o."model" ILIKE ${`%${input.searchQuery}%`} OR
|
||||
t."name" ILIKE ${`%${input.searchQuery}%`}
|
||||
)`
|
||||
: Prisma.empty;
|
||||
|
||||
const filterCondition = filterToPrismaSql(
|
||||
input.filter,
|
||||
observationsTableCols,
|
||||
);
|
||||
|
||||
const orderByCondition = orderByToPrismaSql(
|
||||
input.orderBy,
|
||||
observationsTableCols,
|
||||
);
|
||||
|
||||
// to improve query performance, add timeseries filter to observation queries as well
|
||||
const startTimeFilter = input.filter.find(
|
||||
(f) => f.column === "start_time" && f.type === "datetime",
|
||||
);
|
||||
const datetimeFilter =
|
||||
startTimeFilter && startTimeFilter.type === "datetime"
|
||||
? datetimeFilterToPrismaSql(
|
||||
"start_time",
|
||||
startTimeFilter.operator,
|
||||
startTimeFilter.value,
|
||||
)
|
||||
: Prisma.empty;
|
||||
|
||||
const generations = await ctx.prisma.$queryRaw<
|
||||
Array<
|
||||
ObservationView & {
|
||||
traceId: string;
|
||||
traceName: string;
|
||||
latency: number | null;
|
||||
}
|
||||
>
|
||||
>(
|
||||
Prisma.sql`
|
||||
WITH observations_with_latency AS (
|
||||
SELECT
|
||||
o.*,
|
||||
CASE WHEN o.end_time IS NULL THEN NULL ELSE (EXTRACT(EPOCH FROM o."end_time") - EXTRACT(EPOCH FROM o."start_time"))::double precision END AS "latency"
|
||||
FROM observations_view o
|
||||
WHERE o.type = 'GENERATION'
|
||||
AND o.project_id = ${input.projectId}
|
||||
${datetimeFilter}
|
||||
),
|
||||
-- used for filtering
|
||||
scores_avg AS (
|
||||
SELECT
|
||||
trace_id,
|
||||
observation_id,
|
||||
jsonb_object_agg(name::text, avg_value::double precision) AS scores_avg
|
||||
FROM (
|
||||
SELECT
|
||||
trace_id,
|
||||
observation_id,
|
||||
name,
|
||||
avg(value) avg_value
|
||||
FROM
|
||||
scores
|
||||
GROUP BY
|
||||
1,
|
||||
2,
|
||||
3
|
||||
ORDER BY
|
||||
1) tmp
|
||||
GROUP BY
|
||||
1, 2
|
||||
)
|
||||
SELECT
|
||||
o.id,
|
||||
o.name,
|
||||
o.model,
|
||||
o.start_time as "startTime",
|
||||
o.end_time as "endTime",
|
||||
o.latency,
|
||||
o.input,
|
||||
o.output,
|
||||
o.metadata,
|
||||
o.trace_id as "traceId",
|
||||
t.name as "traceName",
|
||||
o.completion_start_time as "completionStartTime",
|
||||
o.prompt_tokens as "promptTokens",
|
||||
o.completion_tokens as "completionTokens",
|
||||
o.total_tokens as "totalTokens",
|
||||
o.level,
|
||||
o.status_message as "statusMessage",
|
||||
o.version,
|
||||
o.model_id as "modelId",
|
||||
o.input_price as "inputPrice",
|
||||
o.output_price as "outputPrice",
|
||||
o.total_price as "totalPrice",
|
||||
o.calculated_input_cost as "calculatedInputCost",
|
||||
o.calculated_output_cost as "calculatedOutputCost",
|
||||
o.calculated_total_cost as "calculatedTotalCost"
|
||||
FROM observations_with_latency o
|
||||
JOIN traces t ON t.id = o.trace_id
|
||||
LEFT JOIN scores_avg AS s_avg ON s_avg.trace_id = t.id and s_avg.observation_id = o.id
|
||||
WHERE
|
||||
t.project_id = ${input.projectId}
|
||||
${searchCondition}
|
||||
${filterCondition}
|
||||
${orderByCondition}
|
||||
LIMIT ${input.limit}
|
||||
OFFSET ${input.page * input.limit}
|
||||
`,
|
||||
);
|
||||
|
||||
const totalGenerations = await ctx.prisma.$queryRaw<
|
||||
Array<{ count: bigint }>
|
||||
>(
|
||||
Prisma.sql`
|
||||
WITH observations_with_latency AS (
|
||||
SELECT
|
||||
o.*,
|
||||
CASE WHEN o.end_time IS NULL THEN NULL ELSE (EXTRACT(EPOCH FROM o."end_time") - EXTRACT(EPOCH FROM o."start_time"))::double precision END AS "latency"
|
||||
FROM observations_view o
|
||||
WHERE o.type = 'GENERATION'
|
||||
AND o.project_id = ${input.projectId}
|
||||
${datetimeFilter}
|
||||
),
|
||||
-- used for filtering
|
||||
scores_avg AS (
|
||||
SELECT
|
||||
trace_id,
|
||||
observation_id,
|
||||
jsonb_object_agg(name::text, avg_value::double precision) AS scores_avg
|
||||
FROM (
|
||||
SELECT
|
||||
trace_id,
|
||||
observation_id,
|
||||
name,
|
||||
avg(value) avg_value
|
||||
FROM
|
||||
scores
|
||||
GROUP BY
|
||||
1,
|
||||
2,
|
||||
3
|
||||
ORDER BY
|
||||
1) tmp
|
||||
GROUP BY
|
||||
1, 2
|
||||
)
|
||||
SELECT
|
||||
count(*)
|
||||
FROM observations_with_latency o
|
||||
JOIN traces t ON t.id = o.trace_id
|
||||
LEFT JOIN scores_avg AS s_avg ON s_avg.trace_id = t.id and s_avg.observation_id = o.id
|
||||
WHERE
|
||||
t.project_id = ${input.projectId}
|
||||
${searchCondition}
|
||||
${filterCondition}
|
||||
`,
|
||||
);
|
||||
|
||||
const scores = await ctx.prisma.score.findMany({
|
||||
where: {
|
||||
trace: {
|
||||
projectId: input.projectId,
|
||||
},
|
||||
observationId: {
|
||||
in: generations.map((gen) => gen.id),
|
||||
},
|
||||
},
|
||||
});
|
||||
const count = totalGenerations[0]?.count;
|
||||
return {
|
||||
totalCount: count ? Number(count) : undefined,
|
||||
generations: generations.map((generation) => {
|
||||
const filteredScores = scores.filter(
|
||||
(s) => s.observationId === generation.id,
|
||||
);
|
||||
return {
|
||||
...generation,
|
||||
scores: filteredScores,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
|
||||
export: protectedProjectProcedure
|
||||
.input(ExportInputs)
|
||||
.query(async ({ input, ctx }) => {
|
||||
// ATTENTION: When making changes to this query, make sure to also update the all query
|
||||
const searchCondition = input.searchQuery
|
||||
? Prisma.sql`AND (
|
||||
o."id" ILIKE ${`%${input.searchQuery}%`} OR
|
||||
o."name" ILIKE ${`%${input.searchQuery}%`} OR
|
||||
o."model" ILIKE ${`%${input.searchQuery}%`} OR
|
||||
t."name" ILIKE ${`%${input.searchQuery}%`}
|
||||
)`
|
||||
: Prisma.empty;
|
||||
|
||||
const filterCondition = filterToPrismaSql(
|
||||
input.filter,
|
||||
observationsTableCols,
|
||||
);
|
||||
|
||||
const orderByCondition = orderByToPrismaSql(
|
||||
input.orderBy,
|
||||
observationsTableCols,
|
||||
);
|
||||
|
||||
// to improve query performance, add timeseries filter to observation queries as well
|
||||
const startTimeFilter = input.filter.find(
|
||||
(f) => f.column === "start_time" && f.type === "datetime",
|
||||
);
|
||||
const datetimeFilter =
|
||||
startTimeFilter && startTimeFilter.type === "datetime"
|
||||
? datetimeFilterToPrismaSql(
|
||||
"start_time",
|
||||
startTimeFilter.operator,
|
||||
startTimeFilter.value,
|
||||
)
|
||||
: Prisma.empty;
|
||||
|
||||
const generations = await ctx.prisma.$queryRaw<
|
||||
Array<
|
||||
ObservationView & {
|
||||
traceId: string;
|
||||
traceName: string;
|
||||
latency: number | null;
|
||||
}
|
||||
>
|
||||
>(
|
||||
Prisma.sql`
|
||||
WITH observations_with_latency AS (
|
||||
SELECT
|
||||
o.*,
|
||||
CASE WHEN o.end_time IS NULL THEN NULL ELSE (EXTRACT(EPOCH FROM o."end_time") - EXTRACT(EPOCH FROM o."start_time"))::double precision END AS "latency"
|
||||
FROM observations_view o
|
||||
WHERE o.type = 'GENERATION'
|
||||
AND o.project_id = ${input.projectId}
|
||||
${datetimeFilter}
|
||||
),
|
||||
-- used for filtering
|
||||
scores_avg AS (
|
||||
SELECT
|
||||
trace_id,
|
||||
observation_id,
|
||||
jsonb_object_agg(name::text, avg_value::double precision) AS scores_avg
|
||||
FROM (
|
||||
SELECT
|
||||
trace_id,
|
||||
observation_id,
|
||||
name,
|
||||
avg(value) avg_value
|
||||
FROM
|
||||
scores
|
||||
GROUP BY
|
||||
1,
|
||||
2,
|
||||
3
|
||||
ORDER BY
|
||||
1) tmp
|
||||
GROUP BY
|
||||
1, 2
|
||||
)
|
||||
SELECT
|
||||
o.id,
|
||||
o.name,
|
||||
o.model,
|
||||
o.start_time as "startTime",
|
||||
o.end_time as "endTime",
|
||||
o.latency,
|
||||
o.input,
|
||||
o.output,
|
||||
o.metadata,
|
||||
o.trace_id as "traceId",
|
||||
t.name as "traceName",
|
||||
o.completion_start_time as "completionStartTime",
|
||||
o.prompt_tokens as "promptTokens",
|
||||
o.completion_tokens as "completionTokens",
|
||||
o.total_tokens as "totalTokens",
|
||||
o.level,
|
||||
o.status_message as "statusMessage",
|
||||
o.version,
|
||||
o.model_id as "modelId",
|
||||
o.input_price as "inputPrice",
|
||||
o.output_price as "outputPrice",
|
||||
o.total_price as "totalPrice",
|
||||
o.calculated_input_cost as "calculatedInputCost",
|
||||
o.calculated_output_cost as "calculatedOutputCost",
|
||||
o.calculated_total_cost as "calculatedTotalCost"
|
||||
FROM observations_with_latency o
|
||||
JOIN traces t ON t.id = o.trace_id
|
||||
LEFT JOIN scores_avg AS s_avg ON s_avg.trace_id = t.id and s_avg.observation_id = o.id
|
||||
WHERE
|
||||
t.project_id = ${input.projectId}
|
||||
${searchCondition}
|
||||
${filterCondition}
|
||||
${orderByCondition}
|
||||
`,
|
||||
);
|
||||
|
||||
let output: string = "";
|
||||
|
||||
// create file
|
||||
switch (input.fileFormat) {
|
||||
case "CSV":
|
||||
output = [
|
||||
[
|
||||
"traceId",
|
||||
"name",
|
||||
"model",
|
||||
"startTime",
|
||||
"endTime",
|
||||
"cost",
|
||||
"prompt",
|
||||
"completion",
|
||||
"metadata",
|
||||
],
|
||||
]
|
||||
.concat(
|
||||
generations.map((generation) =>
|
||||
[
|
||||
generation.traceId,
|
||||
generation.name ?? "",
|
||||
generation.model ?? "",
|
||||
generation.startTime.toISOString(),
|
||||
generation.endTime?.toISOString() ?? "",
|
||||
generation.calculatedTotalCost
|
||||
? usdFormatter(
|
||||
generation.calculatedTotalCost.toNumber(),
|
||||
2,
|
||||
8,
|
||||
)
|
||||
: "",
|
||||
JSON.stringify(generation.input),
|
||||
JSON.stringify(generation.output),
|
||||
JSON.stringify(generation.metadata),
|
||||
].map((field) => {
|
||||
const str = typeof field === "string" ? field : String(field);
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}),
|
||||
),
|
||||
)
|
||||
.map((row) => row.join(","))
|
||||
.join("\n");
|
||||
break;
|
||||
case "JSON":
|
||||
output = JSON.stringify(generations);
|
||||
break;
|
||||
case "OPENAI-JSONL":
|
||||
const inputSchemaOpenAI = z.array(
|
||||
z.object({
|
||||
role: z.enum(["system", "user", "assistant"]),
|
||||
content: z.string(),
|
||||
}),
|
||||
);
|
||||
const outputSchema = z
|
||||
.object({
|
||||
completion: jsonSchema,
|
||||
})
|
||||
.or(jsonSchema);
|
||||
output = generations
|
||||
.map((generation) => ({
|
||||
parsedInput: inputSchemaOpenAI.safeParse(generation.input),
|
||||
parsedOutput: outputSchema.safeParse(generation.output),
|
||||
}))
|
||||
.filter((generation) => generation.parsedInput.success)
|
||||
.map((generation) =>
|
||||
generation.parsedInput.success // check for typescript validation, is always true due to previous filter
|
||||
? generation.parsedInput.data.concat(
|
||||
generation.parsedOutput.success
|
||||
? [
|
||||
{
|
||||
role: "assistant",
|
||||
content:
|
||||
typeof generation.parsedOutput.data ===
|
||||
"object" &&
|
||||
"completion" in generation.parsedOutput.data
|
||||
? JSON.stringify(
|
||||
generation.parsedOutput.data.completion,
|
||||
)
|
||||
: JSON.stringify(generation.parsedOutput.data),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
)
|
||||
: [],
|
||||
)
|
||||
// to jsonl
|
||||
.map((row) => JSON.stringify(row))
|
||||
.join("\n");
|
||||
|
||||
break;
|
||||
default:
|
||||
throw new Error("Invalid export file format");
|
||||
}
|
||||
|
||||
const fileName = `lf-export-${
|
||||
input.projectId
|
||||
}-${new Date().toISOString()}.${
|
||||
exportOptions[input.fileFormat].extension
|
||||
}`;
|
||||
|
||||
if (
|
||||
env.S3_BUCKET_NAME &&
|
||||
env.S3_ACCESS_KEY_ID &&
|
||||
env.S3_SECRET_ACCESS_KEY &&
|
||||
env.S3_ENDPOINT &&
|
||||
env.S3_REGION
|
||||
) {
|
||||
const client = new S3Client({
|
||||
credentials: {
|
||||
accessKeyId: env.S3_ACCESS_KEY_ID,
|
||||
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
|
||||
},
|
||||
endpoint: env.S3_ENDPOINT,
|
||||
region: env.S3_REGION,
|
||||
});
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: env.S3_BUCKET_NAME,
|
||||
Key: fileName,
|
||||
Body: output,
|
||||
ContentType: exportOptions[input.fileFormat].fileType,
|
||||
}),
|
||||
);
|
||||
const signedUrl = await getSignedUrl(
|
||||
client,
|
||||
new GetObjectCommand({
|
||||
Bucket: env.S3_BUCKET_NAME,
|
||||
Key: fileName,
|
||||
ResponseContentDisposition: `attachment; filename="${fileName}"`,
|
||||
}),
|
||||
{
|
||||
expiresIn: 60 * 60, // in 1 hour, signed url will expire
|
||||
},
|
||||
);
|
||||
return {
|
||||
type: "s3",
|
||||
url: signedUrl,
|
||||
fileName,
|
||||
} as const;
|
||||
} else {
|
||||
return {
|
||||
type: "data",
|
||||
data: output,
|
||||
fileName,
|
||||
} as const;
|
||||
}
|
||||
}),
|
||||
filterOptions: protectedProjectProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.query(async ({ input, ctx }) => {
|
||||
const queryFilter = {
|
||||
projectId: input.projectId,
|
||||
type: "GENERATION",
|
||||
} as const;
|
||||
|
||||
const scores = await ctx.prisma.score.groupBy({
|
||||
where: {
|
||||
observation: {
|
||||
projectId: input.projectId,
|
||||
},
|
||||
},
|
||||
by: ["name"],
|
||||
});
|
||||
|
||||
const model = await ctx.prisma.observation.groupBy({
|
||||
by: ["model"],
|
||||
where: queryFilter,
|
||||
_count: { _all: true },
|
||||
});
|
||||
const name = await ctx.prisma.observation.groupBy({
|
||||
by: ["name"],
|
||||
where: queryFilter,
|
||||
_count: { _all: true },
|
||||
});
|
||||
const traceName = await ctx.prisma.$queryRaw<
|
||||
Array<{
|
||||
traceName: string | null;
|
||||
count: number;
|
||||
}>
|
||||
>(Prisma.sql`
|
||||
SELECT
|
||||
t.name "traceName",
|
||||
count(*)::int AS count
|
||||
FROM traces t
|
||||
JOIN observations o ON o.trace_id = t.id
|
||||
WHERE o.type = 'GENERATION'
|
||||
AND o.project_id = ${input.projectId}
|
||||
AND t.project_id = ${input.projectId}
|
||||
GROUP BY 1
|
||||
`);
|
||||
|
||||
// typecheck filter options, needs to include all columns with options
|
||||
const res: ObservationOptions = {
|
||||
model: model
|
||||
.filter((i) => i.model !== null)
|
||||
.map((i) => ({
|
||||
value: i.model as string,
|
||||
count: i._count._all,
|
||||
})),
|
||||
name: name
|
||||
.filter((i) => i.name !== null)
|
||||
.map((i) => ({
|
||||
value: i.name as string,
|
||||
count: i._count._all,
|
||||
})),
|
||||
traceName: traceName
|
||||
.filter((i) => i.traceName !== null)
|
||||
.map((i) => ({
|
||||
value: i.traceName as string,
|
||||
count: i.count,
|
||||
})),
|
||||
scores_avg: scores.map((score) => score.name),
|
||||
};
|
||||
return res;
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Readable } from "stream";
|
||||
|
||||
import { Prisma, type PrismaClient } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* DatabaseReadStream fetches and streams database records in paginated batches,
|
||||
* simulating a streaming behavior. This class is designed for efficient, memory-optimized chunking of
|
||||
* database queries, ideal for processing large datasets with minimal memory overhead. It operates in
|
||||
* object mode, directly streaming database entity objects.
|
||||
*
|
||||
* Note: Due to Prisma's lack of direct streaming support, this class implements a chunk-based approach
|
||||
* rather than true database streaming. It fetches data in paginated batches determined by the pageSize.
|
||||
* GitHub issue: https://github.com/prisma/prisma/issues/5055
|
||||
*
|
||||
* @param prisma - The PrismaClient instance for database queries.
|
||||
* @param rawSqlQuery - A Prisma.Sql object representing the base SQL query, excluding OFFSET and LIMIT.
|
||||
* @param pageSize - Number of records per batch, defining the chunk size.
|
||||
*
|
||||
* The class extends Node.js's Readable stream, using async iteration and Prisma's pagination for scalable
|
||||
* data processing. It's suitable for applications requiring large dataset processing with a low memory footprint.
|
||||
*/
|
||||
export class DatabaseReadStream<EntityType> extends Readable {
|
||||
private hasNextPage: boolean;
|
||||
private offset: number;
|
||||
private isReading: boolean;
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaClient,
|
||||
private rawSqlQuery: Prisma.Sql,
|
||||
private pageSize: number,
|
||||
) {
|
||||
super({ objectMode: true }); // Set object mode to true to allow pushing objects to the stream rather than strings or buffers
|
||||
|
||||
this.isReading = false; // Prevent concurrent read executions
|
||||
this.hasNextPage = true;
|
||||
this.offset = 0;
|
||||
}
|
||||
|
||||
async _read() {
|
||||
if (!this.hasNextPage || this.isReading) return; // Avoid calling the database if there's no more data or if a read operation is already in progress
|
||||
|
||||
this.isReading = true;
|
||||
|
||||
try {
|
||||
const query = Prisma.sql`${this.rawSqlQuery} OFFSET ${this.offset} LIMIT ${this.pageSize}`;
|
||||
const rows = await this.prisma.$queryRaw<EntityType[]>(query);
|
||||
|
||||
if (rows.length > 0) {
|
||||
rows.forEach((row) => this.push(row));
|
||||
this.offset += rows.length;
|
||||
} else {
|
||||
this.hasNextPage = false;
|
||||
this.push(null); // Signal end of stream
|
||||
}
|
||||
} catch (error) {
|
||||
this.emit("error", error);
|
||||
} finally {
|
||||
this.isReading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user