Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b7f046e71 | ||
|
|
fe63704508 | ||
|
|
c39279f677 | ||
|
|
4206f5025d | ||
|
|
0260a4e861 | ||
|
|
244adf0dc3 | ||
|
|
dc6b98d009 | ||
|
|
b20fc5a20b | ||
|
|
f59ad5fd60 | ||
|
|
06ef41e372 | ||
|
|
e215a2db99 | ||
|
|
5e247e88c9 | ||
|
|
8e1e27c868 | ||
|
|
4648a5be8b | ||
|
|
4677cb10a9 | ||
|
|
7ed03a0946 | ||
|
|
bbe40a0ba8 | ||
|
|
2cf9a75fa5 | ||
|
|
38acccbfac | ||
|
|
8327863176 | ||
|
|
6045324d2b | ||
|
|
87923e5d6c | ||
|
|
f584de7577 | ||
|
|
d01c403f62 | ||
|
|
a001e6072c | ||
|
|
d9a3f977bc | ||
|
|
8fae4f3051 | ||
|
|
c36b1ff51d | ||
|
|
d3cf604f4f | ||
|
|
cc886917b6 | ||
|
|
b924bf9e09 | ||
|
|
895825dd1c | ||
|
|
6ceccb649f | ||
|
|
911021ac5d | ||
|
|
f39778ef9f | ||
|
|
4d00e39c6b | ||
|
|
7af49fd649 | ||
|
|
662f1917b6 | ||
|
|
d8f047f2a1 | ||
|
|
69815a51b9 | ||
|
|
2bb419213a | ||
|
|
a330035c40 | ||
|
|
dd4434fe25 | ||
|
|
f898f57bff | ||
|
|
eda52c7b77 | ||
|
|
e5c7686bc7 | ||
|
|
251be1a240 | ||
|
|
66c810e4a7 | ||
|
|
c3c8a741e4 | ||
|
|
81c898b4ed | ||
|
|
834d0bcc7a | ||
|
|
40953d7732 | ||
|
|
d471be8783 | ||
|
|
0ca38d8070 | ||
|
|
12458e3d71 | ||
|
|
51575b5491 | ||
|
|
a9c11f3323 | ||
|
|
066140c903 | ||
|
|
0b7de157ad | ||
|
|
7dcc718c77 | ||
|
|
e098618614 | ||
|
|
3e8ba54aa0 | ||
|
|
ea36cd58a4 | ||
|
|
c7b726a73a | ||
|
|
112e7a2dcf | ||
|
|
c1d98aba6a | ||
|
|
75fd1cbd3f | ||
|
|
75ec734a45 | ||
|
|
98e951423c | ||
|
|
8e1dc3ee99 | ||
|
|
7668995ec9 | ||
|
|
06233f878c | ||
|
|
dae3d4e4e2 | ||
|
|
04dca2ad6b | ||
|
|
48c61ad77f | ||
|
|
34140302a6 | ||
|
|
93cc36ecdc | ||
|
|
ace30782a0 | ||
|
|
3c94a0cda9 | ||
|
|
b0fea44556 | ||
|
|
ed91330c37 | ||
|
|
b8ed9d681b | ||
|
|
ea2e1b4873 | ||
|
|
3ddef14e75 | ||
|
|
4658942701 | ||
|
|
51be8f8fb3 | ||
|
|
6ede3b7690 | ||
|
|
dee36bbe29 | ||
|
|
f71cf2f894 | ||
|
|
1a707fe147 | ||
|
|
8da1640777 | ||
|
|
1e56499f00 | ||
|
|
2524fb9648 | ||
|
|
91cd86a286 | ||
|
|
e6b9d23615 | ||
|
|
8b76feeffa | ||
|
|
88a53dc72d | ||
|
|
649a9af91c | ||
|
|
876917d112 | ||
|
|
bb6bc79bdc | ||
|
|
7aff2b2e61 | ||
|
|
8f742f660a |
+7
-2
@@ -139,12 +139,12 @@ ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000
|
||||
# NEXT_PUBLIC_POSTHOG_KEY=
|
||||
|
||||
# Sentry
|
||||
# NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE
|
||||
# NEXT_PUBLIC_SENTRY_DSN=
|
||||
# NEXT_SENTRY_ORG=
|
||||
# NEXT_SENTRY_PROJECT=
|
||||
# SENTRY_AUTH_TOKEN=
|
||||
# Used to determine the Sentry sample rate
|
||||
# LANGFUSE_TRACING_SAMPLE_RATE=
|
||||
# SENTRY_CSP_REPORT_URI=
|
||||
# LANGFUSE_WORKER_BETTERSTACK_TOKEN=
|
||||
|
||||
|
||||
@@ -167,6 +167,7 @@ ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000
|
||||
# REDIS_PORT=
|
||||
# REDIS_AUTH=
|
||||
# REDIS_CONNECTION_STRING=
|
||||
# REDIS_ENABLE_AUTO_PIPELINING=
|
||||
|
||||
# Cache configuration
|
||||
# LANGFUSE_CACHE_API_KEY_ENABLED=
|
||||
@@ -174,6 +175,9 @@ ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000
|
||||
# LANGFUSE_CACHE_PROMPT_ENABLED=
|
||||
# LANGFUSE_CACHE_PROMPT_TTL_SECONDS=
|
||||
|
||||
# Rate limiting
|
||||
# LANGFUSE_RATE_LIMITS_ENABLED=
|
||||
|
||||
# Stripe
|
||||
# STRIPE_SECRET_KEY=
|
||||
# STRIPE_WEBHOOK_SIGNING_SECRET=
|
||||
@@ -200,5 +204,6 @@ ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000
|
||||
# LANGFUSE_LOG_LEVEL=
|
||||
# LANGFUSE_LEGACY_INGESTION_WORKER_CONCURRENCY=
|
||||
# LANGFUSE_ASYNC_INGESTION_PROCESSING="true"
|
||||
# QUEUE_CONSUMER_LEGACY_INGESTION_QUEUE_IS_ENABLED="true"
|
||||
|
||||
## END Langfuse V3 Ingestion
|
||||
+16
-7
@@ -8,6 +8,7 @@ updates:
|
||||
- package-ecosystem: npm
|
||||
directory: "/" # Location of package manifests
|
||||
rebase-strategy: "disabled" # use dependabot-rebase-stale
|
||||
open-pull-requests-limit: 10
|
||||
schedule:
|
||||
interval: "daily"
|
||||
versioning-strategy: "increase"
|
||||
@@ -19,17 +20,25 @@ updates:
|
||||
- dependency-name: "@types/node"
|
||||
- dependency-name: "@trpc/*"
|
||||
groups:
|
||||
sentry:
|
||||
patterns:
|
||||
- "@sentry/*"
|
||||
prisma:
|
||||
patterns:
|
||||
- "prisma"
|
||||
- "@prisma/*"
|
||||
- "@prisma/client"
|
||||
next:
|
||||
patterns:
|
||||
- "eslint-config-next"
|
||||
- "next"
|
||||
patches:
|
||||
update-types:
|
||||
- "patch"
|
||||
lodash:
|
||||
patterns:
|
||||
- "lodash"
|
||||
- "@types/lodash"
|
||||
observability:
|
||||
patterns:
|
||||
- "dd-trace"
|
||||
- "@opentelemetry/*"
|
||||
- "@appsignal/opentelemetry-instrumentation-bullmq"
|
||||
- "@prisma/instrumentation"
|
||||
- "@sentry/*"
|
||||
radix-ui:
|
||||
patterns:
|
||||
- "@radix-ui/*"
|
||||
|
||||
@@ -111,6 +111,10 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
run: pnpm run build
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
|
||||
|
||||
- name: Start Langfuse
|
||||
run: (pnpm run start&)
|
||||
@@ -136,6 +140,7 @@ jobs:
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9.5.0
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
@@ -165,18 +170,26 @@ jobs:
|
||||
sleep 5 # Wait for PostgreSQL to accept connections
|
||||
docker compose ps
|
||||
|
||||
- name: Ensure no unhealthy status
|
||||
run: |
|
||||
if docker compose ps | grep "(unhealthy)"; then
|
||||
echo "One or more services are unhealthy"
|
||||
exit 1
|
||||
else
|
||||
echo "All services are healthy"
|
||||
fi
|
||||
|
||||
- name: Seed DB
|
||||
run: |
|
||||
pnpm run --filter=shared ch:up
|
||||
pnpm run db:migrate
|
||||
pnpm run db:seed
|
||||
pnpm run --filter=shared ch:up
|
||||
|
||||
- name: Build
|
||||
run: pnpm --filter=worker... run build
|
||||
|
||||
- name: run tests
|
||||
run: pnpm --filter=worker run test
|
||||
|
||||
e2e-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
name: Snyk Container
|
||||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- "dependabot/**"
|
||||
pull_request:
|
||||
branches:
|
||||
- "**"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
snyk:
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@
|
||||
"dependencies": {
|
||||
"@langfuse/shared": "workspace:*",
|
||||
"@opentelemetry/api": ">=1.0.0 <1.9.0",
|
||||
"next": "^14.2.3",
|
||||
"next": "^14.2.6",
|
||||
"next-auth": "^4.24.7",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "langfuse",
|
||||
"version": "2.74.0",
|
||||
"version": "2.76.0",
|
||||
"author": "engineering@langfuse.com",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "^7.1.0",
|
||||
"@typescript-eslint/parser": "^7.12.0",
|
||||
"@vercel/style-guide": "^6.0.0",
|
||||
"eslint-config-next": "^14.2.3",
|
||||
"eslint-config-next": "^14.2.6",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-turbo": "^1.13.4",
|
||||
"eslint-plugin-only-warn": "^1.1.0",
|
||||
|
||||
@@ -18,5 +18,6 @@ if [ "$CLICKHOUSE_MIGRATION_SSL" = true ] ; then
|
||||
else
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&x-migrations-table-engine=MergeTree"
|
||||
fi
|
||||
|
||||
# Execute the up command
|
||||
migrate -source file://clickhouse/migrations -database "$DATABASE_URL" up
|
||||
|
||||
@@ -63,12 +63,12 @@
|
||||
"@react-email/render": "^0.0.15",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bullmq": "^5.4.2",
|
||||
"bullmq": "^5.12.10",
|
||||
"dd-trace": "^5.19.0",
|
||||
"decimal.js": "^10.4.3",
|
||||
"exponential-backoff": "^3.1.1",
|
||||
"dd-trace": "^5.19.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"kysely": "^0.27.3",
|
||||
"kysely": "^0.27.4",
|
||||
"langchain": "^0.2.6",
|
||||
"lodash": "^4.17.21",
|
||||
"next-auth": "^4.24.7",
|
||||
@@ -76,12 +76,12 @@
|
||||
"prisma-extension-kysely": "^2.1.0",
|
||||
"uuid": "^9.0.1",
|
||||
"zod": "^3.23.8",
|
||||
"zod-to-json-schema": "^3.22.5"
|
||||
"zod-to-json-schema": "^3.23.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@types/lodash": "^4.17.5",
|
||||
"@types/lodash": "^4.17.7",
|
||||
"@types/node": "^20.11.29",
|
||||
"@types/nodemailer": "^6.4.15",
|
||||
"@types/pg": "^8.11.6",
|
||||
|
||||
@@ -16,6 +16,7 @@ const EnvSchema = z.object({
|
||||
.nullable(),
|
||||
REDIS_AUTH: z.string().nullish(),
|
||||
REDIS_CONNECTION_STRING: z.string().nullish(),
|
||||
REDIS_ENABLE_AUTO_PIPELINING: z.enum(["true", "false"]).default("true"),
|
||||
ENCRYPTION_KEY: z
|
||||
.string()
|
||||
.length(
|
||||
|
||||
+3
@@ -12,3 +12,6 @@ export const plans = Object.keys(planLabels) as Plan[];
|
||||
|
||||
// This function is kept here to ensure consistency when updating plan names in the future.
|
||||
export const isCloudPlan = (plan: Plan) => plan.startsWith("cloud");
|
||||
|
||||
export const isPlan = (value: string): value is Plan =>
|
||||
plans.includes(value as Plan);
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { ColumnDefinition, type TableNames } from "./tableDefinitions";
|
||||
import { FilterState } from "./types";
|
||||
import { filterOperators } from "./interfaces/filters";
|
||||
import { filterOperators, timeFilter } from "./interfaces/filters";
|
||||
import { z } from "zod";
|
||||
|
||||
const operatorReplacements = {
|
||||
"any of": "IN",
|
||||
@@ -179,3 +180,19 @@ export const datetimeFilterToPrismaSql = (
|
||||
operator
|
||||
)} ${value}::timestamp with time zone at time zone 'UTC'`;
|
||||
};
|
||||
|
||||
export const datetimeFilterToPrisma = (
|
||||
timestampFilter: z.infer<typeof timeFilter>
|
||||
) => {
|
||||
const prismaTimestampFilter =
|
||||
timestampFilter.operator === ">="
|
||||
? { gte: timestampFilter.value }
|
||||
: timestampFilter.operator === ">"
|
||||
? { gt: timestampFilter.value }
|
||||
: timestampFilter.operator === "<="
|
||||
? { lte: timestampFilter.value }
|
||||
: timestampFilter.operator === "<"
|
||||
? { lt: timestampFilter.value }
|
||||
: {};
|
||||
return prismaTimestampFilter;
|
||||
};
|
||||
|
||||
@@ -15,6 +15,8 @@ export * from "./utils/zod";
|
||||
export * from "./utils/json";
|
||||
export * from "./utils/objects";
|
||||
export * from "./utils/typeChecks";
|
||||
export * from "./features/entitlements/plans";
|
||||
export * from "./interfaces/rate-limits";
|
||||
export { env } from "./env";
|
||||
|
||||
// llm api
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { CloudConfigRateLimit } from "./rate-limits";
|
||||
|
||||
export const CloudConfigSchema = z.object({
|
||||
plan: z.enum(["Hobby", "Pro", "Team", "Enterprise"]).optional(),
|
||||
@@ -13,5 +14,9 @@ export const CloudConfigSchema = z.object({
|
||||
activeProductId: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
|
||||
// custom rate limits for an organization
|
||||
rateLimitOverrides: CloudConfigRateLimit.optional(),
|
||||
});
|
||||
|
||||
export type CloudConfigSchema = z.infer<typeof CloudConfigSchema>;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import z from "zod";
|
||||
import { ApiAccessScope } from "../server";
|
||||
|
||||
export const RateLimitResource = z.enum([
|
||||
"ingestion",
|
||||
"public-api",
|
||||
"public-api-metrics",
|
||||
"prompts",
|
||||
]);
|
||||
|
||||
// result of the rate limit check.
|
||||
export type RateLimitResult = {
|
||||
resource: z.infer<typeof RateLimitResource>;
|
||||
points: number;
|
||||
scope: ApiAccessScope;
|
||||
|
||||
// from rate-limiter-flexible
|
||||
remainingPoints: number;
|
||||
msBeforeNext: number;
|
||||
consumedPoints: number;
|
||||
isFirstInDuration: boolean;
|
||||
};
|
||||
|
||||
export const RateLimitConfig = z.object({
|
||||
points: z.number().nullish(),
|
||||
durationInSec: z.number().nullish(),
|
||||
resource: RateLimitResource,
|
||||
});
|
||||
|
||||
export const CloudConfigRateLimit = z.array(RateLimitConfig);
|
||||
@@ -18,6 +18,7 @@ export const observationsTableCols: ColumnDefinition[] = [
|
||||
type: "stringOptions",
|
||||
internal: 'o."name"',
|
||||
options: [], // to be added at runtime
|
||||
nullable: true,
|
||||
},
|
||||
{ name: "Trace ID", id: "traceId", type: "string", internal: 't."id"' },
|
||||
{
|
||||
@@ -26,8 +27,15 @@ export const observationsTableCols: ColumnDefinition[] = [
|
||||
type: "stringOptions",
|
||||
internal: 't."name"',
|
||||
options: [], // to be added at runtime
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "User ID",
|
||||
id: "userId",
|
||||
type: "string",
|
||||
internal: 't."user_id"',
|
||||
nullable: true,
|
||||
},
|
||||
{ name: "User ID", id: "userId", type: "string", internal: 't."user_id"' },
|
||||
{
|
||||
name: "Start Time",
|
||||
id: "startTime",
|
||||
@@ -45,6 +53,7 @@ export const observationsTableCols: ColumnDefinition[] = [
|
||||
id: "timeToFirstToken",
|
||||
type: "number",
|
||||
internal: 'o."completion_start_time" - o."start_time"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Latency (s)",
|
||||
@@ -57,24 +66,28 @@ export const observationsTableCols: ColumnDefinition[] = [
|
||||
id: "timePerOutputToken",
|
||||
type: "number",
|
||||
internal: '"latency" / o."completion_tokens"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Input Cost ($)",
|
||||
id: "inputCost",
|
||||
type: "number",
|
||||
internal: 'o."calculated_input_cost"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Output Cost ($)",
|
||||
id: "outputCost",
|
||||
type: "number",
|
||||
internal: 'o."calculated_output_cost"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Total Cost ($)",
|
||||
id: "totalCost",
|
||||
type: "number",
|
||||
internal: 'o."calculated_total_cost"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Level",
|
||||
@@ -88,6 +101,7 @@ export const observationsTableCols: ColumnDefinition[] = [
|
||||
id: "statusMessage",
|
||||
type: "string",
|
||||
internal: 'o."status_message"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Model",
|
||||
@@ -95,24 +109,28 @@ export const observationsTableCols: ColumnDefinition[] = [
|
||||
type: "stringOptions",
|
||||
internal: 'o."model"',
|
||||
options: [], // to be added at runtime
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Input Tokens",
|
||||
id: "inputTokens",
|
||||
type: "number",
|
||||
internal: 'o."prompt_tokens"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Output Tokens",
|
||||
id: "outputTokens",
|
||||
type: "number",
|
||||
internal: 'o."completion_tokens"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Total Tokens",
|
||||
id: "totalTokens",
|
||||
type: "number",
|
||||
internal: 'o."total_tokens"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Usage",
|
||||
@@ -137,6 +155,7 @@ export const observationsTableCols: ColumnDefinition[] = [
|
||||
id: "version",
|
||||
type: "string",
|
||||
internal: 'o."version"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Prompt Name",
|
||||
@@ -144,12 +163,21 @@ export const observationsTableCols: ColumnDefinition[] = [
|
||||
type: "stringOptions",
|
||||
internal: "p.name",
|
||||
options: [], // to be added at runtime
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Prompt Version",
|
||||
id: "promptVersion",
|
||||
type: "number",
|
||||
internal: "p.version",
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Trace Tags",
|
||||
id: "tags",
|
||||
type: "arrayOptions",
|
||||
internal: "t.tags",
|
||||
options: [], // to be added at runtime
|
||||
},
|
||||
];
|
||||
|
||||
@@ -161,6 +189,7 @@ export type ObservationOptions = {
|
||||
traceName: Array<OptionsDefinition>;
|
||||
scores_avg: Array<string>;
|
||||
promptName: Array<OptionsDefinition>;
|
||||
tags: Array<OptionsDefinition>;
|
||||
};
|
||||
|
||||
export function observationsTableColsWithOptions(
|
||||
@@ -182,6 +211,9 @@ export function observationsTableColsWithOptions(
|
||||
if (col.id === "promptName") {
|
||||
return { ...col, options: options?.promptName ?? [] };
|
||||
}
|
||||
if (col.id === "tags") {
|
||||
return { ...col, options: options?.tags ?? [] };
|
||||
}
|
||||
return col;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export function orderByToPrismaSql(
|
||||
tableColumns: ColumnDefinition[]
|
||||
): Prisma.Sql {
|
||||
if (!orderBy) {
|
||||
return Prisma.sql`ORDER BY t.timestamp DESC NULLS LAST`;
|
||||
return Prisma.sql`ORDER BY t.timestamp DESC`;
|
||||
}
|
||||
// Get column definition to map column to internal name, e.g. "t.id"
|
||||
const col = tableColumns.find(
|
||||
@@ -40,6 +40,6 @@ export function orderByToPrismaSql(
|
||||
|
||||
// Both column and order are safe, can use raw SQL
|
||||
return Prisma.raw(
|
||||
`ORDER BY ${col.internal} ${order.data} ${orderBy.order === "DESC" ? "NULLS LAST" : "NULLS FIRST"}`
|
||||
`ORDER BY ${col.internal} ${order.data} ${col.nullable ? (orderBy.order === "DESC" ? "NULLS LAST" : "NULLS FIRST") : ""}`
|
||||
);
|
||||
}
|
||||
|
||||
+18
-18
@@ -16,8 +16,13 @@ const GetSessionTableSQLParamsSchema = z.object({
|
||||
});
|
||||
type GetSessionTableSQLParams = z.infer<typeof GetSessionTableSQLParamsSchema>;
|
||||
|
||||
export const getSessionTableSQL = (
|
||||
params: GetSessionTableSQLParams
|
||||
export const createSessionsAllQuery = (
|
||||
select: Prisma.Sql,
|
||||
params: GetSessionTableSQLParams,
|
||||
options?: {
|
||||
ignoreOrderBy?: boolean; // used by session.metrics and session.all.totalCount
|
||||
sessionIdList?: string[]; // used by session.metrics
|
||||
}
|
||||
): Prisma.Sql => {
|
||||
const { projectId, filter, orderBy, page, limit } =
|
||||
GetSessionTableSQLParamsSchema.parse(params);
|
||||
@@ -29,22 +34,13 @@ export const getSessionTableSQL = (
|
||||
);
|
||||
const orderByCondition = orderByToPrismaSql(orderBy, sessionsViewCols);
|
||||
|
||||
const sessionIdFilter = options?.sessionIdList
|
||||
? Prisma.sql`AND s.id IN (${Prisma.join(options?.sessionIdList)})`
|
||||
: Prisma.sql``;
|
||||
|
||||
const sql = Prisma.sql`
|
||||
SELECT
|
||||
s.id,
|
||||
s. "created_at" AS "createdAt",
|
||||
s.bookmarked,
|
||||
s.public,
|
||||
t. "userIds",
|
||||
t. "countTraces",
|
||||
o. "sessionDuration",
|
||||
o. "totalCost" AS "totalCost",
|
||||
o. "inputCost" AS "inputCost",
|
||||
o. "outputCost" AS "outputCost",
|
||||
o. "promptTokens" AS "promptTokens",
|
||||
o. "completionTokens" AS "completionTokens",
|
||||
o. "totalTokens" AS "totalTokens",
|
||||
(count(*) OVER ())::int AS "totalCount"
|
||||
${select}
|
||||
FROM
|
||||
trace_sessions AS s
|
||||
LEFT JOIN LATERAL (
|
||||
@@ -54,9 +50,12 @@ export const getSessionTableSQL = (
|
||||
MIN(t. "timestamp") AS "min_timestamp",
|
||||
array_agg(t.id) AS "traceIds",
|
||||
array_agg(DISTINCT t.user_id) AS "userIds",
|
||||
count(t.id)::int AS "countTraces"
|
||||
count(t.id)::int AS "countTraces",
|
||||
array_agg(DISTINCT u.tag) AS "tags"
|
||||
FROM
|
||||
traces t
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT DISTINCT UNNEST(t.tags) AS tag) AS u ON TRUE
|
||||
WHERE
|
||||
t.project_id = ${projectId}
|
||||
AND t.session_id = s.id
|
||||
@@ -79,7 +78,8 @@ export const getSessionTableSQL = (
|
||||
WHERE
|
||||
s. "project_id" = ${projectId}
|
||||
${filterCondition}
|
||||
${orderByCondition}
|
||||
${sessionIdFilter}
|
||||
${options?.ignoreOrderBy ? Prisma.sql`` : orderByCondition}
|
||||
LIMIT ${limit}
|
||||
OFFSET ${page * limit}
|
||||
`;
|
||||
@@ -1 +1 @@
|
||||
export { getSessionTableSQL } from "./getSessionTableSQL";
|
||||
export { createSessionsAllQuery } from "./createSessionsAllQuery";
|
||||
|
||||
@@ -1,3 +1,30 @@
|
||||
import z from "zod";
|
||||
import { Plan, plans } from "../../features/entitlements/plans";
|
||||
import { CloudConfigRateLimit } from "../../interfaces/rate-limits";
|
||||
|
||||
export const OrgEnrichedApiKey = z.object({
|
||||
id: z.string(),
|
||||
note: z.string().nullable(),
|
||||
publicKey: z.string(),
|
||||
displaySecretKey: z.string(),
|
||||
createdAt: z.string().datetime().nullable(),
|
||||
lastUsedAt: z.string().datetime().nullable(),
|
||||
expiresAt: z.string().datetime().nullable(),
|
||||
projectId: z.string(),
|
||||
fastHashedSecretKey: z.string(),
|
||||
hashedSecretKey: z.string(),
|
||||
orgId: z.string(),
|
||||
plan: z.enum(plans as unknown as [string, ...string[]]),
|
||||
rateLimitOverrides: CloudConfigRateLimit.nullish(),
|
||||
});
|
||||
|
||||
export const API_KEY_NON_EXISTENT = "api-key-non-existent";
|
||||
|
||||
export const CachedApiKey = z.union([
|
||||
OrgEnrichedApiKey,
|
||||
z.literal(API_KEY_NON_EXISTENT),
|
||||
]);
|
||||
|
||||
export type AuthHeaderVerificationResult =
|
||||
| AuthHeaderValidVerificationResult
|
||||
| {
|
||||
@@ -13,4 +40,7 @@ export type AuthHeaderValidVerificationResult = {
|
||||
export type ApiAccessScope = {
|
||||
projectId: string;
|
||||
accessLevel: "all" | "scores";
|
||||
orgId: string;
|
||||
plan: Plan;
|
||||
rateLimitOverrides: z.infer<typeof CloudConfigRateLimit>;
|
||||
};
|
||||
|
||||
@@ -12,18 +12,18 @@ import {
|
||||
sdkLogEvent,
|
||||
} from "../types";
|
||||
import { validateAndInflateScore } from "../validateAndInflateScore";
|
||||
import { ApiAccessScope } from "../../auth/types";
|
||||
import { Trace, Observation, Score, Prisma, Model } from "@prisma/client";
|
||||
import { ForbiddenError, LangfuseNotFoundError } from "../../../errors";
|
||||
import { mergeJson } from "../../../utils/json";
|
||||
import { jsonSchema } from "../../../utils/zod";
|
||||
import { prisma } from "../../../db";
|
||||
import { LegacyIngestionAccessScope } from ".";
|
||||
|
||||
export interface EventProcessor {
|
||||
auth(apiScope: ApiAccessScope): void;
|
||||
auth(apiScope: LegacyIngestionAccessScope): void;
|
||||
|
||||
process(
|
||||
apiScope: ApiAccessScope
|
||||
apiScope: LegacyIngestionAccessScope
|
||||
): Promise<Trace | Observation | Score> | undefined;
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ export class ObservationProcessor implements EventProcessor {
|
||||
}
|
||||
|
||||
async convertToObservation(
|
||||
apiScope: ApiAccessScope,
|
||||
apiScope: LegacyIngestionAccessScope,
|
||||
existingObservation: Observation | null
|
||||
): Promise<{
|
||||
id: string;
|
||||
@@ -398,12 +398,12 @@ export class ObservationProcessor implements EventProcessor {
|
||||
};
|
||||
}
|
||||
|
||||
auth(apiScope: ApiAccessScope): void {
|
||||
auth(apiScope: LegacyIngestionAccessScope): void {
|
||||
if (apiScope.accessLevel !== "all")
|
||||
throw new ForbiddenError("Access denied for observation creation");
|
||||
}
|
||||
|
||||
async process(apiScope: ApiAccessScope): Promise<Observation> {
|
||||
async process(apiScope: LegacyIngestionAccessScope): Promise<Observation> {
|
||||
this.auth(apiScope);
|
||||
|
||||
const existingObservation = this.event.body.id
|
||||
@@ -441,13 +441,13 @@ export class TraceProcessor implements EventProcessor {
|
||||
this.event = event;
|
||||
}
|
||||
|
||||
auth(apiScope: ApiAccessScope): void {
|
||||
auth(apiScope: LegacyIngestionAccessScope): void {
|
||||
if (apiScope.accessLevel !== "all")
|
||||
throw new ForbiddenError("Access denied for trace creation");
|
||||
}
|
||||
|
||||
async process(
|
||||
apiScope: ApiAccessScope
|
||||
apiScope: LegacyIngestionAccessScope
|
||||
): Promise<Trace | Observation | Score> {
|
||||
const { body } = this.event;
|
||||
|
||||
@@ -553,7 +553,7 @@ export class ScoreProcessor implements EventProcessor {
|
||||
this.event = event;
|
||||
}
|
||||
|
||||
auth(apiScope: ApiAccessScope) {
|
||||
auth(apiScope: LegacyIngestionAccessScope) {
|
||||
if (apiScope.accessLevel !== "scores" && apiScope.accessLevel !== "all")
|
||||
throw new ForbiddenError(
|
||||
`Access denied for score creation, ${apiScope.accessLevel}`
|
||||
@@ -561,7 +561,7 @@ export class ScoreProcessor implements EventProcessor {
|
||||
}
|
||||
|
||||
async process(
|
||||
apiScope: ApiAccessScope
|
||||
apiScope: LegacyIngestionAccessScope
|
||||
): Promise<Trace | Observation | Score> {
|
||||
const { body } = this.event;
|
||||
|
||||
@@ -613,7 +613,7 @@ export class SdkLogProcessor implements EventProcessor {
|
||||
this.event = event;
|
||||
}
|
||||
|
||||
auth(apiScope: ApiAccessScope) {
|
||||
auth(apiScope: LegacyIngestionAccessScope) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
convertTraceUpsertEventsToRedisEvents,
|
||||
getTraceUpsertQueue,
|
||||
} from "../../redis/trace-upsert";
|
||||
import { ApiAccessScope, AuthHeaderVerificationResult } from "../../auth/types";
|
||||
import { ApiAccessScope } from "../../auth/types";
|
||||
import { redis } from "../../redis/redis";
|
||||
import { backOff } from "exponential-backoff";
|
||||
import { Model } from "../../..";
|
||||
@@ -31,9 +31,24 @@ type TokenCountInput = {
|
||||
text: unknown;
|
||||
};
|
||||
|
||||
export type LegacyIngestionAccessScope = Omit<
|
||||
ApiAccessScope,
|
||||
"orgId" | "plan" | "rateLimitOverrides"
|
||||
>;
|
||||
|
||||
type LegacyIngestionAuthHeaderVerificationResult =
|
||||
| {
|
||||
validKey: true;
|
||||
scope: LegacyIngestionAccessScope;
|
||||
}
|
||||
| {
|
||||
validKey: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export const handleBatch = async (
|
||||
events: z.infer<typeof ingestionApiSchema>["batch"],
|
||||
authCheck: AuthHeaderVerificationResult,
|
||||
authCheck: LegacyIngestionAuthHeaderVerificationResult,
|
||||
calculateTokenDelegate: (p: TokenCountInput) => number | undefined
|
||||
) => {
|
||||
console.log(`handling ingestion ${events.length} events`);
|
||||
@@ -103,7 +118,7 @@ async function retry<T>(request: () => Promise<T>): Promise<T> {
|
||||
|
||||
const handleSingleEvent = async (
|
||||
event: z.infer<typeof ingestionEvent>,
|
||||
apiScope: ApiAccessScope,
|
||||
apiScope: LegacyIngestionAccessScope,
|
||||
calculateTokenDelegate: (p: {
|
||||
model: Model;
|
||||
text: unknown;
|
||||
|
||||
@@ -10,12 +10,38 @@ export type SpanCtx = {
|
||||
traceScope?: string;
|
||||
};
|
||||
|
||||
type CallbackFn<T> = () => T | Promise<T>;
|
||||
type AsyncCallbackFn<T> = () => Promise<T>;
|
||||
|
||||
export function instrument<T>(
|
||||
export async function instrumentAsync<T>(
|
||||
ctx: SpanCtx,
|
||||
callback: CallbackFn<T>
|
||||
): T extends Promise<any> ? Promise<T> : T {
|
||||
callback: AsyncCallbackFn<T>
|
||||
): Promise<T> {
|
||||
return await getTracer(ctx.traceScope ?? callback.name).startActiveSpan(
|
||||
ctx.name,
|
||||
{
|
||||
root: ctx.rootSpan,
|
||||
kind: ctx.spanKind,
|
||||
},
|
||||
async (span) => {
|
||||
try {
|
||||
const result = await callback();
|
||||
span.end();
|
||||
return result;
|
||||
} catch (ex) {
|
||||
traceException(ex as opentelemetry.Exception, span);
|
||||
span.end();
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
type SyncCallbackFn<T> = () => T;
|
||||
|
||||
export function instrumentSync<T>(
|
||||
ctx: SpanCtx,
|
||||
callback: SyncCallbackFn<T>
|
||||
): T {
|
||||
return getTracer(ctx.traceScope ?? callback.name).startActiveSpan(
|
||||
ctx.name,
|
||||
{
|
||||
@@ -23,30 +49,14 @@ export function instrument<T>(
|
||||
kind: ctx.spanKind,
|
||||
},
|
||||
(span) => {
|
||||
const handleResult = (result: T) => {
|
||||
try {
|
||||
const result = callback();
|
||||
span.end();
|
||||
return result;
|
||||
};
|
||||
|
||||
const handleError = (ex: unknown) => {
|
||||
} catch (ex) {
|
||||
traceException(ex as opentelemetry.Exception, span);
|
||||
span.end();
|
||||
throw ex;
|
||||
};
|
||||
|
||||
try {
|
||||
const result = callback();
|
||||
if (result instanceof Promise) {
|
||||
return result
|
||||
.then(handleResult)
|
||||
.catch(handleError) as T extends Promise<any> ? Promise<T> : T;
|
||||
} else {
|
||||
return handleResult(result) as T extends Promise<any>
|
||||
? Promise<T>
|
||||
: T;
|
||||
}
|
||||
} catch (ex) {
|
||||
return handleError(ex) as T extends Promise<any> ? Promise<T> : T;
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -67,9 +77,24 @@ export const traceException = (
|
||||
|
||||
const exception = {
|
||||
code: code,
|
||||
message: ex instanceof Error ? ex.message : String(ex),
|
||||
name: ex instanceof Error ? ex.name : "Error",
|
||||
stack: ex instanceof Error ? ex.stack : undefined,
|
||||
message:
|
||||
ex instanceof Error
|
||||
? ex.message
|
||||
: typeof ex === "object" && ex !== null && "message" in ex
|
||||
? JSON.stringify(ex.message)
|
||||
: JSON.stringify(ex),
|
||||
name:
|
||||
ex instanceof Error
|
||||
? ex.name
|
||||
: typeof ex === "object" && ex !== null && "name" in ex
|
||||
? JSON.stringify(ex.name)
|
||||
: "Error",
|
||||
stack:
|
||||
ex instanceof Error
|
||||
? JSON.stringify(ex.stack)
|
||||
: typeof ex === "object" && ex !== null && "stack" in ex
|
||||
? JSON.stringify(ex.stack)
|
||||
: undefined,
|
||||
};
|
||||
|
||||
// adds an otel event
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Queue } from "bullmq";
|
||||
import { QueueName, TQueueJobTypes } from "../queues";
|
||||
import { redis } from "./redis";
|
||||
import { createNewRedisInstance } from "./redis";
|
||||
|
||||
let batchExportQueue: Queue<TQueueJobTypes[QueueName.BatchExport]> | null =
|
||||
null;
|
||||
@@ -8,9 +8,11 @@ let batchExportQueue: Queue<TQueueJobTypes[QueueName.BatchExport]> | null =
|
||||
export const getBatchExportQueue = () => {
|
||||
if (batchExportQueue) return batchExportQueue;
|
||||
|
||||
batchExportQueue = redis
|
||||
const connection = createNewRedisInstance();
|
||||
|
||||
batchExportQueue = connection
|
||||
? new Queue<TQueueJobTypes[QueueName.BatchExport]>(QueueName.BatchExport, {
|
||||
connection: redis,
|
||||
connection: connection,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 100,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Queue } from "bullmq";
|
||||
|
||||
import { env } from "../../env";
|
||||
import { redis } from "../redis/redis";
|
||||
import { createNewRedisInstance } from "../redis/redis";
|
||||
import { QueueName } from "../queues";
|
||||
|
||||
export type IngestionFlushQueue = Queue<null>;
|
||||
@@ -11,9 +11,11 @@ let ingestionFlushQueue: IngestionFlushQueue | null = null;
|
||||
export const getIngestionFlushQueue = () => {
|
||||
if (ingestionFlushQueue) return ingestionFlushQueue;
|
||||
|
||||
ingestionFlushQueue = redis
|
||||
const connection = createNewRedisInstance();
|
||||
|
||||
ingestionFlushQueue = connection
|
||||
? new Queue<null>(QueueName.IngestionFlushQueue, {
|
||||
connection: redis,
|
||||
connection: connection,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true, // Important: If not true, new jobs for that ID would be ignored as jobs in the complete set are still considered as part of the queue
|
||||
removeOnFail: 1_000,
|
||||
|
||||
@@ -1,27 +1,33 @@
|
||||
import { Queue } from "bullmq";
|
||||
import { QueueName, TQueueJobTypes } from "../queues";
|
||||
import { redis } from "./redis";
|
||||
import { createNewRedisInstance } from "./redis";
|
||||
|
||||
let legacyIngestionQueue: Queue<
|
||||
TQueueJobTypes[QueueName.LegacyIngestionQueue]
|
||||
> | null = null;
|
||||
export class LegacyIngestionQueue {
|
||||
private static instance: Queue<
|
||||
TQueueJobTypes[QueueName.LegacyIngestionQueue]
|
||||
> | null = null;
|
||||
|
||||
export const getLegacyIngestionQueue = () => {
|
||||
if (legacyIngestionQueue) return legacyIngestionQueue;
|
||||
public static getInstance(): Queue<
|
||||
TQueueJobTypes[QueueName.LegacyIngestionQueue]
|
||||
> | null {
|
||||
if (LegacyIngestionQueue.instance) return LegacyIngestionQueue.instance;
|
||||
|
||||
legacyIngestionQueue = redis
|
||||
? new Queue<TQueueJobTypes[QueueName.LegacyIngestionQueue]>(
|
||||
QueueName.LegacyIngestionQueue,
|
||||
{
|
||||
connection: redis,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 100,
|
||||
attempts: 5,
|
||||
const newRedis = createNewRedisInstance({ enableOfflineQueue: false });
|
||||
|
||||
LegacyIngestionQueue.instance = newRedis
|
||||
? new Queue<TQueueJobTypes[QueueName.LegacyIngestionQueue]>(
|
||||
QueueName.LegacyIngestionQueue,
|
||||
{
|
||||
connection: newRedis,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 100,
|
||||
attempts: 5,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
: null;
|
||||
)
|
||||
: null;
|
||||
|
||||
return legacyIngestionQueue;
|
||||
};
|
||||
return LegacyIngestionQueue.instance;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,30 @@
|
||||
import Redis from "ioredis";
|
||||
import Redis, { RedisOptions } from "ioredis";
|
||||
import { env } from "../../env";
|
||||
|
||||
export const createNewRedisInstance = (
|
||||
additionalOptions: Partial<RedisOptions> = {},
|
||||
) => {
|
||||
return env.REDIS_CONNECTION_STRING
|
||||
? new Redis(env.REDIS_CONNECTION_STRING, {
|
||||
maxRetriesPerRequest: null,
|
||||
enableAutoPipelining: env.REDIS_ENABLE_AUTO_PIPELINING === "true",
|
||||
...additionalOptions,
|
||||
})
|
||||
: env.REDIS_HOST
|
||||
? new Redis({
|
||||
host: String(env.REDIS_HOST),
|
||||
port: Number(env.REDIS_PORT),
|
||||
password: String(env.REDIS_AUTH),
|
||||
maxRetriesPerRequest: null, // Set to `null` to disable retrying
|
||||
enableAutoPipelining: env.REDIS_ENABLE_AUTO_PIPELINING === "true",
|
||||
...additionalOptions,
|
||||
})
|
||||
: null;
|
||||
};
|
||||
|
||||
const createRedisClient = () => {
|
||||
try {
|
||||
return env.REDIS_CONNECTION_STRING
|
||||
? new Redis(env.REDIS_CONNECTION_STRING, { maxRetriesPerRequest: null })
|
||||
: env.REDIS_HOST
|
||||
? new Redis({
|
||||
host: String(env.REDIS_HOST),
|
||||
port: Number(env.REDIS_PORT),
|
||||
password: String(env.REDIS_AUTH),
|
||||
maxRetriesPerRequest: null, // Set to `null` to disable retrying
|
||||
})
|
||||
: null;
|
||||
return createNewRedisInstance();
|
||||
} catch (e) {
|
||||
console.error(e, "Failed to connect to redis");
|
||||
return null;
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
TraceUpsertEventType,
|
||||
} from "../queues";
|
||||
import { Queue } from "bullmq";
|
||||
import { redis } from "./redis";
|
||||
import { createNewRedisInstance } from "./redis";
|
||||
|
||||
let traceUpsertQueue: Queue<TQueueJobTypes[QueueName.TraceUpsert]> | null =
|
||||
null;
|
||||
@@ -14,9 +14,11 @@ let traceUpsertQueue: Queue<TQueueJobTypes[QueueName.TraceUpsert]> | null =
|
||||
export const getTraceUpsertQueue = () => {
|
||||
if (traceUpsertQueue) return traceUpsertQueue;
|
||||
|
||||
traceUpsertQueue = redis
|
||||
const connection = createNewRedisInstance();
|
||||
|
||||
traceUpsertQueue = connection
|
||||
? new Queue<TQueueJobTypes[QueueName.TraceUpsert]>(QueueName.TraceUpsert, {
|
||||
connection: redis,
|
||||
connection: connection,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: 100, // Important: If not true, new jobs for that ID would be ignored as jobs in the complete set are still considered as part of the queue
|
||||
removeOnFail: 1_000,
|
||||
|
||||
@@ -17,6 +17,7 @@ export const sessionsViewCols: ColumnDefinition[] = [
|
||||
type: "arrayOptions",
|
||||
internal: 't."userIds"',
|
||||
options: [], // to be filled in at runtime
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Session Duration (s)",
|
||||
@@ -78,10 +79,18 @@ export const sessionsViewCols: ColumnDefinition[] = [
|
||||
type: "number",
|
||||
internal: 'o."totalTokens"',
|
||||
},
|
||||
{
|
||||
name: "Trace Tags",
|
||||
id: "tags",
|
||||
type: "arrayOptions",
|
||||
internal: 't."tags"',
|
||||
options: [], // to be filled in at runtime
|
||||
},
|
||||
];
|
||||
|
||||
export type SessionOptions = {
|
||||
userIds: Array<OptionsDefinition>;
|
||||
tags: Array<OptionsDefinition>;
|
||||
};
|
||||
|
||||
export function sessionsTableColsWithOptions(
|
||||
@@ -91,6 +100,9 @@ export function sessionsTableColsWithOptions(
|
||||
if (col.id === "userIds") {
|
||||
return { ...col, options: options?.userIds ?? [] };
|
||||
}
|
||||
if (col.id === "tags") {
|
||||
return { ...col, options: options?.tags ?? [] };
|
||||
}
|
||||
return col;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export type ColumnDefinition =
|
||||
id: string;
|
||||
type: "number" | "string" | "datetime" | "boolean";
|
||||
internal: string;
|
||||
nullable?: boolean;
|
||||
}
|
||||
| {
|
||||
name: string;
|
||||
@@ -16,6 +17,7 @@ export type ColumnDefinition =
|
||||
type: "stringOptions";
|
||||
options: Array<OptionsDefinition>;
|
||||
internal: string;
|
||||
nullable?: boolean;
|
||||
}
|
||||
| {
|
||||
name: string;
|
||||
@@ -23,6 +25,7 @@ export type ColumnDefinition =
|
||||
type: "arrayOptions";
|
||||
options: Array<OptionsDefinition>;
|
||||
internal: string;
|
||||
nullable?: boolean;
|
||||
}
|
||||
| {
|
||||
name: string;
|
||||
@@ -30,6 +33,7 @@ export type ColumnDefinition =
|
||||
type: "stringObject" | "numberObject";
|
||||
internal: string;
|
||||
keyOptions?: Array<string>;
|
||||
nullable?: boolean;
|
||||
};
|
||||
|
||||
export const tableNames = [
|
||||
|
||||
@@ -15,6 +15,7 @@ const tracesOnlyCols: ColumnDefinition[] = [
|
||||
type: "stringOptions",
|
||||
internal: 't."name"',
|
||||
options: [], // to be filled in at runtime
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Timestamp",
|
||||
@@ -22,12 +23,19 @@ const tracesOnlyCols: ColumnDefinition[] = [
|
||||
type: "datetime",
|
||||
internal: 't."timestamp"',
|
||||
},
|
||||
{ name: "User ID", id: "userId", type: "string", internal: 't."user_id"' },
|
||||
{
|
||||
name: "User ID",
|
||||
id: "userId",
|
||||
type: "string",
|
||||
internal: 't."user_id"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Session ID",
|
||||
id: "sessionId",
|
||||
type: "string",
|
||||
internal: 't."session_id"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Metadata",
|
||||
@@ -40,12 +48,14 @@ const tracesOnlyCols: ColumnDefinition[] = [
|
||||
id: "version",
|
||||
type: "string",
|
||||
internal: 't."version"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Release",
|
||||
id: "release",
|
||||
type: "string",
|
||||
internal: 't."release"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Level",
|
||||
@@ -69,24 +79,28 @@ export const tracesTableCols: ColumnDefinition[] = [
|
||||
id: "inputTokens",
|
||||
type: "number",
|
||||
internal: 'tm."promptTokens"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Output Tokens",
|
||||
id: "outputTokens",
|
||||
type: "number",
|
||||
internal: 'tm."completionTokens"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Total Tokens",
|
||||
id: "totalTokens",
|
||||
type: "number",
|
||||
internal: 'tm."totalTokens"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Usage",
|
||||
id: "usage",
|
||||
type: "number",
|
||||
internal: 'tm."totalTokens"',
|
||||
nullable: true,
|
||||
},
|
||||
|
||||
{
|
||||
@@ -106,18 +120,21 @@ export const tracesTableCols: ColumnDefinition[] = [
|
||||
id: "inputCost",
|
||||
type: "number",
|
||||
internal: '"calculatedInputCost"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Output Cost ($)",
|
||||
id: "outputCost",
|
||||
type: "number",
|
||||
internal: '"calculatedOutputCost"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Total Cost ($)",
|
||||
id: "totalCost",
|
||||
type: "number",
|
||||
internal: '"calculatedTotalCost"',
|
||||
nullable: true,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
Generated
+1612
-1298
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -41,6 +41,8 @@ ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY
|
||||
ARG NEXT_PUBLIC_POSTHOG_KEY
|
||||
ARG NEXT_PUBLIC_POSTHOG_HOST
|
||||
ARG NEXT_PUBLIC_CRISP_WEBSITE_ID
|
||||
ARG NEXT_PUBLIC_SENTRY_DSN
|
||||
ARG NEXT_LANGFUSE_TRACING_SAMPLE_RATE
|
||||
|
||||
# Copy source code of isolated subworkspace
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
@@ -100,4 +102,4 @@ ENV PORT 3000
|
||||
ENTRYPOINT ["dumb-init", "--", "./web/entrypoint.sh"]
|
||||
|
||||
# startup command
|
||||
CMD ["node", "./web/server.js"]
|
||||
CMD ["node", "./web/server.js", "--keepAliveTimeout", "110000"]
|
||||
|
||||
+76
-49
@@ -12,25 +12,47 @@ import { env } from "./src/env.mjs";
|
||||
*/
|
||||
const cspHeader = `
|
||||
default-src 'self' https://*.langfuse.com https://*.posthog.com https://*.sentry.io wss://*.crisp.chat https://*.crisp.chat;
|
||||
script-src 'self' 'unsafe-eval' https://*.langfuse.com https://*.crisp.chat https://challenges.cloudflare.com https://*.sentry.io https://ph.langfuse.com https://static.cloudflareinsights.com https://*.stripe.com;
|
||||
style-src 'self' 'unsafe-inline' https://*.crisp.chat;
|
||||
img-src 'self' https: blob: data:;
|
||||
font-src 'self' https://*.crisp.chat;
|
||||
frame-src 'self' https://challenges.cloudflare.com https://*.stripe.com;
|
||||
script-src 'self' 'unsafe-eval' 'unsafe-inline' https://*.langfuse.com https://client.crisp.chat https://settings.crisp.chat https://challenges.cloudflare.com https://*.sentry.io https://ph.langfuse.com https://static.cloudflareinsights.com https://*.stripe.com;
|
||||
style-src 'self' 'unsafe-inline' https://client.crisp.chat;
|
||||
img-src 'self' https: blob: data: https://client.crisp.chat https://image.crisp.chat https://storage.crisp.chat;
|
||||
font-src 'self' https://client.crisp.chat;
|
||||
frame-src 'self' https://challenges.cloudflare.com https://*.stripe.com https://game.crisp.chat;
|
||||
worker-src 'self' blob:;
|
||||
object-src 'none';
|
||||
base-uri 'self';
|
||||
form-action 'self';
|
||||
frame-ancestors 'none';
|
||||
connect-src 'self' https://client.crisp.chat https://storage.crisp.chat wss://client.relay.crisp.chat wss://stream.relay.crisp.chat https://*.ingest.us.sentry.io https://ph.langfuse.com;
|
||||
media-src 'self' https://client.crisp.chat;
|
||||
${env.LANGFUSE_CSP_ENFORCE_HTTPS === "true" ? "upgrade-insecure-requests; block-all-mixed-content;" : ""}
|
||||
${env.SENTRY_CSP_REPORT_URI ? `report-uri ${env.SENTRY_CSP_REPORT_URI}; report-to csp-endpoint;` : ""}
|
||||
`;
|
||||
|
||||
const reportToHeader = {
|
||||
key: "Report-To",
|
||||
value: JSON.stringify({
|
||||
group: "csp-endpoint",
|
||||
max_age: 10886400,
|
||||
endpoints: [
|
||||
{
|
||||
url: env.SENTRY_CSP_REPORT_URI,
|
||||
},
|
||||
],
|
||||
include_subdomains: true,
|
||||
}),
|
||||
};
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const nextConfig = {
|
||||
transpilePackages: ["@langfuse/shared"],
|
||||
reactStrictMode: true,
|
||||
experimental: {
|
||||
instrumentationHook: true,
|
||||
serverComponentsExternalPackages: [
|
||||
"dd-trace",
|
||||
"@opentelemetry/auto-instrumentations-node",
|
||||
"@opentelemetry/api",
|
||||
],
|
||||
},
|
||||
poweredByHeader: false,
|
||||
|
||||
@@ -67,6 +89,7 @@ const nextConfig = {
|
||||
key: "Permissions-Policy",
|
||||
value: "autoplay=*, fullscreen=*, microphone=*",
|
||||
},
|
||||
...(env.SENTRY_CSP_REPORT_URI ? [reportToHeader] : []),
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -81,22 +104,22 @@ const nextConfig = {
|
||||
// Required to check authentication status from langfuse.com
|
||||
...(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== undefined
|
||||
? [
|
||||
{
|
||||
source: "/api/auth/session",
|
||||
headers: [
|
||||
{
|
||||
key: "Access-Control-Allow-Origin",
|
||||
value: "https://langfuse.com",
|
||||
},
|
||||
{ key: "Access-Control-Allow-Credentials", value: "true" },
|
||||
{ key: "Access-Control-Allow-Methods", value: "GET,POST" },
|
||||
{
|
||||
key: "Access-Control-Allow-Headers",
|
||||
value: "Content-Type, Authorization",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
{
|
||||
source: "/api/auth/session",
|
||||
headers: [
|
||||
{
|
||||
key: "Access-Control-Allow-Origin",
|
||||
value: "https://langfuse.com",
|
||||
},
|
||||
{ key: "Access-Control-Allow-Credentials", value: "true" },
|
||||
{ key: "Access-Control-Allow-Methods", value: "GET,POST" },
|
||||
{
|
||||
key: "Access-Control-Allow-Headers",
|
||||
value: "Content-Type, Authorization",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
// all files in /public/generated are public and can be accessed from any origin, e.g. to render an API reference based on our openapi schema
|
||||
{
|
||||
@@ -117,10 +140,6 @@ const nextConfig = {
|
||||
|
||||
// webassembly support for @dqbd/tiktoken
|
||||
webpack(config, { isServer }) {
|
||||
if (isServer) {
|
||||
// https://github.com/open-telemetry/opentelemetry-js/issues/4173
|
||||
config.ignoreWarnings = [{ module: /opentelemetry/ }];
|
||||
}
|
||||
config.experiments = {
|
||||
asyncWebAssembly: true,
|
||||
layers: true,
|
||||
@@ -130,36 +149,44 @@ const nextConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
const sentryOptions = {
|
||||
// Additional config options for the Sentry Webpack plugin. Keep in mind that
|
||||
// the following options are set automatically, and overriding them is not
|
||||
// recommended:
|
||||
// release, url, authToken, configFile, stripPrefix,
|
||||
// urlPrefix, include, ignore
|
||||
export default withSentryConfig(nextConfig, {
|
||||
// For all available options, see:
|
||||
// https://github.com/getsentry/sentry-webpack-plugin#options
|
||||
|
||||
org: process.env.SENTRY_ORG,
|
||||
project: process.env.SENTRY_PROJECT,
|
||||
|
||||
silent: true, // Suppresses all logs
|
||||
authToken: env.SENTRY_AUTH_TOKEN,
|
||||
|
||||
// Only print logs for uploading source maps in CI
|
||||
silent: !process.env.CI,
|
||||
|
||||
// For all available options, see:
|
||||
// https://github.com/getsentry/sentry-webpack-plugin#options.
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
|
||||
|
||||
// See the sections below for information on the following options:
|
||||
// 'Configure Source Maps':
|
||||
// - disableServerWebpackPlugin
|
||||
// - disableClientWebpackPlugin
|
||||
// - hideSourceMaps
|
||||
hideSourceMaps: true,
|
||||
// - widenClientFileUpload
|
||||
// 'Configure Legacy Browser Support':
|
||||
// - transpileClientSDK
|
||||
// 'Configure Serverside Auto-instrumentation':
|
||||
// - autoInstrumentServerFunctions
|
||||
// - excludeServerRoutes
|
||||
// 'Configure Tunneling':
|
||||
// - tunnelRoute
|
||||
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
||||
widenClientFileUpload: true,
|
||||
|
||||
// Automatically annotate React components to show their full name in breadcrumbs and session replay
|
||||
reactComponentAnnotation: {
|
||||
enabled: true,
|
||||
},
|
||||
|
||||
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
|
||||
// This can increase your server load as well as your hosting bill.
|
||||
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
|
||||
// side errors will fail.
|
||||
tunnelRoute: "/api/monitoring-tunnel",
|
||||
};
|
||||
|
||||
export default withSentryConfig(nextConfig, sentryOptions);
|
||||
// Hides source maps from generated client bundles
|
||||
hideSourceMaps: true,
|
||||
|
||||
// Automatically tree-shake Sentry logger statements to reduce bundle size
|
||||
disableLogger: true,
|
||||
|
||||
// Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.)
|
||||
// See the following for more information:
|
||||
// https://docs.sentry.io/product/crons/
|
||||
// https://vercel.com/docs/cron-jobs
|
||||
automaticVercelMonitors: false,
|
||||
});
|
||||
|
||||
+36
-35
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "2.74.0",
|
||||
"version": "2.76.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -54,32 +54,31 @@
|
||||
"@opentelemetry/sdk-trace-node": "^1.25.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.25.1",
|
||||
"@prisma/instrumentation": "^5.13.0",
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.5",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-hover-card": "^1.0.7",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||
"@radix-ui/react-avatar": "^1.1.0",
|
||||
"@radix-ui/react-checkbox": "^1.1.1",
|
||||
"@radix-ui/react-collapsible": "^1.1.0",
|
||||
"@radix-ui/react-dialog": "^1.1.1",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||
"@radix-ui/react-hover-card": "^1.1.1",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-scroll-area": "^1.0.5",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-separator": "^1.0.3",
|
||||
"@radix-ui/react-slider": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-toggle": "^1.0.3",
|
||||
"@radix-ui/react-toggle-group": "^1.0.4",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-popover": "^1.1.1",
|
||||
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||
"@radix-ui/react-select": "^2.1.1",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slider": "^1.2.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.0",
|
||||
"@radix-ui/react-tabs": "^1.1.0",
|
||||
"@radix-ui/react-toggle": "^1.1.0",
|
||||
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.2",
|
||||
"@remixicon/react": "^4.2.0",
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@sentry/nextjs": "8.13.0",
|
||||
"@sentry/node": "8.13.0",
|
||||
"@sentry/nextjs": "8.26.0",
|
||||
"@t3-oss/env-nextjs": "^0.10.1",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tanstack/react-query": "^4.36.1",
|
||||
@@ -89,16 +88,16 @@
|
||||
"@trpc/next": "^10.45.0",
|
||||
"@trpc/react-query": "^10.45.0",
|
||||
"@trpc/server": "^10.45.0",
|
||||
"@uiw/codemirror-theme-github": "^4.21.25",
|
||||
"@uiw/codemirror-theme-github": "^4.23.0",
|
||||
"@uiw/codemirror-theme-tokyo-night": "^4.22.2",
|
||||
"@uiw/react-codemirror": "^4.21.25",
|
||||
"ai": "^3.0.23",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bullmq": "^5.4.2",
|
||||
"bullmq": "^5.12.10",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"core-js": "^3.36.1",
|
||||
"core-js": "^3.38.1",
|
||||
"cors": "^2.8.5",
|
||||
"date-fns": "^3.3.1",
|
||||
"dd-trace": "^5.21.0",
|
||||
@@ -108,11 +107,11 @@
|
||||
"ioredis": "^5.4.1",
|
||||
"ip-address": "^9.0.5",
|
||||
"js-tiktoken": "^1.0.12",
|
||||
"kysely": "^0.27.3",
|
||||
"kysely": "^0.27.4",
|
||||
"langchain": "^0.2.6",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.364.0",
|
||||
"next": "^14.2.3",
|
||||
"lucide-react": "^0.436.0",
|
||||
"next": "^14.2.6",
|
||||
"next-auth": "^4.24.7",
|
||||
"next-query-params": "^5.0.0",
|
||||
"next-themes": "^0.3.0",
|
||||
@@ -120,12 +119,14 @@
|
||||
"posthog-node": "^3.6.3",
|
||||
"prexit": "^2.2.0",
|
||||
"prisma": "^5.13.0",
|
||||
"rate-limiter-flexible": "^5.0.3",
|
||||
"react": "18.2.0",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "^7.51.5",
|
||||
"react-icons": "^5.2.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-resizable-panels": "^2.1.1",
|
||||
"react-responsive": "^10.0.0",
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"react18-json-view": "^0.2.8-canary.6",
|
||||
@@ -134,7 +135,7 @@
|
||||
"sonner": "^1.4.41",
|
||||
"stripe": "^16.8.0",
|
||||
"superjson": "2.2.1",
|
||||
"tailwind-merge": "^2.2.1",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"use-query-params": "^2.2.1",
|
||||
"uuid": "^9.0.1",
|
||||
@@ -153,7 +154,7 @@
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/eslint": "^8.56.7",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/lodash": "^4.17.5",
|
||||
"@types/lodash": "^4.17.7",
|
||||
"@types/node": "20.10.5",
|
||||
"@types/react": "^18.2.79",
|
||||
"@types/react-dom": "^18.2.25",
|
||||
@@ -164,17 +165,17 @@
|
||||
"autoprefixer": "^10.4.19",
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-next": "^14.2.3",
|
||||
"eslint-config-next": "^14.2.6",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"node-mocks-http": "^1.14.1",
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-tailwindcss": "^0.5.14",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"prettier-plugin-tailwindcss": "^0.6.6",
|
||||
"tailwindcss": "^3.4.10",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.7.2",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.4.5",
|
||||
"wait-for-expect": "^3.0.2"
|
||||
},
|
||||
|
||||
@@ -3,19 +3,28 @@ import * as Sentry from "@sentry/nextjs";
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
// Replay may only be enabled for the client-side
|
||||
integrations: [Sentry.replayIntegration()],
|
||||
integrations: [
|
||||
Sentry.replayIntegration(),
|
||||
// Sentry.debugIntegration(),
|
||||
Sentry.captureConsoleIntegration({
|
||||
levels: ["error"],
|
||||
}),
|
||||
],
|
||||
|
||||
// Set tracesSampleRate to 1.0 to capture 100%
|
||||
// of transactions for performance monitoring.
|
||||
// We recommend adjusting this value in production
|
||||
tracesSampleRate: process.env.NEXT_LANGFUSE_TRACING_SAMPLE_RATE
|
||||
? Number(process.env.NEXT_LANGFUSE_TRACING_SAMPLE_RATE)
|
||||
: 0.1,
|
||||
tracesSampleRate: process.env.NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE
|
||||
? Number(process.env.NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE)
|
||||
: 0.5,
|
||||
|
||||
// Capture Replay for 100% of all sessions,
|
||||
// plus for 100% of sessions with an error
|
||||
replaysSessionSampleRate: 0,
|
||||
replaysSessionSampleRate: process.env.NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE
|
||||
? Number(process.env.NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE)
|
||||
: 0.5,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
debug: false,
|
||||
|
||||
beforeSend(event, _hint) {
|
||||
// Check if it is an exception, and if so, show the report dialog
|
||||
@@ -1,8 +1,7 @@
|
||||
import { v4 } from "uuid";
|
||||
import { JobExecutionStatus, prisma } from "@langfuse/shared/src/db";
|
||||
import { redis } from "@langfuse/shared/src/server";
|
||||
import { JobExecutionStatus, Prisma, prisma } from "@langfuse/shared/src/db";
|
||||
import { OrgEnrichedApiKey, redis } from "@langfuse/shared/src/server";
|
||||
import waitForExpect from "wait-for-expect";
|
||||
import { ApiKeyZod } from "@/src/features/public-api/server/apiAuth";
|
||||
|
||||
const generateAuth = (username: string, password: string) => {
|
||||
const auth = Buffer.from(`${username}:${password}`).toString("base64");
|
||||
@@ -38,6 +37,14 @@ describe("Health endpoints", () => {
|
||||
});
|
||||
|
||||
describe("Ingestion Pipeline", () => {
|
||||
beforeEach(async () => {
|
||||
// clear the redis cache
|
||||
const keys = await redis?.keys("*");
|
||||
if (keys && keys.length > 0) {
|
||||
await redis?.del(keys);
|
||||
}
|
||||
});
|
||||
|
||||
it("ingest a trace", async () => {
|
||||
const traceId = v4();
|
||||
const spanId = v4();
|
||||
@@ -118,7 +125,7 @@ describe("Ingestion Pipeline", () => {
|
||||
expect(redisKeys?.length).toBe(1);
|
||||
const redisValue = await redis?.get(redisKeys![0]);
|
||||
|
||||
const llmApiKey = ApiKeyZod.parse(JSON.parse(redisValue!));
|
||||
const llmApiKey = OrgEnrichedApiKey.parse(JSON.parse(redisValue!));
|
||||
expect(llmApiKey.projectId).toBe("7a88fb47-b4e2-43b8-a06c-a5ce950dc53a");
|
||||
});
|
||||
|
||||
@@ -143,6 +150,91 @@ describe("Ingestion Pipeline", () => {
|
||||
|
||||
expect(response.status).toBe(207);
|
||||
}, 25000);
|
||||
|
||||
it("rate limit ingestion", async () => {
|
||||
// update the org in the database and set the rate limit to 1 for ingestion
|
||||
const org = await prisma.organization.findUnique({
|
||||
where: {
|
||||
id: "seed-org-id",
|
||||
},
|
||||
});
|
||||
await prisma.organization.update({
|
||||
where: {
|
||||
id: "seed-org-id",
|
||||
},
|
||||
data: {
|
||||
cloudConfig: {
|
||||
...(typeof org?.cloudConfig === "object" ? org.cloudConfig : {}),
|
||||
rateLimitOverrides: [
|
||||
{
|
||||
resource: "ingestion",
|
||||
points: 1,
|
||||
durationInSec: 60,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const traceId = v4();
|
||||
const spanId = v4();
|
||||
|
||||
const event = {
|
||||
batch: [
|
||||
{
|
||||
id: v4(),
|
||||
type: "trace-create",
|
||||
timestamp: new Date().toISOString(),
|
||||
body: {
|
||||
name: "test trace",
|
||||
id: traceId,
|
||||
userId: "user-1", // triggers the eval
|
||||
},
|
||||
},
|
||||
{
|
||||
id: v4(),
|
||||
type: "span-create",
|
||||
timestamp: new Date().toISOString(),
|
||||
body: {
|
||||
id: spanId,
|
||||
traceId: traceId,
|
||||
name: "test span",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
// Arrange
|
||||
const url = "http://localhost:3000/api/public/ingestion";
|
||||
|
||||
// Act
|
||||
let responses = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
responses.push(
|
||||
await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: userApiKeyAuth,
|
||||
},
|
||||
body: JSON.stringify(event),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// check that at least one of the responses is a 429
|
||||
const rateLimitedResponse = responses.find((r) => r.status === 429);
|
||||
expect(rateLimitedResponse).not.toBeNull();
|
||||
|
||||
// revert the rate limit on the org
|
||||
await prisma.organization.update({
|
||||
where: {
|
||||
id: "seed-org-id",
|
||||
},
|
||||
data: {
|
||||
cloudConfig: org?.cloudConfig ?? Prisma.JsonNull,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Prompts endpoint", () => {
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import {
|
||||
getDisplaySecretKey,
|
||||
hashSecretKey,
|
||||
OrgEnrichedApiKey,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import {
|
||||
ApiAuthService,
|
||||
ApiKeyZod,
|
||||
} from "@/src/features/public-api/server/apiAuth";
|
||||
import { type PrismaClient, prisma } from "@langfuse/shared/src/db";
|
||||
import { Prisma, type PrismaClient, prisma } from "@langfuse/shared/src/db";
|
||||
import { Redis } from "ioredis";
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { ApiAuthService } from "@/src/features/public-api/server/apiAuth";
|
||||
|
||||
describe("Authenticate API calls", () => {
|
||||
beforeEach(async () => {
|
||||
@@ -68,6 +66,118 @@ describe("Authenticate API calls", () => {
|
||||
expect(auth2.validKey).toBe(true);
|
||||
});
|
||||
|
||||
it("should create new api key with stripe data and succeed with new key", async () => {
|
||||
await createAPIKey();
|
||||
|
||||
await prisma.organization.update({
|
||||
where: {
|
||||
id: "seed-org-id",
|
||||
},
|
||||
data: {
|
||||
cloudConfig: {
|
||||
stripe: {
|
||||
customerId: "cus_test123",
|
||||
activeSubscriptionId: "sub_test123",
|
||||
activeProductId: "prod_test123",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await new ApiAuthService(prisma, null).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
const auth = await new ApiAuthService(
|
||||
prisma,
|
||||
null,
|
||||
).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
expect(auth.validKey).toBe(true);
|
||||
|
||||
if (auth.validKey) {
|
||||
expect(auth.scope.orgId).toBe("seed-org-id");
|
||||
expect(auth.scope.plan).toBe("cloud:hobby");
|
||||
expect(auth.scope.rateLimitOverrides).toEqual([]);
|
||||
}
|
||||
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
});
|
||||
expect(apiKey).not.toBeNull();
|
||||
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
|
||||
|
||||
await prisma.organization.update({
|
||||
where: {
|
||||
id: "seed-org-id",
|
||||
},
|
||||
data: {
|
||||
cloudConfig: Prisma.JsonNull,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should create new api key with custom rate limits and succeed with new key", async () => {
|
||||
await createAPIKey();
|
||||
|
||||
await prisma.organization.update({
|
||||
where: {
|
||||
id: "seed-org-id",
|
||||
},
|
||||
data: {
|
||||
cloudConfig: {
|
||||
rateLimitOverrides: [
|
||||
{
|
||||
resource: "ingestion",
|
||||
points: 100,
|
||||
durationInSec: 60,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await new ApiAuthService(prisma, null).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
const auth = await new ApiAuthService(
|
||||
prisma,
|
||||
null,
|
||||
).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
expect(auth.validKey).toBe(true);
|
||||
|
||||
if (auth.validKey) {
|
||||
expect(auth.scope.orgId).toBe("seed-org-id");
|
||||
expect(auth.scope.plan).toBe("cloud:hobby");
|
||||
expect(auth.scope.rateLimitOverrides).toEqual([
|
||||
{
|
||||
resource: "ingestion",
|
||||
points: 100,
|
||||
durationInSec: 60,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
});
|
||||
expect(apiKey).not.toBeNull();
|
||||
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
|
||||
|
||||
await prisma.organization.update({
|
||||
where: {
|
||||
id: "seed-org-id",
|
||||
},
|
||||
data: {
|
||||
cloudConfig: Prisma.JsonNull,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail on wrong api key with new key", async () => {
|
||||
await createAPIKey();
|
||||
const auth = await new ApiAuthService(
|
||||
@@ -149,6 +259,25 @@ describe("Authenticate API calls", () => {
|
||||
it("should create new api key and read from cache", async () => {
|
||||
await createAPIKey();
|
||||
|
||||
// update the organization with a cloud config
|
||||
await prisma.organization.update({
|
||||
where: { id: "seed-org-id" },
|
||||
data: {
|
||||
cloudConfig: {
|
||||
rateLimitOverrides: [
|
||||
{
|
||||
resource: "public-api",
|
||||
points: 1000,
|
||||
durationInSec: 60,
|
||||
},
|
||||
{
|
||||
resource: "ingestion",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// first auth will generate the fast hashed api key
|
||||
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
@@ -177,12 +306,31 @@ describe("Authenticate API calls", () => {
|
||||
|
||||
expect(cachedKey2).not.toBeNull();
|
||||
|
||||
const parsed = ApiKeyZod.parse(JSON.parse(cachedKey2!));
|
||||
const parsed = OrgEnrichedApiKey.parse(JSON.parse(cachedKey2!));
|
||||
|
||||
expect(parsed).toEqual({
|
||||
...apiKey,
|
||||
orgId: "seed-org-id",
|
||||
plan: "cloud:hobby",
|
||||
rateLimitOverrides: [
|
||||
{
|
||||
resource: "public-api",
|
||||
points: 1000,
|
||||
durationInSec: 60,
|
||||
},
|
||||
{
|
||||
resource: "ingestion",
|
||||
},
|
||||
],
|
||||
createdAt: apiKey?.createdAt.toISOString(),
|
||||
});
|
||||
|
||||
await prisma.organization.update({
|
||||
where: { id: "seed-org-id" },
|
||||
data: {
|
||||
cloudConfig: Prisma.JsonNull,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("searching for non-existing key stores flag in redis and fails auth", async () => {
|
||||
@@ -275,7 +423,7 @@ describe("Authenticate API calls", () => {
|
||||
);
|
||||
expect(cachedKey).not.toBeNull();
|
||||
|
||||
const parsed = ApiKeyZod.parse(JSON.parse(cachedKey!));
|
||||
const parsed = OrgEnrichedApiKey.parse(JSON.parse(cachedKey!));
|
||||
|
||||
expect(parsed).toEqual({
|
||||
id: expect.any(String),
|
||||
@@ -288,6 +436,8 @@ describe("Authenticate API calls", () => {
|
||||
lastUsedAt: null,
|
||||
expiresAt: null,
|
||||
projectId: expect.any(String),
|
||||
orgId: "seed-org-id",
|
||||
plan: "cloud:hobby",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -372,10 +522,12 @@ describe("Authenticate API calls", () => {
|
||||
);
|
||||
expect(cachedKey).not.toBeNull();
|
||||
|
||||
const parsed = ApiKeyZod.parse(JSON.parse(cachedKey!));
|
||||
const parsed = OrgEnrichedApiKey.parse(JSON.parse(cachedKey!));
|
||||
|
||||
expect(parsed).toEqual({
|
||||
...apiKey,
|
||||
orgId: "seed-org-id",
|
||||
plan: "cloud:hobby",
|
||||
createdAt: apiKey?.createdAt.toISOString(),
|
||||
});
|
||||
|
||||
@@ -396,6 +548,167 @@ describe("Authenticate API calls", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalidates api keys in redis", () => {
|
||||
const redis = new Redis("redis://:myredissecret@127.0.0.1:6379", {
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// if we do not remove the key, it will remain in the cache and
|
||||
// calling the test twice will not add the key to the cache
|
||||
|
||||
const keys = await redis.keys("api-key*");
|
||||
console.log("before each deleting keys", keys);
|
||||
if (keys.length > 0) {
|
||||
console.log("before each deleting keys. actually deleting", keys);
|
||||
await redis.del(keys);
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// if we do not remove the key, it will remain in the cache and
|
||||
// calling the test twice will not add the key to the cache
|
||||
|
||||
const keys = await redis.keys("api-key*");
|
||||
console.log("after each deleting keys", keys);
|
||||
if (keys.length > 0) {
|
||||
await redis.del(keys);
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
redis.disconnect();
|
||||
});
|
||||
|
||||
it("should invalidate organization API keys in redis", async () => {
|
||||
await createAPIKey();
|
||||
|
||||
// put keys into cache
|
||||
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
});
|
||||
expect(apiKey).not.toBeNull();
|
||||
|
||||
const cachedKey = await redis.get(
|
||||
`api-key:${apiKey?.fastHashedSecretKey}`,
|
||||
);
|
||||
expect(cachedKey).not.toBeNull();
|
||||
|
||||
await new ApiAuthService(prisma, redis).invalidateOrgApiKeys(
|
||||
"seed-org-id",
|
||||
);
|
||||
|
||||
const invalidatedCachedKey = await redis.get(
|
||||
`api-key:${apiKey?.fastHashedSecretKey}`,
|
||||
);
|
||||
expect(invalidatedCachedKey).toBeNull();
|
||||
});
|
||||
|
||||
it("if no keys in redis, invalidating org keys should do nothing", async () => {
|
||||
await createAPIKey();
|
||||
|
||||
await prisma.apiKey.update({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
data: {
|
||||
fastHashedSecretKey: Math.random().toString(36).substring(2, 15),
|
||||
},
|
||||
});
|
||||
|
||||
await new ApiAuthService(prisma, redis).invalidateOrgApiKeys(
|
||||
"seed-org-id",
|
||||
);
|
||||
|
||||
const keys = await redis.keys("api-key*");
|
||||
expect(keys.length).toBe(0);
|
||||
});
|
||||
|
||||
it("if no keys in redis, invalidating org keys without fast hash should do nothing", async () => {
|
||||
await createAPIKey();
|
||||
|
||||
await new ApiAuthService(prisma, redis).invalidateOrgApiKeys(
|
||||
"seed-org-id",
|
||||
);
|
||||
|
||||
const keys = await redis.keys("api-key*");
|
||||
expect(keys.length).toBe(0);
|
||||
});
|
||||
|
||||
it("should invalidate project API keys in redis", async () => {
|
||||
await createAPIKey();
|
||||
|
||||
// put keys into cache
|
||||
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
});
|
||||
expect(apiKey).not.toBeNull();
|
||||
|
||||
const cachedKey = await redis.get(
|
||||
`api-key:${apiKey?.fastHashedSecretKey}`,
|
||||
);
|
||||
expect(cachedKey).not.toBeNull();
|
||||
|
||||
await new ApiAuthService(prisma, redis).invalidateProjectApiKeys(
|
||||
"7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
);
|
||||
|
||||
const invalidatedCachedKey = await redis.get(
|
||||
`api-key:${apiKey?.fastHashedSecretKey}`,
|
||||
);
|
||||
expect(invalidatedCachedKey).toBeNull();
|
||||
});
|
||||
|
||||
it("if no keys in redis, invalidating project keys should do nothing", async () => {
|
||||
await createAPIKey();
|
||||
|
||||
await prisma.apiKey.update({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
data: {
|
||||
fastHashedSecretKey: Math.random().toString(36).substring(2, 15),
|
||||
},
|
||||
});
|
||||
|
||||
await new ApiAuthService(prisma, redis).invalidateProjectApiKeys(
|
||||
"7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
);
|
||||
|
||||
const keys = await redis.keys("api-key*");
|
||||
expect(keys.length).toBe(0);
|
||||
});
|
||||
|
||||
it("if no keys in redis, invalidating project keys without fast hash should do nothing", async () => {
|
||||
await createAPIKey();
|
||||
|
||||
await prisma.apiKey.update({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
data: {
|
||||
fastHashedSecretKey: Math.random().toString(36).substring(2, 15),
|
||||
},
|
||||
});
|
||||
|
||||
await new ApiAuthService(prisma, redis).invalidateProjectApiKeys(
|
||||
"7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
);
|
||||
|
||||
const keys = await redis.keys("api-key*");
|
||||
expect(keys.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
const createAPIKey = async () => {
|
||||
const seedApiKey = {
|
||||
id: "seed-api-key",
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import { RateLimitService } from "@/src/features/public-api/server/RateLimitService";
|
||||
import { Redis } from "ioredis";
|
||||
|
||||
describe("RateLimitService", () => {
|
||||
const orgId = "seed-org-id";
|
||||
let redis: Redis;
|
||||
|
||||
beforeAll(() => {
|
||||
redis = new Redis("redis://:myredissecret@127.0.0.1:6379", {
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
redis.disconnect();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
expect(redis).toBeDefined();
|
||||
const keys = await redis?.keys("rate-limit*");
|
||||
if (keys && keys?.length > 0) {
|
||||
await redis?.del(keys);
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const keys = await redis?.keys("rate-limit*");
|
||||
if (keys && keys.length > 0) {
|
||||
await redis?.del(keys);
|
||||
}
|
||||
});
|
||||
|
||||
it("should rate limit", async () => {
|
||||
const scope = {
|
||||
orgId: orgId,
|
||||
plan: "cloud:hobby" as const,
|
||||
projectId: "test-project-id",
|
||||
accessLevel: "all" as const,
|
||||
rateLimitOverrides: [],
|
||||
};
|
||||
|
||||
const rateLimitService = new RateLimitService(redis!);
|
||||
const result = await rateLimitService.rateLimitRequest(scope, "public-api");
|
||||
|
||||
expect(result?.res).toEqual({
|
||||
scope: scope,
|
||||
resource: "public-api",
|
||||
points: 1000,
|
||||
remainingPoints: 999,
|
||||
msBeforeNext: expect.any(Number),
|
||||
consumedPoints: 1,
|
||||
isFirstInDuration: true,
|
||||
});
|
||||
|
||||
expect(result?.isRateLimited()).toBe(false);
|
||||
|
||||
// check redis for the rate limit key
|
||||
const value = await redis.get("rate-limit:public-api:seed-org-id");
|
||||
|
||||
expect(value).toBeDefined();
|
||||
expect(parseInt(value ?? "0")).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should increment the rate limit count", async () => {
|
||||
const scope = {
|
||||
orgId: orgId,
|
||||
plan: "cloud:hobby" as const,
|
||||
projectId: "test-project-id",
|
||||
accessLevel: "all" as const,
|
||||
rateLimitOverrides: [],
|
||||
};
|
||||
|
||||
const rateLimitService = new RateLimitService(redis!);
|
||||
await rateLimitService.rateLimitRequest(scope, "public-api");
|
||||
|
||||
const result = await rateLimitService.rateLimitRequest(scope, "public-api");
|
||||
|
||||
expect(result?.res).toEqual({
|
||||
scope: scope,
|
||||
resource: "public-api",
|
||||
points: 1000,
|
||||
remainingPoints: 998,
|
||||
msBeforeNext: expect.any(Number),
|
||||
consumedPoints: 2,
|
||||
isFirstInDuration: false,
|
||||
});
|
||||
expect(result?.isRateLimited()).toBe(false);
|
||||
});
|
||||
|
||||
it("should reset the rate limit count after the window expires", async () => {
|
||||
const scope = {
|
||||
orgId: orgId,
|
||||
plan: "cloud:hobby" as const,
|
||||
projectId: "test-project-id",
|
||||
accessLevel: "all" as const,
|
||||
rateLimitOverrides: [
|
||||
{ resource: "public-api" as const, points: 100, durationInSec: 2 },
|
||||
],
|
||||
};
|
||||
|
||||
const rateLimitService = new RateLimitService(redis!);
|
||||
await rateLimitService.rateLimitRequest(scope, "public-api");
|
||||
|
||||
const firstResult = await rateLimitService.rateLimitRequest(
|
||||
scope,
|
||||
"public-api",
|
||||
);
|
||||
expect(firstResult?.isRateLimited()).toBe(false);
|
||||
|
||||
expect(firstResult?.res).toEqual({
|
||||
scope: scope,
|
||||
resource: "public-api",
|
||||
points: 100,
|
||||
remainingPoints: 98,
|
||||
msBeforeNext: expect.any(Number),
|
||||
consumedPoints: 2,
|
||||
isFirstInDuration: false,
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
|
||||
const secondResult = await rateLimitService.rateLimitRequest(
|
||||
scope,
|
||||
"public-api",
|
||||
);
|
||||
|
||||
expect(secondResult?.res).toEqual({
|
||||
scope: scope,
|
||||
resource: "public-api",
|
||||
points: 100,
|
||||
remainingPoints: 99,
|
||||
msBeforeNext: expect.any(Number),
|
||||
consumedPoints: 1,
|
||||
isFirstInDuration: true,
|
||||
});
|
||||
|
||||
expect(secondResult?.isRateLimited()).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when rate limit is exceeded", async () => {
|
||||
const scope = {
|
||||
orgId: orgId,
|
||||
plan: "cloud:hobby" as const,
|
||||
projectId: "test-project-id",
|
||||
accessLevel: "all" as const,
|
||||
rateLimitOverrides: [
|
||||
{ resource: "public-api" as const, points: 5, durationInSec: 60 },
|
||||
],
|
||||
};
|
||||
|
||||
const rateLimitService = new RateLimitService(redis!);
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await rateLimitService.rateLimitRequest(scope, "public-api");
|
||||
}
|
||||
|
||||
const result = await rateLimitService.rateLimitRequest(scope, "public-api");
|
||||
|
||||
expect(result?.res).toEqual({
|
||||
scope: scope,
|
||||
resource: "public-api",
|
||||
points: 5,
|
||||
remainingPoints: 0,
|
||||
msBeforeNext: expect.any(Number),
|
||||
consumedPoints: 6,
|
||||
isFirstInDuration: false,
|
||||
});
|
||||
expect(result?.isRateLimited()).toBe(true);
|
||||
});
|
||||
|
||||
it("should apply rate limits with override for specific resource", async () => {
|
||||
const scope = {
|
||||
orgId: orgId,
|
||||
plan: "cloud:hobby" as const,
|
||||
projectId: "test-project-id",
|
||||
accessLevel: "all" as const,
|
||||
rateLimitOverrides: [
|
||||
{ resource: "public-api" as const, points: 5, durationInSec: 10 },
|
||||
],
|
||||
};
|
||||
|
||||
const rateLimitService = new RateLimitService(redis!);
|
||||
|
||||
const result = await rateLimitService.rateLimitRequest(scope, "public-api");
|
||||
|
||||
expect(result?.res).toEqual({
|
||||
scope: scope,
|
||||
resource: "public-api",
|
||||
points: 5,
|
||||
remainingPoints: 4,
|
||||
msBeforeNext: expect.any(Number),
|
||||
consumedPoints: 1,
|
||||
isFirstInDuration: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should not apply rate limits for resource prompts", async () => {
|
||||
const scope = {
|
||||
orgId: orgId,
|
||||
plan: "cloud:hobby" as const,
|
||||
projectId: "test-project-id",
|
||||
accessLevel: "all" as const,
|
||||
rateLimitOverrides: [
|
||||
{ resource: "public-api" as const, points: 5, durationInSec: 10 },
|
||||
],
|
||||
};
|
||||
|
||||
const rateLimitService = new RateLimitService(redis!);
|
||||
|
||||
const result = await rateLimitService.rateLimitRequest(scope, "prompts");
|
||||
|
||||
expect(result?.res).toBeUndefined();
|
||||
expect(result?.isRateLimited()).toBe(false);
|
||||
});
|
||||
|
||||
it("should not apply rate limits for ingestion when overridden to null in API key", async () => {
|
||||
const scope = {
|
||||
orgId: orgId,
|
||||
plan: "cloud:hobby" as const,
|
||||
projectId: "test-project-id",
|
||||
accessLevel: "all" as const,
|
||||
rateLimitOverrides: [
|
||||
{ resource: "ingestion" as const, points: null, durationInSec: null },
|
||||
],
|
||||
};
|
||||
|
||||
const rateLimitService = new RateLimitService(redis!);
|
||||
|
||||
const result = await rateLimitService.rateLimitRequest(scope, "ingestion");
|
||||
|
||||
expect(result?.res).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should not apply rate limits when redis is not defined", async () => {
|
||||
const scope = {
|
||||
orgId: orgId,
|
||||
plan: "cloud:hobby" as const,
|
||||
projectId: "test-project-id",
|
||||
accessLevel: "all" as const,
|
||||
rateLimitOverrides: [
|
||||
{ resource: "public-api" as const, points: 5, durationInSec: 10 },
|
||||
],
|
||||
};
|
||||
|
||||
const rateLimitService = new RateLimitService(null);
|
||||
|
||||
const result = await rateLimitService.rateLimitRequest(scope, "public-api");
|
||||
|
||||
expect(result?.res).toBeUndefined();
|
||||
expect(result?.isRateLimited()).toBe(false);
|
||||
});
|
||||
|
||||
it("should not apply rate limits for OSS plan", async () => {
|
||||
const scope = {
|
||||
orgId: orgId,
|
||||
plan: "oss" as const,
|
||||
projectId: "test-project-id",
|
||||
accessLevel: "all" as const,
|
||||
rateLimitOverrides: [],
|
||||
};
|
||||
|
||||
const rateLimitService = new RateLimitService(redis!);
|
||||
|
||||
const result = await rateLimitService.rateLimitRequest(scope, "public-api");
|
||||
|
||||
expect(result?.res).toBeUndefined();
|
||||
expect(result?.isRateLimited()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -64,7 +64,7 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
|
||||
<Textarea
|
||||
ref={textAreaRef}
|
||||
id={message.id}
|
||||
className="height-[auto] min-h-8 w-full pt-3 font-mono text-xs focus:outline-none"
|
||||
className="height-[auto] min-h-6 w-full font-mono text-xs focus:outline-none"
|
||||
placeholder={placeholder}
|
||||
value={message.content}
|
||||
onChange={handleContentChange}
|
||||
|
||||
@@ -24,13 +24,21 @@ export const ChatMessages: React.FC<ChatMessagesProps> = (props) => {
|
||||
}, [scrollAreaRef, messages.length]);
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto scroll-smooth" ref={scrollAreaRef}>
|
||||
<div className="mb-4 flex-1 space-y-4">
|
||||
{props.messages.map((message) => {
|
||||
return (
|
||||
<ChatMessageComponent {...{ message, ...props }} key={message.id} />
|
||||
);
|
||||
})}
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="mb-2 font-semibold">Messages</div>
|
||||
<div className="flex-1 overflow-auto scroll-smooth" ref={scrollAreaRef}>
|
||||
<div className="mb-4 flex-1 space-y-3">
|
||||
{props.messages.map((message) => {
|
||||
return (
|
||||
<ChatMessageComponent
|
||||
{...{ message, ...props }}
|
||||
key={message.id}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-3">
|
||||
<AddMessageButton {...props} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,10 +60,10 @@ const AddMessageButton: React.FC<AddMessageButtonProps> = ({
|
||||
<Button
|
||||
type="button" // prevents submitting a form if this button is inside a form
|
||||
variant="outline"
|
||||
className="w-full space-x-2 py-6"
|
||||
className="w-full"
|
||||
onClick={() => addMessage(nextMessageRole)}
|
||||
>
|
||||
<PlusCircleIcon size={16} />
|
||||
<PlusCircleIcon size={14} className="mr-2" />
|
||||
<p>Add message</p>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -47,7 +47,7 @@ export const LLMApiKeyComponent = (p: {
|
||||
<Label className="text-xs font-semibold">API key</Label>
|
||||
<div>
|
||||
{apiKey ? (
|
||||
<Link href={`/project/${p.projectId}/settings#llm-api-keys`}>
|
||||
<Link href={`/project/${p.projectId}/settings/api-keys`}>
|
||||
<span className="mr-2 rounded-sm bg-input p-1 text-xs">
|
||||
{apiKey.displaySecretKey}
|
||||
</span>
|
||||
|
||||
@@ -31,12 +31,15 @@ export type ModelParamsContext = {
|
||||
formDisabled?: boolean;
|
||||
};
|
||||
|
||||
export const ModelParameters: React.FC<ModelParamsContext> = ({
|
||||
export const ModelParameters: React.FC<
|
||||
ModelParamsContext & { evalModelsOnly: boolean }
|
||||
> = ({
|
||||
modelParams,
|
||||
availableProviders,
|
||||
availableModels,
|
||||
updateModelParamValue,
|
||||
setModelParamEnabled,
|
||||
evalModelsOnly,
|
||||
formDisabled = false,
|
||||
}) => {
|
||||
const projectId = useProjectIdFromURL();
|
||||
@@ -48,11 +51,11 @@ export const ModelParameters: React.FC<ModelParamsContext> = ({
|
||||
<p className="font-semibold">Model</p>
|
||||
{availableProviders.length === 0 ? (
|
||||
<>
|
||||
<p className="text-sm">
|
||||
No LLM API key set in project. For evals, we only support OpenAI so
|
||||
far.
|
||||
<p className="text-xs">
|
||||
No LLM API key set in project.{" "}
|
||||
{evalModelsOnly && "For evals, only OpenAI models are supported."}
|
||||
</p>
|
||||
<CreateLLMApiKeyDialog evalModelsOnly={true} />
|
||||
<CreateLLMApiKeyDialog evalModelsOnly={evalModelsOnly} />
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -49,7 +49,7 @@ export const PagedSettingsContainer = ({
|
||||
|
||||
return (
|
||||
<main className="flex flex-1 flex-col gap-4 py-4 md:gap-8">
|
||||
<div className="grid w-full items-start gap-4 md:grid-cols-[180px_1fr] lg:grid-cols-[220px_1fr]">
|
||||
<div className="grid w-full items-start gap-4 md:grid-cols-[150px_1fr] lg:grid-cols-[220px_1fr]">
|
||||
<nav className="block md:hidden">
|
||||
<Select
|
||||
onValueChange={(slug) => {
|
||||
|
||||
@@ -11,11 +11,11 @@ export const SettingsDangerZone: React.FC<{
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Header title="Danger Zone" level="h3" />
|
||||
<div className="rounded border">
|
||||
<div className="rounded-lg border">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between gap-4 border-b p-4 last:border-b-0"
|
||||
className="flex items-center justify-between gap-4 border-b p-3 last:border-b-0"
|
||||
>
|
||||
<div>
|
||||
<h4 className="font-semibold">{item.title}</h4>
|
||||
|
||||
@@ -9,15 +9,14 @@ import {
|
||||
} from "@/src/components/ui/select";
|
||||
|
||||
import {
|
||||
DEFAULT_AGGREGATION_SELECTION,
|
||||
tableDateRangeAggregationSettings,
|
||||
dashboardDateRangeAggregationSettings,
|
||||
DASHBOARD_AGGREGATION_PLACEHOLDER,
|
||||
type DashboardDateRangeOptions,
|
||||
type TableDateRangeOptions,
|
||||
DASHBOARD_AGGREGATION_OPTIONS,
|
||||
type DashboardDateRange,
|
||||
TABLE_RANGE_DROPDOWN_OPTIONS,
|
||||
TABLE_AGGREGATION_OPTIONS,
|
||||
getDateFromOption,
|
||||
} from "@/src/utils/date-range-utils";
|
||||
import { Clock } from "lucide-react";
|
||||
|
||||
@@ -101,24 +100,22 @@ export const TableDateRangeDropdown: React.FC<TableDateRangeDropdownProps> = ({
|
||||
setDateRangeAndOption,
|
||||
}) => {
|
||||
const onDropDownSelection = (value: TableDateRangeOptions) => {
|
||||
if (value === DEFAULT_AGGREGATION_SELECTION) {
|
||||
setDateRangeAndOption(DEFAULT_AGGREGATION_SELECTION, undefined);
|
||||
return;
|
||||
}
|
||||
const setting =
|
||||
tableDateRangeAggregationSettings[
|
||||
value as keyof typeof tableDateRangeAggregationSettings
|
||||
];
|
||||
setDateRangeAndOption(value, {
|
||||
from: addMinutes(new Date(), -setting),
|
||||
to: new Date(),
|
||||
const dateFromOption = getDateFromOption({
|
||||
filterSource: "TABLE",
|
||||
option: value,
|
||||
});
|
||||
|
||||
const initialDateRange = !!dateFromOption
|
||||
? { from: dateFromOption, to: new Date() }
|
||||
: undefined;
|
||||
|
||||
setDateRangeAndOption(value, initialDateRange);
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseDateRangeDropdown
|
||||
selectedOption={selectedOption}
|
||||
options={TABLE_RANGE_DROPDOWN_OPTIONS}
|
||||
options={TABLE_AGGREGATION_OPTIONS}
|
||||
onSelectionChange={onDropDownSelection}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -45,7 +45,7 @@ export function JsonEditor({
|
||||
if (onChange) onChange(c);
|
||||
setLinterEnabled(c !== "");
|
||||
}}
|
||||
className={cn("overflow-hidden rounded-md border", className)}
|
||||
className={cn("overflow-hidden rounded-md border text-xs", className)}
|
||||
editable={editable}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
export const FullScreenPage: React.FC<React.PropsWithChildren<{}>> = ({
|
||||
children,
|
||||
}) => {
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
|
||||
export const FullScreenPage: React.FC<
|
||||
React.PropsWithChildren<{
|
||||
mobile?: boolean;
|
||||
className?: string;
|
||||
}>
|
||||
> = ({ children, mobile = true, className }) => {
|
||||
return (
|
||||
<div className="flex h-[calc(100dvh-6rem)] flex-col overflow-hidden lg:h-[calc(100dvh-2rem)]">
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col overflow-hidden lg:h-[calc(100dvh-1.5rem)]",
|
||||
mobile && "h-[calc(100dvh-6rem)]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -212,7 +212,7 @@ const BreadcrumbComponent = ({
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="-my-1 ml-4 mr-1 hover:bg-background"
|
||||
className="-my-1 ml-4 hover:bg-background"
|
||||
>
|
||||
<div
|
||||
onClick={(e) => {
|
||||
@@ -299,7 +299,7 @@ const BreadcrumbComponent = ({
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="-my-1 ml-4 mr-1 hover:bg-background"
|
||||
className="-my-1 ml-4 hover:bg-background"
|
||||
>
|
||||
<div
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -118,6 +118,10 @@ export default function Layout(props: PropsWithChildren) {
|
||||
|
||||
const uiCustomization = useUiCustomization();
|
||||
|
||||
const cloudAdmin =
|
||||
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== undefined &&
|
||||
session.data?.user?.admin === true;
|
||||
|
||||
// project info based on projectId in the URL
|
||||
const { project, organization } = useQueryProjectOrOrganization();
|
||||
|
||||
@@ -134,7 +138,7 @@ export default function Layout(props: PropsWithChildren) {
|
||||
if (
|
||||
route.featureFlag !== undefined &&
|
||||
!enableExperimentalFeatures &&
|
||||
session.data?.user?.admin !== true &&
|
||||
!cloudAdmin &&
|
||||
session.data?.user?.featureFlags[route.featureFlag] !== true
|
||||
)
|
||||
return null;
|
||||
@@ -143,14 +147,14 @@ export default function Layout(props: PropsWithChildren) {
|
||||
if (
|
||||
route.entitlement !== undefined &&
|
||||
!entitlements.includes(route.entitlement) &&
|
||||
session.data?.user?.admin !== true
|
||||
!cloudAdmin
|
||||
)
|
||||
return null;
|
||||
|
||||
// RBAC
|
||||
if (
|
||||
route.projectRbacScope !== undefined &&
|
||||
session.data?.user?.admin !== true &&
|
||||
!cloudAdmin &&
|
||||
(!project ||
|
||||
!organization ||
|
||||
!hasProjectAccess({
|
||||
@@ -346,7 +350,7 @@ export default function Layout(props: PropsWithChildren) {
|
||||
</Transition.Root>
|
||||
|
||||
{/* Static sidebar for desktop */}
|
||||
<div className="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-56 lg:flex-col">
|
||||
<div className="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-52 lg:flex-col">
|
||||
{/* Sidebar component, swap this element with another sidebar if you like */}
|
||||
<div className="flex h-screen grow flex-col border-r border-border bg-background">
|
||||
<nav className="flex h-full flex-1 flex-col overflow-y-auto px-4 py-3">
|
||||
@@ -418,7 +422,7 @@ export default function Layout(props: PropsWithChildren) {
|
||||
>
|
||||
<Menu.Items className="absolute -top-full bottom-1 right-0 z-10 overflow-hidden rounded-md bg-background py-2 shadow-lg ring-1 ring-border focus:outline-none">
|
||||
<span
|
||||
className="block max-w-56 overflow-hidden truncate border-b px-3 pb-2 text-sm leading-6 text-muted-foreground"
|
||||
className="block max-w-52 overflow-hidden truncate border-b px-3 pb-2 text-sm leading-6 text-muted-foreground"
|
||||
title={session.data?.user?.email ?? undefined}
|
||||
>
|
||||
{session.data?.user?.email}
|
||||
@@ -486,7 +490,7 @@ export default function Layout(props: PropsWithChildren) {
|
||||
>
|
||||
<Menu.Items className="absolute right-0 z-10 mt-2.5 rounded-md bg-background py-2 pb-1 shadow-lg ring-1 ring-border focus:outline-none">
|
||||
<span
|
||||
className="mb-1 block max-w-56 overflow-hidden truncate border-b px-3 pb-2 text-sm leading-6 text-muted-foreground"
|
||||
className="mb-1 block max-w-52 overflow-hidden truncate border-b px-3 pb-2 text-sm leading-6 text-muted-foreground"
|
||||
title={session.data?.user?.email ?? undefined}
|
||||
>
|
||||
{session.data?.user?.email}
|
||||
@@ -511,7 +515,7 @@ export default function Layout(props: PropsWithChildren) {
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
<div className="lg:pl-56">
|
||||
<div className="lg:pl-52">
|
||||
{env.NEXT_PUBLIC_DEMO_ORG_ID &&
|
||||
env.NEXT_PUBLIC_DEMO_PROJECT_ID &&
|
||||
routerProjectId === env.NEXT_PUBLIC_DEMO_PROJECT_ID &&
|
||||
@@ -532,7 +536,7 @@ export default function Layout(props: PropsWithChildren) {
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<main className="p-4">{props.children}</main>
|
||||
<main className="p-3">{props.children}</main>
|
||||
<Toaster visibleToasts={1} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -634,7 +638,7 @@ const MainNavigation: React.FC<{
|
||||
{item.label && (
|
||||
<span
|
||||
className={cn(
|
||||
"-my-0.5 self-center whitespace-nowrap break-keep rounded-sm border px-1 py-0.5 text-xs",
|
||||
"-my-0.5 self-center whitespace-nowrap break-keep rounded-sm border px-1 text-xs",
|
||||
item.current
|
||||
? "border-primary-accent text-primary-accent"
|
||||
: "border-border text-muted-foreground group-hover:border-primary-accent group-hover:text-primary-accent",
|
||||
@@ -648,7 +652,7 @@ const MainNavigation: React.FC<{
|
||||
open
|
||||
? "rotate-90 text-muted-foreground"
|
||||
: "text-muted-foreground",
|
||||
"ml-auto h-5 w-5 shrink-0",
|
||||
"ml-auto h-4 w-4 shrink-0",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
@@ -663,7 +667,7 @@ const MainNavigation: React.FC<{
|
||||
subItem.current
|
||||
? "bg-primary-foreground text-primary-accent"
|
||||
: "text-primary hover:bg-primary-foreground hover:text-primary-accent",
|
||||
"ml-0.5 flex w-full items-center gap-x-3 rounded-md p-1.5 pl-7 pr-2 text-sm",
|
||||
"ml-0.5 flex w-full items-center gap-x-3 rounded-md p-1 pl-7 pr-2 text-sm",
|
||||
)}
|
||||
target={subItem.newTab ? "_blank" : undefined}
|
||||
>
|
||||
|
||||
@@ -53,10 +53,10 @@ export const SessionPage: React.FC<{
|
||||
string[]
|
||||
>("emptySelectedConfigIds", []);
|
||||
|
||||
const commentCounts = api.comments.getCountsByObjectIds.useQuery(
|
||||
const commentCounts = api.comments.getCountByObjectId.useQuery(
|
||||
{
|
||||
projectId,
|
||||
objectIds: [sessionId],
|
||||
objectId: sessionId,
|
||||
objectType: "SESSION",
|
||||
},
|
||||
{ enabled: session.isSuccess },
|
||||
|
||||
@@ -13,13 +13,13 @@ export function StarToggle({
|
||||
value,
|
||||
disabled = false,
|
||||
onClick,
|
||||
size = "sm",
|
||||
size = "icon",
|
||||
isLoading,
|
||||
}: {
|
||||
value: boolean;
|
||||
disabled?: boolean;
|
||||
onClick: (value: boolean) => Promise<unknown>;
|
||||
size?: "sm" | "xs";
|
||||
size?: "icon" | "icon-xs";
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
return (
|
||||
@@ -40,18 +40,19 @@ export function StarToggle({
|
||||
);
|
||||
}
|
||||
|
||||
// use by the trace table
|
||||
export function StarTraceToggle({
|
||||
tracesFilter,
|
||||
projectId,
|
||||
traceId,
|
||||
value,
|
||||
size = "sm",
|
||||
size = "icon",
|
||||
}: {
|
||||
tracesFilter: RouterInput["traces"]["all"];
|
||||
projectId: string;
|
||||
traceId: string;
|
||||
value: boolean;
|
||||
size?: "sm" | "xs";
|
||||
size?: "icon" | "icon-xs";
|
||||
}) {
|
||||
const utils = api.useUtils();
|
||||
const hasAccess = useHasProjectAccess({
|
||||
@@ -89,7 +90,6 @@ export function StarTraceToggle({
|
||||
tracesFilter,
|
||||
(oldQueryData: RouterOutput["traces"]["all"] | undefined) => {
|
||||
return {
|
||||
totalCount: oldQueryData?.totalCount,
|
||||
traces: oldQueryData?.traces
|
||||
? oldQueryData.traces.map((trace) => {
|
||||
return {
|
||||
@@ -130,16 +130,17 @@ export function StarTraceToggle({
|
||||
);
|
||||
}
|
||||
|
||||
// use by the single trace view
|
||||
export function StarTraceDetailsToggle({
|
||||
projectId,
|
||||
traceId,
|
||||
value,
|
||||
size = "sm",
|
||||
size = "icon",
|
||||
}: {
|
||||
projectId: string;
|
||||
traceId: string;
|
||||
value: boolean;
|
||||
size?: "sm" | "xs";
|
||||
size?: "icon" | "icon-xs";
|
||||
}) {
|
||||
const utils = api.useUtils();
|
||||
const hasAccess = useHasProjectAccess({
|
||||
@@ -153,12 +154,15 @@ export function StarTraceDetailsToggle({
|
||||
onMutate: async () => {
|
||||
// Cancel any outgoing refetches
|
||||
// (so they don't overwrite our optimistic update)
|
||||
await utils.traces.byId.cancel();
|
||||
await utils.traces.byIdWithObservationsAndScores.cancel();
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
// Snapshot the previous value
|
||||
const prevData = utils.traces.byId.getData({ traceId, projectId });
|
||||
const prevData = utils.traces.byIdWithObservationsAndScores.getData({
|
||||
traceId,
|
||||
projectId,
|
||||
});
|
||||
|
||||
return { prevData };
|
||||
},
|
||||
@@ -166,14 +170,21 @@ export function StarTraceDetailsToggle({
|
||||
setIsLoading(false);
|
||||
trpcErrorToast(err);
|
||||
// Rollback to the previous value if mutation fails
|
||||
utils.traces.byId.setData({ traceId, projectId }, context?.prevData);
|
||||
utils.traces.byIdWithObservationsAndScores.setData(
|
||||
{ traceId, projectId },
|
||||
context?.prevData,
|
||||
);
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsLoading(false);
|
||||
|
||||
utils.traces.byId.setData(
|
||||
utils.traces.byIdWithObservationsAndScores.setData(
|
||||
{ traceId, projectId },
|
||||
(oldQueryData: RouterOutput["traces"]["byId"] | undefined) => {
|
||||
(
|
||||
oldQueryData:
|
||||
| RouterOutput["traces"]["byIdWithObservationsAndScores"]
|
||||
| undefined,
|
||||
) => {
|
||||
return oldQueryData
|
||||
? {
|
||||
...oldQueryData,
|
||||
@@ -182,7 +193,7 @@ export function StarTraceDetailsToggle({
|
||||
: undefined;
|
||||
},
|
||||
);
|
||||
void utils.traces.byId.invalidate();
|
||||
void utils.traces.byIdWithObservationsAndScores.invalidate();
|
||||
void utils.traces.all.invalidate();
|
||||
},
|
||||
});
|
||||
@@ -212,12 +223,12 @@ export function StarSessionToggle({
|
||||
projectId,
|
||||
sessionId,
|
||||
value,
|
||||
size = "sm",
|
||||
size = "icon",
|
||||
}: {
|
||||
projectId: string;
|
||||
sessionId: string;
|
||||
value: boolean;
|
||||
size?: "sm" | "xs";
|
||||
size?: "icon" | "icon-xs";
|
||||
}) {
|
||||
const utils = api.useUtils();
|
||||
const hasAccess = useHasProjectAccess({
|
||||
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
SelectValue,
|
||||
} from "@/src/components/ui/select";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { LoaderCircle } from "lucide-react";
|
||||
import { Input } from "@/src/components/ui/input";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface DataTablePaginationProps<TData> {
|
||||
table: Table<TData>;
|
||||
@@ -26,6 +29,22 @@ export function DataTablePagination<TData>({
|
||||
paginationOptions = [10, 20, 30, 40, 50],
|
||||
}: DataTablePaginationProps<TData>) {
|
||||
const capture = usePostHogClientCapture();
|
||||
|
||||
const currentPage = table.getState().pagination.pageIndex + 1;
|
||||
const [inputState, setInputState] = useState<number | string>(currentPage);
|
||||
|
||||
useEffect(() => {
|
||||
setInputState(currentPage);
|
||||
}, [currentPage]);
|
||||
|
||||
const pageCount = table.getPageCount();
|
||||
const setPageIndex = table.setPageIndex;
|
||||
useEffect(() => {
|
||||
if (currentPage > pageCount && pageCount > 0) {
|
||||
setPageIndex(0);
|
||||
}
|
||||
}, [currentPage, pageCount, setPageIndex]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 text-sm text-muted-foreground">
|
||||
@@ -61,10 +80,60 @@ export function DataTablePagination<TData>({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center justify-center whitespace-nowrap text-sm font-medium">
|
||||
Page {table.getState().pagination.pageIndex + 1} of{" "}
|
||||
{Math.max(table.getPageCount(), 1)}
|
||||
<div className="flex items-center justify-center gap-1 whitespace-nowrap text-sm font-medium">
|
||||
{table.getPageCount() !== -1 ? (
|
||||
<>
|
||||
Page
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={pageCount}
|
||||
value={inputState} // Ensure the value is within bounds
|
||||
onChange={(e) => {
|
||||
setInputState(e.target.value);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const newValue = e.target.value;
|
||||
if (newValue === "") {
|
||||
table.setPageIndex(0);
|
||||
setInputState(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// if nan, reset to current page
|
||||
if (isNaN(Number(newValue))) {
|
||||
setInputState(currentPage);
|
||||
return;
|
||||
}
|
||||
|
||||
const newPageIndex = Number(newValue) - 1;
|
||||
if (newPageIndex < 0 || newPageIndex >= pageCount) {
|
||||
setInputState(currentPage);
|
||||
return;
|
||||
}
|
||||
|
||||
table.setPageIndex(newPageIndex);
|
||||
setInputState(newPageIndex + 1);
|
||||
}}
|
||||
className="h-8 appearance-none"
|
||||
style={{
|
||||
width: `${3 + Math.max(1, pageCount.toString().length)}ch`,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
`Page ${currentPage}`
|
||||
)}
|
||||
{pageCount !== -1 ? (
|
||||
<span>of {pageCount}</span>
|
||||
) : (
|
||||
<span>
|
||||
of{" "}
|
||||
<LoaderCircle className="ml-1 inline-block h-3 w-3 animate-spin text-muted-foreground" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -112,12 +181,12 @@ export function DataTablePagination<TData>({
|
||||
variant="outline"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={() => {
|
||||
table.setPageIndex(table.getPageCount() - 1);
|
||||
table.setPageIndex(pageCount - 1);
|
||||
capture("table:pagination_button_click", {
|
||||
type: "lastPage",
|
||||
});
|
||||
}}
|
||||
disabled={!table.getCanNextPage()}
|
||||
disabled={!table.getCanNextPage() || pageCount === -1}
|
||||
>
|
||||
<span className="sr-only">Go to last page</span>
|
||||
<DoubleArrowRightIcon className="h-4 w-4" />
|
||||
|
||||
@@ -31,7 +31,9 @@ interface DataTableToolbarProps<TData, TValue> {
|
||||
searchConfig?: SearchConfig;
|
||||
actionButtons?: React.ReactNode;
|
||||
filterState?: FilterState;
|
||||
setFilterState?: Dispatch<SetStateAction<FilterState>>;
|
||||
setFilterState?:
|
||||
| Dispatch<SetStateAction<FilterState>>
|
||||
| ((newState: FilterState) => void);
|
||||
columnVisibility?: VisibilityState;
|
||||
setColumnVisibility?: Dispatch<SetStateAction<VisibilityState>>;
|
||||
rowHeight?: RowHeight;
|
||||
@@ -79,7 +81,7 @@ export function DataTableToolbar<TData, TValue>({
|
||||
searchConfig.updateQuery(searchString);
|
||||
}
|
||||
}}
|
||||
className="h-10 w-[150px] rounded-r-none @6xl:w-[250px]"
|
||||
className="w-[150px] rounded-r-none @6xl:w-[250px]"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -36,7 +36,7 @@ interface DataTableProps<TData, TValue> {
|
||||
columns: LangfuseColumnDef<TData, TValue>[];
|
||||
data: AsyncTableData<TData[]>;
|
||||
pagination?: {
|
||||
pageCount: number;
|
||||
totalCount: number | null; // null if loading
|
||||
onChange: OnChangeFn<PaginationState>;
|
||||
state: PaginationState;
|
||||
options?: number[];
|
||||
@@ -90,7 +90,13 @@ export function DataTable<TData extends object, TValue>({
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: pagination !== undefined,
|
||||
pageCount: pagination?.pageCount ?? 0,
|
||||
pageCount:
|
||||
pagination?.totalCount === null ||
|
||||
pagination?.state.pageSize === undefined
|
||||
? -1
|
||||
: Math.ceil(
|
||||
Number(pagination?.totalCount) / pagination?.state.pageSize,
|
||||
),
|
||||
onPaginationChange: pagination?.onChange,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onColumnVisibilityChange: onColumnVisibilityChange,
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function TableLink({
|
||||
return (
|
||||
<Link
|
||||
className={cn(
|
||||
"inline-block max-w-full overflow-hidden text-ellipsis text-nowrap rounded bg-primary-accent/20 px-2 py-1 text-xs font-semibold text-accent-dark-blue shadow-sm hover:bg-accent-light-blue/45",
|
||||
"inline-block max-w-full overflow-hidden text-ellipsis text-nowrap rounded bg-primary-accent/20 px-2 py-0.5 text-xs font-semibold text-accent-dark-blue shadow-sm hover:bg-accent-light-blue/45",
|
||||
className,
|
||||
)}
|
||||
href={path}
|
||||
|
||||
@@ -49,6 +49,7 @@ import { useTableDateRange } from "@/src/hooks/useTableDateRange";
|
||||
import { useDebounce } from "@/src/hooks/useDebounce";
|
||||
import { type ScoreAggregate } from "@/src/features/scores/lib/types";
|
||||
import { useIndividualScoreColumns } from "@/src/features/scores/hooks/useIndividualScoreColumns";
|
||||
import TagList from "@/src/features/tag/components/TagList";
|
||||
|
||||
export type GenerationsTableRow = {
|
||||
id: string;
|
||||
@@ -80,6 +81,7 @@ export type GenerationsTableRow = {
|
||||
promptId?: string;
|
||||
promptName?: string;
|
||||
promptVersion?: string;
|
||||
traceTags?: string[];
|
||||
};
|
||||
|
||||
export type GenerationsTableProps = {
|
||||
@@ -113,11 +115,12 @@ export default function GenerationsTable({
|
||||
);
|
||||
|
||||
const { selectedOption, dateRange, setDateRangeAndOption } =
|
||||
useTableDateRange();
|
||||
useTableDateRange(projectId);
|
||||
|
||||
const [inputFilterState, setInputFilterState] = useQueryFilterState(
|
||||
[],
|
||||
"generations",
|
||||
projectId,
|
||||
);
|
||||
|
||||
const [orderByState, setOrderByState] = useOrderByState({
|
||||
@@ -164,16 +167,26 @@ export default function GenerationsTable({
|
||||
...promptVersionFilter,
|
||||
]);
|
||||
|
||||
const generations = api.generations.all.useQuery({
|
||||
page: paginationState.pageIndex,
|
||||
limit: paginationState.pageSize,
|
||||
const getCountPayload = {
|
||||
projectId,
|
||||
filter: filterState,
|
||||
orderBy: orderByState,
|
||||
searchQuery,
|
||||
});
|
||||
page: 0,
|
||||
limit: 0,
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
const totalCount = generations.data?.totalCount ?? 0;
|
||||
const getAllPayload = {
|
||||
...getCountPayload,
|
||||
page: paginationState.pageIndex,
|
||||
limit: paginationState.pageSize,
|
||||
orderBy: orderByState,
|
||||
};
|
||||
|
||||
const generations = api.generations.all.useQuery(getAllPayload);
|
||||
const totalCountQuery = api.generations.countAll.useQuery(getCountPayload);
|
||||
|
||||
const totalCount = totalCountQuery.data?.totalCount ?? null;
|
||||
|
||||
const startTimeFilter = filterState.find((f) => f.column === "Start Time");
|
||||
const filterOptions = api.generations.filterOptions.useQuery(
|
||||
@@ -653,6 +666,30 @@ export default function GenerationsTable({
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "traceTags",
|
||||
id: "traceTags",
|
||||
header: "Trace Tags",
|
||||
size: 250,
|
||||
enableHiding: true,
|
||||
defaultHidden: true,
|
||||
cell: ({ row }) => {
|
||||
const traceTags: string[] | undefined = row.getValue("traceTags");
|
||||
console.log(traceTags);
|
||||
return (
|
||||
traceTags && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-x-2 gap-y-1",
|
||||
rowHeight !== "s" && "flex-wrap",
|
||||
)}
|
||||
>
|
||||
<TagList selectedTags={traceTags} isLoading={false} viewOnly />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const [columnVisibility, setColumnVisibilityState] =
|
||||
@@ -692,6 +729,7 @@ export default function GenerationsTable({
|
||||
promptId: generation.promptId ?? undefined,
|
||||
promptName: generation.promptName ?? undefined,
|
||||
promptVersion: generation.promptVersion ?? undefined,
|
||||
traceTags: generation.traceTags ?? undefined,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
@@ -709,6 +747,7 @@ export default function GenerationsTable({
|
||||
updateQuery: setSearchQuery,
|
||||
currentQuery: searchQuery ?? undefined,
|
||||
}}
|
||||
columnsWithCustomSelect={["model", "name", "traceName", "promptName"]}
|
||||
columnVisibility={columnVisibility}
|
||||
setColumnVisibility={setColumnVisibilityState}
|
||||
rowHeight={rowHeight}
|
||||
@@ -766,7 +805,7 @@ export default function GenerationsTable({
|
||||
}
|
||||
}
|
||||
pagination={{
|
||||
pageCount: Math.ceil(totalCount / paginationState.pageSize),
|
||||
totalCount,
|
||||
onChange: setPaginationState,
|
||||
state: paginationState,
|
||||
}}
|
||||
|
||||
@@ -63,7 +63,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
limit: paginationState.pageSize,
|
||||
projectId,
|
||||
});
|
||||
const totalCount = models.data?.totalCount ?? 0;
|
||||
const totalCount = models.data?.totalCount ?? null;
|
||||
|
||||
const [rowHeight, setRowHeight] = useRowHeightLocalStorage("models", "s");
|
||||
|
||||
@@ -303,7 +303,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
}
|
||||
}
|
||||
pagination={{
|
||||
pageCount: Math.ceil(totalCount / paginationState.pageSize),
|
||||
totalCount,
|
||||
onChange: setPaginationState,
|
||||
state: paginationState,
|
||||
}}
|
||||
|
||||
@@ -97,7 +97,7 @@ export function ScoreConfigsTable({ projectId }: { projectId: string }) {
|
||||
limit: paginationState.pageSize,
|
||||
});
|
||||
|
||||
const totalCount = configs.data?.totalCount ?? 0;
|
||||
const totalCount = configs.data?.totalCount ?? null;
|
||||
|
||||
const columns: LangfuseColumnDef<ScoreConfigTableRow>[] = [
|
||||
{
|
||||
@@ -272,7 +272,7 @@ export function ScoreConfigsTable({ projectId }: { projectId: string }) {
|
||||
}
|
||||
}
|
||||
pagination={{
|
||||
pageCount: Math.ceil(totalCount / paginationState.pageSize),
|
||||
totalCount,
|
||||
onChange: setPaginationState,
|
||||
state: paginationState,
|
||||
}}
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
type ScoreDataType,
|
||||
} from "@langfuse/shared";
|
||||
import { useQueryParams, withDefault, NumberParam } from "use-query-params";
|
||||
import TagList from "@/src/features/tag/components/TagList";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
|
||||
export type ScoresTableRow = {
|
||||
id: string;
|
||||
@@ -43,6 +45,7 @@ export type ScoresTableRow = {
|
||||
traceName?: string;
|
||||
userId?: string;
|
||||
jobConfigurationId?: string;
|
||||
traceTags?: string[];
|
||||
};
|
||||
|
||||
export type ScoreFilterInput = Omit<
|
||||
@@ -90,11 +93,12 @@ export default function ScoresTable({
|
||||
|
||||
const [rowHeight, setRowHeight] = useRowHeightLocalStorage("scores", "s");
|
||||
const { selectedOption, dateRange, setDateRangeAndOption } =
|
||||
useTableDateRange();
|
||||
useTableDateRange(projectId);
|
||||
|
||||
const [userFilterState, setUserFilterState] = useQueryFilterState(
|
||||
[],
|
||||
"scores",
|
||||
projectId,
|
||||
);
|
||||
|
||||
const dateRangeFilter: FilterState = dateRange
|
||||
@@ -120,18 +124,32 @@ export default function ScoresTable({
|
||||
order: "DESC",
|
||||
});
|
||||
|
||||
const scores = api.scores.all.useQuery({
|
||||
page: paginationState.pageIndex,
|
||||
limit: paginationState.pageSize,
|
||||
const getCountPayload = {
|
||||
projectId,
|
||||
filter: filterState,
|
||||
page: 0,
|
||||
limit: 1,
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
const getAllPayload = {
|
||||
...getCountPayload,
|
||||
page: paginationState.pageIndex,
|
||||
limit: paginationState.pageSize,
|
||||
orderBy: orderByState,
|
||||
});
|
||||
const totalCount = scores.data?.totalCount ?? 0;
|
||||
};
|
||||
|
||||
const scores = api.scores.all.useQuery(getAllPayload);
|
||||
const totalScoreCountQuery = api.scores.countAll.useQuery(getCountPayload);
|
||||
const totalCount = totalScoreCountQuery.data?.totalCount ?? null;
|
||||
|
||||
const filterOptions = api.scores.filterOptions.useQuery(
|
||||
{
|
||||
projectId,
|
||||
timestampFilter:
|
||||
dateRangeFilter[0]?.type === "datetime"
|
||||
? dateRangeFilter[0]
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
trpc: {
|
||||
@@ -323,6 +341,29 @@ export default function ScoresTable({
|
||||
) : undefined;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "traceTags",
|
||||
id: "traceTags",
|
||||
header: "Trace Tags",
|
||||
size: 250,
|
||||
enableHiding: true,
|
||||
defaultHidden: true,
|
||||
cell: ({ row }) => {
|
||||
const traceTags: string[] | undefined = row.getValue("traceTags");
|
||||
return (
|
||||
traceTags && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-x-2 gap-y-1",
|
||||
rowHeight !== "s" && "flex-wrap",
|
||||
)}
|
||||
>
|
||||
<TagList selectedTags={traceTags} isLoading={false} viewOnly />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const columns = rawColumns.filter(
|
||||
@@ -358,6 +399,7 @@ export default function ScoresTable({
|
||||
traceName: score.traceName ?? undefined,
|
||||
userId: score.traceUserId ?? undefined,
|
||||
jobConfigurationId: score.jobConfigurationId ?? undefined,
|
||||
traceTags: score.traceTags ?? undefined,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -401,7 +443,7 @@ export default function ScoresTable({
|
||||
}
|
||||
}
|
||||
pagination={{
|
||||
pageCount: Math.ceil(totalCount / paginationState.pageSize),
|
||||
totalCount,
|
||||
onChange: setPaginationState,
|
||||
state: paginationState,
|
||||
}}
|
||||
|
||||
@@ -23,20 +23,26 @@ import { NumberParam, useQueryParams, withDefault } from "use-query-params";
|
||||
import { BatchExportTableButton } from "@/src/components/BatchExportTableButton";
|
||||
import { useTableDateRange } from "@/src/hooks/useTableDateRange";
|
||||
import { useDebounce } from "@/src/hooks/useDebounce";
|
||||
import { joinTableCoreAndMetrics } from "@/src/components/table/utils/joinTableCoreAndMetrics";
|
||||
import { Skeleton } from "@/src/components/ui/skeleton";
|
||||
import TagList from "@/src/features/tag/components/TagList";
|
||||
import { useRowHeightLocalStorage } from "@/src/components/table/data-table-row-height-switch";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
|
||||
export type SessionTableRow = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
userIds: string[];
|
||||
countTraces: number;
|
||||
bookmarked: boolean;
|
||||
sessionDuration: number | null;
|
||||
inputCost: Decimal;
|
||||
outputCost: Decimal;
|
||||
totalCost: Decimal;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
userIds: string[] | undefined;
|
||||
countTraces: number | undefined;
|
||||
sessionDuration: number | null | undefined;
|
||||
inputCost: Decimal | undefined;
|
||||
outputCost: Decimal | undefined;
|
||||
totalCost: Decimal | undefined;
|
||||
inputTokens: number | undefined;
|
||||
outputTokens: number | undefined;
|
||||
totalTokens: number | undefined;
|
||||
traceTags: string[] | undefined;
|
||||
};
|
||||
|
||||
export type SessionTableProps = {
|
||||
@@ -52,11 +58,12 @@ export default function SessionsTable({
|
||||
}: SessionTableProps) {
|
||||
const { setDetailPageList } = useDetailPageLists();
|
||||
const { selectedOption, dateRange, setDateRangeAndOption } =
|
||||
useTableDateRange();
|
||||
useTableDateRange(projectId);
|
||||
|
||||
const [userFilterState, setUserFilterState] = useQueryFilterState(
|
||||
[],
|
||||
"sessions",
|
||||
projectId,
|
||||
);
|
||||
|
||||
const userIdFilter: FilterState = userId
|
||||
@@ -88,22 +95,56 @@ export default function SessionsTable({
|
||||
pageSize: withDefault(NumberParam, 50),
|
||||
});
|
||||
|
||||
const [rowHeight, setRowHeight] = useRowHeightLocalStorage("sessions", "s");
|
||||
|
||||
const [orderByState, setOrderByState] = useOrderByState({
|
||||
column: "createdAt",
|
||||
order: "DESC",
|
||||
});
|
||||
|
||||
const sessions = api.sessions.all.useQuery({
|
||||
page: paginationState.pageIndex,
|
||||
limit: paginationState.pageSize,
|
||||
const payloadCount = {
|
||||
projectId,
|
||||
filter: filterState,
|
||||
orderBy: null,
|
||||
page: 0,
|
||||
limit: 1,
|
||||
};
|
||||
|
||||
const payloadGetAll = {
|
||||
...payloadCount,
|
||||
orderBy: orderByState,
|
||||
});
|
||||
page: paginationState.pageIndex,
|
||||
limit: paginationState.pageSize,
|
||||
};
|
||||
|
||||
const sessions = api.sessions.all.useQuery(payloadGetAll);
|
||||
const sessionCountQuery = api.sessions.countAll.useQuery(payloadCount);
|
||||
|
||||
const sessionMetrics = api.sessions.metrics.useQuery(
|
||||
{
|
||||
projectId,
|
||||
sessionIds: sessions.data?.sessions.map((s) => s.id) ?? [],
|
||||
},
|
||||
{
|
||||
enabled: sessions.data !== undefined,
|
||||
},
|
||||
);
|
||||
|
||||
type SessionCoreOutput = RouterOutput["sessions"]["all"]["sessions"][number];
|
||||
type SessionMetricOutput = RouterOutput["sessions"]["metrics"][number];
|
||||
|
||||
const sessionRowData = joinTableCoreAndMetrics<
|
||||
SessionCoreOutput,
|
||||
SessionMetricOutput
|
||||
>(sessions.data?.sessions, sessionMetrics.data);
|
||||
|
||||
const filterOptions = api.sessions.filterOptions.useQuery(
|
||||
{
|
||||
projectId,
|
||||
timestampFilter:
|
||||
dateRangeFilter[0]?.type === "datetime"
|
||||
? dateRangeFilter[0]
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
trpc: {
|
||||
@@ -114,36 +155,17 @@ export default function SessionsTable({
|
||||
},
|
||||
);
|
||||
|
||||
const totalCount = sessions.data?.slice(1)[0]?.totalCount ?? 0;
|
||||
const totalCount = sessionCountQuery.data?.totalCount ?? null;
|
||||
useEffect(() => {
|
||||
if (sessions.isSuccess) {
|
||||
setDetailPageList(
|
||||
"sessions",
|
||||
sessions.data.map((t) => t.id),
|
||||
sessions.data.sessions.map((t) => t.id),
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessions.isSuccess, sessions.data]);
|
||||
|
||||
const convertToTableRow = (
|
||||
session: RouterOutput["sessions"]["all"][0],
|
||||
): SessionTableRow => {
|
||||
return {
|
||||
id: session.id,
|
||||
createdAt: session.createdAt.toLocaleString(),
|
||||
userIds: session.userIds,
|
||||
countTraces: session.countTraces,
|
||||
bookmarked: session.bookmarked,
|
||||
sessionDuration: session.sessionDuration,
|
||||
inputCost: session.inputCost,
|
||||
outputCost: session.outputCost,
|
||||
totalCost: session.totalCost,
|
||||
inputTokens: session.promptTokens,
|
||||
outputTokens: session.completionTokens,
|
||||
totalTokens: session.totalTokens,
|
||||
};
|
||||
};
|
||||
|
||||
const columns: LangfuseColumnDef<SessionTableRow>[] = [
|
||||
{
|
||||
accessorKey: "bookmarked",
|
||||
@@ -151,8 +173,9 @@ export default function SessionsTable({
|
||||
header: undefined,
|
||||
size: 50,
|
||||
cell: ({ row }) => {
|
||||
const bookmarked = row.getValue("bookmarked");
|
||||
const sessionId = row.getValue("id");
|
||||
const bookmarked: SessionTableRow["bookmarked"] =
|
||||
row.getValue("bookmarked");
|
||||
const sessionId: SessionTableRow["id"] = row.getValue("id");
|
||||
|
||||
return typeof sessionId === "string" &&
|
||||
typeof bookmarked === "boolean" ? (
|
||||
@@ -160,7 +183,7 @@ export default function SessionsTable({
|
||||
sessionId={sessionId}
|
||||
projectId={projectId}
|
||||
value={bookmarked}
|
||||
size="xs"
|
||||
size="icon-xs"
|
||||
/>
|
||||
) : undefined;
|
||||
},
|
||||
@@ -172,7 +195,7 @@ export default function SessionsTable({
|
||||
header: "ID",
|
||||
size: 200,
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue("id");
|
||||
const value: SessionTableRow["id"] = row.getValue("id");
|
||||
return value && typeof value === "string" ? (
|
||||
<TableLink
|
||||
path={`/project/${projectId}/sessions/${encodeURIComponent(value)}`}
|
||||
@@ -197,7 +220,11 @@ export default function SessionsTable({
|
||||
size: 130,
|
||||
enableHiding: true,
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue("sessionDuration");
|
||||
const value: SessionTableRow["sessionDuration"] =
|
||||
row.getValue("sessionDuration");
|
||||
if (!sessionMetrics.isSuccess) {
|
||||
return <Skeleton className="h-3 w-1/2" />;
|
||||
}
|
||||
return value && typeof value === "number"
|
||||
? formatIntervalSeconds(value)
|
||||
: undefined;
|
||||
@@ -212,7 +239,10 @@ export default function SessionsTable({
|
||||
size: 200,
|
||||
enableHiding: true,
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue("userIds");
|
||||
const value: SessionTableRow["userIds"] = row.getValue("userIds");
|
||||
if (!sessionMetrics.isSuccess) {
|
||||
return <Skeleton className="h-3 w-1/2" />;
|
||||
}
|
||||
return value && Array.isArray(value) ? (
|
||||
<div className="flex gap-1">
|
||||
{(value as string[]).map((user) => (
|
||||
@@ -236,6 +266,14 @@ export default function SessionsTable({
|
||||
},
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const value: SessionTableRow["countTraces"] =
|
||||
row.getValue("countTraces");
|
||||
if (!sessionMetrics.isSuccess) {
|
||||
return <Skeleton className="h-3 w-1/2" />;
|
||||
}
|
||||
return value ? <span>{numberFormatter(value, 0)}</span> : undefined;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "inputCost",
|
||||
@@ -246,7 +284,10 @@ export default function SessionsTable({
|
||||
defaultHidden: true,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const value: Decimal | null | undefined = row.getValue("inputCost");
|
||||
const value: SessionTableRow["inputCost"] = row.getValue("inputCost");
|
||||
if (!sessionMetrics.isSuccess) {
|
||||
return <Skeleton className="h-3 w-1/2" />;
|
||||
}
|
||||
return value ? (
|
||||
<span>{usdFormatter(value.toNumber())}</span>
|
||||
) : undefined;
|
||||
@@ -261,8 +302,10 @@ export default function SessionsTable({
|
||||
enableSorting: true,
|
||||
defaultHidden: true,
|
||||
cell: ({ row }) => {
|
||||
const value: Decimal | null | undefined = row.getValue("outputCost");
|
||||
|
||||
const value: SessionTableRow["outputCost"] = row.getValue("outputCost");
|
||||
if (!sessionMetrics.isSuccess) {
|
||||
return <Skeleton className="h-3 w-1/2" />;
|
||||
}
|
||||
return value ? (
|
||||
<span>{usdFormatter(value.toNumber())}</span>
|
||||
) : undefined;
|
||||
@@ -276,8 +319,10 @@ export default function SessionsTable({
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const value: Decimal | null | undefined = row.getValue("totalCost");
|
||||
|
||||
const value: SessionTableRow["totalCost"] = row.getValue("totalCost");
|
||||
if (!sessionMetrics.isSuccess) {
|
||||
return <Skeleton className="h-3 w-1/2" />;
|
||||
}
|
||||
return value ? (
|
||||
<span>{usdFormatter(value.toNumber())}</span>
|
||||
) : undefined;
|
||||
@@ -292,8 +337,11 @@ export default function SessionsTable({
|
||||
defaultHidden: true,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const value: number | undefined = row.getValue("inputTokens");
|
||||
|
||||
const value: SessionTableRow["inputTokens"] =
|
||||
row.getValue("inputTokens");
|
||||
if (!sessionMetrics.isSuccess) {
|
||||
return <Skeleton className="h-3 w-1/2" />;
|
||||
}
|
||||
return value ? (
|
||||
<span>{numberFormatter(Number(value), 0)}</span>
|
||||
) : undefined;
|
||||
@@ -308,8 +356,11 @@ export default function SessionsTable({
|
||||
defaultHidden: true,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue("outputTokens");
|
||||
|
||||
const value: SessionTableRow["outputTokens"] =
|
||||
row.getValue("outputTokens");
|
||||
if (!sessionMetrics.isSuccess) {
|
||||
return <Skeleton className="h-3 w-1/2" />;
|
||||
}
|
||||
return value ? (
|
||||
<span>{numberFormatter(Number(value), 0)}</span>
|
||||
) : undefined;
|
||||
@@ -324,7 +375,11 @@ export default function SessionsTable({
|
||||
defaultHidden: true,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue("totalTokens");
|
||||
const value: SessionTableRow["totalTokens"] =
|
||||
row.getValue("totalTokens");
|
||||
if (!sessionMetrics.isSuccess) {
|
||||
return <Skeleton className="h-3 w-1/2" />;
|
||||
}
|
||||
return value ? (
|
||||
<span>{numberFormatter(Number(value), 0)}</span>
|
||||
) : undefined;
|
||||
@@ -338,9 +393,15 @@ export default function SessionsTable({
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const promptTokens = row.getValue("inputTokens");
|
||||
const completionTokens = row.getValue("outputTokens");
|
||||
const totalTokens = row.getValue("totalTokens");
|
||||
const promptTokens: SessionTableRow["inputTokens"] =
|
||||
row.getValue("inputTokens");
|
||||
const completionTokens: SessionTableRow["outputTokens"] =
|
||||
row.getValue("outputTokens");
|
||||
const totalTokens: SessionTableRow["totalTokens"] =
|
||||
row.getValue("totalTokens");
|
||||
if (!sessionMetrics.isSuccess) {
|
||||
return <Skeleton className="h-3 w-1/2" />;
|
||||
}
|
||||
return (
|
||||
<TokenUsageBadge
|
||||
promptTokens={Number(promptTokens)}
|
||||
@@ -351,6 +412,32 @@ export default function SessionsTable({
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "traceTags",
|
||||
id: "traceTags",
|
||||
header: "Trace Tags",
|
||||
size: 250,
|
||||
enableHiding: true,
|
||||
defaultHidden: true,
|
||||
cell: ({ row }) => {
|
||||
const value: SessionTableRow["traceTags"] = row.getValue("traceTags");
|
||||
if (!sessionMetrics.isSuccess) {
|
||||
return <Skeleton className="h-3 w-1/2" />;
|
||||
}
|
||||
return (
|
||||
value && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-x-2 gap-y-1",
|
||||
rowHeight !== "s" && "flex-wrap",
|
||||
)}
|
||||
>
|
||||
<TagList selectedTags={value} isLoading={false} viewOnly />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const transformFilterOptions = () => {
|
||||
@@ -381,6 +468,8 @@ export default function SessionsTable({
|
||||
selectedOption={selectedOption}
|
||||
setDateRangeAndOption={setDateRangeAndOption}
|
||||
columnsWithCustomSelect={["userIds"]}
|
||||
rowHeight={rowHeight}
|
||||
setRowHeight={setRowHeight}
|
||||
/>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
@@ -396,11 +485,25 @@ export default function SessionsTable({
|
||||
: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: sessions.data.map((t) => convertToTableRow(t)),
|
||||
data: sessionRowData.rows?.map((session) => ({
|
||||
id: session.id,
|
||||
createdAt: session.createdAt.toLocaleString(),
|
||||
userIds: session.userIds,
|
||||
countTraces: session.countTraces,
|
||||
bookmarked: session.bookmarked,
|
||||
sessionDuration: session.sessionDuration,
|
||||
inputCost: session.inputCost,
|
||||
outputCost: session.outputCost,
|
||||
totalCost: session.totalCost,
|
||||
inputTokens: session.promptTokens,
|
||||
outputTokens: session.completionTokens,
|
||||
totalTokens: session.totalTokens,
|
||||
traceTags: session.traceTags,
|
||||
})),
|
||||
}
|
||||
}
|
||||
pagination={{
|
||||
pageCount: Math.ceil(totalCount / paginationState.pageSize),
|
||||
totalCount,
|
||||
onChange: setPaginationState,
|
||||
state: paginationState,
|
||||
}}
|
||||
@@ -413,6 +516,7 @@ export default function SessionsTable({
|
||||
"A session is a collection of related traces, such as a conversation or thread. To begin, add a sessionId to the trace.",
|
||||
href: "https://langfuse.com/docs/tracing-features/sessions",
|
||||
}}
|
||||
rowHeight={rowHeight}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ import useColumnVisibility from "@/src/features/column-visibility/hooks/useColum
|
||||
import { useQueryFilterState } from "@/src/features/filters/hooks/useFilterState";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { formatIntervalSeconds } from "@/src/utils/dates";
|
||||
import { type RouterInput } from "@/src/utils/types";
|
||||
import { type RouterOutput, type RouterInput } from "@/src/utils/types";
|
||||
import { type RowSelectionState } from "@tanstack/react-table";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
@@ -44,6 +44,8 @@ import { useTableDateRange } from "@/src/hooks/useTableDateRange";
|
||||
import { useDebounce } from "@/src/hooks/useDebounce";
|
||||
import { type ScoreAggregate } from "@/src/features/scores/lib/types";
|
||||
import { useIndividualScoreColumns } from "@/src/features/scores/hooks/useIndividualScoreColumns";
|
||||
import { joinTableCoreAndMetrics } from "@/src/components/table/utils/joinTableCoreAndMetrics";
|
||||
import { Skeleton } from "@/src/components/ui/skeleton";
|
||||
|
||||
export type TracesTableRow = {
|
||||
bookmarked: boolean;
|
||||
@@ -51,10 +53,10 @@ export type TracesTableRow = {
|
||||
timestamp: string;
|
||||
name: string;
|
||||
userId: string;
|
||||
level: ObservationLevel;
|
||||
observationCount: number;
|
||||
level?: ObservationLevel;
|
||||
observationCount?: number;
|
||||
// scores holds grouped column with individual scores
|
||||
scores: ScoreAggregate;
|
||||
scores?: ScoreAggregate;
|
||||
latency?: number;
|
||||
release?: string;
|
||||
version?: string;
|
||||
@@ -65,9 +67,9 @@ export type TracesTableRow = {
|
||||
metadata?: unknown;
|
||||
tags: string[];
|
||||
usage: {
|
||||
promptTokens: bigint;
|
||||
completionTokens: bigint;
|
||||
totalTokens: bigint;
|
||||
promptTokens?: bigint;
|
||||
completionTokens?: bigint;
|
||||
totalTokens?: bigint;
|
||||
};
|
||||
inputCost?: Decimal;
|
||||
outputCost?: Decimal;
|
||||
@@ -96,10 +98,11 @@ export default function TracesTable({
|
||||
);
|
||||
|
||||
const { selectedOption, dateRange, setDateRangeAndOption } =
|
||||
useTableDateRange();
|
||||
useTableDateRange(projectId);
|
||||
const [userFilterState, setUserFilterState] = useQueryFilterState(
|
||||
[],
|
||||
"traces",
|
||||
projectId,
|
||||
);
|
||||
const [orderByState, setOrderByState] = useOrderByState({
|
||||
column: "timestamp",
|
||||
@@ -133,17 +136,44 @@ export default function TracesTable({
|
||||
pageSize: withDefault(NumberParam, 50),
|
||||
});
|
||||
|
||||
const tracesAllQueryFilter = {
|
||||
page: paginationState.pageIndex,
|
||||
limit: paginationState.pageSize,
|
||||
const tracesAllCountFilter = {
|
||||
projectId,
|
||||
filter: filterState,
|
||||
searchQuery,
|
||||
// "empty" values as they do not matter for total count
|
||||
page: 0,
|
||||
limit: 0,
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
const tracesAllQueryFilter = {
|
||||
...tracesAllCountFilter,
|
||||
page: paginationState.pageIndex,
|
||||
limit: paginationState.pageSize,
|
||||
orderBy: orderByState,
|
||||
};
|
||||
const traces = api.traces.all.useQuery(tracesAllQueryFilter);
|
||||
const totalCountQuery = api.traces.countAll.useQuery(tracesAllCountFilter);
|
||||
const traceMetrics = api.traces.metrics.useQuery(
|
||||
{
|
||||
projectId,
|
||||
traceIds: traces.data?.traces.map((t) => t.id) ?? [],
|
||||
},
|
||||
{
|
||||
enabled: traces.data !== undefined,
|
||||
},
|
||||
);
|
||||
|
||||
type TracesCoreOutput = RouterOutput["traces"]["all"]["traces"][number];
|
||||
type TraceMetricOutput = RouterOutput["traces"]["metrics"][number];
|
||||
|
||||
const traceRowData = joinTableCoreAndMetrics<
|
||||
TracesCoreOutput,
|
||||
TraceMetricOutput
|
||||
>(traces.data?.traces, traceMetrics.data);
|
||||
|
||||
const totalCount = totalCountQuery.data?.totalCount ?? null;
|
||||
|
||||
const totalCount = traces.data?.totalCount ?? 0;
|
||||
useEffect(() => {
|
||||
if (traces.isSuccess) {
|
||||
setDetailPageList(
|
||||
@@ -188,6 +218,7 @@ export default function TracesTable({
|
||||
projectId,
|
||||
scoreColumnKey: "scores",
|
||||
selectedFilterOption: selectedOption,
|
||||
cellsLoading: !traceMetrics.data,
|
||||
});
|
||||
|
||||
const columns: LangfuseColumnDef<TracesTableRow>[] = [
|
||||
@@ -239,7 +270,7 @@ export default function TracesTable({
|
||||
traceId={traceId}
|
||||
projectId={projectId}
|
||||
value={bookmarked}
|
||||
size="xs"
|
||||
size="icon-xs"
|
||||
/>
|
||||
) : undefined;
|
||||
},
|
||||
@@ -328,6 +359,7 @@ export default function TracesTable({
|
||||
// add seconds to the end of the latency
|
||||
cell: ({ row }) => {
|
||||
const value: TracesTableRow["latency"] = row.getValue("latency");
|
||||
if (!traceMetrics.data) return <Skeleton className="h-3 w-1/2" />;
|
||||
return value !== undefined ? formatIntervalSeconds(value) : undefined;
|
||||
},
|
||||
enableHiding: true,
|
||||
@@ -340,6 +372,7 @@ export default function TracesTable({
|
||||
size: 110,
|
||||
cell: ({ row }) => {
|
||||
const value: TracesTableRow["usage"] = row.getValue("usage");
|
||||
if (!traceMetrics.data) return <Skeleton className="h-3 w-1/2" />;
|
||||
return <span>{numberFormatter(value.promptTokens, 0)}</span>;
|
||||
},
|
||||
enableHiding: true,
|
||||
@@ -353,6 +386,7 @@ export default function TracesTable({
|
||||
size: 110,
|
||||
cell: ({ row }) => {
|
||||
const value: TracesTableRow["usage"] = row.getValue("usage");
|
||||
if (!traceMetrics.data) return <Skeleton className="h-3 w-1/2" />;
|
||||
return <span>{numberFormatter(value.completionTokens, 0)}</span>;
|
||||
},
|
||||
enableHiding: true,
|
||||
@@ -366,6 +400,7 @@ export default function TracesTable({
|
||||
size: 110,
|
||||
cell: ({ row }) => {
|
||||
const value: TracesTableRow["usage"] = row.getValue("usage");
|
||||
if (!traceMetrics.data) return <Skeleton className="h-3 w-1/2" />;
|
||||
return <span>{numberFormatter(value.totalTokens, 0)}</span>;
|
||||
},
|
||||
enableHiding: true,
|
||||
@@ -379,11 +414,12 @@ export default function TracesTable({
|
||||
size: 220,
|
||||
cell: ({ row }) => {
|
||||
const value: TracesTableRow["usage"] = row.getValue("usage");
|
||||
if (!traceMetrics.data) return <Skeleton className="h-3 w-1/2" />;
|
||||
return (
|
||||
<TokenUsageBadge
|
||||
promptTokens={value.promptTokens}
|
||||
completionTokens={value.completionTokens}
|
||||
totalTokens={value.totalTokens}
|
||||
promptTokens={value.promptTokens ?? 0}
|
||||
completionTokens={value.completionTokens ?? 0}
|
||||
totalTokens={value.totalTokens ?? 0}
|
||||
inline
|
||||
/>
|
||||
);
|
||||
@@ -391,7 +427,10 @@ export default function TracesTable({
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
},
|
||||
{ ...getScoreGroupColumnProps(isColumnLoading), columns: scoreColumns },
|
||||
{
|
||||
...getScoreGroupColumnProps(isColumnLoading || !traceMetrics.data),
|
||||
columns: scoreColumns,
|
||||
},
|
||||
{
|
||||
accessorKey: "inputCost",
|
||||
id: "inputCost",
|
||||
@@ -399,6 +438,7 @@ export default function TracesTable({
|
||||
size: 100,
|
||||
cell: ({ row }) => {
|
||||
const cost: TracesTableRow["inputCost"] = row.getValue("inputCost");
|
||||
if (!traceMetrics.data) return <Skeleton className="h-3 w-1/2" />;
|
||||
return (
|
||||
<div>
|
||||
{cost ? (
|
||||
@@ -420,6 +460,7 @@ export default function TracesTable({
|
||||
size: 100,
|
||||
cell: ({ row }) => {
|
||||
const cost: TracesTableRow["outputCost"] = row.getValue("outputCost");
|
||||
if (!traceMetrics.data) return <Skeleton className="h-3 w-1/2" />;
|
||||
return (
|
||||
<div>
|
||||
{cost ? (
|
||||
@@ -441,6 +482,7 @@ export default function TracesTable({
|
||||
size: 100,
|
||||
cell: ({ row }) => {
|
||||
const cost: TracesTableRow["totalCost"] = row.getValue("totalCost");
|
||||
if (!traceMetrics.data) return <Skeleton className="h-3 w-1/2" />;
|
||||
return (
|
||||
<div>
|
||||
{cost ? (
|
||||
@@ -521,7 +563,8 @@ export default function TracesTable({
|
||||
size: 75,
|
||||
cell: ({ row }) => {
|
||||
const value: TracesTableRow["level"] = row.getValue("level");
|
||||
return (
|
||||
if (!traceMetrics.data) return <Skeleton className="h-3 w-1/2" />;
|
||||
return value ? (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-sm p-0.5 text-xs",
|
||||
@@ -531,6 +574,8 @@ export default function TracesTable({
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
) : (
|
||||
<span>-</span>
|
||||
);
|
||||
},
|
||||
enableHiding: true,
|
||||
@@ -547,6 +592,12 @@ export default function TracesTable({
|
||||
},
|
||||
enableHiding: true,
|
||||
defaultHidden: true,
|
||||
cell: ({ row }) => {
|
||||
const value: TracesTableRow["observationCount"] =
|
||||
row.getValue("observationCount");
|
||||
if (!traceMetrics.data) return <Skeleton className="h-3 w-1/2" />;
|
||||
return <span>{value}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "version",
|
||||
@@ -629,7 +680,7 @@ export default function TracesTable({
|
||||
|
||||
const rows = useMemo(() => {
|
||||
return traces.isSuccess
|
||||
? traces.data.traces.map((trace) => {
|
||||
? traceRowData?.rows?.map((trace) => {
|
||||
return {
|
||||
bookmarked: trace.bookmarked,
|
||||
id: trace.id,
|
||||
@@ -648,17 +699,19 @@ export default function TracesTable({
|
||||
completionTokens: trace.completionTokens,
|
||||
totalTokens: trace.totalTokens,
|
||||
},
|
||||
scores: verifyAndPrefixScoreDataAgainstKeys(
|
||||
scoreKeysAndProps,
|
||||
trace.scores,
|
||||
),
|
||||
scores: trace.scores
|
||||
? verifyAndPrefixScoreDataAgainstKeys(
|
||||
scoreKeysAndProps,
|
||||
trace.scores,
|
||||
)
|
||||
: undefined,
|
||||
inputCost: trace.calculatedInputCost ?? undefined,
|
||||
outputCost: trace.calculatedOutputCost ?? undefined,
|
||||
totalCost: trace.calculatedTotalCost ?? undefined,
|
||||
};
|
||||
})
|
||||
}) ?? []
|
||||
: [];
|
||||
}, [traces, scoreKeysAndProps]);
|
||||
}, [traces, traceRowData, scoreKeysAndProps]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -672,6 +725,7 @@ export default function TracesTable({
|
||||
}}
|
||||
filterState={userFilterState}
|
||||
setFilterState={useDebounce(setUserFilterState)}
|
||||
columnsWithCustomSelect={["name", "tags"]}
|
||||
actionButtons={
|
||||
Object.keys(selectedRows).filter((traceId) =>
|
||||
traces.data?.traces.map((t) => t.id).includes(traceId),
|
||||
@@ -713,7 +767,7 @@ export default function TracesTable({
|
||||
}
|
||||
}
|
||||
pagination={{
|
||||
pageCount: Math.ceil(Number(totalCount) / paginationState.pageSize),
|
||||
totalCount,
|
||||
onChange: setPaginationState,
|
||||
state: paginationState,
|
||||
}}
|
||||
|
||||
@@ -186,7 +186,6 @@ export const ObservationPreview = (props: {
|
||||
source="generation"
|
||||
generation={observationWithInputAndOutput.data}
|
||||
analyticsEventName="trace_detail:test_in_playground_button_click"
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
{observationWithInputAndOutput.data ? (
|
||||
|
||||
@@ -84,7 +84,9 @@ const ObservationTreeTraceNode = (props: {
|
||||
onClick={() => props.setCurrentObservationId(undefined)}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<span className={cn("rounded-sm bg-input p-1 text-xs")}>TRACE</span>
|
||||
<span className={cn("rounded-sm bg-input px-1 py-0.5 text-xs")}>
|
||||
TRACE
|
||||
</span>
|
||||
<span className="break-all text-sm">{props.trace.name}</span>
|
||||
{props.comments ? (
|
||||
<CommentCountIcon count={props.comments.get(props.trace.id)} />
|
||||
@@ -191,7 +193,7 @@ const ObservationTreeNode = (props: {
|
||||
variant="default"
|
||||
pressed={collapsed}
|
||||
size="xs"
|
||||
className="w-7"
|
||||
className="-m-1 h-6 w-6"
|
||||
title={
|
||||
collapsed ? "Expand children" : "Collapse children"
|
||||
}
|
||||
@@ -284,7 +286,7 @@ const ColorCodedObservationType = (props: {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"self-start rounded-sm p-1 text-xs",
|
||||
"self-start rounded-sm px-1 py-0.5 text-xs",
|
||||
treeItemColors.get(props.observationType),
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -30,6 +30,7 @@ import { TracePreview } from "@/src/components/trace/TracePreview";
|
||||
import { ObservationPreview } from "@/src/components/trace/ObservationPreview";
|
||||
import useSessionStorage from "@/src/components/useSessionStorage";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
// Fixed widths for styling for v1
|
||||
const SCALE_WIDTH = 800;
|
||||
@@ -45,14 +46,12 @@ const PREDEFINED_STEP_SIZES = [
|
||||
|
||||
const getNestedObservationKeys = (
|
||||
observations: NestedObservation[],
|
||||
): { keys: string[]; ids: string[] } => {
|
||||
): string[] => {
|
||||
const keys: string[] = [];
|
||||
const ids: string[] = [];
|
||||
|
||||
const collectKeys = (obs: NestedObservation[]) => {
|
||||
obs.forEach((observation) => {
|
||||
keys.push(`observation-${observation.id}`);
|
||||
ids.push(observation.id);
|
||||
if (observation.children) {
|
||||
collectKeys(observation.children);
|
||||
}
|
||||
@@ -60,7 +59,7 @@ const getNestedObservationKeys = (
|
||||
};
|
||||
|
||||
collectKeys(observations);
|
||||
return { keys, ids };
|
||||
return keys;
|
||||
};
|
||||
|
||||
const calculateStepSize = (latency: number, scaleWidth: number) => {
|
||||
@@ -97,7 +96,7 @@ function TreeItemInner({
|
||||
const customLabelWidth = cardWidth - SCALE_WIDTH - CARD_PADDING;
|
||||
|
||||
return (
|
||||
<div className="group my-1 grid w-full min-w-fit grid-cols-[1fr,auto] items-center">
|
||||
<div className="group my-0.5 grid w-full min-w-fit grid-cols-[1fr,auto] items-center">
|
||||
<div
|
||||
className="flex flex-row items-center gap-2"
|
||||
style={{
|
||||
@@ -106,7 +105,10 @@ function TreeItemInner({
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={cn("rounded-sm p-1 text-xs", treeItemColors.get(type))}
|
||||
className={cn(
|
||||
"rounded-sm px-1 py-0.5 text-xs",
|
||||
treeItemColors.get(type),
|
||||
)}
|
||||
>
|
||||
{type}
|
||||
</span>
|
||||
@@ -305,15 +307,16 @@ export function TraceTimelineView({
|
||||
() => nestObservations(observations),
|
||||
[observations],
|
||||
);
|
||||
const { keys: nestedObservationKeys, ids: nestedObservationIds } = useMemo(
|
||||
const nestedObservationKeys = useMemo(
|
||||
() => getNestedObservationKeys(nestedObservations),
|
||||
[nestedObservations],
|
||||
);
|
||||
|
||||
const observationCommentCounts = api.comments.getCountsByObjectIds.useQuery(
|
||||
const session = useSession();
|
||||
|
||||
const observationCommentCounts = api.comments.getCountByObjectType.useQuery(
|
||||
{
|
||||
projectId: trace.projectId,
|
||||
objectIds: nestedObservationIds,
|
||||
objectType: "OBSERVATION",
|
||||
},
|
||||
{
|
||||
@@ -323,13 +326,14 @@ export function TraceTimelineView({
|
||||
},
|
||||
},
|
||||
refetchOnMount: false, // prevents refetching loops
|
||||
enabled: session.status === "authenticated",
|
||||
},
|
||||
);
|
||||
|
||||
const traceCommentCounts = api.comments.getCountsByObjectIds.useQuery(
|
||||
const traceCommentCounts = api.comments.getCountByObjectId.useQuery(
|
||||
{
|
||||
projectId: trace.projectId,
|
||||
objectIds: [trace.id],
|
||||
objectId: trace.id,
|
||||
objectType: "TRACE",
|
||||
},
|
||||
{
|
||||
@@ -339,6 +343,7 @@ export function TraceTimelineView({
|
||||
},
|
||||
},
|
||||
refetchOnMount: false, // prevents refetching loops
|
||||
enabled: session.status === "authenticated",
|
||||
},
|
||||
);
|
||||
|
||||
@@ -360,7 +365,7 @@ export function TraceTimelineView({
|
||||
minWidth: `${MIN_LABEL_WIDTH}px`,
|
||||
}}
|
||||
>
|
||||
<h3 className="text-2xl font-semibold tracking-tight">
|
||||
<h3 className="text-xl font-semibold tracking-tight">
|
||||
Trace Timeline
|
||||
</h3>
|
||||
<div className="flex h-full items-center">
|
||||
|
||||
@@ -27,13 +27,14 @@ import {
|
||||
} from "lucide-react";
|
||||
import { usdFormatter } from "@/src/utils/numbers";
|
||||
import Decimal from "decimal.js";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { DeleteButton } from "@/src/components/deleteButton";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/src/components/ui/tabs";
|
||||
import { TraceTimelineView } from "@/src/components/trace/TraceTimelineView";
|
||||
import { type APIScore } from "@langfuse/shared";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { FullScreenPage } from "@/src/components/layouts/full-screen-page";
|
||||
|
||||
export function Trace(props: {
|
||||
observations: Array<ObservationReturnType>;
|
||||
@@ -57,14 +58,11 @@ export function Trace(props: {
|
||||
[],
|
||||
);
|
||||
|
||||
const observationObjectIds: string[] = useMemo(() => {
|
||||
return props.observations.map(({ id }) => id);
|
||||
}, [props.observations]);
|
||||
const session = useSession();
|
||||
|
||||
const observationCommentCounts = api.comments.getCountsByObjectIds.useQuery(
|
||||
const observationCommentCounts = api.comments.getCountByObjectType.useQuery(
|
||||
{
|
||||
projectId: props.trace.projectId,
|
||||
objectIds: observationObjectIds,
|
||||
objectType: "OBSERVATION",
|
||||
},
|
||||
{
|
||||
@@ -74,13 +72,14 @@ export function Trace(props: {
|
||||
},
|
||||
},
|
||||
refetchOnMount: false, // prevents refetching loops
|
||||
enabled: session.status === "authenticated",
|
||||
},
|
||||
);
|
||||
|
||||
const traceCommentCounts = api.comments.getCountsByObjectIds.useQuery(
|
||||
const traceCommentCounts = api.comments.getCountByObjectId.useQuery(
|
||||
{
|
||||
projectId: props.trace.projectId,
|
||||
objectIds: [props.trace.id],
|
||||
objectId: props.trace.id,
|
||||
objectType: "TRACE",
|
||||
},
|
||||
{
|
||||
@@ -90,6 +89,7 @@ export function Trace(props: {
|
||||
},
|
||||
},
|
||||
refetchOnMount: false, // prevents refetching loops
|
||||
enabled: session.status === "authenticated",
|
||||
},
|
||||
);
|
||||
|
||||
@@ -173,7 +173,7 @@ export function Trace(props: {
|
||||
});
|
||||
setScoresOnObservationTree(e);
|
||||
}}
|
||||
size="sm"
|
||||
size="xs"
|
||||
title="Show scores"
|
||||
>
|
||||
<Award className="h-4 w-4" />
|
||||
@@ -186,7 +186,7 @@ export function Trace(props: {
|
||||
});
|
||||
setMetricsOnObservationTree(e);
|
||||
}}
|
||||
size="sm"
|
||||
size="xs"
|
||||
title="Show metrics"
|
||||
>
|
||||
{metricsOnObservationTree ? (
|
||||
@@ -223,7 +223,7 @@ export function TracePage({ traceId }: { traceId: string }) {
|
||||
const router = useRouter();
|
||||
const utils = api.useUtils();
|
||||
const session = useSession();
|
||||
const trace = api.traces.byId.useQuery(
|
||||
const trace = api.traces.byIdWithObservationsAndScores.useQuery(
|
||||
{ traceId, projectId: router.query.projectId as string },
|
||||
{
|
||||
retry(failureCount, error) {
|
||||
@@ -264,7 +264,7 @@ export function TracePage({ traceId }: { traceId: string }) {
|
||||
return <ErrorPage message="You do not have access to this trace." />;
|
||||
if (!trace.data) return <div>loading...</div>;
|
||||
return (
|
||||
<div className="flex flex-col overflow-hidden 2xl:container md:h-[calc(100vh-2rem)]">
|
||||
<FullScreenPage mobile={false} className="2xl:container">
|
||||
<Header
|
||||
title="Trace Detail"
|
||||
breadcrumb={[
|
||||
@@ -397,7 +397,7 @@ export function TracePage({ traceId }: { traceId: string }) {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FullScreenPage>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ export function JSONView(props: {
|
||||
title="Enable Markdown"
|
||||
variant="ghost"
|
||||
type="button"
|
||||
size="xs"
|
||||
size="icon-xs"
|
||||
onClick={() => {
|
||||
setIsMarkdownEnabled(true);
|
||||
capture("trace_detail:io_pretty_format_toggle_group", {
|
||||
@@ -71,10 +71,10 @@ export function JSONView(props: {
|
||||
<Button
|
||||
title="Copy to clipboard"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="hover:bg-border"
|
||||
className="-mr-2 hover:bg-border"
|
||||
>
|
||||
{isCopied ? (
|
||||
<Check className="h-3 w-3" />
|
||||
@@ -164,7 +164,12 @@ export function CodeView(props: {
|
||||
)}
|
||||
</Button>
|
||||
) : undefined}
|
||||
<Button variant="secondary" size="xs" onClick={handleCopy}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon-xs"
|
||||
onClick={handleCopy}
|
||||
className=""
|
||||
>
|
||||
{isCopied ? (
|
||||
<Check className="h-3 w-3" />
|
||||
) : (
|
||||
|
||||
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
"relative w-full rounded-lg border p-3 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -12,7 +12,7 @@ const Avatar = React.forwardRef<
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
"relative flex h-8 w-8 shrink-0 overflow-hidden rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -25,11 +25,12 @@ const buttonVariants = cva(
|
||||
"bg-destructive-foreground/90 text-destructive hover:bg-destructive-foreground/80",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
xs: "h-6 px-1 rounded-sm",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
default: "h-8 px-3 py-1",
|
||||
xs: "h-4 px-1 rounded-sm",
|
||||
sm: "h-6 rounded-md px-3",
|
||||
lg: "h-9 rounded-md px-8",
|
||||
icon: "h-8 w-8",
|
||||
"icon-xs": "h-6 w-6",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -23,7 +23,7 @@ const CardHeader = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
className={cn("flex flex-col space-y-1 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -60,7 +60,7 @@ const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
<div ref={ref} className={cn("p-4 pt-0", className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
@@ -70,7 +70,7 @@ const CardFooter = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
className={cn("flex items-center p-4 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -41,15 +41,12 @@ const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
className="flex items-center rounded-md border px-3"
|
||||
cmdk-input-wrapper=""
|
||||
>
|
||||
<div className="flex items-center rounded border px-3" cmdk-input-wrapper="">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-11 w-full rounded-md border-transparent bg-transparent py-3 text-sm outline-none placeholder:text-slate-500 focus:border-0 focus:border-none focus:border-transparent focus:ring-0 disabled:cursor-not-allowed disabled:opacity-50 dark:placeholder:text-slate-400 ",
|
||||
"flex h-8 w-full rounded border-transparent bg-transparent py-3 text-sm outline-none placeholder:text-slate-500 focus:border-0 focus:border-none focus:border-transparent focus:ring-0 disabled:cursor-not-allowed disabled:opacity-50 dark:placeholder:text-slate-400 ",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -88,7 +88,7 @@ const DialogTitle = React.forwardRef<
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
"text-xl font-semibold leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -18,7 +18,7 @@ const HoverCardContent = React.forwardRef<
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"z-50 w-64 rounded-md border bg-popover p-3 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -11,7 +11,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex h-8 w-full rounded-md border border-input bg-background px-2 py-1 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -14,7 +14,7 @@ const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 pr-10 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 pr-10 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -19,7 +19,7 @@ const PopoverContent = React.forwardRef<
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"z-50 w-72 rounded-md border bg-popover p-3 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import { GripVertical } from "lucide-react"
|
||||
import * as ResizablePrimitive from "react-resizable-panels"
|
||||
|
||||
import { cn } from "@/src/utils/tailwind"
|
||||
|
||||
const ResizablePanelGroup = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
||||
<ResizablePrimitive.PanelGroup
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
const ResizablePanel = ResizablePrimitive.Panel
|
||||
|
||||
const ResizableHandle = ({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||
withHandle?: boolean
|
||||
}) => (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
className={cn(
|
||||
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
|
||||
<GripVertical className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
)
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||
@@ -19,7 +19,7 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
"flex h-8 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -11,7 +11,7 @@ const Switch = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-muted-foreground",
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -19,7 +19,7 @@ const Switch = React.forwardRef<
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
|
||||
@@ -31,7 +31,7 @@ const TableBody = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
className={cn("text-xs [&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -14,7 +14,7 @@ const TabsList = React.forwardRef<
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
"inline-flex h-8 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -29,7 +29,7 @@ const TabsTrigger = React.forwardRef<
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
"inline-flex h-6 items-center justify-center whitespace-nowrap rounded-sm px-2 py-0.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -16,7 +16,7 @@ const toggleVariants = cva(
|
||||
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-3",
|
||||
default: "h-8 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.74.0";
|
||||
export const VERSION = "v2.76.0";
|
||||
|
||||
@@ -16,7 +16,6 @@ if (!process.env.VERCEL && process.env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION) {
|
||||
opentelemetry.context.setGlobalContextManager(contextManager);
|
||||
|
||||
const tracer = dd.init({
|
||||
profiling: false,
|
||||
runtimeMetrics: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -16,10 +16,7 @@ import { useQueryOrganization } from "@/src/features/organizations/hooks";
|
||||
import { Card } from "@/src/components/ui/card";
|
||||
import { numberFormatter, compactNumberFormatter } from "@/src/utils/numbers";
|
||||
import { useHasOrgEntitlement } from "@/src/features/entitlements/hooks";
|
||||
import {
|
||||
type Plan,
|
||||
planLabels,
|
||||
} from "@/src/features/entitlements/constants/plans";
|
||||
import { type Plan, planLabels } from "@langfuse/shared";
|
||||
import { stripeProducts } from "@/src/ee/features/billing/utils/stripeProducts";
|
||||
import { useRouter } from "next/router";
|
||||
import {
|
||||
@@ -52,7 +49,7 @@ export const BillingSettings = () => {
|
||||
</Alert>
|
||||
);
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div>
|
||||
<Header title="Usage & Billing" level="h3" />
|
||||
<OrganizationUsageChart />
|
||||
</div>
|
||||
@@ -81,12 +78,12 @@ const OrganizationUsageChart = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card className="p-4">
|
||||
<Card className="p-3">
|
||||
{usage.data !== undefined ? (
|
||||
<>
|
||||
<Text>
|
||||
{usage.data.billingPeriod
|
||||
? `Observations in billing period`
|
||||
? `Observations in current billing period`
|
||||
: "Observations / last 30d"}
|
||||
</Text>
|
||||
<Metric>{numberFormatter(usage.data.countObservations, 0)}</Metric>
|
||||
@@ -107,7 +104,9 @@ const OrganizationUsageChart = () => {
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
"Loading (might take a moment) ..."
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Loading (might take a moment) ...
|
||||
</span>
|
||||
)}
|
||||
</Card>
|
||||
<div className="mt-2 flex flex-col gap-1 text-sm text-muted-foreground">
|
||||
|
||||
@@ -40,7 +40,7 @@ export const cloudBillingRouter = createTRPCRouter({
|
||||
});
|
||||
if (!org) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Organization not found",
|
||||
});
|
||||
}
|
||||
@@ -48,7 +48,7 @@ export const cloudBillingRouter = createTRPCRouter({
|
||||
const parsedOrg = parseDbOrg(org);
|
||||
if (parsedOrg.cloudConfig?.plan)
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message:
|
||||
"Cannot initialize stripe checkout for orgs that have a manual/legacy plan",
|
||||
});
|
||||
@@ -65,7 +65,7 @@ export const cloudBillingRouter = createTRPCRouter({
|
||||
if (stripeActiveSubscriptionId) {
|
||||
// If the org has a customer ID, do not return checkout options, should use the billing portal instead
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Organization already has an active subscription",
|
||||
});
|
||||
}
|
||||
@@ -78,7 +78,7 @@ export const cloudBillingRouter = createTRPCRouter({
|
||||
)
|
||||
)
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Invalid stripe product id",
|
||||
});
|
||||
|
||||
@@ -87,7 +87,7 @@ export const cloudBillingRouter = createTRPCRouter({
|
||||
);
|
||||
if (!product.default_price) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Product does not have a default price in Stripe",
|
||||
});
|
||||
}
|
||||
@@ -168,7 +168,7 @@ export const cloudBillingRouter = createTRPCRouter({
|
||||
});
|
||||
if (!org) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Organization not found",
|
||||
});
|
||||
}
|
||||
@@ -214,18 +214,6 @@ export const cloudBillingRouter = createTRPCRouter({
|
||||
session: ctx.session,
|
||||
});
|
||||
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
thirtyDaysAgo.setHours(0, 0, 0, 0);
|
||||
let billingPeriod: {
|
||||
start: Date;
|
||||
end: Date;
|
||||
} | null = null;
|
||||
let upcomingInvoice: {
|
||||
usdAmount: number;
|
||||
date: Date;
|
||||
} | null = null;
|
||||
|
||||
const organization = await ctx.prisma.organization.findUnique({
|
||||
where: {
|
||||
id: input.orgId,
|
||||
@@ -233,7 +221,7 @@ export const cloudBillingRouter = createTRPCRouter({
|
||||
});
|
||||
if (!organization) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Organization not found",
|
||||
});
|
||||
}
|
||||
@@ -249,35 +237,49 @@ export const cloudBillingRouter = createTRPCRouter({
|
||||
parsedOrg.cloudConfig.stripe.activeSubscriptionId,
|
||||
);
|
||||
if (subscription) {
|
||||
billingPeriod = {
|
||||
const billingPeriod = {
|
||||
start: new Date(subscription.current_period_start * 1000),
|
||||
end: new Date(subscription.current_period_end * 1000),
|
||||
};
|
||||
const stripeInvoice = await stripeClient.invoices.retrieveUpcoming({
|
||||
subscription: parsedOrg.cloudConfig.stripe.activeSubscriptionId,
|
||||
});
|
||||
upcomingInvoice = {
|
||||
const upcomingInvoice = {
|
||||
usdAmount: stripeInvoice.amount_due / 100,
|
||||
date: new Date(stripeInvoice.period_end * 1000),
|
||||
};
|
||||
const usage = stripeInvoice.lines.data.reduce((acc, line) => {
|
||||
if (line.quantity) {
|
||||
return acc + line.quantity;
|
||||
}
|
||||
return acc;
|
||||
}, 0);
|
||||
return {
|
||||
countObservations: usage,
|
||||
billingPeriod,
|
||||
upcomingInvoice,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// For non-Stripe subscriptions, we can only get usage from the LangFuse API
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
thirtyDaysAgo.setHours(0, 0, 0, 0);
|
||||
|
||||
const usage = await ctx.prisma.observation.count({
|
||||
where: {
|
||||
project: {
|
||||
orgId: input.orgId,
|
||||
},
|
||||
startTime: {
|
||||
gte: billingPeriod?.start ?? thirtyDaysAgo,
|
||||
gte: thirtyDaysAgo,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
countObservations: usage,
|
||||
billingPeriod,
|
||||
upcomingInvoice,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -8,7 +8,8 @@ import { prisma } from "@langfuse/shared/src/db";
|
||||
import { stripeClient } from "@/src/ee/features/billing/utils/stripe";
|
||||
import type Stripe from "stripe";
|
||||
import { CloudConfigSchema, parseDbOrg } from "@langfuse/shared";
|
||||
import { traceException } from "@langfuse/shared/src/server";
|
||||
import { traceException, redis } from "@langfuse/shared/src/server";
|
||||
import { ApiAuthService } from "@/src/features/public-api/server/apiAuth";
|
||||
|
||||
/*
|
||||
* Sign-up endpoint (email/password users), creates user in database.
|
||||
@@ -227,5 +228,8 @@ async function handleSubscriptionChanged(
|
||||
});
|
||||
}
|
||||
|
||||
// need to update the plan in the api keys
|
||||
await new ApiAuthService(prisma, redis).invalidateOrgApiKeys(parsedOrg.id);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { type Plan } from "@/src/features/entitlements/constants/plans";
|
||||
import { type Plan } from "@langfuse/shared";
|
||||
|
||||
type StripeProduct = {
|
||||
stripeProductId: string;
|
||||
|
||||
@@ -514,8 +514,11 @@ export const InnerEvalConfigForm = (props: {
|
||||
<span>0%</span>
|
||||
<span>100%</span>
|
||||
</FormDescription>
|
||||
<FormDescription>
|
||||
Percentage of traces to evaluate.
|
||||
<FormDescription className="mt-1 flex flex-row gap-1">
|
||||
<span>Percentage of traces to evaluate.</span>
|
||||
<span>
|
||||
Currently set to {(field.value * 100).toFixed(0)}%.
|
||||
</span>
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormMessage />
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function EvalConfigTable({ projectId }: { projectId: string }) {
|
||||
limit: paginationState.pageSize,
|
||||
projectId,
|
||||
});
|
||||
const totalCount = templates.data?.totalCount ?? 0;
|
||||
const totalCount = templates.data?.totalCount ?? null;
|
||||
|
||||
const columnHelper = createColumnHelper<EvalConfigRow>();
|
||||
const columns = [
|
||||
@@ -120,7 +120,7 @@ export default function EvalConfigTable({ projectId }: { projectId: string }) {
|
||||
}
|
||||
}
|
||||
pagination={{
|
||||
pageCount: Math.ceil(totalCount / paginationState.pageSize),
|
||||
totalCount,
|
||||
onChange: setPaginationState,
|
||||
state: paginationState,
|
||||
}}
|
||||
|
||||
@@ -41,7 +41,7 @@ export default function EvalLogTable({
|
||||
jobConfigurationId,
|
||||
projectId,
|
||||
});
|
||||
const totalCount = logs.data?.totalCount ?? 0;
|
||||
const totalCount = logs.data?.totalCount ?? null;
|
||||
|
||||
const columnHelper = createColumnHelper<JobExecutionRow>();
|
||||
const columns = [
|
||||
@@ -199,7 +199,7 @@ export default function EvalLogTable({
|
||||
}
|
||||
}
|
||||
pagination={{
|
||||
pageCount: Math.ceil(totalCount / paginationState.pageSize),
|
||||
totalCount,
|
||||
onChange: setPaginationState,
|
||||
state: paginationState,
|
||||
}}
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function EvalsTemplateTable({
|
||||
page: paginationState.pageIndex,
|
||||
limit: paginationState.pageSize,
|
||||
});
|
||||
const totalCount = templates.data?.totalCount ?? 0;
|
||||
const totalCount = templates.data?.totalCount ?? null;
|
||||
|
||||
const columnHelper = createColumnHelper<EvalsTemplateRow>();
|
||||
|
||||
@@ -104,7 +104,7 @@ export default function EvalsTemplateTable({
|
||||
}
|
||||
}
|
||||
pagination={{
|
||||
pageCount: Math.ceil(totalCount / paginationState.pageSize),
|
||||
totalCount,
|
||||
onChange: setPaginationState,
|
||||
state: paginationState,
|
||||
}}
|
||||
|
||||
@@ -427,6 +427,7 @@ export const InnerEvalTemplateForm = (props: {
|
||||
updateModelParamValue: updateModelParamValue,
|
||||
setModelParamEnabled,
|
||||
}}
|
||||
evalModelsOnly
|
||||
formDisabled={!props.isEditing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -74,9 +74,7 @@ export const GenerationOutput = () => {
|
||||
<p className="flex-1 text-xs font-semibold">Output</p>
|
||||
{copyButton}
|
||||
</div>
|
||||
<pre
|
||||
className={`whitespace-break-spaces break-words ${isJson ? "text-xs" : ""}`}
|
||||
>
|
||||
<pre className="whitespace-break-spaces break-words text-xs">
|
||||
{isJson ? outputJson : output}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,7 @@ type JumpToPlaygroundButtonProps = (
|
||||
analyticsEventName: "trace_detail:test_in_playground_button_click";
|
||||
}
|
||||
) & {
|
||||
fullWidth?: boolean;
|
||||
variant?: "outline" | "secondary";
|
||||
};
|
||||
|
||||
export const JumpToPlaygroundButton: React.FC<JumpToPlaygroundButtonProps> = (
|
||||
@@ -61,17 +61,14 @@ export const JumpToPlaygroundButton: React.FC<JumpToPlaygroundButtonProps> = (
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={props.fullWidth ? "secondary" : "outline"}
|
||||
variant={props.variant ?? "secondary"}
|
||||
title="Test in LLM playground"
|
||||
size={!props.fullWidth ? "icon" : undefined}
|
||||
onClick={handleClick}
|
||||
asChild
|
||||
>
|
||||
<Link href={`/project/${projectId}/playground`}>
|
||||
<Terminal className="h-5 w-5" />
|
||||
{props.fullWidth ? (
|
||||
<span className="ml-2">Test in playground</span>
|
||||
) : null}
|
||||
<Terminal className="h-4 w-4" />
|
||||
<span className="ml-2">Test in playground</span>
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -4,13 +4,28 @@ import { usePlaygroundContext } from "@/src/ee/features/playground/page/context"
|
||||
import { GenerationOutput } from "./GenerationOutput";
|
||||
import { ChatMessages } from "@/src/components/ChatMessages";
|
||||
import { type MessagesContext } from "@/src/components/ChatMessages/types";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/src/components/ui/resizable";
|
||||
|
||||
export const Messages: React.FC<MessagesContext> = (props) => {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-4 pr-4">
|
||||
<p className="font-semibold">Messages</p>
|
||||
<ChatMessages {...props} />
|
||||
<GenerationOutput />
|
||||
<ResizablePanelGroup direction="vertical">
|
||||
<ResizablePanel minSize={10}>
|
||||
<ChatMessages {...props} />
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle className="bg-transparent" />
|
||||
<ResizablePanel
|
||||
minSize={10}
|
||||
defaultSize={20}
|
||||
className="flex flex-col space-y-4"
|
||||
>
|
||||
<GenerationOutput />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
<SubmitButton />
|
||||
</div>
|
||||
);
|
||||
@@ -21,8 +36,7 @@ const SubmitButton = () => {
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="default"
|
||||
className="h-[88px] w-full space-x-2 py-3"
|
||||
className="flex-0"
|
||||
onClick={() => {
|
||||
handleSubmit().catch((err) => console.error(err));
|
||||
}}
|
||||
|
||||
@@ -24,7 +24,7 @@ export const ResetPlaygroundButton: React.FC = () => {
|
||||
title="Reset playground state"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<ListRestartIcon className="mr-1 h-5 w-5" />
|
||||
<ListRestartIcon className="mr-1 h-4 w-4" />
|
||||
<span>Reset playground</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -91,7 +91,7 @@ export const SaveToPromptButton: React.FC = () => {
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant={"outline"} title="Save to prompt" asChild>
|
||||
<Link href={`/project/${projectId}/playground`}>
|
||||
<FileInput className="mr-1 h-5 w-5" />
|
||||
<FileInput className="mr-1 h-4 w-4" />
|
||||
<span>Save as prompt</span>
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -155,7 +155,7 @@ export const SaveToPromptButton: React.FC = () => {
|
||||
|
||||
export function Divider() {
|
||||
return (
|
||||
<div className="my-6 flex flex-row justify-center align-middle">
|
||||
<div className="my-3 flex flex-row justify-center align-middle">
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex-1 border-b-2 border-gray-200" />
|
||||
<div className="flex-1" />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FullScreenPage } from "@/src/components/layouts/full-screen-page";
|
||||
import Header from "@/src/components/layouts/header";
|
||||
import { ResetPlaygroundButton } from "@/src/ee/features/playground/page/components/ResetPlaygroundButton";
|
||||
import { SaveToPromptButton } from "@/src/ee/features/playground/page/components/SaveToPromptButton";
|
||||
@@ -10,7 +11,7 @@ export default function PlaygroundPage() {
|
||||
if (!available) return null;
|
||||
return (
|
||||
<PlaygroundProvider>
|
||||
<div className="flex h-[95vh] flex-col">
|
||||
<FullScreenPage>
|
||||
<Header
|
||||
title="Playground"
|
||||
help={{
|
||||
@@ -27,7 +28,7 @@ export default function PlaygroundPage() {
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Playground />
|
||||
</div>
|
||||
</div>
|
||||
</FullScreenPage>
|
||||
</PlaygroundProvider>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user