Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
8245cf8ae7 | ||
|
|
05b3021bff | ||
|
|
44bcebdaec | ||
|
|
2ac15e8714 | ||
|
|
13b027cd3c | ||
|
|
4a89cf4e45 | ||
|
|
e776af544f | ||
|
|
0d781f12f3 | ||
|
|
5d81505d84 | ||
|
|
8471b7f7b3 | ||
|
|
78a90afdab | ||
|
|
9e304ad873 | ||
|
|
5457053fdf | ||
|
|
e91716c6a2 | ||
|
|
5148cfa690 |
+6
-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=
|
||||
|
||||
+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/*"
|
||||
|
||||
@@ -136,6 +136,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 +166,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.72.0",
|
||||
"version": "2.75.2",
|
||||
"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,7 +76,7 @@
|
||||
"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:*",
|
||||
|
||||
@@ -832,6 +832,8 @@ function createObjects(
|
||||
)
|
||||
];
|
||||
|
||||
const { input, output } = getGenerationInputOutput();
|
||||
|
||||
const generation = {
|
||||
type: ObservationType.GENERATION,
|
||||
id: `generation-${v4()}`,
|
||||
@@ -843,35 +845,8 @@ function createObjects(
|
||||
name: `generation-${i}-${j}-${k}`,
|
||||
projectId: trace.projectId,
|
||||
promptId: promptId,
|
||||
input:
|
||||
Math.random() > 0.5
|
||||
? [
|
||||
{
|
||||
role: "system",
|
||||
content: "Be a helpful assistant",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: "How can i create a *React* component?",
|
||||
},
|
||||
]
|
||||
: {
|
||||
input: "How can i create a React component?",
|
||||
retrievedDocuments: [
|
||||
{
|
||||
title: "How to create a React component",
|
||||
url: "https://www.google.com",
|
||||
description: "A guide to creating React components",
|
||||
},
|
||||
{
|
||||
title: "React component creation",
|
||||
url: "https://www.google.com",
|
||||
description: "A guide to creating React components",
|
||||
},
|
||||
],
|
||||
},
|
||||
output:
|
||||
"Creating a React component can be done in two ways: as a functional component or as a class component. Let's start with a basic example of both.\n\n**Image**\n\n\n\n1. **Functional Component**:\n\nA functional component is just a plain JavaScript function that accepts props as an argument, and returns a React element. Here's how you can create one:\n\n```javascript\nimport React from 'react';\nfunction Greeting(props) {\n return <h1>Hello, {props.name}</h1>;\n}\nexport default Greeting;\n```\n\nTo use this component in another file, you can do:\n\n```javascript\nimport Greeting from './Greeting';\nfunction App() {\n return (\n <div>\n <Greeting name=\"John\" />\n </div>\n );\n}\nexport default App;\n```\n\n2. **Class Component**:\n\nYou can also define components as classes in React. These have some additional features compared to functional components:\n\n```javascript\nimport React, { Component } from 'react';\nclass Greeting extends Component {\n render() {\n return <h1>Hello, {this.props.name}</h1>;\n }\n}\nexport default Greeting;\n```\n\nAnd here's how to use this component:\n\n```javascript\nimport Greeting from './Greeting';\nclass App extends Component {\n render() {\n return (\n <div>\n <Greeting name=\"John\" />\n </div>\n );\n }\n}\nexport default App;\n```\n\nWith the advent of hooks in React, functional components can do everything that class components can do and hence, the community has been favoring functional components over class components.\n\nRemember to import React at the top of your file whenever you're creating a component, because JSX transpiles to `React.createElement` calls under the hood.",
|
||||
input,
|
||||
output,
|
||||
model: model,
|
||||
internalModel: model,
|
||||
modelParameters: {
|
||||
@@ -1243,3 +1218,63 @@ async function generateConfigs(project: Project) {
|
||||
|
||||
return configNameAndId;
|
||||
}
|
||||
function getGenerationInputOutput(): {
|
||||
input: Prisma.InputJsonValue;
|
||||
output: Prisma.InputJsonValue;
|
||||
} {
|
||||
if (Math.random() > 0.9) {
|
||||
const input = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ text: "What’s depicted in this image?", type: "text" },
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: "https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg",
|
||||
},
|
||||
},
|
||||
{ text: "Describe the scene in detail.", type: "text" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const output =
|
||||
"The image depicts a serene landscape featuring a wooden pathway or boardwalk that winds through a lush green field. The field is filled with tall grass and surrounded by trees and shrubs. Above, the sky is bright with scattered clouds, suggesting a clear and pleasant day. The scene conveys a sense of tranquility and natural beauty.";
|
||||
|
||||
return { input, output };
|
||||
}
|
||||
|
||||
const input =
|
||||
Math.random() > 0.5
|
||||
? [
|
||||
{
|
||||
role: "system",
|
||||
content: "Be a helpful assistant",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: "How can i create a *React* component?",
|
||||
},
|
||||
]
|
||||
: {
|
||||
input: "How can i create a React component?",
|
||||
retrievedDocuments: [
|
||||
{
|
||||
title: "How to create a React component",
|
||||
url: "https://www.google.com",
|
||||
description: "A guide to creating React components",
|
||||
},
|
||||
{
|
||||
title: "React component creation",
|
||||
url: "https://www.google.com",
|
||||
description: "A guide to creating React components",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const output =
|
||||
"Creating a React component can be done in two ways: as a functional component or as a class component. Let's start with a basic example of both.\n\n**Image**\n\n\n\n1. **Functional Component**:\n\nA functional component is just a plain JavaScript function that accepts props as an argument, and returns a React element. Here's how you can create one:\n\n```javascript\nimport React from 'react';\nfunction Greeting(props) {\n return <h1>Hello, {props.name}</h1>;\n}\nexport default Greeting;\n```\n\nTo use this component in another file, you can do:\n\n```javascript\nimport Greeting from './Greeting';\nfunction App() {\n return (\n <div>\n <Greeting name=\"John\" />\n </div>\n );\n}\nexport default App;\n```\n\n2. **Class Component**:\n\nYou can also define components as classes in React. These have some additional features compared to functional components:\n\n```javascript\nimport React, { Component } from 'react';\nclass Greeting extends Component {\n render() {\n return <h1>Hello, {this.props.name}</h1>;\n }\n}\nexport default Greeting;\n```\n\nAnd here's how to use this component:\n\n```javascript\nimport Greeting from './Greeting';\nclass App extends Component {\n render() {\n return (\n <div>\n <Greeting name=\"John\" />\n </div>\n );\n }\n}\nexport default App;\n```\n\nWith the advent of hooks in React, functional components can do everything that class components can do and hence, the community has been favoring functional components over class components.\n\nRemember to import React at the top of your file whenever you're creating a component, because JSX transpiles to `React.createElement` calls under the hood.";
|
||||
|
||||
return { input, output };
|
||||
}
|
||||
|
||||
@@ -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,12 +11,14 @@ 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: 1000,
|
||||
removeOnFail: 1_000,
|
||||
delay: env.LANGFUSE_INGESTION_FLUSH_DELAY_MS,
|
||||
attempts: env.LANGFUSE_INGESTION_FLUSH_ATTEMPTS,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Queue } from "bullmq";
|
||||
import { QueueName, TQueueJobTypes } from "../queues";
|
||||
import { redis } from "./redis";
|
||||
import { createNewRedisInstance } from "./redis";
|
||||
|
||||
let legacyIngestionQueue: Queue<
|
||||
TQueueJobTypes[QueueName.LegacyIngestionQueue]
|
||||
@@ -9,11 +9,13 @@ let legacyIngestionQueue: Queue<
|
||||
export const getLegacyIngestionQueue = () => {
|
||||
if (legacyIngestionQueue) return legacyIngestionQueue;
|
||||
|
||||
legacyIngestionQueue = redis
|
||||
const newRedis = createNewRedisInstance();
|
||||
|
||||
legacyIngestionQueue = newRedis
|
||||
? new Queue<TQueueJobTypes[QueueName.LegacyIngestionQueue]>(
|
||||
QueueName.LegacyIngestionQueue,
|
||||
{
|
||||
connection: redis,
|
||||
connection: newRedis,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 100,
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
import Redis from "ioredis";
|
||||
import { env } from "../../env";
|
||||
|
||||
export const createNewRedisInstance = () => {
|
||||
return env.REDIS_CONNECTION_STRING
|
||||
? new Redis(env.REDIS_CONNECTION_STRING, {
|
||||
maxRetriesPerRequest: null,
|
||||
enableAutoPipelining: env.REDIS_ENABLE_AUTO_PIPELINING === "true",
|
||||
})
|
||||
: 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",
|
||||
})
|
||||
: 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,12 +14,14 @@ 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: 1000,
|
||||
removeOnFail: 1_000,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
@@ -54,7 +56,7 @@ export function convertTraceUpsertEventsToRedisEvents(
|
||||
name: QueueJobs.TraceUpsert as const,
|
||||
},
|
||||
opts: {
|
||||
removeOnFail: 10000,
|
||||
removeOnFail: 1_000,
|
||||
removeOnComplete: true,
|
||||
attempts: 5,
|
||||
backoff: {
|
||||
|
||||
@@ -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
+1531
-1198
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"]
|
||||
|
||||
+32
-9
@@ -12,26 +12,49 @@ 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,
|
||||
|
||||
/**
|
||||
* If you have `experimental: { appDir: true }` set, then you must comment the below `i18n` config
|
||||
@@ -66,6 +89,7 @@ const nextConfig = {
|
||||
key: "Permissions-Policy",
|
||||
value: "autoplay=*, fullscreen=*, microphone=*",
|
||||
},
|
||||
...(env.SENTRY_CSP_REPORT_URI ? [reportToHeader] : []),
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -116,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,
|
||||
@@ -158,6 +178,9 @@ const sentryOptions = {
|
||||
// - excludeServerRoutes
|
||||
// 'Configure Tunneling':
|
||||
// - tunnelRoute
|
||||
|
||||
// An auth token is required for uploading source maps.
|
||||
authToken: env.SENTRY_AUTH_TOKEN,
|
||||
tunnelRoute: "/api/monitoring-tunnel",
|
||||
};
|
||||
|
||||
|
||||
+34
-33
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "2.72.0",
|
||||
"version": "2.75.2",
|
||||
"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.429.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",
|
||||
@@ -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",
|
||||
"prettier-plugin-tailwindcss": "^0.6.6",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.7.2",
|
||||
"tsx": "^4.18.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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -60,7 +60,7 @@ export const LLMApiKeyComponent = (p: {
|
||||
{`No LLM API key found for provider ${modelProvider}.`}
|
||||
|
||||
<Link
|
||||
href={`/project/${p.projectId}/settings`}
|
||||
href={`/project/${p.projectId}/settings/api-keys`}
|
||||
className="flex flex-row"
|
||||
>
|
||||
Create a new LLM API key here. <ArrowTopRightIcon />
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -1,11 +1,43 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// OpenAI API Content Schema defined as per https://platform.openai.com/docs/api-reference/chat/create#chat-create-messages, 20.08.2024
|
||||
const OpenAITextContentPart = z.object({
|
||||
type: z.literal("text"),
|
||||
text: z.string(),
|
||||
});
|
||||
|
||||
export const OpenAIUrlImageUrl = z.string().regex(/^https?:/);
|
||||
|
||||
const OpenAIBase64ImageUrl = z
|
||||
.string()
|
||||
.regex(/^data:image\/(png|jpeg|jpg|gif|webp);base64,/);
|
||||
|
||||
const OpenAIImageContentPart = z.object({
|
||||
type: z.literal("image_url"),
|
||||
image_url: z.object({
|
||||
url: z.union([OpenAIUrlImageUrl, OpenAIBase64ImageUrl]),
|
||||
detail: z.enum(["low", "high", "auto"]).optional(), // Controls how the model processes the image. Defaults to "auto". [https://platform.openai.com/docs/guides/vision/low-or-high-fidelity-image-understanding]
|
||||
}),
|
||||
});
|
||||
|
||||
export const OpenAIContentParts = z.array(
|
||||
z.union([OpenAITextContentPart, OpenAIImageContentPart]),
|
||||
);
|
||||
|
||||
export const OpenAIContentSchema = z.union([z.string(), OpenAIContentParts]);
|
||||
export type OpenAIContentSchema = z.infer<typeof OpenAIContentSchema>;
|
||||
|
||||
export const ChatMlMessageSchema = z
|
||||
.object({
|
||||
role: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
content: z
|
||||
.union([z.record(z.any()), z.string(), z.array(z.any())])
|
||||
.union([
|
||||
z.record(z.any()),
|
||||
z.string(),
|
||||
z.array(z.any()),
|
||||
OpenAIContentSchema,
|
||||
])
|
||||
.nullish(),
|
||||
additional_kwargs: z.record(z.any()).optional(),
|
||||
})
|
||||
@@ -21,5 +53,7 @@ export const ChatMlMessageSchema = z
|
||||
content,
|
||||
json: Object.keys(other).length === 0 ? undefined : other,
|
||||
}));
|
||||
export type ChatMlMessageSchema = z.infer<typeof ChatMlMessageSchema>;
|
||||
|
||||
export const ChatMlArraySchema = z.array(ChatMlMessageSchema).min(1);
|
||||
export type ChatMlArraySchema = z.infer<typeof ChatMlArraySchema>;
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -11,10 +11,19 @@ import { MarkdownView } from "@/src/components/ui/MarkdownViewer";
|
||||
import { MarkdownSchema } from "@/src/components/schemas/MarkdownSchema";
|
||||
import {
|
||||
ChatMlArraySchema,
|
||||
ChatMlMessageSchema,
|
||||
type ChatMlMessageSchema,
|
||||
OpenAIContentSchema,
|
||||
} from "@/src/components/schemas/ChatMlSchema";
|
||||
import { useMarkdownContext } from "@/src/features/theming/useMarkdownContext";
|
||||
|
||||
const isSupportedMarkdownFormat = (
|
||||
content: unknown,
|
||||
contentValidation: z.SafeParseReturnType<
|
||||
string,
|
||||
z.infer<typeof OpenAIContentSchema>
|
||||
>,
|
||||
): content is z.infer<typeof OpenAIContentSchema> => contentValidation.success;
|
||||
|
||||
// MarkdownOrJsonView will render markdown if `isMarkdownEnabled` (global context) is true and the content is valid markdown
|
||||
// otherwise, if content is valid markdown will render JSON with switch to enable markdown globally
|
||||
function MarkdownOrJsonView({
|
||||
@@ -32,12 +41,20 @@ function MarkdownOrJsonView({
|
||||
() => MarkdownSchema.safeParse(content),
|
||||
[content],
|
||||
);
|
||||
const validatedOpenAIContent = useMemo(
|
||||
() => OpenAIContentSchema.safeParse(content),
|
||||
[content],
|
||||
);
|
||||
|
||||
const { isMarkdownEnabled } = useMarkdownContext();
|
||||
const canEnableMarkdown = isSupportedMarkdownFormat(
|
||||
content,
|
||||
validatedOpenAIContent,
|
||||
);
|
||||
|
||||
return validatedMarkdown.success && isMarkdownEnabled ? (
|
||||
return isMarkdownEnabled && canEnableMarkdown ? (
|
||||
<MarkdownView
|
||||
markdown={validatedMarkdown.data}
|
||||
markdown={validatedMarkdown.data ?? content}
|
||||
title={title}
|
||||
className={className}
|
||||
customCodeHeaderClassName={customCodeHeaderClassName}
|
||||
@@ -45,7 +62,7 @@ function MarkdownOrJsonView({
|
||||
) : (
|
||||
<JSONView
|
||||
json={content}
|
||||
canEnableMarkdown={validatedMarkdown.success}
|
||||
canEnableMarkdown={canEnableMarkdown}
|
||||
title={title}
|
||||
className={className}
|
||||
/>
|
||||
@@ -140,10 +157,12 @@ export const IOPreview: React.FC<{
|
||||
role: m.role ?? "assistant",
|
||||
}))
|
||||
: [
|
||||
ChatMlMessageSchema.parse({
|
||||
{
|
||||
role: "assistant",
|
||||
content: outputClean,
|
||||
}),
|
||||
...(typeof outputClean === "string"
|
||||
? { content: outputClean }
|
||||
: { json: outputClean }),
|
||||
} as ChatMlMessageSchema,
|
||||
]),
|
||||
]}
|
||||
shouldRenderMarkdown
|
||||
|
||||
@@ -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" />
|
||||
) : (
|
||||
|
||||
@@ -9,31 +9,25 @@ import {
|
||||
Children,
|
||||
createElement,
|
||||
} from "react";
|
||||
import ReactMarkdown, { type Components, type Options } from "react-markdown";
|
||||
import ReactMarkdown, { type Options } from "react-markdown";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkMath from "remark-math";
|
||||
import { CodeBlock } from "@/src/components/ui/Codeblock";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import {
|
||||
Check,
|
||||
Copy,
|
||||
Image as ImageIcon,
|
||||
ImageOff,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
} from "lucide-react";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { isPresent } from "@langfuse/shared";
|
||||
import { Check, Copy, ImageOff } from "lucide-react";
|
||||
import { BsMarkdown } from "react-icons/bs";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { Skeleton } from "@/src/components/ui/skeleton";
|
||||
import { useMarkdownContext } from "@/src/features/theming/useMarkdownContext";
|
||||
import { captureException } from "@sentry/nextjs";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { type ExtraProps as ReactMarkdownExtraProps } from "react-markdown";
|
||||
import {
|
||||
OpenAIUrlImageUrl,
|
||||
type OpenAIContentParts,
|
||||
type OpenAIContentSchema,
|
||||
} from "@/src/components/schemas/ChatMlSchema";
|
||||
import { type z } from "zod";
|
||||
import { ResizableImage } from "@/src/components/ui/resizable-image";
|
||||
|
||||
type ReactMarkdownNode = ReactMarkdownExtraProps["node"];
|
||||
type ReactMarkdownNodeChildren = Exclude<
|
||||
@@ -50,147 +44,6 @@ const MemoizedReactMarkdown: FC<Options> = memo(
|
||||
prevProps.className === nextProps.className,
|
||||
);
|
||||
|
||||
/**
|
||||
* Implemented customLoader as we cannot whitelist user provided image domains
|
||||
* Security risks are taken care of by a validation in api.utilities.validateImgUrl
|
||||
* Fetching image will fail if SSL/TLS certificate is invalid or expired, will be handled by onError
|
||||
* Do not use this customLoader in production if you are not using the above mentioned security measures */
|
||||
const customLoader = ({
|
||||
src,
|
||||
width,
|
||||
quality,
|
||||
}: {
|
||||
src: string;
|
||||
width: number;
|
||||
quality?: number;
|
||||
}) => {
|
||||
return `${src}?w=${width}&q=${quality || 75}`;
|
||||
};
|
||||
|
||||
const ImageErrorDisplay = ({
|
||||
src,
|
||||
displayError,
|
||||
}: {
|
||||
src: string;
|
||||
displayError: string;
|
||||
}) => (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<span title={displayError} className="h-4 w-4">
|
||||
<ImageOff className="h-4 w-4" />
|
||||
</span>
|
||||
<Link href={src} className="underline" target="_blank">
|
||||
{src}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
const MarkdownImage: Components["img"] = ({ src, alt }) => {
|
||||
const [isZoomedIn, setIsZoomedIn] = useState(true);
|
||||
const [hasFetchError, setHasFetchError] = useState(false);
|
||||
const [isImageVisible, setIsImageVisible] = useState(false);
|
||||
const session = useSession();
|
||||
|
||||
if (!isPresent(src)) return null;
|
||||
|
||||
const isValidImage = api.utilities.validateImgUrl.useQuery(src, {
|
||||
enabled: session.status === "authenticated",
|
||||
});
|
||||
|
||||
if (session.status !== "authenticated") {
|
||||
return (
|
||||
<ImageErrorDisplay
|
||||
src={src}
|
||||
displayError="Images not rendered on public traces and observations"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isValidImage.isLoading) {
|
||||
return (
|
||||
<Skeleton className="h-8 w-1/2 items-center p-2 text-xs">
|
||||
<span className="opacity-80">Loading image...</span>
|
||||
</Skeleton>
|
||||
);
|
||||
}
|
||||
|
||||
const displayError = `Cannot load image. ${src.includes("http") ? "Http images are not rendered in Langfuse for security reasons" : "Invalid image URL"}`;
|
||||
|
||||
if (isValidImage.data?.isValid) {
|
||||
return (
|
||||
<div>
|
||||
{hasFetchError ? (
|
||||
<ImageErrorDisplay src={src} displayError={displayError} />
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative w-full overflow-hidden",
|
||||
isZoomedIn ? "h-1/2 w-1/2" : "h-full w-full",
|
||||
)}
|
||||
>
|
||||
{isImageVisible ? (
|
||||
<Image
|
||||
loader={customLoader}
|
||||
src={src}
|
||||
alt={alt ?? `Markdown Image-${Math.random()}`}
|
||||
loading="lazy"
|
||||
width={0}
|
||||
height={0}
|
||||
title={src}
|
||||
className="h-full w-full rounded border object-contain"
|
||||
onError={(error) => {
|
||||
setHasFetchError(true);
|
||||
captureException(error);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid h-14 w-full grid-cols-[auto,1fr] items-center gap-2 rounded border border-dashed bg-muted/30 p-2 text-xs text-muted-foreground/60">
|
||||
<Button
|
||||
title="Render image"
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => setIsImageVisible(!isImageVisible)}
|
||||
>
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex items-center overflow-hidden">
|
||||
<Link
|
||||
href={src}
|
||||
title={src}
|
||||
className="overflow-hidden underline"
|
||||
target="_blank"
|
||||
>
|
||||
<div className="h-8 overflow-hidden overflow-ellipsis">
|
||||
{src}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isImageVisible && (
|
||||
<Button
|
||||
type="button"
|
||||
className="absolute right-0 top-0 mr-1 mt-1 h-8 w-8 opacity-0 group-hover:!bg-accent/30 group-hover:opacity-100"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsZoomedIn(!isZoomedIn)}
|
||||
>
|
||||
{isZoomedIn ? (
|
||||
<Maximize2 className="h-4 w-4"></Maximize2>
|
||||
) : (
|
||||
<Minimize2 className="h-4 w-4"></Minimize2>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <ImageErrorDisplay src={src} displayError={displayError} />;
|
||||
};
|
||||
|
||||
const isTextElement = (child: ReactNode): child is ReactElement =>
|
||||
isValidElement(child) &&
|
||||
typeof child.type !== "string" &&
|
||||
@@ -219,25 +72,179 @@ const isImageNode = (node?: ReactMarkdownNode): boolean =>
|
||||
"tagName" in child && child.tagName === "img",
|
||||
);
|
||||
|
||||
function MarkdownRenderer({
|
||||
markdown,
|
||||
theme,
|
||||
className,
|
||||
customCodeHeaderClassName,
|
||||
}: {
|
||||
markdown: string;
|
||||
theme?: string;
|
||||
className?: string;
|
||||
customCodeHeaderClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<MemoizedReactMarkdown
|
||||
className={cn("space-y-2 overflow-x-auto break-words text-sm", className)}
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
components={{
|
||||
p({ children, node }) {
|
||||
if (isImageNode(node)) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
return (
|
||||
<p className="mb-2 whitespace-pre-wrap last:mb-0">{children}</p>
|
||||
);
|
||||
},
|
||||
a({ children, href }) {
|
||||
if (href)
|
||||
return (
|
||||
<Link href={href} className="underline" target="_blank">
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
ul({ children }) {
|
||||
if (isChecklist(children))
|
||||
return <ul className="list-none">{children}</ul>;
|
||||
|
||||
return <ul className="list-outside list-disc pl-4">{children}</ul>;
|
||||
},
|
||||
ol({ children }) {
|
||||
return <ol className="list-outside list-decimal pl-4">{children}</ol>;
|
||||
},
|
||||
li({ children }) {
|
||||
return (
|
||||
<li className="mb-1 list-item">
|
||||
{transformListItemChildren(children)}
|
||||
</li>
|
||||
);
|
||||
},
|
||||
pre({ children }) {
|
||||
return <pre className="rounded p-2">{children}</pre>;
|
||||
},
|
||||
h1({ children }) {
|
||||
return <h1 className="text-2xl font-bold">{children}</h1>;
|
||||
},
|
||||
h2({ children }) {
|
||||
return <h2 className="text-xl font-bold">{children}</h2>;
|
||||
},
|
||||
h3({ children }) {
|
||||
return <h3 className="text-lg font-bold">{children}</h3>;
|
||||
},
|
||||
h4({ children }) {
|
||||
return <h4 className="text-base font-bold">{children}</h4>;
|
||||
},
|
||||
h5({ children }) {
|
||||
return <h5 className="text-sm font-bold">{children}</h5>;
|
||||
},
|
||||
h6({ children }) {
|
||||
return <h6 className="text-xs font-bold">{children}</h6>;
|
||||
},
|
||||
code({ children, className }) {
|
||||
const languageMatch = /language-(\w+)/.exec(className || "");
|
||||
const language = languageMatch ? languageMatch[1] : "";
|
||||
const codeContent = String(children).replace(/\n$/, "");
|
||||
const isMultiLine = codeContent.includes("\n");
|
||||
|
||||
return language || isMultiLine ? (
|
||||
// code block
|
||||
<CodeBlock
|
||||
key={Math.random()}
|
||||
language={language}
|
||||
value={codeContent}
|
||||
theme={theme}
|
||||
className={customCodeHeaderClassName}
|
||||
/>
|
||||
) : (
|
||||
// inline code
|
||||
<code className="rounded border bg-secondary px-0.5">
|
||||
{codeContent}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
blockquote({ children }) {
|
||||
return (
|
||||
<blockquote className="border-l-4 pl-4 italic">
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
||||
},
|
||||
img({ src, alt }) {
|
||||
return <ResizableImage src={src} alt={alt} />;
|
||||
},
|
||||
hr() {
|
||||
return <hr className="my-4" />;
|
||||
},
|
||||
table({ children }) {
|
||||
return (
|
||||
<div className="overflow-x-auto rounded border text-xs">
|
||||
<table className="min-w-full divide-y">{children}</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
thead({ children }) {
|
||||
return <thead>{children}</thead>;
|
||||
},
|
||||
tbody({ children }) {
|
||||
return <tbody className="divide-y divide-border">{children}</tbody>;
|
||||
},
|
||||
tr({ children }) {
|
||||
return <tr>{children}</tr>;
|
||||
},
|
||||
th({ children }) {
|
||||
return (
|
||||
<th className="px-4 py-2 text-left text-xs font-medium uppercase tracking-wider">
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
},
|
||||
td({ children }) {
|
||||
return <td className="whitespace-nowrap px-4 py-2">{children}</td>;
|
||||
},
|
||||
}}
|
||||
>
|
||||
{markdown}
|
||||
</MemoizedReactMarkdown>
|
||||
);
|
||||
}
|
||||
const parseOpenAIContentParts = (
|
||||
content: z.infer<typeof OpenAIContentParts>,
|
||||
): string => {
|
||||
return content
|
||||
.map((item) => {
|
||||
if (item.type === "text") {
|
||||
return item.text;
|
||||
} else {
|
||||
return ``;
|
||||
}
|
||||
})
|
||||
.join("\n");
|
||||
};
|
||||
|
||||
export function MarkdownView({
|
||||
markdown,
|
||||
title,
|
||||
className,
|
||||
customCodeHeaderClassName,
|
||||
}: {
|
||||
markdown: string;
|
||||
markdown: string | z.infer<typeof OpenAIContentSchema>;
|
||||
title?: string;
|
||||
className?: string;
|
||||
customCodeHeaderClassName?: string;
|
||||
}) {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const { resolvedTheme: theme } = useTheme();
|
||||
const capture = usePostHogClientCapture();
|
||||
const { resolvedTheme: theme } = useTheme();
|
||||
const { setIsMarkdownEnabled } = useMarkdownContext();
|
||||
|
||||
const handleCopy = () => {
|
||||
setIsCopied(true);
|
||||
void navigator.clipboard.writeText(markdown);
|
||||
const rawText =
|
||||
typeof markdown === "string"
|
||||
? markdown
|
||||
: parseOpenAIContentParts(markdown);
|
||||
void navigator.clipboard.writeText(rawText);
|
||||
setTimeout(() => setIsCopied(false), 1000);
|
||||
};
|
||||
|
||||
@@ -288,132 +295,44 @@ export function MarkdownView({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : undefined}
|
||||
<MemoizedReactMarkdown
|
||||
className={cn(
|
||||
"space-y-4 overflow-x-auto break-words p-3 text-sm",
|
||||
className,
|
||||
)}
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
components={{
|
||||
p({ children, node }) {
|
||||
if (isImageNode(node)) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
return (
|
||||
<p className="mb-2 whitespace-pre-wrap last:mb-0">{children}</p>
|
||||
);
|
||||
},
|
||||
a({ children, href }) {
|
||||
if (href)
|
||||
return (
|
||||
<Link href={href} className="underline" target="_blank">
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
ul({ children }) {
|
||||
if (isChecklist(children))
|
||||
return <ul className="list-none">{children}</ul>;
|
||||
|
||||
return <ul className="list-outside list-disc pl-4">{children}</ul>;
|
||||
},
|
||||
ol({ children }) {
|
||||
return (
|
||||
<ol className="list-outside list-decimal pl-4">{children}</ol>
|
||||
);
|
||||
},
|
||||
li({ children }) {
|
||||
return (
|
||||
<li className="mb-1 list-item">
|
||||
{transformListItemChildren(children)}
|
||||
</li>
|
||||
);
|
||||
},
|
||||
pre({ children }) {
|
||||
return <pre className="rounded p-2">{children}</pre>;
|
||||
},
|
||||
h1({ children }) {
|
||||
return <h1 className="text-2xl font-bold">{children}</h1>;
|
||||
},
|
||||
h2({ children }) {
|
||||
return <h2 className="text-xl font-bold">{children}</h2>;
|
||||
},
|
||||
h3({ children }) {
|
||||
return <h3 className="text-lg font-bold">{children}</h3>;
|
||||
},
|
||||
h4({ children }) {
|
||||
return <h4 className="text-base font-bold">{children}</h4>;
|
||||
},
|
||||
h5({ children }) {
|
||||
return <h5 className="text-sm font-bold">{children}</h5>;
|
||||
},
|
||||
h6({ children }) {
|
||||
return <h6 className="text-xs font-bold">{children}</h6>;
|
||||
},
|
||||
code({ children, className }) {
|
||||
const languageMatch = /language-(\w+)/.exec(className || "");
|
||||
const language = languageMatch ? languageMatch[1] : "";
|
||||
const codeContent = String(children).replace(/\n$/, "");
|
||||
const isMultiLine = codeContent.includes("\n");
|
||||
|
||||
return language || isMultiLine ? (
|
||||
// code block
|
||||
<CodeBlock
|
||||
key={Math.random()}
|
||||
language={language}
|
||||
value={codeContent}
|
||||
) : null}
|
||||
<div className="grid grid-flow-row gap-2 p-3">
|
||||
{typeof markdown === "string" ? (
|
||||
// plain string
|
||||
<MarkdownRenderer
|
||||
markdown={markdown}
|
||||
theme={theme}
|
||||
className={className}
|
||||
customCodeHeaderClassName={customCodeHeaderClassName}
|
||||
/>
|
||||
) : (
|
||||
// content parts (multi-modal)
|
||||
markdown.map((content, index) =>
|
||||
content.type === "text" ? (
|
||||
<MarkdownRenderer
|
||||
key={index}
|
||||
markdown={content.text}
|
||||
theme={theme}
|
||||
className={customCodeHeaderClassName}
|
||||
className={className}
|
||||
customCodeHeaderClassName={customCodeHeaderClassName}
|
||||
/>
|
||||
) : (
|
||||
// inline code
|
||||
<code className="rounded border bg-secondary px-0.5">
|
||||
{codeContent}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
blockquote({ children }) {
|
||||
return (
|
||||
<blockquote className="border-l-4 pl-4 italic">
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
||||
},
|
||||
img: MarkdownImage,
|
||||
hr() {
|
||||
return <hr className="my-4" />;
|
||||
},
|
||||
table({ children }) {
|
||||
return (
|
||||
<div className="overflow-x-auto rounded border text-xs">
|
||||
<table className="min-w-full divide-y">{children}</table>
|
||||
) : OpenAIUrlImageUrl.safeParse(content.image_url.url).success ? (
|
||||
<div key={index}>
|
||||
<ResizableImage src={content.image_url.url} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
thead({ children }) {
|
||||
return <thead>{children}</thead>;
|
||||
},
|
||||
tbody({ children }) {
|
||||
return <tbody className="divide-y divide-border">{children}</tbody>;
|
||||
},
|
||||
tr({ children }) {
|
||||
return <tr>{children}</tr>;
|
||||
},
|
||||
th({ children }) {
|
||||
return (
|
||||
<th className="px-4 py-2 text-left text-xs font-medium uppercase tracking-wider">
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
},
|
||||
td({ children }) {
|
||||
return <td className="whitespace-nowrap px-4 py-2">{children}</td>;
|
||||
},
|
||||
}}
|
||||
>
|
||||
{markdown}
|
||||
</MemoizedReactMarkdown>
|
||||
) : (
|
||||
<div className="grid grid-cols-[auto,1fr] items-center gap-2">
|
||||
<span title="No Base64 image support yet" className="h-4 w-4">
|
||||
<ImageOff className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="truncate text-sm">
|
||||
{content.image_url.url}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,159 @@
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import {
|
||||
Image as ImageIcon,
|
||||
ImageOff,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
} from "lucide-react";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { isPresent } from "@langfuse/shared";
|
||||
import { Skeleton } from "@/src/components/ui/skeleton";
|
||||
import { captureException } from "@sentry/nextjs";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
/**
|
||||
* Implemented customLoader as we cannot whitelist user provided image domains
|
||||
* Security risks are taken care of by a validation in api.utilities.validateImgUrl
|
||||
* Fetching image will fail if SSL/TLS certificate is invalid or expired, will be handled by onError
|
||||
* Do not use this customLoader in production if you are not using the above mentioned security measures */
|
||||
const customLoader = ({
|
||||
src,
|
||||
width,
|
||||
quality,
|
||||
}: {
|
||||
src: string;
|
||||
width: number;
|
||||
quality?: number;
|
||||
}) => {
|
||||
return `${src}?w=${width}&q=${quality || 75}`;
|
||||
};
|
||||
|
||||
const ImageErrorDisplay = ({
|
||||
src,
|
||||
displayError,
|
||||
}: {
|
||||
src: string;
|
||||
displayError: string;
|
||||
}) => (
|
||||
<div className="grid grid-cols-[auto,1fr] items-center gap-2">
|
||||
<span title={displayError} className="h-4 w-4">
|
||||
<ImageOff className="h-4 w-4" />
|
||||
</span>
|
||||
<Link href={src} className="truncate text-sm underline" target="_blank">
|
||||
{src}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ResizableImage = ({
|
||||
src,
|
||||
alt,
|
||||
}: {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
}) => {
|
||||
const [isZoomedIn, setIsZoomedIn] = useState(true);
|
||||
const [hasFetchError, setHasFetchError] = useState(false);
|
||||
const [isImageVisible, setIsImageVisible] = useState(false);
|
||||
const session = useSession();
|
||||
|
||||
if (!isPresent(src)) return null;
|
||||
|
||||
const isValidImage = api.utilities.validateImgUrl.useQuery(src, {
|
||||
enabled: session.status === "authenticated" && isImageVisible,
|
||||
});
|
||||
|
||||
if (session.status !== "authenticated") {
|
||||
return (
|
||||
<ImageErrorDisplay
|
||||
src={src}
|
||||
displayError="Images not rendered on public traces and observations"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isValidImage.isLoading && isImageVisible) {
|
||||
return (
|
||||
<Skeleton className="h-8 w-1/2 items-center p-2 text-xs">
|
||||
<span className="opacity-80">Loading image...</span>
|
||||
</Skeleton>
|
||||
);
|
||||
}
|
||||
|
||||
const displayError = `Cannot load image. ${src.includes("http") ? "Http images are not rendered in Langfuse for security reasons" : "Invalid image URL"}`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{hasFetchError ? (
|
||||
<ImageErrorDisplay src={src} displayError={displayError} />
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative w-full overflow-hidden",
|
||||
isZoomedIn ? "h-1/2 w-1/2" : "h-full w-full",
|
||||
)}
|
||||
>
|
||||
{isImageVisible && isValidImage.data?.isValid ? (
|
||||
<>
|
||||
<Image
|
||||
loader={customLoader}
|
||||
src={src}
|
||||
alt={alt ?? `Markdown Image-${Math.random()}`}
|
||||
loading="lazy"
|
||||
width={0}
|
||||
height={0}
|
||||
title={src}
|
||||
className="h-full w-full rounded border object-contain"
|
||||
onError={(error) => {
|
||||
setHasFetchError(true);
|
||||
captureException(error);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
className="absolute right-0 top-0 mr-1 mt-1 h-8 w-8 opacity-0 group-hover:!bg-accent/30 group-hover:opacity-100"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsZoomedIn(!isZoomedIn)}
|
||||
>
|
||||
{isZoomedIn ? (
|
||||
<Maximize2 className="h-4 w-4"></Maximize2>
|
||||
) : (
|
||||
<Minimize2 className="h-4 w-4"></Minimize2>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<div className="grid h-14 w-full grid-cols-[auto,1fr] items-center gap-2 rounded border border-dashed bg-muted/30 p-2 text-xs text-muted-foreground/60">
|
||||
<Button
|
||||
title="Render image"
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => setIsImageVisible(!isImageVisible)}
|
||||
>
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex items-center overflow-hidden">
|
||||
<Link
|
||||
href={src}
|
||||
title={src}
|
||||
className="overflow-hidden underline"
|
||||
target="_blank"
|
||||
>
|
||||
<div className="h-8 overflow-hidden overflow-ellipsis">
|
||||
{src}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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.72.0";
|
||||
export const VERSION = "v2.75.2";
|
||||
|
||||
@@ -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,7 +78,7 @@ const OrganizationUsageChart = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card className="p-4">
|
||||
<Card className="p-3">
|
||||
{usage.data !== undefined ? (
|
||||
<>
|
||||
<Text>
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -233,7 +233,7 @@ export const cloudBillingRouter = createTRPCRouter({
|
||||
});
|
||||
if (!organization) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Organization not found",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -68,7 +68,7 @@ export const JumpToPlaygroundButton: React.FC<JumpToPlaygroundButtonProps> = (
|
||||
asChild
|
||||
>
|
||||
<Link href={`/project/${projectId}/playground`}>
|
||||
<Terminal className="h-5 w-5" />
|
||||
<Terminal className="h-4 w-4" />
|
||||
{props.fullWidth ? (
|
||||
<span className="ml-2">Test in playground</span>
|
||||
) : null}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user