Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
898cca56f9 | ||
|
|
83e052e769 | ||
|
|
b5f19771fa | ||
|
|
644390183b | ||
|
|
4d8e9003fe | ||
|
|
651c46a270 | ||
|
|
6b127bced0 | ||
|
|
e3a837b561 | ||
|
|
11c04f02c8 | ||
|
|
536aa138bc | ||
|
|
61b596c46a | ||
|
|
ddf43c0836 | ||
|
|
d5ab03892a | ||
|
|
918b6643d2 | ||
|
|
937b4c0f0f | ||
|
|
9b7f046e71 | ||
|
|
fe63704508 | ||
|
|
c39279f677 | ||
|
|
4206f5025d | ||
|
|
0260a4e861 | ||
|
|
244adf0dc3 | ||
|
|
dc6b98d009 | ||
|
|
b20fc5a20b | ||
|
|
f59ad5fd60 | ||
|
|
06ef41e372 | ||
|
|
e215a2db99 | ||
|
|
5e247e88c9 | ||
|
|
8e1e27c868 | ||
|
|
4648a5be8b |
+16
-2
@@ -39,6 +39,10 @@ ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000
|
||||
# LANGFUSE_DEFAULT_PROJECT_ID=
|
||||
# LANGFUSE_DEFAULT_PROJECT_ROLE=
|
||||
|
||||
# Logging, optional
|
||||
# LANGFUSE_LOG_LEVEL=info
|
||||
# LANGFUSE_LOG_FORMAT=text
|
||||
|
||||
# Enable experimental features, optional
|
||||
# LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES=true
|
||||
|
||||
@@ -97,6 +101,17 @@ ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000
|
||||
# The page size can be adjusted if needed to optimize performance
|
||||
# DB_EXPORT_PAGE_SIZE=1000
|
||||
|
||||
# Automated provisioning of default resources
|
||||
# LANGFUSE_INIT_ORG_ID=org-id
|
||||
# LANGFUSE_INIT_ORG_NAME=org-name
|
||||
# LANGFUSE_INIT_PROJECT_ID=project-id
|
||||
# LANGFUSE_INIT_PROJECT_NAME=project-name
|
||||
# LANGFUSE_INIT_PROJECT_PUBLIC_KEY=pk-1234567890
|
||||
# LANGFUSE_INIT_PROJECT_SECRET_KEY=sk-1234567890
|
||||
# LANGFUSE_INIT_USER_EMAIL=user@example.com
|
||||
# LANGFUSE_INIT_USER_NAME=User Name
|
||||
# LANGFUSE_INIT_USER_PASSWORD=password
|
||||
|
||||
|
||||
|
||||
### START Enterprise Edition Configuration
|
||||
@@ -145,7 +160,6 @@ ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000
|
||||
# NEXT_SENTRY_PROJECT=
|
||||
# SENTRY_AUTH_TOKEN=
|
||||
# SENTRY_CSP_REPORT_URI=
|
||||
# LANGFUSE_WORKER_BETTERSTACK_TOKEN=
|
||||
|
||||
|
||||
# Cloudflare Turnstile
|
||||
@@ -201,8 +215,8 @@ ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000
|
||||
# LANGFUSE_INGESTION_CLICKHOUSE_WRITE_BATCH_SIZE=
|
||||
# LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS=
|
||||
# LANGFUSE_INGESTION_CLICKHOUSE_MAX_ATTEMPTS=
|
||||
# LANGFUSE_LOG_LEVEL=
|
||||
# LANGFUSE_LEGACY_INGESTION_WORKER_CONCURRENCY=
|
||||
# LANGFUSE_ASYNC_INGESTION_PROCESSING="true"
|
||||
# QUEUE_CONSUMER_LEGACY_INGESTION_QUEUE_IS_ENABLED="true"
|
||||
|
||||
## END Langfuse V3 Ingestion
|
||||
@@ -40,6 +40,9 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set NEXT_PUBLIC_BUILD_ID
|
||||
run: echo "NEXT_PUBLIC_BUILD_ID=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
||||
|
||||
- name: Build and run both images from compose
|
||||
run: |
|
||||
docker compose -f docker-compose.build.yml up -d
|
||||
@@ -107,13 +110,26 @@ jobs:
|
||||
- name: Seed DB
|
||||
run: |
|
||||
pnpm run db:migrate
|
||||
pnpm run db:seed
|
||||
|
||||
- name: Build
|
||||
run: pnpm run build
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
|
||||
|
||||
- name: Start Langfuse
|
||||
run: (pnpm run start&)
|
||||
env:
|
||||
LANGFUSE_INIT_ORG_ID: "seed-org-id"
|
||||
LANGFUSE_INIT_ORG_NAME: "Seed Org"
|
||||
LANGFUSE_INIT_PROJECT_ID: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a"
|
||||
LANGFUSE_INIT_PROJECT_NAME: "Seed Project"
|
||||
LANGFUSE_INIT_PROJECT_PUBLIC_KEY: "pk-lf-1234567890"
|
||||
LANGFUSE_INIT_PROJECT_SECRET_KEY: "sk-lf-1234567890"
|
||||
LANGFUSE_INIT_USER_EMAIL: "demo@langfuse.com"
|
||||
LANGFUSE_INIT_USER_NAME: "Demo User"
|
||||
LANGFUSE_INIT_USER_PASSWORD: "password"
|
||||
|
||||
- name: run tests
|
||||
run: pnpm --filter=web run test
|
||||
@@ -317,6 +333,9 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set NEXT_PUBLIC_BUILD_ID
|
||||
run: echo "NEXT_PUBLIC_BUILD_ID=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
||||
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
|
||||
@@ -27,3 +27,4 @@ jobs:
|
||||
PORTER_STACK_NAME: web
|
||||
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
|
||||
PORTER_TOKEN: ${{ secrets.PORTER_STACK_12565_4060 }}
|
||||
PORTER_NEXT_PUBLIC_BUILD_ID: ${{ steps.vars.outputs.sha_short }}
|
||||
|
||||
@@ -27,3 +27,4 @@ jobs:
|
||||
PORTER_STACK_NAME: web
|
||||
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
|
||||
PORTER_TOKEN: ${{ secrets.PORTER_STACK_12565_4037 }}
|
||||
PORTER_NEXT_PUBLIC_BUILD_ID: ${{ steps.vars.outputs.sha_short }}
|
||||
|
||||
@@ -27,3 +27,4 @@ jobs:
|
||||
PORTER_STACK_NAME: web
|
||||
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
|
||||
PORTER_TOKEN: ${{ secrets.PORTER_STACK_12565_4054 }}
|
||||
PORTER_NEXT_PUBLIC_BUILD_ID: ${{ steps.vars.outputs.sha_short }}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "langfuse",
|
||||
"version": "2.75.2",
|
||||
"version": "2.78.0",
|
||||
"author": "engineering@langfuse.com",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
"import": "./dist/src/server/index.js",
|
||||
"require": "./dist/src/server/index.js"
|
||||
},
|
||||
"./src/server/auth/apiKeys": {
|
||||
"import": "./dist/src/server/auth/apiKeys.js",
|
||||
"require": "./dist/src/server/auth/apiKeys.js"
|
||||
},
|
||||
"./encryption": {
|
||||
"import": "./dist/src/encryption/index.js",
|
||||
"require": "./dist/src/encryption/index.js"
|
||||
@@ -75,13 +79,14 @@
|
||||
"nodemailer": "^6.9.13",
|
||||
"prisma-extension-kysely": "^2.1.0",
|
||||
"uuid": "^9.0.1",
|
||||
"winston": "^3.14.2",
|
||||
"zod": "^3.23.8",
|
||||
"zod-to-json-schema": "^3.23.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@types/lodash": "^4.17.5",
|
||||
"@types/lodash": "^4.17.7",
|
||||
"@types/node": "^20.11.29",
|
||||
"@types/nodemailer": "^6.4.15",
|
||||
"@types/pg": "^8.11.6",
|
||||
@@ -103,7 +108,7 @@
|
||||
"vitest": "^1.5.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.79",
|
||||
"react": "^18.0.0"
|
||||
"@types/react": "~18.2.79",
|
||||
"react": "~18.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { parseArgs } from "node:util";
|
||||
import { chunk } from "lodash";
|
||||
import { v4 } from "uuid";
|
||||
import { ModelUsageUnit } from "../src";
|
||||
import { getDisplaySecretKey, hashSecretKey } from "../src/server";
|
||||
import { getDisplaySecretKey, hashSecretKey, logger } from "../src/server";
|
||||
import { encrypt } from "../src/encryption";
|
||||
import { redis } from "../src/server/redis/redis";
|
||||
|
||||
@@ -265,11 +265,11 @@ async function main() {
|
||||
project1,
|
||||
project2,
|
||||
promptIds,
|
||||
configIdsAndNames
|
||||
configIdsAndNames,
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Seeding ${traces.length} traces, ${observations.length} observations, and ${scores.length} scores`
|
||||
logger.info(
|
||||
`Seeding ${traces.length} traces, ${observations.length} observations, and ${scores.length} scores`,
|
||||
);
|
||||
|
||||
await uploadObjects(
|
||||
@@ -278,7 +278,7 @@ async function main() {
|
||||
scores,
|
||||
sessions,
|
||||
events,
|
||||
comments
|
||||
comments,
|
||||
);
|
||||
|
||||
// If openai key is in environment, add it to the projects LLM API keys
|
||||
@@ -295,8 +295,8 @@ async function main() {
|
||||
},
|
||||
});
|
||||
} else {
|
||||
console.warn(
|
||||
"No OPENAI_API_KEY found in environment. Skipping seeding LLM API key."
|
||||
logger.warn(
|
||||
"No OPENAI_API_KEY found in environment. Skipping seeding LLM API key.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -431,7 +431,7 @@ async function main() {
|
||||
|
||||
for (const datasetItemId of datasetItemIds) {
|
||||
const relevantObservations = observations.filter(
|
||||
(o) => o.projectId === project2.id
|
||||
(o) => o.projectId === project2.id,
|
||||
);
|
||||
const observation =
|
||||
relevantObservations[
|
||||
@@ -457,13 +457,13 @@ main()
|
||||
.then(async () => {
|
||||
await prisma.$disconnect();
|
||||
redis?.disconnect();
|
||||
console.log("Disconnected from postgres and redis");
|
||||
logger.info("Disconnected from postgres and redis");
|
||||
})
|
||||
.catch(async (e) => {
|
||||
console.error(e);
|
||||
logger.error(e);
|
||||
await prisma.$disconnect();
|
||||
redis?.disconnect();
|
||||
console.log("Disconnected from postgres and redis");
|
||||
logger.info("Disconnected from postgres and redis");
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -473,7 +473,7 @@ async function uploadObjects(
|
||||
scores: Prisma.ScoreCreateManyInput[],
|
||||
sessions: Prisma.TraceSessionCreateManyInput[],
|
||||
events: Prisma.ObservationCreateManyInput[],
|
||||
comments: Prisma.CommentCreateManyInput[]
|
||||
comments: Prisma.CommentCreateManyInput[],
|
||||
) {
|
||||
let promises: Prisma.PrismaPromise<unknown>[] = [];
|
||||
|
||||
@@ -487,14 +487,14 @@ async function uploadObjects(
|
||||
},
|
||||
create: chunk[0]!,
|
||||
update: {},
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
for (let i = 0; i < promises.length; i++) {
|
||||
if (i + 1 >= promises.length || i % Math.ceil(promises.length / 10) === 0)
|
||||
console.log(
|
||||
`Seeding of Sessions ${((i + 1) / promises.length) * 100}% complete`
|
||||
logger.info(
|
||||
`Seeding of Sessions ${((i + 1) / promises.length) * 100}% complete`,
|
||||
);
|
||||
await promises[i];
|
||||
}
|
||||
@@ -505,13 +505,13 @@ async function uploadObjects(
|
||||
promises.push(
|
||||
prisma.trace.createMany({
|
||||
data: chunk,
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
for (let i = 0; i < promises.length; i++) {
|
||||
if (i + 1 >= promises.length || i % Math.ceil(promises.length / 10) === 0)
|
||||
console.log(
|
||||
`Seeding of Traces ${((i + 1) / promises.length) * 100}% complete`
|
||||
logger.info(
|
||||
`Seeding of Traces ${((i + 1) / promises.length) * 100}% complete`,
|
||||
);
|
||||
await promises[i];
|
||||
}
|
||||
@@ -521,14 +521,14 @@ async function uploadObjects(
|
||||
promises.push(
|
||||
prisma.observation.createMany({
|
||||
data: chunk,
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
for (let i = 0; i < promises.length; i++) {
|
||||
if (i + 1 >= promises.length || i % Math.ceil(promises.length / 10) === 0)
|
||||
console.log(
|
||||
`Seeding of Observations ${((i + 1) / promises.length) * 100}% complete`
|
||||
logger.info(
|
||||
`Seeding of Observations ${((i + 1) / promises.length) * 100}% complete`,
|
||||
);
|
||||
await promises[i];
|
||||
}
|
||||
@@ -538,14 +538,14 @@ async function uploadObjects(
|
||||
promises.push(
|
||||
prisma.observation.createMany({
|
||||
data: chunk,
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
for (let i = 0; i < promises.length; i++) {
|
||||
if (i + 1 >= promises.length || i % Math.ceil(promises.length / 10) === 0)
|
||||
console.log(
|
||||
`Seeding of Events ${((i + 1) / promises.length) * 100}% complete`
|
||||
logger.info(
|
||||
`Seeding of Events ${((i + 1) / promises.length) * 100}% complete`,
|
||||
);
|
||||
await promises[i];
|
||||
}
|
||||
@@ -555,13 +555,13 @@ async function uploadObjects(
|
||||
promises.push(
|
||||
prisma.score.createMany({
|
||||
data: chunk,
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
for (let i = 0; i < promises.length; i++) {
|
||||
if (i + 1 >= promises.length || i % Math.ceil(promises.length / 10) === 0)
|
||||
console.log(
|
||||
`Seeding of Scores ${((i + 1) / promises.length) * 100}% complete`
|
||||
logger.info(
|
||||
`Seeding of Scores ${((i + 1) / promises.length) * 100}% complete`,
|
||||
);
|
||||
await promises[i];
|
||||
}
|
||||
@@ -571,13 +571,13 @@ async function uploadObjects(
|
||||
promises.push(
|
||||
prisma.comment.createMany({
|
||||
data: chunk,
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
for (let i = 0; i < promises.length; i++) {
|
||||
if (i + 1 >= promises.length || i % Math.ceil(promises.length / 10) === 0)
|
||||
console.log(
|
||||
`Seeding of Comments ${((i + 1) / promises.length) * 100}% complete`
|
||||
logger.info(
|
||||
`Seeding of Comments ${((i + 1) / promises.length) * 100}% complete`,
|
||||
);
|
||||
await promises[i];
|
||||
}
|
||||
@@ -598,7 +598,7 @@ function createObjects(
|
||||
dataType: ScoreDataType;
|
||||
categories: ConfigCategory[] | null;
|
||||
}[]
|
||||
>
|
||||
>,
|
||||
) {
|
||||
const traces: Prisma.TraceCreateManyInput[] = [];
|
||||
const observations: Prisma.ObservationCreateManyInput[] = [];
|
||||
@@ -612,7 +612,7 @@ function createObjects(
|
||||
// print progress to console with a progress bar that refreshes every 10 iterations
|
||||
// random date within last 90 days, with a linear bias towards more recent dates
|
||||
const traceTs = new Date(
|
||||
Date.now() - Math.floor(Math.random() ** 1.5 * 90 * 24 * 60 * 60 * 1000)
|
||||
Date.now() - Math.floor(Math.random() ** 1.5 * 90 * 24 * 60 * 60 * 1000),
|
||||
);
|
||||
|
||||
const envTag = envTags[Math.floor(Math.random() * envTags.length)];
|
||||
@@ -753,11 +753,11 @@ function createObjects(
|
||||
for (let j = 0; j < Math.floor(Math.random() * 10) + 1; j++) {
|
||||
// add between 1 and 30 ms to trace timestamp
|
||||
const spanTsStart = new Date(
|
||||
traceTs.getTime() + Math.floor(Math.random() * 30)
|
||||
traceTs.getTime() + Math.floor(Math.random() * 30),
|
||||
);
|
||||
// random duration of upto 5000ms
|
||||
const spanTsEnd = new Date(
|
||||
spanTsStart.getTime() + Math.floor(Math.random() * 5000)
|
||||
spanTsStart.getTime() + Math.floor(Math.random() * 5000),
|
||||
);
|
||||
|
||||
const span = {
|
||||
@@ -792,22 +792,22 @@ function createObjects(
|
||||
const generationTsStart = new Date(
|
||||
spanTsStart.getTime() +
|
||||
Math.floor(
|
||||
Math.random() * (spanTsEnd.getTime() - spanTsStart.getTime())
|
||||
)
|
||||
Math.random() * (spanTsEnd.getTime() - spanTsStart.getTime()),
|
||||
),
|
||||
);
|
||||
const generationTsEnd = new Date(
|
||||
generationTsStart.getTime() +
|
||||
Math.floor(
|
||||
Math.random() *
|
||||
(spanTsEnd.getTime() - generationTsStart.getTime())
|
||||
)
|
||||
(spanTsEnd.getTime() - generationTsStart.getTime()),
|
||||
),
|
||||
);
|
||||
// somewhere in the middle
|
||||
const generationTsCompletionStart = new Date(
|
||||
generationTsStart.getTime() +
|
||||
Math.floor(
|
||||
(generationTsEnd.getTime() - generationTsStart.getTime()) / 3
|
||||
)
|
||||
(generationTsEnd.getTime() - generationTsStart.getTime()) / 3,
|
||||
),
|
||||
);
|
||||
|
||||
const promptTokens = Math.floor(Math.random() * 1000) + 300;
|
||||
@@ -828,7 +828,7 @@ function createObjects(
|
||||
const promptId =
|
||||
promptIds.get(projectId)![
|
||||
Math.floor(
|
||||
Math.random() * Math.floor(promptIds.get(projectId)!.length / 2)
|
||||
Math.random() * Math.floor(promptIds.get(projectId)!.length / 2),
|
||||
)
|
||||
];
|
||||
|
||||
@@ -910,8 +910,8 @@ function createObjects(
|
||||
const eventTs = new Date(
|
||||
spanTsStart.getTime() +
|
||||
Math.floor(
|
||||
Math.random() * (spanTsEnd.getTime() - spanTsStart.getTime())
|
||||
)
|
||||
Math.random() * (spanTsEnd.getTime() - spanTsStart.getTime()),
|
||||
),
|
||||
);
|
||||
|
||||
events.push({
|
||||
@@ -933,7 +933,7 @@ function createObjects(
|
||||
}
|
||||
// find unique sessions by id and projectid
|
||||
const uniqueSessions: Prisma.TraceSessionCreateManyInput[] = Array.from(
|
||||
new Set(sessions.map((session) => JSON.stringify(session)))
|
||||
new Set(sessions.map((session) => JSON.stringify(session))),
|
||||
).map((session) => JSON.parse(session) as Prisma.TraceSessionCreateManyInput);
|
||||
|
||||
return {
|
||||
@@ -954,7 +954,7 @@ async function generatePromptsForProject(projects: Project[]) {
|
||||
projects.map(async (project) => {
|
||||
const promptIdsForProject = await generatePrompts(project);
|
||||
promptIds.set(project.id, promptIdsForProject);
|
||||
})
|
||||
}),
|
||||
);
|
||||
return promptIds;
|
||||
}
|
||||
@@ -1140,7 +1140,7 @@ async function generateConfigsForProject(projects: Project[]) {
|
||||
projects.map(async (project) => {
|
||||
const configNameAndId = await generateConfigs(project);
|
||||
projectIdsToConfigs.set(project.id, configNameAndId);
|
||||
})
|
||||
}),
|
||||
);
|
||||
return projectIdsToConfigs;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// This file exports the prisma db connection, the Prisma Object, and the Typescript types.
|
||||
// This is not imported in the index.ts file of this package, as we must not import this into FE code.
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { Prisma, PrismaClient } from "@prisma/client";
|
||||
import { env } from "process";
|
||||
import kyselyExtension from "prisma-extension-kysely";
|
||||
import {
|
||||
@@ -11,17 +11,38 @@ import {
|
||||
PostgresQueryCompiler,
|
||||
} from "kysely";
|
||||
import { DB } from ".";
|
||||
import { logger } from "./server";
|
||||
|
||||
// Instantiated according to the Prisma documentation
|
||||
// https://www.prisma.io/docs/orm/more/help-and-troubleshooting/help-articles/nextjs-prisma-client-dev-practices
|
||||
|
||||
const prismaClientSingleton = () => {
|
||||
return new PrismaClient({
|
||||
log:
|
||||
env.NODE_ENV === "development"
|
||||
? ["query", "error", "warn"]
|
||||
: ["error", "warn"],
|
||||
const client = new PrismaClient<
|
||||
Prisma.PrismaClientOptions,
|
||||
"warn" | "error" | "query"
|
||||
>({
|
||||
log: [
|
||||
{ emit: "event", level: "query" },
|
||||
{ emit: "event", level: "error" },
|
||||
{ emit: "event", level: "warn" },
|
||||
],
|
||||
});
|
||||
|
||||
if (env.NODE_ENV === "development") {
|
||||
client.$on("query", (event) => {
|
||||
logger.info(`prisma:query ${event.query}, ${event.duration}ms`);
|
||||
});
|
||||
}
|
||||
|
||||
client.$on("warn", (event) => {
|
||||
logger.warn(`prisma:warn ${event.message}`);
|
||||
});
|
||||
|
||||
client.$on("error", (event) => {
|
||||
logger.error(`prisma:error ${event.message}`);
|
||||
});
|
||||
|
||||
return client;
|
||||
};
|
||||
|
||||
const kyselySingleton = (prismaClient: PrismaClient) => {
|
||||
@@ -38,7 +59,7 @@ const kyselySingleton = (prismaClient: PrismaClient) => {
|
||||
createQueryCompiler: () => new PostgresQueryCompiler(),
|
||||
},
|
||||
}),
|
||||
})
|
||||
}),
|
||||
);
|
||||
};
|
||||
declare global {
|
||||
|
||||
@@ -21,7 +21,7 @@ const EnvSchema = z.object({
|
||||
.string()
|
||||
.length(
|
||||
64,
|
||||
"ENCRYPTION_KEY must be 256 bits, 64 string characters in hex format, generate via: openssl rand -hex 32"
|
||||
"ENCRYPTION_KEY must be 256 bits, 64 string characters in hex format, generate via: openssl rand -hex 32",
|
||||
)
|
||||
.optional(),
|
||||
LANGFUSE_CACHE_PROMPT_ENABLED: z.enum(["true", "false"]).default("false"),
|
||||
@@ -38,6 +38,11 @@ const EnvSchema = z.object({
|
||||
.number()
|
||||
.positive()
|
||||
.default(60 * 10),
|
||||
SALT: z.string().optional(), // used by components imported by web package
|
||||
LANGFUSE_LOG_LEVEL: z
|
||||
.enum(["trace", "debug", "info", "warn", "error", "fatal"])
|
||||
.optional(),
|
||||
LANGFUSE_LOG_FORMAT: z.enum(["text", "json"]).default("text"),
|
||||
});
|
||||
|
||||
export const env = EnvSchema.parse(process.env);
|
||||
|
||||
@@ -83,13 +83,13 @@ export const ScoreBodyWithoutConfig = z.discriminatedUnion("dataType", [
|
||||
z.object({
|
||||
value: z.number(),
|
||||
dataType: z.literal("NUMERIC"),
|
||||
})
|
||||
}),
|
||||
),
|
||||
BaseScoreBody.merge(
|
||||
z.object({
|
||||
value: z.string(),
|
||||
dataType: z.literal("CATEGORICAL"),
|
||||
})
|
||||
}),
|
||||
),
|
||||
BaseScoreBody.merge(
|
||||
z.object({
|
||||
@@ -97,7 +97,7 @@ export const ScoreBodyWithoutConfig = z.discriminatedUnion("dataType", [
|
||||
message: "Value must be either 0 or 1",
|
||||
}),
|
||||
dataType: z.literal("BOOLEAN"),
|
||||
})
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -161,7 +161,7 @@ export const ScorePropsAgainstConfig = z.union([
|
||||
*/
|
||||
export const filterAndValidateDbScoreList = (
|
||||
scores: Score[],
|
||||
onParseError?: (error: z.ZodError) => void
|
||||
onParseError?: (error: z.ZodError) => void,
|
||||
): APIScore[] =>
|
||||
scores.reduce((acc, ts) => {
|
||||
const result = APIScoreSchema.safeParse(ts);
|
||||
@@ -198,14 +198,14 @@ export const PostScoresBody = z.discriminatedUnion("dataType", [
|
||||
value: z.number(),
|
||||
dataType: z.literal("NUMERIC"),
|
||||
configId: z.string().nullish(),
|
||||
})
|
||||
}),
|
||||
),
|
||||
BaseScoreBody.merge(
|
||||
z.object({
|
||||
value: z.string(),
|
||||
dataType: z.literal("CATEGORICAL"),
|
||||
configId: z.string().nullish(),
|
||||
})
|
||||
}),
|
||||
),
|
||||
BaseScoreBody.merge(
|
||||
z.object({
|
||||
@@ -215,14 +215,14 @@ export const PostScoresBody = z.discriminatedUnion("dataType", [
|
||||
}),
|
||||
dataType: z.literal("BOOLEAN"),
|
||||
configId: z.string().nullish(),
|
||||
})
|
||||
}),
|
||||
),
|
||||
BaseScoreBody.merge(
|
||||
z.object({
|
||||
value: z.union([z.string(), z.number()]),
|
||||
dataType: z.undefined(),
|
||||
configId: z.string().nullish(),
|
||||
})
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -256,7 +256,7 @@ const LegacyGetScoreResponseDataV1 = z.intersection(
|
||||
trace: z.object({
|
||||
userId: z.string().nullish(),
|
||||
}),
|
||||
})
|
||||
}),
|
||||
);
|
||||
export const GetScoresResponse = z.object({
|
||||
data: z.array(LegacyGetScoreResponseDataV1),
|
||||
@@ -265,7 +265,7 @@ export const GetScoresResponse = z.object({
|
||||
|
||||
export const legacyFilterAndValidateV1GetScoreList = (
|
||||
scores: unknown[],
|
||||
onParseError?: (error: z.ZodError) => void
|
||||
onParseError?: (error: z.ZodError) => void,
|
||||
): z.infer<typeof LegacyGetScoreResponseDataV1>[] =>
|
||||
scores.reduce(
|
||||
(acc: z.infer<typeof LegacyGetScoreResponseDataV1>[], ts) => {
|
||||
@@ -278,7 +278,7 @@ export const legacyFilterAndValidateV1GetScoreList = (
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[] as z.infer<typeof LegacyGetScoreResponseDataV1>[]
|
||||
[] as z.infer<typeof LegacyGetScoreResponseDataV1>[],
|
||||
);
|
||||
|
||||
// GET /scores/{scoreId}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
export * from "./constants";
|
||||
export * from "./queries";
|
||||
export * from "./interfaces/filters";
|
||||
export * from "./interfaces/orderBy";
|
||||
export * from "./interfaces/cloudConfigSchema";
|
||||
export * from "./interfaces/parseDbOrg";
|
||||
export * from "./tableDefinitions";
|
||||
export * from "./types";
|
||||
export * from "./filterToPrisma";
|
||||
export * from "./orderByToPrisma";
|
||||
export * from "./tracesTable";
|
||||
export * from "./server/auth/auth";
|
||||
export * from "./server/auth/apiKeys";
|
||||
export * from "./observationsTable";
|
||||
export * from "./utils/zod";
|
||||
export * from "./utils/json";
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { compare, hash } from "bcryptjs";
|
||||
import { randomUUID } from "crypto";
|
||||
import * as crypto from "crypto";
|
||||
import { env } from "../../env";
|
||||
|
||||
export function getDisplaySecretKey(secretKey: string) {
|
||||
return secretKey.slice(0, 6) + "..." + secretKey.slice(-4);
|
||||
}
|
||||
|
||||
export async function hashSecretKey(key: string) {
|
||||
// legacy, uses bcrypt, transformed into hashed key upon first use
|
||||
const hashedKey = await hash(key, 11);
|
||||
return hashedKey;
|
||||
}
|
||||
|
||||
async function generateKeySet() {
|
||||
return {
|
||||
pk: `pk-lf-${randomUUID()}`,
|
||||
sk: `sk-lf-${randomUUID()}`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifySecretKey(key: string, hashedKey: string) {
|
||||
const isValid = await compare(key, hashedKey);
|
||||
return isValid;
|
||||
}
|
||||
|
||||
export function createShaHash(privateKey: string, salt: string): string {
|
||||
const hash = crypto
|
||||
.createHash("sha256")
|
||||
.update(privateKey)
|
||||
.update(crypto.createHash("sha256").update(salt, "utf8").digest("hex"))
|
||||
.digest("hex");
|
||||
|
||||
return hash;
|
||||
}
|
||||
|
||||
export async function createAndAddApiKeysToDb(p: {
|
||||
prisma: PrismaClient;
|
||||
projectId: string;
|
||||
note?: string;
|
||||
predefinedKeys?: {
|
||||
secretKey: string;
|
||||
publicKey: string;
|
||||
};
|
||||
}) {
|
||||
const salt = env.SALT;
|
||||
if (!salt) {
|
||||
throw new Error("SALT is not set");
|
||||
}
|
||||
|
||||
const { pk, sk } = p.predefinedKeys
|
||||
? { pk: p.predefinedKeys.publicKey, sk: p.predefinedKeys.secretKey }
|
||||
: await generateKeySet();
|
||||
|
||||
const hashedSk = await hashSecretKey(sk);
|
||||
const displaySk = getDisplaySecretKey(sk);
|
||||
|
||||
const hashFromProvidedKey = createShaHash(sk, salt);
|
||||
|
||||
const apiKey = await p.prisma.apiKey.create({
|
||||
data: {
|
||||
projectId: p.projectId,
|
||||
publicKey: pk,
|
||||
hashedSecretKey: hashedSk,
|
||||
displaySecretKey: displaySk,
|
||||
fastHashedSecretKey: hashFromProvidedKey,
|
||||
note: p.note,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: apiKey.id,
|
||||
createdAt: apiKey.createdAt,
|
||||
note: apiKey.note,
|
||||
publicKey: apiKey.publicKey,
|
||||
secretKey: sk,
|
||||
displaySecretKey: displaySk,
|
||||
};
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { compare, hash } from "bcryptjs";
|
||||
import { randomUUID } from "crypto";
|
||||
import * as crypto from "crypto";
|
||||
import type { OAuthConfig, OAuthUserConfig } from "next-auth/providers/oauth";
|
||||
|
||||
export function generateSecretKey() {
|
||||
return `sk-lf-${randomUUID()}`;
|
||||
}
|
||||
|
||||
export function generatePublicKey() {
|
||||
return `pk-lf-${randomUUID()}`;
|
||||
}
|
||||
|
||||
export function getDisplaySecretKey(secretKey: string) {
|
||||
return secretKey.slice(0, 6) + "..." + secretKey.slice(-4);
|
||||
}
|
||||
|
||||
export async function hashSecretKey(key: string) {
|
||||
// legacy, uses bcrypt, transformed into hashed key upon first use
|
||||
const hashedKey = await hash(key, 11);
|
||||
return hashedKey;
|
||||
}
|
||||
|
||||
export async function generateKeySet() {
|
||||
const pk = generatePublicKey();
|
||||
const sk = generateSecretKey();
|
||||
const hashedSk = await hashSecretKey(sk);
|
||||
const displaySk = getDisplaySecretKey(sk);
|
||||
|
||||
return {
|
||||
pk,
|
||||
sk,
|
||||
hashedSk,
|
||||
displaySk,
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifySecretKey(key: string, hashedKey: string) {
|
||||
const isValid = await compare(key, hashedKey);
|
||||
return isValid;
|
||||
}
|
||||
|
||||
export function createShaHash(privateKey: string, salt: string): string {
|
||||
const hash = crypto
|
||||
.createHash("sha256")
|
||||
.update(privateKey)
|
||||
.update(crypto.createHash("sha256").update(salt, "utf8").digest("hex"))
|
||||
.digest("hex");
|
||||
|
||||
return hash;
|
||||
}
|
||||
|
||||
export interface CustomSSOUser extends Record<string, any> {
|
||||
email: string;
|
||||
id: string;
|
||||
name: string;
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
export function CustomSSOProvider<P extends CustomSSOUser>(
|
||||
options: OAuthUserConfig<P>
|
||||
): OAuthConfig<P> {
|
||||
return {
|
||||
id: "custom",
|
||||
name: "CustomSSOProvider",
|
||||
type: "oauth",
|
||||
wellKnown: `${options.issuer}/.well-known/openid-configuration`,
|
||||
authorization: { params: { scope: "openid email profile" } }, // overridden by options.authorization to be able to set custom scopes, deep merged with this default
|
||||
checks: ["pkce", "state"],
|
||||
idToken: true,
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.sub,
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
image: null,
|
||||
};
|
||||
},
|
||||
options,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { OAuthConfig, OAuthUserConfig } from "next-auth/providers/oauth";
|
||||
|
||||
interface CustomSSOUser extends Record<string, any> {
|
||||
email: string;
|
||||
id: string;
|
||||
name: string;
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
export function CustomSSOProvider<P extends CustomSSOUser>(
|
||||
options: OAuthUserConfig<P>
|
||||
): OAuthConfig<P> {
|
||||
return {
|
||||
id: "custom",
|
||||
name: "CustomSSOProvider",
|
||||
type: "oauth",
|
||||
wellKnown: `${options.issuer}/.well-known/openid-configuration`,
|
||||
authorization: { params: { scope: "openid email profile" } }, // overridden by options.authorization to be able to set custom scopes, deep merged with this default
|
||||
checks: ["pkce", "state"],
|
||||
idToken: true,
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.sub,
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
image: null,
|
||||
};
|
||||
},
|
||||
options,
|
||||
};
|
||||
}
|
||||
+18
-17
@@ -1,8 +1,9 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { ColumnDefinition, type TableNames } from "./tableDefinitions";
|
||||
import { FilterState } from "./types";
|
||||
import { filterOperators, timeFilter } from "./interfaces/filters";
|
||||
import { ColumnDefinition, type TableNames } from "../tableDefinitions";
|
||||
import { FilterState } from "../types";
|
||||
import { filterOperators, timeFilter } from "../interfaces/filters";
|
||||
import { z } from "zod";
|
||||
import { logger } from "./index";
|
||||
|
||||
const operatorReplacements = {
|
||||
"any of": "IN",
|
||||
@@ -25,7 +26,7 @@ const arrayOperatorReplacements = {
|
||||
export function tableColumnsToSqlFilterAndPrefix(
|
||||
filters: FilterState,
|
||||
tableColumns: ColumnDefinition[],
|
||||
table: TableNames
|
||||
table: TableNames,
|
||||
): Prisma.Sql {
|
||||
const sql = tableColumnsToSqlFilter(filters, tableColumns, table);
|
||||
if (sql === Prisma.empty) {
|
||||
@@ -41,17 +42,17 @@ export function tableColumnsToSqlFilterAndPrefix(
|
||||
export function tableColumnsToSqlFilter(
|
||||
filters: FilterState,
|
||||
tableColumns: ColumnDefinition[],
|
||||
table: TableNames
|
||||
table: TableNames,
|
||||
): Prisma.Sql {
|
||||
const internalFilters = filters.map((filter) => {
|
||||
// Get column definition to map column to internal name, e.g. "t.id"
|
||||
const col = tableColumns.find(
|
||||
(c) =>
|
||||
// TODO: Only use id instead of name
|
||||
c.name === filter.column || c.id === filter.column
|
||||
c.name === filter.column || c.id === filter.column,
|
||||
);
|
||||
if (!col) {
|
||||
console.error("Invalid filter column", filter.column);
|
||||
logger.error("Invalid filter column", filter.column);
|
||||
throw new Error("Invalid filter column: " + filter.column);
|
||||
}
|
||||
const colPrisma = Prisma.raw(col.internal);
|
||||
@@ -70,13 +71,13 @@ export function tableColumnsToSqlFilter(
|
||||
? Prisma.raw(
|
||||
arrayOperatorReplacements[
|
||||
filter.operator as keyof typeof arrayOperatorReplacements
|
||||
]
|
||||
],
|
||||
)
|
||||
: filter.operator in operatorReplacements
|
||||
? Prisma.raw(
|
||||
operatorReplacements[
|
||||
filter.operator as keyof typeof operatorReplacements
|
||||
]
|
||||
],
|
||||
)
|
||||
: Prisma.raw(filter.operator); //checked by zod
|
||||
|
||||
@@ -96,13 +97,13 @@ export function tableColumnsToSqlFilter(
|
||||
break;
|
||||
case "stringOptions":
|
||||
valuePrisma = Prisma.sql`(${Prisma.join(
|
||||
filter.value.map((v) => Prisma.sql`${v}`)
|
||||
filter.value.map((v) => Prisma.sql`${v}`),
|
||||
)})`;
|
||||
break;
|
||||
case "arrayOptions":
|
||||
valuePrisma = Prisma.sql`ARRAY[${Prisma.join(
|
||||
filter.value.map((v) => Prisma.sql`${v}`),
|
||||
", "
|
||||
", ",
|
||||
)}] `;
|
||||
break;
|
||||
|
||||
@@ -122,12 +123,12 @@ export function tableColumnsToSqlFilter(
|
||||
filter.type === "string" || filter.type === "stringObject"
|
||||
? [
|
||||
["contains", "does not contain", "ends with"].includes(
|
||||
filter.operator
|
||||
filter.operator,
|
||||
)
|
||||
? Prisma.raw("'%' || ")
|
||||
: Prisma.empty,
|
||||
["contains", "does not contain", "starts with"].includes(
|
||||
filter.operator
|
||||
filter.operator,
|
||||
)
|
||||
? Prisma.raw(" || '%'")
|
||||
: Prisma.empty,
|
||||
@@ -151,7 +152,7 @@ export function tableColumnsToSqlFilter(
|
||||
|
||||
const castValueToPostgresTypes = (
|
||||
column: ColumnDefinition,
|
||||
table: TableNames
|
||||
table: TableNames,
|
||||
) => {
|
||||
return column.name === "type" &&
|
||||
(table === "observations" ||
|
||||
@@ -167,7 +168,7 @@ const dateOperators = filterOperators["datetime"];
|
||||
export const datetimeFilterToPrismaSql = (
|
||||
safeColumn: string,
|
||||
operator: (typeof dateOperators)[number],
|
||||
value: Date
|
||||
value: Date,
|
||||
) => {
|
||||
if (!dateOperators.includes(operator)) {
|
||||
throw new Error("Invalid operator: " + operator);
|
||||
@@ -177,12 +178,12 @@ export const datetimeFilterToPrismaSql = (
|
||||
}
|
||||
|
||||
return Prisma.sql`AND ${Prisma.raw(safeColumn)} ${Prisma.raw(
|
||||
operator
|
||||
operator,
|
||||
)} ${value}::timestamp with time zone at time zone 'UTC'`;
|
||||
};
|
||||
|
||||
export const datetimeFilterToPrisma = (
|
||||
timestampFilter: z.infer<typeof timeFilter>
|
||||
timestampFilter: z.infer<typeof timeFilter>,
|
||||
) => {
|
||||
const prismaTimestampFilter =
|
||||
timestampFilter.operator === ">="
|
||||
@@ -3,7 +3,8 @@ export * from "./services/email/organizationInvitation/sendMembershipInvitationE
|
||||
export * from "./services/email/batchExportSuccess/sendBatchExportSuccessEmail";
|
||||
export * from "./services/email/passwordReset/sendResetPasswordVerificationRequest";
|
||||
export * from "./services/PromptService";
|
||||
export * from "./auth/auth";
|
||||
export * from "./auth/apiKeys";
|
||||
export * from "./auth/customSsoProvider";
|
||||
export * from "./llm/fetchLLMCompletion";
|
||||
export * from "./llm/types";
|
||||
export * from "./utils/DatabaseReadStream";
|
||||
@@ -24,4 +25,8 @@ export * from "./auth/types";
|
||||
export * from "./ingestion/legacy/index";
|
||||
export * from "./queues";
|
||||
export * from "./ingestion/legacy/EventProcessor";
|
||||
export * from "./orderByToPrisma";
|
||||
export * from "./filterToPrisma";
|
||||
export * from "./instrumentation";
|
||||
export * from "./logger";
|
||||
export * from "./queries";
|
||||
|
||||
@@ -18,12 +18,13 @@ import { mergeJson } from "../../../utils/json";
|
||||
import { jsonSchema } from "../../../utils/zod";
|
||||
import { prisma } from "../../../db";
|
||||
import { LegacyIngestionAccessScope } from ".";
|
||||
import { logger } from "../../logger";
|
||||
|
||||
export interface EventProcessor {
|
||||
auth(apiScope: LegacyIngestionAccessScope): void;
|
||||
|
||||
process(
|
||||
apiScope: LegacyIngestionAccessScope
|
||||
apiScope: LegacyIngestionAccessScope,
|
||||
): Promise<Trace | Observation | Score> | undefined;
|
||||
}
|
||||
|
||||
@@ -39,7 +40,7 @@ export class ObservationProcessor implements EventProcessor {
|
||||
calculateTokenDelegate: (p: {
|
||||
model: Model;
|
||||
text: unknown;
|
||||
}) => number | undefined
|
||||
}) => number | undefined,
|
||||
) {
|
||||
this.event = event;
|
||||
this.calculateTokenDelegate = calculateTokenDelegate;
|
||||
@@ -47,7 +48,7 @@ export class ObservationProcessor implements EventProcessor {
|
||||
|
||||
async convertToObservation(
|
||||
apiScope: LegacyIngestionAccessScope,
|
||||
existingObservation: Observation | null
|
||||
existingObservation: Observation | null,
|
||||
): Promise<{
|
||||
id: string;
|
||||
create: Prisma.ObservationUncheckedCreateInput;
|
||||
@@ -77,7 +78,7 @@ export class ObservationProcessor implements EventProcessor {
|
||||
!existingObservation
|
||||
) {
|
||||
throw new LangfuseNotFoundError(
|
||||
`Observation with id ${this.event.id} not found`
|
||||
`Observation with id ${this.event.id} not found`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -123,7 +124,7 @@ export class ObservationProcessor implements EventProcessor {
|
||||
this.event.body,
|
||||
this.calculateTokenDelegate,
|
||||
internalModel ?? undefined,
|
||||
existingObservation ?? undefined
|
||||
existingObservation ?? undefined,
|
||||
)
|
||||
: [undefined, undefined];
|
||||
|
||||
@@ -159,7 +160,7 @@ export class ObservationProcessor implements EventProcessor {
|
||||
const calculatedCosts = ObservationProcessor.calculateTokenCosts(
|
||||
internalModel,
|
||||
userProvidedTokenCosts,
|
||||
tokenCounts
|
||||
tokenCounts,
|
||||
);
|
||||
|
||||
// merge metadata from existingObservation.metadata and metadata
|
||||
@@ -167,7 +168,7 @@ export class ObservationProcessor implements EventProcessor {
|
||||
existingObservation?.metadata
|
||||
? jsonSchema.parse(existingObservation.metadata)
|
||||
: undefined,
|
||||
this.event.body.metadata ?? undefined
|
||||
this.event.body.metadata ?? undefined,
|
||||
);
|
||||
|
||||
const prompt =
|
||||
@@ -187,8 +188,9 @@ export class ObservationProcessor implements EventProcessor {
|
||||
: undefined;
|
||||
|
||||
// Only null if promptName and promptVersion are set but prompt is not found
|
||||
if (prompt === null)
|
||||
console.warn("Prompt not found for observation", this.event.body);
|
||||
if (prompt === null) {
|
||||
logger.warn("Prompt not found for observation", this.event.body);
|
||||
}
|
||||
|
||||
const observationId = this.event.body.id ?? v4();
|
||||
|
||||
@@ -318,7 +320,7 @@ export class ObservationProcessor implements EventProcessor {
|
||||
text: unknown;
|
||||
}) => number | undefined,
|
||||
model?: Model,
|
||||
existingObservation?: Observation
|
||||
existingObservation?: Observation,
|
||||
) {
|
||||
const newPromptTokens =
|
||||
body.usage?.input ??
|
||||
@@ -350,7 +352,7 @@ export class ObservationProcessor implements EventProcessor {
|
||||
outputCost?: Decimal | null;
|
||||
totalCost?: Decimal | null;
|
||||
},
|
||||
tokenCounts: { input?: number; output?: number; total?: number }
|
||||
tokenCounts: { input?: number; output?: number; total?: number },
|
||||
): {
|
||||
inputCost?: Decimal | null;
|
||||
outputCost?: Decimal | null;
|
||||
@@ -367,7 +369,7 @@ export class ObservationProcessor implements EventProcessor {
|
||||
totalCost:
|
||||
userProvidedCosts.totalCost ??
|
||||
(userProvidedCosts.inputCost ?? new Decimal(0)).add(
|
||||
userProvidedCosts.outputCost ?? new Decimal(0)
|
||||
userProvidedCosts.outputCost ?? new Decimal(0),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -417,7 +419,7 @@ export class ObservationProcessor implements EventProcessor {
|
||||
existingObservation.projectId !== apiScope.projectId
|
||||
) {
|
||||
throw new ForbiddenError(
|
||||
`Access denied for observation creation ${existingObservation.projectId} `
|
||||
`Access denied for observation creation ${existingObservation.projectId} `,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -447,7 +449,7 @@ export class TraceProcessor implements EventProcessor {
|
||||
}
|
||||
|
||||
async process(
|
||||
apiScope: LegacyIngestionAccessScope
|
||||
apiScope: LegacyIngestionAccessScope,
|
||||
): Promise<Trace | Observation | Score> {
|
||||
const { body } = this.event;
|
||||
|
||||
@@ -455,11 +457,8 @@ export class TraceProcessor implements EventProcessor {
|
||||
|
||||
const internalId = body.id ?? v4();
|
||||
|
||||
console.log(
|
||||
"Trying to create trace, project ",
|
||||
apiScope.projectId,
|
||||
", id:",
|
||||
internalId
|
||||
logger.info(
|
||||
`Trying to create trace, project ${apiScope.projectId}, id: ${internalId}`,
|
||||
);
|
||||
|
||||
const existingTrace = await prisma.trace.findFirst({
|
||||
@@ -470,7 +469,7 @@ export class TraceProcessor implements EventProcessor {
|
||||
|
||||
if (existingTrace && existingTrace.projectId !== apiScope.projectId) {
|
||||
throw new ForbiddenError(
|
||||
`Access denied for trace creation ${existingTrace.projectId}`
|
||||
`Access denied for trace creation ${existingTrace.projectId}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -478,7 +477,7 @@ export class TraceProcessor implements EventProcessor {
|
||||
existingTrace?.metadata
|
||||
? jsonSchema.parse(existingTrace.metadata)
|
||||
: undefined,
|
||||
body.metadata ?? undefined
|
||||
body.metadata ?? undefined,
|
||||
);
|
||||
|
||||
const mergedTags =
|
||||
@@ -556,12 +555,12 @@ export class ScoreProcessor implements EventProcessor {
|
||||
auth(apiScope: LegacyIngestionAccessScope) {
|
||||
if (apiScope.accessLevel !== "scores" && apiScope.accessLevel !== "all")
|
||||
throw new ForbiddenError(
|
||||
`Access denied for score creation, ${apiScope.accessLevel}`
|
||||
`Access denied for score creation, ${apiScope.accessLevel}`,
|
||||
);
|
||||
}
|
||||
|
||||
async process(
|
||||
apiScope: LegacyIngestionAccessScope
|
||||
apiScope: LegacyIngestionAccessScope,
|
||||
): Promise<Trace | Observation | Score> {
|
||||
const { body } = this.event;
|
||||
|
||||
@@ -579,7 +578,7 @@ export class ScoreProcessor implements EventProcessor {
|
||||
});
|
||||
if (existingScore && existingScore.projectId !== apiScope.projectId) {
|
||||
throw new ForbiddenError(
|
||||
`Access denied for score creation ${existingScore.projectId}`
|
||||
`Access denied for score creation ${existingScore.projectId}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -619,7 +618,7 @@ export class SdkLogProcessor implements EventProcessor {
|
||||
|
||||
process() {
|
||||
try {
|
||||
console.log("SDK Log", this.event);
|
||||
logger.info("SDK Log", this.event);
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
return undefined;
|
||||
|
||||
@@ -8,10 +8,11 @@ import { IngestionUtils } from "../IngestionUtils";
|
||||
import { IngestionEventType } from "../types";
|
||||
import { redis } from "../../redis/redis";
|
||||
import { env } from "../../../env";
|
||||
import { logger } from "../../logger";
|
||||
|
||||
export async function enqueueIngestionEvents(
|
||||
projectId: string,
|
||||
events: IngestionEventType[]
|
||||
events: IngestionEventType[],
|
||||
) {
|
||||
const ingestionFlushQueue = getIngestionFlushQueue();
|
||||
|
||||
@@ -31,8 +32,8 @@ export async function enqueueIngestionEvents(
|
||||
event,
|
||||
redis,
|
||||
ingestionFlushQueue,
|
||||
batchTimestamp
|
||||
)
|
||||
batchTimestamp,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,11 +45,11 @@ async function enqueueSingleIngestionEvent(
|
||||
event: IngestionEventType,
|
||||
redis: Redis,
|
||||
ingestionFlushQueue: IngestionFlushQueue,
|
||||
batchTimestamp: string
|
||||
batchTimestamp: string,
|
||||
): Promise<void> {
|
||||
if (!("id" in event.body && event.body.id)) {
|
||||
console.warn(
|
||||
`Received ingestion event without id: ${JSON.stringify(event)}`
|
||||
logger.warn(
|
||||
`Received ingestion event without id: ${JSON.stringify(event)}`,
|
||||
);
|
||||
|
||||
return;
|
||||
|
||||
@@ -19,6 +19,7 @@ import { redis } from "../../redis/redis";
|
||||
import { backOff } from "exponential-backoff";
|
||||
import { Model } from "../../..";
|
||||
import { enqueueIngestionEvents } from "./enqueueIngestionEvents";
|
||||
import { logger } from "../../logger";
|
||||
|
||||
export type BatchResult = {
|
||||
result: unknown;
|
||||
@@ -49,9 +50,9 @@ type LegacyIngestionAuthHeaderVerificationResult =
|
||||
export const handleBatch = async (
|
||||
events: z.infer<typeof ingestionApiSchema>["batch"],
|
||||
authCheck: LegacyIngestionAuthHeaderVerificationResult,
|
||||
calculateTokenDelegate: (p: TokenCountInput) => number | undefined
|
||||
calculateTokenDelegate: (p: TokenCountInput) => number | undefined,
|
||||
) => {
|
||||
console.log(`handling ingestion ${events.length} events`);
|
||||
logger.info(`handling ingestion ${events.length} events`);
|
||||
|
||||
if (!authCheck.validKey) throw new UnauthorizedError(authCheck.error);
|
||||
|
||||
@@ -69,7 +70,7 @@ export const handleBatch = async (
|
||||
return await handleSingleEvent(
|
||||
singleEvent,
|
||||
authCheck.scope,
|
||||
calculateTokenDelegate
|
||||
calculateTokenDelegate,
|
||||
);
|
||||
});
|
||||
results.push({
|
||||
@@ -79,7 +80,7 @@ export const handleBatch = async (
|
||||
}); // Push each result into the array
|
||||
} catch (error) {
|
||||
// Handle or log the error if `handleSingleEvent` fails
|
||||
console.error("Error handling event:", error);
|
||||
logger.error("Error handling event:", error);
|
||||
// Decide how to handle the error: rethrow, continue, or push an error object to results
|
||||
// For example, push an error object:
|
||||
errors.push({
|
||||
@@ -93,9 +94,9 @@ export const handleBatch = async (
|
||||
if (env.CLICKHOUSE_URL) {
|
||||
try {
|
||||
await enqueueIngestionEvents(authCheck.scope.projectId, events);
|
||||
console.log(`Added ${events.length} ingestion events to queue`);
|
||||
logger.info(`Added ${events.length} ingestion events to queue`);
|
||||
} catch (err) {
|
||||
console.error("Error adding ingestion events to queue", err);
|
||||
logger.error("Error adding ingestion events to queue", err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,10 +108,10 @@ async function retry<T>(request: () => Promise<T>): Promise<T> {
|
||||
numOfAttempts: env.LANGFUSE_ASYNC_INGESTION_PROCESSING === "true" ? 5 : 3,
|
||||
retry: (e: Error, attemptNumber: number) => {
|
||||
if (e instanceof UnauthorizedError || e instanceof ForbiddenError) {
|
||||
console.log("not retrying auth error");
|
||||
logger.info("not retrying auth error");
|
||||
return false;
|
||||
}
|
||||
console.log(`retrying processing events ${attemptNumber}`);
|
||||
logger.info(`retrying processing events ${attemptNumber}`);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
@@ -122,7 +123,7 @@ const handleSingleEvent = async (
|
||||
calculateTokenDelegate: (p: {
|
||||
model: Model;
|
||||
text: unknown;
|
||||
}) => number | undefined
|
||||
}) => number | undefined,
|
||||
) => {
|
||||
const { body } = event;
|
||||
let restEvent = body;
|
||||
@@ -137,8 +138,8 @@ const handleSingleEvent = async (
|
||||
restEvent = rest;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`handling single event ${event.id} of type ${event.type}: ${JSON.stringify({ body: restEvent })}`
|
||||
logger.info(
|
||||
`handling single event ${event.id} of type ${event.type}: ${JSON.stringify({ body: restEvent })}`,
|
||||
);
|
||||
|
||||
const cleanedEvent = ingestionEvent.parse(cleanEvent(event));
|
||||
@@ -159,7 +160,7 @@ const handleSingleEvent = async (
|
||||
case eventTypes.GENERATION_UPDATE:
|
||||
processor = new ObservationProcessor(
|
||||
cleanedEvent,
|
||||
calculateTokenDelegate
|
||||
calculateTokenDelegate,
|
||||
);
|
||||
break;
|
||||
case eventTypes.SCORE_CREATE: {
|
||||
@@ -201,7 +202,7 @@ export function cleanEvent(obj: unknown): unknown {
|
||||
}
|
||||
|
||||
export const isNotNullOrUndefined = <T>(
|
||||
val?: T | null
|
||||
val?: T | null,
|
||||
): val is Exclude<T, null | undefined> => !isUndefinedOrNull(val);
|
||||
|
||||
export const isUndefinedOrNull = <T>(val?: T | null): val is undefined | null =>
|
||||
@@ -209,7 +210,7 @@ export const isUndefinedOrNull = <T>(val?: T | null): val is undefined | null =>
|
||||
|
||||
export const sendToWorkerIfEnvironmentConfigured = async (
|
||||
batchResults: BatchResult[],
|
||||
projectId: string
|
||||
projectId: string,
|
||||
): Promise<void> => {
|
||||
const traceEvents: TraceUpsertEventType[] = batchResults
|
||||
.filter((result) => result.type === eventTypes.TRACE_CREATE) // we only have create, no update.
|
||||
@@ -219,17 +220,17 @@ export const sendToWorkerIfEnvironmentConfigured = async (
|
||||
"id" in result.result
|
||||
? // ingestion API only gets traces for one projectId
|
||||
{ traceId: result.result.id as string, projectId }
|
||||
: null
|
||||
: null,
|
||||
)
|
||||
.filter(isNotNullOrUndefined);
|
||||
|
||||
try {
|
||||
if (env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION && redis) {
|
||||
console.log(`Sending ${traceEvents.length} events to worker via Redis`);
|
||||
logger.info(`Sending ${traceEvents.length} events to worker via Redis`);
|
||||
|
||||
const queue = getTraceUpsertQueue();
|
||||
if (!queue) {
|
||||
console.error("TraceUpsertQueue not initialized");
|
||||
logger.error("TraceUpsertQueue not initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -239,7 +240,7 @@ export const sendToWorkerIfEnvironmentConfigured = async (
|
||||
env.LANGFUSE_WORKER_PASSWORD &&
|
||||
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION
|
||||
) {
|
||||
console.log(`Sending ${traceEvents.length} events to worker via HTTP`);
|
||||
logger.info(`Sending ${traceEvents.length} events to worker via HTTP`);
|
||||
const body: EventBodyType = {
|
||||
name: EventName.TraceUpsert,
|
||||
payload: traceEvents,
|
||||
@@ -253,7 +254,7 @@ export const sendToWorkerIfEnvironmentConfigured = async (
|
||||
Authorization:
|
||||
"Basic " +
|
||||
Buffer.from(
|
||||
"admin" + ":" + env.LANGFUSE_WORKER_PASSWORD
|
||||
"admin" + ":" + env.LANGFUSE_WORKER_PASSWORD,
|
||||
).toString("base64"),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
@@ -262,6 +263,6 @@ export const sendToWorkerIfEnvironmentConfigured = async (
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error sending events to worker", error);
|
||||
logger.error("Error sending events to worker", error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ type ValidateAndInflateScoreParams = {
|
||||
};
|
||||
|
||||
export async function validateAndInflateScore(
|
||||
params: ValidateAndInflateScoreParams
|
||||
params: ValidateAndInflateScoreParams,
|
||||
): Promise<Score> {
|
||||
const { body, projectId } = params;
|
||||
|
||||
@@ -29,7 +29,7 @@ export async function validateAndInflateScore(
|
||||
|
||||
if (!config || !validateDbScoreConfigSafe(config).success)
|
||||
throw new LangfuseNotFoundError(
|
||||
"The configId you provided does not match a valid config in this project"
|
||||
"The configId you provided does not match a valid config in this project",
|
||||
);
|
||||
|
||||
validateConfigAgainstBody(body, config as ValidatedScoreConfig);
|
||||
@@ -47,7 +47,7 @@ export async function validateAndInflateScore(
|
||||
|
||||
if (!validation.success) {
|
||||
throw new InvalidRequestError(
|
||||
`Ingested score value type not valid against provided data type. Provide numeric values for numeric and boolean scores, and string values for categorical scores.`
|
||||
`Ingested score value type not valid against provided data type. Provide numeric values for numeric and boolean scores, and string values for categorical scores.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ function inferDataType(value: string | number): ScoreDataType {
|
||||
|
||||
function mapStringValueToNumericValue(
|
||||
config: ValidatedScoreConfig,
|
||||
label: string
|
||||
label: string,
|
||||
): number | null {
|
||||
return (
|
||||
config.categories?.find((category) => category.label === label)?.value ??
|
||||
@@ -71,7 +71,7 @@ function mapStringValueToNumericValue(
|
||||
}
|
||||
|
||||
function inflateScoreBody(
|
||||
params: ValidateAndInflateScoreParams & { config?: ValidatedScoreConfig }
|
||||
params: ValidateAndInflateScoreParams & { config?: ValidatedScoreConfig },
|
||||
): Score {
|
||||
const { body, projectId, scoreId, config } = params;
|
||||
|
||||
@@ -105,25 +105,25 @@ function inflateScoreBody(
|
||||
|
||||
function validateConfigAgainstBody(
|
||||
body: any,
|
||||
config: ValidatedScoreConfig
|
||||
config: ValidatedScoreConfig,
|
||||
): void {
|
||||
const { maxValue, minValue, categories, dataType: configDataType } = config;
|
||||
|
||||
if (body.dataType && body.dataType !== configDataType) {
|
||||
throw new InvalidRequestError(
|
||||
`Data type mismatch based on config: expected ${configDataType}, got ${body.dataType}`
|
||||
`Data type mismatch based on config: expected ${configDataType}, got ${body.dataType}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (config.isArchived) {
|
||||
throw new InvalidRequestError(
|
||||
"Config is archived and cannot be used to create new scores. Please restore the config first."
|
||||
"Config is archived and cannot be used to create new scores. Please restore the config first.",
|
||||
);
|
||||
}
|
||||
|
||||
if (config.name !== body.name) {
|
||||
throw new InvalidRequestError(
|
||||
`Name mismatch based on config: expected ${config.name}, got ${body.name}`
|
||||
`Name mismatch based on config: expected ${config.name}, got ${body.name}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ function validateConfigAgainstBody(
|
||||
|
||||
if (!dataTypeValidation.success) {
|
||||
throw new InvalidRequestError(
|
||||
`Ingested score body not valid against provided config data type.`
|
||||
`Ingested score body not valid against provided config data type.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ function validateConfigAgainstBody(
|
||||
.join(", ");
|
||||
|
||||
throw new InvalidRequestError(
|
||||
`Ingested score body not valid against provided config: ${errorDetails}`
|
||||
`Ingested score body not valid against provided config: ${errorDetails}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { env } from "../env";
|
||||
import winston from "winston";
|
||||
|
||||
const getWinstonLogger = (
|
||||
nodeEnv: "development" | "production" | "test",
|
||||
minLevel = "info",
|
||||
) => {
|
||||
const textLoggerFormat = winston.format.combine(
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.timestamp(),
|
||||
winston.format.align(),
|
||||
winston.format.printf((info) => {
|
||||
const logMessage = `${info.timestamp} ${info.level} ${info.message}`;
|
||||
return info.stack ? `${logMessage}\n${info.stack}` : logMessage;
|
||||
}),
|
||||
);
|
||||
|
||||
const jsonLoggerFormat = winston.format.combine(
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.timestamp(),
|
||||
winston.format.json(),
|
||||
);
|
||||
|
||||
const format =
|
||||
env.LANGFUSE_LOG_FORMAT === "text" ? textLoggerFormat : jsonLoggerFormat;
|
||||
return winston.createLogger({
|
||||
level: minLevel,
|
||||
format: format,
|
||||
transports: [new winston.transports.Console()],
|
||||
});
|
||||
};
|
||||
|
||||
export const logger = getWinstonLogger(env.NODE_ENV, env.LANGFUSE_LOG_LEVEL);
|
||||
+8
-7
@@ -2,8 +2,9 @@ import { z } from "zod";
|
||||
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
import type { ColumnDefinition } from "./tableDefinitions/types";
|
||||
import type { OrderByState } from "./interfaces/orderBy";
|
||||
import type { ColumnDefinition } from "../tableDefinitions/types";
|
||||
import type { OrderByState } from "../interfaces/orderBy";
|
||||
import { logger } from "./logger";
|
||||
|
||||
/**
|
||||
* Convert orderBy to SQL ORDER BY clause
|
||||
@@ -13,7 +14,7 @@ import type { OrderByState } from "./interfaces/orderBy";
|
||||
*/
|
||||
export function orderByToPrismaSql(
|
||||
orderBy: OrderByState,
|
||||
tableColumns: ColumnDefinition[]
|
||||
tableColumns: ColumnDefinition[],
|
||||
): Prisma.Sql {
|
||||
if (!orderBy) {
|
||||
return Prisma.sql`ORDER BY t.timestamp DESC`;
|
||||
@@ -22,11 +23,11 @@ export function orderByToPrismaSql(
|
||||
const col = tableColumns.find(
|
||||
// TODO: Only use id instead of name.
|
||||
// It's less error-prone & decouples data fetching from the human-readable UI labels
|
||||
(c) => c.name === orderBy.column || c.id === orderBy.column
|
||||
(c) => c.name === orderBy.column || c.id === orderBy.column,
|
||||
);
|
||||
|
||||
if (!col) {
|
||||
console.log("Invalid filter column", orderBy.column);
|
||||
logger.warn("Invalid filter column", orderBy.column);
|
||||
throw new Error("Invalid filter column: " + orderBy.column);
|
||||
}
|
||||
|
||||
@@ -34,12 +35,12 @@ export function orderByToPrismaSql(
|
||||
const orderByOrder = z.enum(["ASC", "DESC"]);
|
||||
const order = orderByOrder.safeParse(orderBy.order);
|
||||
if (!order.success) {
|
||||
console.log("Invalid order", orderBy.order);
|
||||
logger.warn("Invalid order", orderBy.order);
|
||||
throw new Error("Invalid order: " + orderBy.order);
|
||||
}
|
||||
|
||||
// Both column and order are safe, can use raw SQL
|
||||
return Prisma.raw(
|
||||
`ORDER BY ${col.internal} ${order.data} ${col.nullable ? (orderBy.order === "DESC" ? "NULLS LAST" : "NULLS FIRST") : ""}`
|
||||
`ORDER BY ${col.internal} ${order.data} ${col.nullable ? (orderBy.order === "DESC" ? "NULLS LAST" : "NULLS FIRST") : ""}`,
|
||||
);
|
||||
}
|
||||
+5
-5
@@ -2,10 +2,10 @@ import { z } from "zod";
|
||||
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { tableColumnsToSqlFilterAndPrefix } from "../filterToPrisma";
|
||||
import { singleFilter } from "../interfaces/filters";
|
||||
import { orderBy } from "../interfaces/orderBy";
|
||||
import { singleFilter } from "../../interfaces/filters";
|
||||
import { orderBy } from "../../interfaces/orderBy";
|
||||
import { orderByToPrismaSql } from "../orderByToPrisma";
|
||||
import { sessionsViewCols } from "../tableDefinitions/index";
|
||||
import { sessionsViewCols } from "../../tableDefinitions";
|
||||
|
||||
const GetSessionTableSQLParamsSchema = z.object({
|
||||
projectId: z.string(),
|
||||
@@ -22,7 +22,7 @@ export const createSessionsAllQuery = (
|
||||
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);
|
||||
@@ -30,7 +30,7 @@ export const createSessionsAllQuery = (
|
||||
const filterCondition = tableColumnsToSqlFilterAndPrefix(
|
||||
filter ?? [],
|
||||
sessionsViewCols,
|
||||
"sessions"
|
||||
"sessions",
|
||||
);
|
||||
const orderByCondition = orderByToPrismaSql(orderBy, sessionsViewCols);
|
||||
|
||||
@@ -2,28 +2,32 @@ import { Queue } from "bullmq";
|
||||
import { QueueName, TQueueJobTypes } from "../queues";
|
||||
import { createNewRedisInstance } from "./redis";
|
||||
|
||||
let legacyIngestionQueue: Queue<
|
||||
TQueueJobTypes[QueueName.LegacyIngestionQueue]
|
||||
> | null = null;
|
||||
export class LegacyIngestionQueue {
|
||||
private static instance: Queue<
|
||||
TQueueJobTypes[QueueName.LegacyIngestionQueue]
|
||||
> | null = null;
|
||||
|
||||
export const getLegacyIngestionQueue = () => {
|
||||
if (legacyIngestionQueue) return legacyIngestionQueue;
|
||||
public static getInstance(): Queue<
|
||||
TQueueJobTypes[QueueName.LegacyIngestionQueue]
|
||||
> | null {
|
||||
if (LegacyIngestionQueue.instance) return LegacyIngestionQueue.instance;
|
||||
|
||||
const newRedis = createNewRedisInstance();
|
||||
const newRedis = createNewRedisInstance({ enableOfflineQueue: false });
|
||||
|
||||
legacyIngestionQueue = newRedis
|
||||
? new Queue<TQueueJobTypes[QueueName.LegacyIngestionQueue]>(
|
||||
QueueName.LegacyIngestionQueue,
|
||||
{
|
||||
connection: newRedis,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 100,
|
||||
attempts: 5,
|
||||
LegacyIngestionQueue.instance = newRedis
|
||||
? new Queue<TQueueJobTypes[QueueName.LegacyIngestionQueue]>(
|
||||
QueueName.LegacyIngestionQueue,
|
||||
{
|
||||
connection: newRedis,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 100,
|
||||
attempts: 5,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
: null;
|
||||
)
|
||||
: null;
|
||||
|
||||
return legacyIngestionQueue;
|
||||
};
|
||||
return LegacyIngestionQueue.instance;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import Redis from "ioredis";
|
||||
import Redis, { RedisOptions } from "ioredis";
|
||||
import { env } from "../../env";
|
||||
import { logger } from "../logger";
|
||||
|
||||
export const createNewRedisInstance = () => {
|
||||
export const createNewRedisInstance = (
|
||||
additionalOptions: Partial<RedisOptions> = {},
|
||||
) => {
|
||||
return env.REDIS_CONNECTION_STRING
|
||||
? new Redis(env.REDIS_CONNECTION_STRING, {
|
||||
maxRetriesPerRequest: null,
|
||||
enableAutoPipelining: env.REDIS_ENABLE_AUTO_PIPELINING === "true",
|
||||
...additionalOptions,
|
||||
})
|
||||
: env.REDIS_HOST
|
||||
? new Redis({
|
||||
@@ -14,6 +18,7 @@ export const createNewRedisInstance = () => {
|
||||
password: String(env.REDIS_AUTH),
|
||||
maxRetriesPerRequest: null, // Set to `null` to disable retrying
|
||||
enableAutoPipelining: env.REDIS_ENABLE_AUTO_PIPELINING === "true",
|
||||
...additionalOptions,
|
||||
})
|
||||
: null;
|
||||
};
|
||||
@@ -22,7 +27,7 @@ const createRedisClient = () => {
|
||||
try {
|
||||
return createNewRedisInstance();
|
||||
} catch (e) {
|
||||
console.error(e, "Failed to connect to redis");
|
||||
logger.error("Failed to connect to redis", e);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Prompt, PrismaClient } from "@prisma/client";
|
||||
import { Redis } from "ioredis";
|
||||
import { env } from "../../env";
|
||||
import { logger } from "../logger";
|
||||
|
||||
export class PromptService {
|
||||
private cacheEnabled: boolean;
|
||||
@@ -11,7 +12,7 @@ export class PromptService {
|
||||
private redis: Redis | null,
|
||||
private metricIncrementer?: // used for otel metrics
|
||||
(name: string, value?: number) => void,
|
||||
cacheEnabled?: boolean // used for testing
|
||||
cacheEnabled?: boolean, // used for testing
|
||||
) {
|
||||
this.cacheEnabled =
|
||||
Boolean(redis) &&
|
||||
@@ -25,7 +26,7 @@ export class PromptService {
|
||||
const cachedPrompt = await this.getCachedPrompt(params);
|
||||
|
||||
this.incrementMetric(
|
||||
cachedPrompt ? Metrics.PromptCacheHit : Metrics.PromptCacheMiss
|
||||
cachedPrompt ? Metrics.PromptCacheHit : Metrics.PromptCacheMiss,
|
||||
);
|
||||
|
||||
if (cachedPrompt) {
|
||||
@@ -117,7 +118,7 @@ export class PromptService {
|
||||
}
|
||||
|
||||
public async lockCache(
|
||||
params: Pick<PromptParams, "projectId" | "promptName">
|
||||
params: Pick<PromptParams, "projectId" | "promptName">,
|
||||
): Promise<void> {
|
||||
if (!this.cacheEnabled) return;
|
||||
|
||||
@@ -133,7 +134,7 @@ export class PromptService {
|
||||
}
|
||||
|
||||
public async unlockCache(
|
||||
params: Pick<PromptParams, "projectId" | "promptName">
|
||||
params: Pick<PromptParams, "projectId" | "promptName">,
|
||||
): Promise<void> {
|
||||
if (!this.cacheEnabled) return;
|
||||
|
||||
@@ -149,7 +150,7 @@ export class PromptService {
|
||||
}
|
||||
|
||||
private async isCacheLocked(
|
||||
params: Pick<PromptParams, "projectId" | "promptName">
|
||||
params: Pick<PromptParams, "projectId" | "promptName">,
|
||||
): Promise<boolean> {
|
||||
const lockKey = this.getLockKey(params);
|
||||
|
||||
@@ -163,14 +164,14 @@ export class PromptService {
|
||||
}
|
||||
|
||||
private getLockKey(
|
||||
params: Pick<PromptParams, "projectId" | "promptName">
|
||||
params: Pick<PromptParams, "projectId" | "promptName">,
|
||||
): string {
|
||||
// Important to *pre*fix LOCK as otherwise it would be deleted by deleteKeysByPrefix
|
||||
return `LOCK:${this.getCacheKeyPrefix(params)}`;
|
||||
}
|
||||
|
||||
public async invalidateCache(
|
||||
params: Pick<PromptParams, "projectId" | "promptName">
|
||||
params: Pick<PromptParams, "projectId" | "promptName">,
|
||||
): Promise<void> {
|
||||
if (!this.cacheEnabled) return;
|
||||
|
||||
@@ -187,7 +188,7 @@ export class PromptService {
|
||||
await this.redis?.del([...(keys ?? []), keyIndexKey]);
|
||||
|
||||
this.logInfo(
|
||||
`Cache invalidated for prefix ${cacheKeyPrefix} in ${Date.now() - startTime}ms`
|
||||
`Cache invalidated for prefix ${cacheKeyPrefix} in ${Date.now() - startTime}ms`,
|
||||
);
|
||||
} catch (e) {
|
||||
this.logError("Error deleting keys for prefix", cacheKeyPrefix, e);
|
||||
@@ -203,23 +204,23 @@ export class PromptService {
|
||||
}
|
||||
|
||||
private getCacheKeyPrefix(
|
||||
params: Pick<PromptParams, "projectId" | "promptName">
|
||||
params: Pick<PromptParams, "projectId" | "promptName">,
|
||||
): string {
|
||||
return `prompt:${params.projectId}:${params.promptName}`;
|
||||
}
|
||||
|
||||
private getKeyIndexKey(
|
||||
params: Pick<PromptParams, "projectId" | "promptName">
|
||||
params: Pick<PromptParams, "projectId" | "promptName">,
|
||||
): string {
|
||||
return `prompt_key_index:${params.projectId}:${params.promptName}`;
|
||||
}
|
||||
|
||||
private logError(message: string, ...args: any[]) {
|
||||
console.error(`[PromptService] ${message}`, ...args);
|
||||
logger.error(`[PromptService] ${message}`, ...args);
|
||||
}
|
||||
|
||||
private logInfo(message: string, ...args: any[]) {
|
||||
console.log(`[PromptService] ${message}`, ...args);
|
||||
logger.info(`[PromptService] ${message}`, ...args);
|
||||
}
|
||||
|
||||
private incrementMetric(name: Metrics, value: number = 1) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Readable } from "stream";
|
||||
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||
import { Upload } from "@aws-sdk/lib-storage";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
import { logger } from "../logger";
|
||||
|
||||
type UploadFile = {
|
||||
fileName: string;
|
||||
@@ -56,15 +57,14 @@ export class S3StorageService {
|
||||
|
||||
return { signedUrl };
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
logger.error(err);
|
||||
throw new Error("Failed to upload to S3 or generate signed URL");
|
||||
}
|
||||
}
|
||||
|
||||
private async getSignedUrl(
|
||||
fileName: string,
|
||||
ttlSeconds: number
|
||||
ttlSeconds: number,
|
||||
): Promise<string> {
|
||||
try {
|
||||
return await getSignedUrl(
|
||||
@@ -74,7 +74,7 @@ export class S3StorageService {
|
||||
Key: fileName,
|
||||
ResponseContentDisposition: `attachment; filename="${fileName}"`,
|
||||
}),
|
||||
{ expiresIn: ttlSeconds }
|
||||
{ expiresIn: ttlSeconds },
|
||||
);
|
||||
} catch (err) {
|
||||
throw Error("Failed to generate signed URL");
|
||||
|
||||
+4
-4
@@ -3,6 +3,7 @@ import { parseConnectionUrl } from "nodemailer/lib/shared/index.js";
|
||||
import { render } from "@react-email/render";
|
||||
|
||||
import { BatchExportSuccessEmailTemplate } from "./BatchExportSuccessEmailTemplate";
|
||||
import { logger } from "../../../logger";
|
||||
|
||||
type SendBatchExportSuccessParams = {
|
||||
env: Partial<
|
||||
@@ -24,8 +25,7 @@ export const sendBatchExportSuccessEmail = async ({
|
||||
expiresInHours,
|
||||
}: SendBatchExportSuccessParams) => {
|
||||
if (!env.EMAIL_FROM_ADDRESS || !env.SMTP_CONNECTION_URL) {
|
||||
console.error("Missing environment variables for sending email.");
|
||||
|
||||
logger.error("Missing environment variables for sending email.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export const sendBatchExportSuccessEmail = async ({
|
||||
userName,
|
||||
batchExportName,
|
||||
expiresInHours,
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
await mailer.sendMail({
|
||||
@@ -51,6 +51,6 @@ export const sendBatchExportSuccessEmail = async ({
|
||||
html: htmlTemplate,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
logger.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
+7
-6
@@ -3,6 +3,7 @@ import { parseConnectionUrl } from "nodemailer/lib/shared/index.js";
|
||||
import { render } from "@react-email/render";
|
||||
|
||||
import MembershipInvitationTemplate from "./MembershipInvitationEmailTemplate";
|
||||
import { logger } from "../../../logger";
|
||||
|
||||
const langfuseUrls = {
|
||||
US: "https://us.cloud.langfuse.com",
|
||||
@@ -34,8 +35,8 @@ export const sendMembershipInvitationEmail = async ({
|
||||
orgName,
|
||||
}: SendMembershipInvitationParams) => {
|
||||
if (!env.EMAIL_FROM_ADDRESS || !env.SMTP_CONNECTION_URL) {
|
||||
console.error(
|
||||
"Missing environment variables for sending membership invitation email."
|
||||
logger.error(
|
||||
"Missing environment variables for sending membership invitation email.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -49,8 +50,8 @@ export const sendMembershipInvitationEmail = async ({
|
||||
|
||||
const authUrl = getAuthURL();
|
||||
if (!authUrl) {
|
||||
console.error(
|
||||
"Missing NEXTAUTH_URL or NEXT_PUBLIC_LANGFUSE_CLOUD_REGION environment variable."
|
||||
logger.error(
|
||||
"Missing NEXTAUTH_URL or NEXT_PUBLIC_LANGFUSE_CLOUD_REGION environment variable.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -67,7 +68,7 @@ export const sendMembershipInvitationEmail = async ({
|
||||
inviteLink: authUrl,
|
||||
emailFromAddress: env.EMAIL_FROM_ADDRESS,
|
||||
langfuseCloudRegion: env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION,
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
await mailer.sendMail({
|
||||
@@ -77,6 +78,6 @@ export const sendMembershipInvitationEmail = async ({
|
||||
html: htmlTemplate,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
logger.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
Generated
+484
-603
File diff suppressed because it is too large
Load Diff
+6
-1
@@ -44,6 +44,11 @@ ARG NEXT_PUBLIC_CRISP_WEBSITE_ID
|
||||
ARG NEXT_PUBLIC_SENTRY_DSN
|
||||
ARG NEXT_LANGFUSE_TRACING_SAMPLE_RATE
|
||||
|
||||
# Accept build id as NEXT_PUBLIC_BUILD_ID or PORTER_NEXT_PUBLIC_BUILD_ID
|
||||
ARG PORTER_NEXT_PUBLIC_BUILD_ID
|
||||
ARG NEXT_PUBLIC_BUILD_ID
|
||||
ENV NEXT_PUBLIC_BUILD_ID=${PORTER_NEXT_PUBLIC_BUILD_ID:-$NEXT_PUBLIC_BUILD_ID}
|
||||
|
||||
# Copy source code of isolated subworkspace
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
|
||||
@@ -56,7 +61,7 @@ RUN rm -f ./web/src/middleware.ts
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
ENV NEXT_MANUAL_SIG_HANDLE true
|
||||
|
||||
RUN turbo run build --filter=web
|
||||
RUN NODE_OPTIONS='--max-old-space-size=4096' turbo run build --filter=web
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM --platform=${TARGETPLATFORM:-linux/amd64} base AS runner
|
||||
|
||||
+31
-26
@@ -149,39 +149,44 @@ const nextConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
const sentryOptions = {
|
||||
// Additional config options for the Sentry Webpack plugin. Keep in mind that
|
||||
// the following options are set automatically, and overriding them is not
|
||||
// recommended:
|
||||
// release, url, authToken, configFile, stripPrefix,
|
||||
// urlPrefix, include, ignore
|
||||
export default withSentryConfig(nextConfig, {
|
||||
// For all available options, see:
|
||||
// https://github.com/getsentry/sentry-webpack-plugin#options
|
||||
|
||||
org: process.env.SENTRY_ORG,
|
||||
project: process.env.SENTRY_PROJECT,
|
||||
|
||||
silent: true, // Suppresses all logs
|
||||
authToken: env.SENTRY_AUTH_TOKEN,
|
||||
|
||||
// Only print logs for uploading source maps in CI
|
||||
silent: !process.env.CI,
|
||||
|
||||
// For all available options, see:
|
||||
// https://github.com/getsentry/sentry-webpack-plugin#options.
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
|
||||
|
||||
// See the sections below for information on the following options:
|
||||
// 'Configure Source Maps':
|
||||
// - disableServerWebpackPlugin
|
||||
// - disableClientWebpackPlugin
|
||||
// - hideSourceMaps
|
||||
hideSourceMaps: true,
|
||||
// - widenClientFileUpload
|
||||
// 'Configure Legacy Browser Support':
|
||||
// - transpileClientSDK
|
||||
// 'Configure Serverside Auto-instrumentation':
|
||||
// - autoInstrumentServerFunctions
|
||||
// - excludeServerRoutes
|
||||
// 'Configure Tunneling':
|
||||
// - tunnelRoute
|
||||
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
||||
widenClientFileUpload: true,
|
||||
|
||||
// An auth token is required for uploading source maps.
|
||||
authToken: env.SENTRY_AUTH_TOKEN,
|
||||
// Automatically annotate React components to show their full name in breadcrumbs and session replay
|
||||
reactComponentAnnotation: {
|
||||
enabled: true,
|
||||
},
|
||||
|
||||
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
|
||||
// This can increase your server load as well as your hosting bill.
|
||||
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
|
||||
// side errors will fail.
|
||||
tunnelRoute: "/api/monitoring-tunnel",
|
||||
};
|
||||
|
||||
export default withSentryConfig(nextConfig, sentryOptions);
|
||||
// Hides source maps from generated client bundles
|
||||
hideSourceMaps: true,
|
||||
|
||||
// Automatically tree-shake Sentry logger statements to reduce bundle size
|
||||
disableLogger: true,
|
||||
|
||||
// Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.)
|
||||
// See the following for more information:
|
||||
// https://docs.sentry.io/product/crons/
|
||||
// https://vercel.com/docs/cron-jobs
|
||||
automaticVercelMonitors: false,
|
||||
});
|
||||
|
||||
+8
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "2.75.2",
|
||||
"version": "2.78.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -46,6 +46,7 @@
|
||||
"@opentelemetry/instrumentation": "^0.52.1",
|
||||
"@opentelemetry/instrumentation-http": "^0.52.1",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.42.0",
|
||||
"@opentelemetry/instrumentation-winston": "^0.40.0",
|
||||
"@opentelemetry/instrumentation-undici": "^0.4.0",
|
||||
"@opentelemetry/resources": "^1.25.1",
|
||||
"@opentelemetry/sdk-metrics": "1.23.0",
|
||||
@@ -110,7 +111,7 @@
|
||||
"kysely": "^0.27.4",
|
||||
"langchain": "^0.2.6",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.429.0",
|
||||
"lucide-react": "^0.436.0",
|
||||
"next": "^14.2.6",
|
||||
"next-auth": "^4.24.7",
|
||||
"next-query-params": "^5.0.0",
|
||||
@@ -154,10 +155,10 @@
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/eslint": "^8.56.7",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/lodash": "^4.17.5",
|
||||
"@types/lodash": "^4.17.7",
|
||||
"@types/node": "20.10.5",
|
||||
"@types/react": "^18.2.79",
|
||||
"@types/react-dom": "^18.2.25",
|
||||
"@types/react": "~18.2.79",
|
||||
"@types/react-dom": "~18.2.25",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
@@ -172,10 +173,10 @@
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-tailwindcss": "^0.6.6",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"tailwindcss": "^3.4.10",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.18.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.4.5",
|
||||
"wait-for-expect": "^3.0.2"
|
||||
},
|
||||
|
||||
@@ -548,6 +548,167 @@ describe("Authenticate API calls", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalidates api keys in redis", () => {
|
||||
const redis = new Redis("redis://:myredissecret@127.0.0.1:6379", {
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// if we do not remove the key, it will remain in the cache and
|
||||
// calling the test twice will not add the key to the cache
|
||||
|
||||
const keys = await redis.keys("api-key*");
|
||||
console.log("before each deleting keys", keys);
|
||||
if (keys.length > 0) {
|
||||
console.log("before each deleting keys. actually deleting", keys);
|
||||
await redis.del(keys);
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// if we do not remove the key, it will remain in the cache and
|
||||
// calling the test twice will not add the key to the cache
|
||||
|
||||
const keys = await redis.keys("api-key*");
|
||||
console.log("after each deleting keys", keys);
|
||||
if (keys.length > 0) {
|
||||
await redis.del(keys);
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
redis.disconnect();
|
||||
});
|
||||
|
||||
it("should invalidate organization API keys in redis", async () => {
|
||||
await createAPIKey();
|
||||
|
||||
// put keys into cache
|
||||
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
});
|
||||
expect(apiKey).not.toBeNull();
|
||||
|
||||
const cachedKey = await redis.get(
|
||||
`api-key:${apiKey?.fastHashedSecretKey}`,
|
||||
);
|
||||
expect(cachedKey).not.toBeNull();
|
||||
|
||||
await new ApiAuthService(prisma, redis).invalidateOrgApiKeys(
|
||||
"seed-org-id",
|
||||
);
|
||||
|
||||
const invalidatedCachedKey = await redis.get(
|
||||
`api-key:${apiKey?.fastHashedSecretKey}`,
|
||||
);
|
||||
expect(invalidatedCachedKey).toBeNull();
|
||||
});
|
||||
|
||||
it("if no keys in redis, invalidating org keys should do nothing", async () => {
|
||||
await createAPIKey();
|
||||
|
||||
await prisma.apiKey.update({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
data: {
|
||||
fastHashedSecretKey: Math.random().toString(36).substring(2, 15),
|
||||
},
|
||||
});
|
||||
|
||||
await new ApiAuthService(prisma, redis).invalidateOrgApiKeys(
|
||||
"seed-org-id",
|
||||
);
|
||||
|
||||
const keys = await redis.keys("api-key*");
|
||||
expect(keys.length).toBe(0);
|
||||
});
|
||||
|
||||
it("if no keys in redis, invalidating org keys without fast hash should do nothing", async () => {
|
||||
await createAPIKey();
|
||||
|
||||
await new ApiAuthService(prisma, redis).invalidateOrgApiKeys(
|
||||
"seed-org-id",
|
||||
);
|
||||
|
||||
const keys = await redis.keys("api-key*");
|
||||
expect(keys.length).toBe(0);
|
||||
});
|
||||
|
||||
it("should invalidate project API keys in redis", async () => {
|
||||
await createAPIKey();
|
||||
|
||||
// put keys into cache
|
||||
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
});
|
||||
expect(apiKey).not.toBeNull();
|
||||
|
||||
const cachedKey = await redis.get(
|
||||
`api-key:${apiKey?.fastHashedSecretKey}`,
|
||||
);
|
||||
expect(cachedKey).not.toBeNull();
|
||||
|
||||
await new ApiAuthService(prisma, redis).invalidateProjectApiKeys(
|
||||
"7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
);
|
||||
|
||||
const invalidatedCachedKey = await redis.get(
|
||||
`api-key:${apiKey?.fastHashedSecretKey}`,
|
||||
);
|
||||
expect(invalidatedCachedKey).toBeNull();
|
||||
});
|
||||
|
||||
it("if no keys in redis, invalidating project keys should do nothing", async () => {
|
||||
await createAPIKey();
|
||||
|
||||
await prisma.apiKey.update({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
data: {
|
||||
fastHashedSecretKey: Math.random().toString(36).substring(2, 15),
|
||||
},
|
||||
});
|
||||
|
||||
await new ApiAuthService(prisma, redis).invalidateProjectApiKeys(
|
||||
"7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
);
|
||||
|
||||
const keys = await redis.keys("api-key*");
|
||||
expect(keys.length).toBe(0);
|
||||
});
|
||||
|
||||
it("if no keys in redis, invalidating project keys without fast hash should do nothing", async () => {
|
||||
await createAPIKey();
|
||||
|
||||
await prisma.apiKey.update({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
data: {
|
||||
fastHashedSecretKey: Math.random().toString(36).substring(2, 15),
|
||||
},
|
||||
});
|
||||
|
||||
await new ApiAuthService(prisma, redis).invalidateProjectApiKeys(
|
||||
"7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
);
|
||||
|
||||
const keys = await redis.keys("api-key*");
|
||||
expect(keys.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
const createAPIKey = async () => {
|
||||
const seedApiKey = {
|
||||
id: "seed-api-key",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { orderByToPrismaSql } from "@langfuse/shared";
|
||||
import { orderByToPrismaSql } from "@langfuse/shared/src/server";
|
||||
import { tracesTableCols } from "@langfuse/shared";
|
||||
|
||||
// The test for the orderByToPrisma function
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { hash } from "bcryptjs";
|
||||
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { getDisplaySecretKey, hashSecretKey } from "@langfuse/shared";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { type z } from "zod";
|
||||
|
||||
@@ -24,10 +21,7 @@ export const pruneDatabase = async () => {
|
||||
await prisma.llmApiKeys.deleteMany();
|
||||
};
|
||||
|
||||
export function createBasicAuthHeader(
|
||||
username: string,
|
||||
password: string,
|
||||
): string {
|
||||
function createBasicAuthHeader(username: string, password: string): string {
|
||||
const base64Credentials = Buffer.from(`${username}:${password}`).toString(
|
||||
"base64",
|
||||
);
|
||||
@@ -97,38 +91,3 @@ export async function makeZodVerifiedAPICall<T extends z.ZodTypeAny>(
|
||||
}
|
||||
return { body: resBody, status };
|
||||
}
|
||||
|
||||
export const setupUserAndProject = async () => {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
id: "user-1",
|
||||
name: "Demo User",
|
||||
email: "demo@langfuse.com",
|
||||
password: await hash("password", 12),
|
||||
},
|
||||
});
|
||||
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
name: "llm-app",
|
||||
apiKeys: {
|
||||
create: [
|
||||
{
|
||||
note: "seeded key",
|
||||
hashedSecretKey: await hashSecretKey("sk-lf-1234567890"),
|
||||
displaySecretKey: getDisplaySecretKey("sk-lf-1234567890"),
|
||||
publicKey: "pk-lf-1234567890",
|
||||
},
|
||||
],
|
||||
},
|
||||
projectMembers: {
|
||||
create: {
|
||||
role: "OWNER",
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return { user, project };
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
BadgeCheck,
|
||||
Github,
|
||||
HardDriveDownload,
|
||||
Info,
|
||||
Map,
|
||||
Newspaper,
|
||||
} from "lucide-react";
|
||||
@@ -20,6 +21,7 @@ import { api } from "@/src/utils/api";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { useIsEeEnabled } from "@/src/ee/utils/useIsEeEnabled";
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
|
||||
export const VersionLabel = ({ className }: { className?: string }) => {
|
||||
const checkUpdate = api.public.checkUpdate.useQuery(undefined, {
|
||||
@@ -30,6 +32,7 @@ export const VersionLabel = ({ className }: { className?: string }) => {
|
||||
onError: (error) => console.error("checkUpdate error", error), // do not render default error message
|
||||
});
|
||||
const isEeVersion = useIsEeEnabled();
|
||||
const isLangfuseCloud = Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION);
|
||||
|
||||
const hasUpdate =
|
||||
!env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION &&
|
||||
@@ -46,8 +49,9 @@ export const VersionLabel = ({ className }: { className?: string }) => {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="xs" className={className}>
|
||||
<Button variant="ghost" size="xs" className={cn("text-xs",className)}>
|
||||
{VERSION}
|
||||
{!isLangfuseCloud && (isEeVersion ? " EE" : " OSS")}
|
||||
{hasUpdate && <ArrowUp className={`ml-1 h-3 w-3 ${color}`} />}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -96,6 +100,17 @@ export const VersionLabel = ({ className }: { className?: string }) => {
|
||||
Roadmap
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{!isLangfuseCloud && !isEeVersion && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href="https://langfuse.com/docs/deployment/feature-overview"
|
||||
target="_blank"
|
||||
>
|
||||
<Info size={16} className="mr-2" />
|
||||
Compare Versions
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{hasUpdate && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -39,7 +39,7 @@ export const ROUTES: Route[] = [
|
||||
name: "Langfuse",
|
||||
pathname: "/",
|
||||
icon: LangfuseIcon,
|
||||
label: <VersionLabel />,
|
||||
label: <VersionLabel className="-ml-3" />,
|
||||
},
|
||||
{
|
||||
name: "Projects",
|
||||
|
||||
@@ -186,7 +186,6 @@ export const ObservationPreview = (props: {
|
||||
source="generation"
|
||||
generation={observationWithInputAndOutput.data}
|
||||
analyticsEventName="trace_detail:test_in_playground_button_click"
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
{observationWithInputAndOutput.data ? (
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const VERSION = "v2.75.2";
|
||||
export const VERSION = "v2.78.0";
|
||||
|
||||
@@ -8,9 +8,11 @@ import dd from "dd-trace";
|
||||
import opentelemetry from "@opentelemetry/api";
|
||||
// import { BullMQInstrumentation } from "@appsignal/opentelemetry-instrumentation-bullmq";
|
||||
import { UndiciInstrumentation } from "@opentelemetry/instrumentation-undici";
|
||||
import { logger } from "@langfuse/shared/src/server";
|
||||
import { WinstonInstrumentation } from "@opentelemetry/instrumentation-winston";
|
||||
|
||||
if (!process.env.VERCEL && process.env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION) {
|
||||
console.log("Initializing otel tracing");
|
||||
logger.info("Initializing otel tracing");
|
||||
const contextManager = new AsyncHooksContextManager().enable();
|
||||
|
||||
opentelemetry.context.setGlobalContextManager(contextManager);
|
||||
@@ -51,6 +53,7 @@ if (!process.env.VERCEL && process.env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION) {
|
||||
new IORedisInstrumentation(),
|
||||
new HttpInstrumentation(),
|
||||
new PrismaInstrumentation(),
|
||||
new WinstonInstrumentation({ disableLogSending: true }),
|
||||
getNodeAutoInstrumentations(),
|
||||
new UndiciInstrumentation(),
|
||||
// new BullMQInstrumentation(),
|
||||
|
||||
@@ -83,7 +83,7 @@ const OrganizationUsageChart = () => {
|
||||
<>
|
||||
<Text>
|
||||
{usage.data.billingPeriod
|
||||
? `Observations in billing period`
|
||||
? `Observations in current billing period`
|
||||
: "Observations / last 30d"}
|
||||
</Text>
|
||||
<Metric>{numberFormatter(usage.data.countObservations, 0)}</Metric>
|
||||
|
||||
@@ -214,18 +214,6 @@ export const cloudBillingRouter = createTRPCRouter({
|
||||
session: ctx.session,
|
||||
});
|
||||
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
thirtyDaysAgo.setHours(0, 0, 0, 0);
|
||||
let billingPeriod: {
|
||||
start: Date;
|
||||
end: Date;
|
||||
} | null = null;
|
||||
let upcomingInvoice: {
|
||||
usdAmount: number;
|
||||
date: Date;
|
||||
} | null = null;
|
||||
|
||||
const organization = await ctx.prisma.organization.findUnique({
|
||||
where: {
|
||||
id: input.orgId,
|
||||
@@ -249,35 +237,49 @@ export const cloudBillingRouter = createTRPCRouter({
|
||||
parsedOrg.cloudConfig.stripe.activeSubscriptionId,
|
||||
);
|
||||
if (subscription) {
|
||||
billingPeriod = {
|
||||
const billingPeriod = {
|
||||
start: new Date(subscription.current_period_start * 1000),
|
||||
end: new Date(subscription.current_period_end * 1000),
|
||||
};
|
||||
const stripeInvoice = await stripeClient.invoices.retrieveUpcoming({
|
||||
subscription: parsedOrg.cloudConfig.stripe.activeSubscriptionId,
|
||||
});
|
||||
upcomingInvoice = {
|
||||
const upcomingInvoice = {
|
||||
usdAmount: stripeInvoice.amount_due / 100,
|
||||
date: new Date(stripeInvoice.period_end * 1000),
|
||||
};
|
||||
const usage = stripeInvoice.lines.data.reduce((acc, line) => {
|
||||
if (line.quantity) {
|
||||
return acc + line.quantity;
|
||||
}
|
||||
return acc;
|
||||
}, 0);
|
||||
return {
|
||||
countObservations: usage,
|
||||
billingPeriod,
|
||||
upcomingInvoice,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// For non-Stripe subscriptions, we can only get usage from the LangFuse API
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
thirtyDaysAgo.setHours(0, 0, 0, 0);
|
||||
|
||||
const usage = await ctx.prisma.observation.count({
|
||||
where: {
|
||||
project: {
|
||||
orgId: input.orgId,
|
||||
},
|
||||
startTime: {
|
||||
gte: billingPeriod?.start ?? thirtyDaysAgo,
|
||||
gte: thirtyDaysAgo,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
countObservations: usage,
|
||||
billingPeriod,
|
||||
upcomingInvoice,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@ type JumpToPlaygroundButtonProps = (
|
||||
analyticsEventName: "trace_detail:test_in_playground_button_click";
|
||||
}
|
||||
) & {
|
||||
fullWidth?: boolean;
|
||||
variant?: "outline" | "secondary";
|
||||
};
|
||||
|
||||
export const JumpToPlaygroundButton: React.FC<JumpToPlaygroundButtonProps> = (
|
||||
@@ -61,17 +61,14 @@ export const JumpToPlaygroundButton: React.FC<JumpToPlaygroundButtonProps> = (
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={props.fullWidth ? "secondary" : "outline"}
|
||||
variant={props.variant ?? "secondary"}
|
||||
title="Test in LLM playground"
|
||||
size={!props.fullWidth ? "icon" : undefined}
|
||||
onClick={handleClick}
|
||||
asChild
|
||||
>
|
||||
<Link href={`/project/${projectId}/playground`}>
|
||||
<Terminal className="h-4 w-4" />
|
||||
{props.fullWidth ? (
|
||||
<span className="ml-2">Test in playground</span>
|
||||
) : null}
|
||||
<span className="ml-2">Test in playground</span>
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -171,6 +171,15 @@ export const env = createEnv({
|
||||
SENTRY_AUTH_TOKEN: z.string().optional(),
|
||||
SENTRY_CSP_REPORT_URI: z.string().optional(),
|
||||
LANGFUSE_RATE_LIMITS_ENABLED: z.enum(["true", "false"]).default("true"),
|
||||
LANGFUSE_INIT_ORG_ID: z.string().optional(),
|
||||
LANGFUSE_INIT_ORG_NAME: z.string().optional(),
|
||||
LANGFUSE_INIT_PROJECT_ID: z.string().optional(),
|
||||
LANGFUSE_INIT_PROJECT_NAME: z.string().optional(),
|
||||
LANGFUSE_INIT_PROJECT_PUBLIC_KEY: z.string().optional(),
|
||||
LANGFUSE_INIT_PROJECT_SECRET_KEY: z.string().optional(),
|
||||
LANGFUSE_INIT_USER_EMAIL: z.string().email().optional(),
|
||||
LANGFUSE_INIT_USER_NAME: z.string().optional(),
|
||||
LANGFUSE_INIT_USER_PASSWORD: z.string().optional(),
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -194,6 +203,7 @@ export const env = createEnv({
|
||||
NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(),
|
||||
NEXT_PUBLIC_POSTHOG_HOST: z.string().optional(),
|
||||
NEXT_PUBLIC_CRISP_WEBSITE_ID: z.string().optional(),
|
||||
NEXT_PUBLIC_BUILD_ID: z.string().optional(),
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -206,6 +216,7 @@ export const env = createEnv({
|
||||
NEXT_PUBLIC_DEMO_ORG_ID: process.env.NEXT_PUBLIC_DEMO_ORG_ID,
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
NEXT_PUBLIC_BUILD_ID: process.env.NEXT_PUBLIC_BUILD_ID,
|
||||
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
|
||||
NEXTAUTH_COOKIE_DOMAIN: process.env.NEXTAUTH_COOKIE_DOMAIN,
|
||||
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
|
||||
@@ -321,6 +332,16 @@ export const env = createEnv({
|
||||
SENTRY_AUTH_TOKEN: process.env.SENTRY_AUTH_TOKEN,
|
||||
SENTRY_CSP_REPORT_URI: process.env.SENTRY_CSP_REPORT_URI,
|
||||
LANGFUSE_RATE_LIMITS_ENABLED: process.env.LANGFUSE_RATE_LIMITS_ENABLED,
|
||||
// provisioning
|
||||
LANGFUSE_INIT_ORG_ID: process.env.LANGFUSE_INIT_ORG_ID,
|
||||
LANGFUSE_INIT_ORG_NAME: process.env.LANGFUSE_INIT_ORG_NAME,
|
||||
LANGFUSE_INIT_PROJECT_ID: process.env.LANGFUSE_INIT_PROJECT_ID,
|
||||
LANGFUSE_INIT_PROJECT_NAME: process.env.LANGFUSE_INIT_PROJECT_NAME,
|
||||
LANGFUSE_INIT_PROJECT_PUBLIC_KEY: process.env.LANGFUSE_INIT_PROJECT_PUBLIC_KEY,
|
||||
LANGFUSE_INIT_PROJECT_SECRET_KEY: process.env.LANGFUSE_INIT_PROJECT_SECRET_KEY,
|
||||
LANGFUSE_INIT_USER_EMAIL: process.env.LANGFUSE_INIT_USER_EMAIL,
|
||||
LANGFUSE_INIT_USER_NAME: process.env.LANGFUSE_INIT_USER_NAME,
|
||||
LANGFUSE_INIT_USER_PASSWORD: process.env.LANGFUSE_INIT_USER_PASSWORD,
|
||||
},
|
||||
// Skip validation in Docker builds
|
||||
// DOCKER_BUILD is set in Dockerfile
|
||||
|
||||
@@ -8,6 +8,7 @@ export type AuditableResource =
|
||||
| "comment"
|
||||
| "datasetItem"
|
||||
| "dataset"
|
||||
| "datasetRun"
|
||||
| "trace"
|
||||
| "project"
|
||||
| "observation"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createUserEmailPassword } from "@/src/features/auth-credentials/lib/cre
|
||||
import { signupSchema } from "@/src/features/auth/lib/signupSchema";
|
||||
import { getSsoAuthProviderIdForDomain } from "@/src/ee/features/multi-tenant-sso/utils";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { logger } from "@langfuse/shared/src/server";
|
||||
|
||||
/*
|
||||
* Sign-up endpoint (email/password users), creates user in database.
|
||||
@@ -32,7 +33,7 @@ export async function signupApiHandler(
|
||||
// parse and type check the request body with zod
|
||||
const validBody = signupSchema.safeParse(req.body);
|
||||
if (!validBody.success) {
|
||||
console.log("Signup: Invalid body", validBody.error);
|
||||
logger.warn("Signup: Invalid body", validBody.error);
|
||||
res.status(422).json({ message: validBody.error });
|
||||
return;
|
||||
}
|
||||
@@ -52,8 +53,8 @@ export async function signupApiHandler(
|
||||
}
|
||||
|
||||
// EE: check if custom SSO configuration is enabled for this domain
|
||||
const customSsoProvider = await getSsoAuthProviderIdForDomain(domain);
|
||||
if (customSsoProvider) {
|
||||
const multiTenantSsoProvider = await getSsoAuthProviderIdForDomain(domain);
|
||||
if (multiTenantSsoProvider) {
|
||||
res.status(422).json({
|
||||
message: "You must sign in via SSO for this domain.",
|
||||
});
|
||||
@@ -69,11 +70,12 @@ export async function signupApiHandler(
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.log(
|
||||
logger.warn(
|
||||
"Signup: Error creating user",
|
||||
error.message,
|
||||
body.email.toLowerCase(),
|
||||
body.name,
|
||||
error,
|
||||
);
|
||||
res.status(422).json({ message: error.message });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { prisma, Role } from "@langfuse/shared/src/db";
|
||||
import { logger } from "@langfuse/shared/src/server";
|
||||
|
||||
export async function createProjectMembershipsOnSignup(user: {
|
||||
id: string;
|
||||
@@ -81,7 +82,7 @@ export async function createProjectMembershipsOnSignup(user: {
|
||||
// Invites do not work for users without emails (some future SSO users)
|
||||
if (user.email) await processMembershipInvitations(user.email, user.id);
|
||||
} catch (e) {
|
||||
console.error("Error assigning project access to new user", e);
|
||||
logger.error("Error assigning project access to new user", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,12 +20,23 @@ import {
|
||||
} from "@/src/features/scores/components/ScoreDetailColumnHelpers";
|
||||
import { type ScoreAggregate } from "@/src/features/scores/lib/types";
|
||||
import { useIndividualScoreColumns } from "@/src/features/scores/hooks/useIndividualScoreColumns";
|
||||
import { MoreVertical } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/src/components/ui/dropdown-menu";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { DeleteDatasetRunButton } from "@/src/features/datasets/components/DeleteDatasetRunButton";
|
||||
|
||||
type DatasetRunRowKey = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type DatasetRunRowData = {
|
||||
key: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
key: DatasetRunRowKey;
|
||||
createdAt: string;
|
||||
countRunItems: string;
|
||||
avgLatency: number;
|
||||
@@ -45,6 +56,7 @@ export function DatasetRunsTable(props: {
|
||||
pageIndex: withDefault(NumberParam, 0),
|
||||
pageSize: withDefault(NumberParam, 50),
|
||||
});
|
||||
|
||||
const [rowHeight, setRowHeight] = useRowHeightLocalStorage(
|
||||
"datasetRuns",
|
||||
"s",
|
||||
@@ -149,6 +161,35 @@ export function DatasetRunsTable(props: {
|
||||
) : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
accessorKey: "actions",
|
||||
header: "Actions",
|
||||
size: 70,
|
||||
cell: ({ row }) => {
|
||||
const key: DatasetRunRowKey = row.getValue("key");
|
||||
const { id: datasetRunId } = key;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only [position:relative]">Open menu</span>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DeleteDatasetRunButton
|
||||
projectId={props.projectId}
|
||||
datasetRunId={datasetRunId}
|
||||
fullWidth
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const convertToTableRow = (
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Trash } from "lucide-react";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/src/components/ui/dialog";
|
||||
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { api } from "@/src/utils/api";
|
||||
import React, { useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
export const DeleteDatasetRunButton = ({
|
||||
projectId,
|
||||
datasetRunId,
|
||||
fullWidth = false,
|
||||
redirectUrl,
|
||||
}: {
|
||||
projectId: string;
|
||||
datasetRunId: string;
|
||||
fullWidth?: boolean;
|
||||
redirectUrl?: string;
|
||||
}) => {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const capture = usePostHogClientCapture();
|
||||
const hasAccess = useHasProjectAccess({
|
||||
projectId: projectId,
|
||||
scope: "datasets:CUD",
|
||||
});
|
||||
const utils = api.useUtils();
|
||||
const router = useRouter();
|
||||
const mutDelete = api.datasets.deleteDatasetRun.useMutation({
|
||||
onSuccess: () => {
|
||||
redirectUrl ? router.push(redirectUrl) : utils.datasets.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const button = fullWidth ? (
|
||||
<Button variant="ghost" className="w-full" disabled={!hasAccess}>
|
||||
<div className="flex w-full flex-row items-center gap-1">
|
||||
<Trash className="h-4 w-4" />
|
||||
<span className="text-sm font-normal">Delete</span>
|
||||
</div>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="icon" disabled={!hasAccess}>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
return hasAccess ? (
|
||||
<Dialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!mutDelete.isLoading) {
|
||||
setIsDialogOpen(isOpen);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>{button}</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="mb-4">Please confirm</DialogTitle>
|
||||
<DialogDescription className="text-md p-0">
|
||||
This action cannot be undone and removes all the data associated
|
||||
with this dataset run.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Button
|
||||
variant="destructive"
|
||||
loading={mutDelete.isLoading}
|
||||
disabled={mutDelete.isLoading}
|
||||
onClick={async (event) => {
|
||||
event.preventDefault();
|
||||
capture("dataset_run:delete_form_open");
|
||||
await mutDelete.mutateAsync({
|
||||
projectId,
|
||||
datasetRunId,
|
||||
});
|
||||
setIsDialogOpen(false);
|
||||
}}
|
||||
>
|
||||
Delete Dataset Run
|
||||
</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
button
|
||||
);
|
||||
};
|
||||
@@ -150,7 +150,7 @@ export const EditDatasetItem = ({
|
||||
field.onChange(v);
|
||||
}}
|
||||
editable={hasAccess}
|
||||
className="max-h-[600px] overflow-y-auto"
|
||||
className="max-h-[500px] overflow-y-auto"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -171,7 +171,7 @@ export const EditDatasetItem = ({
|
||||
field.onChange(v);
|
||||
}}
|
||||
editable={hasAccess}
|
||||
className="max-h-[600px] overflow-y-auto"
|
||||
className="max-h-[500px] overflow-y-auto"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -193,7 +193,7 @@ export const EditDatasetItem = ({
|
||||
field.onChange(v);
|
||||
}}
|
||||
editable={hasAccess}
|
||||
className="max-h-[300px] overflow-y-auto"
|
||||
className="max-h-[200px] overflow-y-auto"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
|
||||
@@ -801,4 +801,35 @@ export const datasetRouter = createTRPCRouter({
|
||||
},
|
||||
});
|
||||
}),
|
||||
deleteDatasetRun: protectedProjectProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
datasetRunId: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
throwIfNoProjectAccess({
|
||||
session: ctx.session,
|
||||
projectId: input.projectId,
|
||||
scope: "datasets:CUD",
|
||||
});
|
||||
|
||||
const deletedDatasetRun = await ctx.prisma.datasetRuns.delete({
|
||||
where: {
|
||||
id_projectId: {
|
||||
id: input.datasetRunId,
|
||||
projectId: input.projectId,
|
||||
},
|
||||
},
|
||||
});
|
||||
await auditLog({
|
||||
session: ctx.session,
|
||||
resourceType: "datasetRun",
|
||||
resourceId: deletedDatasetRun.id,
|
||||
action: "delete",
|
||||
before: deletedDatasetRun,
|
||||
});
|
||||
return deletedDatasetRun;
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { runFeedbackCorsMiddleware } from "@/src/features/feedback/server/corsMiddleware";
|
||||
import { sendToSlack } from "@/src/features/slack/server/slack-webhook";
|
||||
import { type NextApiRequest, type NextApiResponse } from "next";
|
||||
import { logger } from "@langfuse/shared/src/server";
|
||||
|
||||
// Collects feedack from users that do not use the cloud version of the app
|
||||
export default async function feedbackApiHandler(
|
||||
@@ -14,11 +15,11 @@ export default async function feedbackApiHandler(
|
||||
if (slackResponse.status === 200) {
|
||||
res.status(200).json({ status: "OK" });
|
||||
} else {
|
||||
console.error(slackResponse);
|
||||
logger.error(slackResponse);
|
||||
res.status(400).json({ status: "Error" });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
logger.error(error);
|
||||
res.status(500).json({ status: "Error" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
supportedModels,
|
||||
} from "@langfuse/shared";
|
||||
import { encrypt } from "@langfuse/shared/encryption";
|
||||
import { logger } from "@langfuse/shared/src/server";
|
||||
|
||||
export function getDisplaySecretKey(secretKey: string) {
|
||||
return "..." + secretKey.slice(-4);
|
||||
@@ -50,7 +51,7 @@ export const llmApiKeyRouter = createTRPCRouter({
|
||||
action: "create",
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
logger.error(e);
|
||||
throw e;
|
||||
}
|
||||
}),
|
||||
@@ -160,7 +161,7 @@ export const llmApiKeyRouter = createTRPCRouter({
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
logger.error(err);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const showVersionUpdateToast = () => {
|
||||
toast.custom(
|
||||
() => (
|
||||
<div className="flex justify-between">
|
||||
<div className="flex min-w-[300px] flex-1 flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="m-0 text-sm font-medium leading-tight text-foreground/70">
|
||||
We have released a new version of Langfuse. Please refresh your
|
||||
browser to get the latest update.
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size={"sm"}
|
||||
className="text-foreground/50"
|
||||
onClick={() => {
|
||||
window.location.reload();
|
||||
}}
|
||||
>
|
||||
Refresh page
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{
|
||||
duration: Infinity,
|
||||
style: {
|
||||
padding: "1rem",
|
||||
borderRadius: "0.5rem",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
backgroundColor: "hsl(var(--border))",
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -95,6 +95,7 @@ const events = {
|
||||
"new_from_trace_form_submit",
|
||||
"new_from_trace_form_open",
|
||||
],
|
||||
dataset_run: ["delete_form_open"],
|
||||
notification: ["click_link", "dismiss_notification"],
|
||||
tag: [
|
||||
"add_existing_tag",
|
||||
|
||||
@@ -83,8 +83,6 @@ export function SetPromptVersionLabels({ prompt }: { prompt: Prompt }) {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
if (!hasAccess) return null;
|
||||
|
||||
return (
|
||||
<Popover
|
||||
key={prompt.id}
|
||||
@@ -98,8 +96,10 @@ export function SetPromptVersionLabels({ prompt }: { prompt: Prompt }) {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-7 w-7 px-0"
|
||||
aria-label="Set prompt labels"
|
||||
title="Set prompt labels"
|
||||
disabled={!hasAccess}
|
||||
>
|
||||
<TagIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -160,7 +160,7 @@ export function SetPromptVersionLabels({ prompt }: { prompt: Prompt }) {
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mt-2 w-full justify-start px-2 py-1 text-sm font-normal"
|
||||
className="mt-2 w-full justify-start px-2 py-1 text-sm font-normal"
|
||||
onClick={() => setIsAddingLabel(true)}
|
||||
>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
|
||||
@@ -62,6 +62,7 @@ export function DeletePromptVersion({
|
||||
variant="outline"
|
||||
type="button"
|
||||
size="icon"
|
||||
className="h-7 w-7 px-0"
|
||||
disabled={!hasAccess}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Pencil } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { NumberParam, useQueryParam } from "use-query-params";
|
||||
@@ -7,10 +6,8 @@ import Header from "@/src/components/layouts/header";
|
||||
import { OpenAiMessageView } from "@/src/components/trace/IOPreview";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/src/components/ui/tabs";
|
||||
import { Badge } from "@/src/components/ui/badge";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { CodeView, JSONView } from "@/src/components/ui/CodeJsonViewer";
|
||||
import { DetailPageNav } from "@/src/features/navigate-detail-pages/DetailPageNav";
|
||||
import { DeletePromptVersion } from "@/src/features/prompts/components/delete-prompt-version";
|
||||
import { PromptType } from "@/src/features/prompts/server/utils/validation";
|
||||
import useProjectIdFromURL from "@/src/hooks/useProjectIdFromURL";
|
||||
import { api } from "@/src/utils/api";
|
||||
@@ -18,7 +15,6 @@ import { extractVariables } from "@/src/utils/string";
|
||||
import { ScrollArea } from "@radix-ui/react-scroll-area";
|
||||
import { TagPromptDetailsPopover } from "@/src/features/tag/components/TagPromptDetailsPopover";
|
||||
import { PromptHistoryNode } from "./prompt-history";
|
||||
import { SetPromptVersionLabels } from "@/src/features/prompts/components/SetPromptVersionLabels";
|
||||
import Generations from "@/src/components/table/use-cases/generations";
|
||||
import {
|
||||
Accordion,
|
||||
@@ -26,14 +22,12 @@ import {
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/src/components/ui/accordion";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { JumpToPlaygroundButton } from "@/src/ee/features/playground/page/components/JumpToPlaygroundButton";
|
||||
import { ChatMlArraySchema } from "@/src/components/schemas/ChatMlSchema";
|
||||
import { CommentList } from "@/src/features/comments/CommentList";
|
||||
|
||||
export const PromptDetail = () => {
|
||||
const projectId = useProjectIdFromURL();
|
||||
const capture = usePostHogClientCapture();
|
||||
const promptName = decodeURIComponent(useRouter().query.promptName as string);
|
||||
const [currentPromptVersion, setCurrentPromptVersion] = useQueryParam(
|
||||
"version",
|
||||
@@ -107,31 +101,11 @@ export const PromptDetail = () => {
|
||||
]}
|
||||
actionButtons={
|
||||
<>
|
||||
<SetPromptVersionLabels prompt={prompt} />
|
||||
|
||||
<JumpToPlaygroundButton
|
||||
source="prompt"
|
||||
prompt={prompt}
|
||||
analyticsEventName="prompt_detail:test_in_playground_button_click"
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => capture("prompts:update_form_open")}
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href={`/project/${projectId}/prompts/new?promptId=${encodeURIComponent(prompt.id)}`}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<DeletePromptVersion
|
||||
promptVersionId={prompt.id}
|
||||
version={prompt.version}
|
||||
countVersions={promptHistory.data.totalCount}
|
||||
/>
|
||||
<DetailPageNav
|
||||
key="nav"
|
||||
@@ -242,6 +216,7 @@ export const PromptDetail = () => {
|
||||
prompts={promptHistory.data.promptVersions}
|
||||
currentPromptVersion={prompt.version}
|
||||
setCurrentPromptVersion={setCurrentPromptVersion}
|
||||
totalCount={promptHistory.data.totalCount}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { StatusBadge } from "@/src/components/layouts/status-badge";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { DeletePromptVersion } from "@/src/features/prompts/components/delete-prompt-version";
|
||||
import { SetPromptVersionLabels } from "@/src/features/prompts/components/SetPromptVersionLabels";
|
||||
import { PRODUCTION_LABEL } from "@/src/features/prompts/constants";
|
||||
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import { type RouterOutputs } from "@/src/utils/api";
|
||||
import { Pencil, PencilOff } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { type NextRouter, useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
|
||||
const PromptHistoryTraceNode = (props: {
|
||||
index: number;
|
||||
@@ -10,7 +18,14 @@ const PromptHistoryTraceNode = (props: {
|
||||
setCurrentPromptVersion: (version: number | undefined) => void;
|
||||
router: NextRouter;
|
||||
projectId: string;
|
||||
totalCount: number;
|
||||
}) => {
|
||||
const capture = usePostHogClientCapture();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const hasAccess = useHasProjectAccess({
|
||||
projectId: props.projectId,
|
||||
scope: "prompts:CUD",
|
||||
});
|
||||
const { prompt } = props;
|
||||
let badges: JSX.Element[] = prompt.labels
|
||||
.sort((a, b) =>
|
||||
@@ -21,7 +36,7 @@ const PromptHistoryTraceNode = (props: {
|
||||
: a.localeCompare(b),
|
||||
)
|
||||
.map((label) => {
|
||||
return <StatusBadge type={label} key={label} />;
|
||||
return <StatusBadge type={label} key={label} className="h-6" />;
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -29,19 +44,63 @@ const PromptHistoryTraceNode = (props: {
|
||||
className={`group mb-2 flex cursor-pointer flex-col gap-1 rounded-sm p-2 hover:bg-primary-foreground ${
|
||||
props.currentPromptVersion === prompt.version ? "bg-muted" : ""
|
||||
}`}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onClick={() => {
|
||||
props.index === 0
|
||||
? props.setCurrentPromptVersion(undefined)
|
||||
: props.setCurrentPromptVersion(prompt.version);
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-sm bg-input p-1 text-xs">
|
||||
Version {prompt.version}
|
||||
</span>
|
||||
{badges}
|
||||
<div className="grid grid-cols-[auto,1fr] items-start gap-2">
|
||||
<div
|
||||
className={`grid grid-cols-[auto,1fr] items-start ${isHovered ? "h-full" : "h-7"}`}
|
||||
>
|
||||
<span className="flex h-6 text-nowrap rounded-sm bg-input p-1 text-xs">
|
||||
Version {prompt.version}
|
||||
</span>
|
||||
{Boolean(prompt.labels.length) && (
|
||||
<div className="ml-2 flex h-full flex-wrap gap-1 overflow-auto">
|
||||
{badges}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isHovered && (
|
||||
<div className="flex flex-row space-x-1">
|
||||
<SetPromptVersionLabels prompt={prompt} />
|
||||
{hasAccess ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-7 w-7 px-0"
|
||||
onClick={() => {
|
||||
capture("prompts:update_form_open");
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
href={`/project/${props.projectId}/prompts/new?promptId=${encodeURIComponent(prompt.id)}`}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-7 w-7 px-0"
|
||||
disabled
|
||||
>
|
||||
<PencilOff className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<DeletePromptVersion
|
||||
promptVersionId={prompt.id}
|
||||
version={prompt.version}
|
||||
countVersions={props.totalCount}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{prompt.createdAt.toLocaleString()}
|
||||
@@ -60,6 +119,7 @@ export const PromptHistoryNode = (props: {
|
||||
prompts: RouterOutputs["prompts"]["allVersions"]["promptVersions"];
|
||||
currentPromptVersion: number | undefined;
|
||||
setCurrentPromptVersion: (id: number | undefined) => void;
|
||||
totalCount: number;
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const projectId = router.query.projectId as string;
|
||||
@@ -74,6 +134,7 @@ export const PromptHistoryNode = (props: {
|
||||
setCurrentPromptVersion={props.setCurrentPromptVersion}
|
||||
router={router}
|
||||
projectId={projectId}
|
||||
totalCount={props.totalCount}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { type GetPromptsMetaType } from "@/src/features/prompts/server/utils/validation";
|
||||
import { promptsTableCols } from "@/src/server/api/definitions/promptsTable";
|
||||
import {
|
||||
tableColumnsToSqlFilterAndPrefix,
|
||||
type FilterState,
|
||||
} from "@langfuse/shared";
|
||||
import { type FilterState } from "@langfuse/shared";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { tableColumnsToSqlFilterAndPrefix } from "@langfuse/shared/src/server";
|
||||
|
||||
export type GetPromptsMetaParams = GetPromptsMetaType & { projectId: string };
|
||||
|
||||
|
||||
@@ -10,16 +10,18 @@ import {
|
||||
import { type Prompt, Prisma } from "@langfuse/shared/src/db";
|
||||
|
||||
import { createPrompt } from "../actions/createPrompt";
|
||||
import { observationsTableCols, orderByToPrismaSql } from "@langfuse/shared";
|
||||
import { observationsTableCols } from "@langfuse/shared";
|
||||
import { promptsTableCols } from "@/src/server/api/definitions/promptsTable";
|
||||
import { optionalPaginationZod, paginationZod } from "@langfuse/shared";
|
||||
import {
|
||||
orderBy,
|
||||
singleFilter,
|
||||
tableColumnsToSqlFilterAndPrefix,
|
||||
} from "@langfuse/shared";
|
||||
import { orderBy, singleFilter } from "@langfuse/shared";
|
||||
import { LATEST_PROMPT_LABEL } from "@/src/features/prompts/constants";
|
||||
import { PromptService, redis } from "@langfuse/shared/src/server";
|
||||
import {
|
||||
orderByToPrismaSql,
|
||||
PromptService,
|
||||
redis,
|
||||
logger,
|
||||
tableColumnsToSqlFilterAndPrefix,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { aggregateScores } from "@/src/features/scores/lib/aggregateScores";
|
||||
import { type ScoreSimplified } from "@/src/features/scores/lib/types";
|
||||
|
||||
@@ -192,7 +194,7 @@ export const promptRouter = createTRPCRouter({
|
||||
|
||||
return prompt;
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
logger.error(e);
|
||||
throw e;
|
||||
}
|
||||
}),
|
||||
@@ -296,7 +298,7 @@ export const promptRouter = createTRPCRouter({
|
||||
// Unlock cache
|
||||
await promptService.unlockCache({ projectId, promptName });
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
logger.error(e);
|
||||
throw e;
|
||||
}
|
||||
}),
|
||||
@@ -384,7 +386,7 @@ export const promptRouter = createTRPCRouter({
|
||||
// Unlock cache
|
||||
await promptService.unlockCache({ projectId, promptName });
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
logger.error(e);
|
||||
throw e;
|
||||
}
|
||||
}),
|
||||
@@ -478,7 +480,7 @@ export const promptRouter = createTRPCRouter({
|
||||
// Unlock cache
|
||||
await promptService.unlockCache({ projectId, promptName });
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
logger.error(e);
|
||||
throw e;
|
||||
}
|
||||
}),
|
||||
@@ -546,7 +548,7 @@ export const promptRouter = createTRPCRouter({
|
||||
// Unlock cache
|
||||
await promptService.unlockCache({ projectId, promptName });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
logger.error(error);
|
||||
}
|
||||
}),
|
||||
allVersions: protectedProjectProcedure
|
||||
|
||||
@@ -11,11 +11,12 @@ import {
|
||||
import {
|
||||
recordIncrement,
|
||||
type ApiAccessScope,
|
||||
logger,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { type NextApiResponse } from "next";
|
||||
|
||||
// Business Logic
|
||||
// - rate limit strategy is based on org-id, org plan, and resources. Rate limits are appliead in buckets of minutes.
|
||||
// - rate limit strategy is based on org-id, org plan, and resources. Rate limits are applied in buckets of minutes.
|
||||
// - rate limits are not applied for self hosters and are also not applied when Redis is not available
|
||||
// - infos for rate-limits are taken from the API access scope. Info for this scope is stored alongside API Keys in Redis for efficient access.
|
||||
// - isRateLimited returns false for self-hosters
|
||||
@@ -41,7 +42,7 @@ export class RateLimitService {
|
||||
}
|
||||
|
||||
if (!this.redis) {
|
||||
console.log("Rate limiting not available without Redis");
|
||||
logger.warn("Rate limiting not available without Redis");
|
||||
return new RateLimitHelper(undefined);
|
||||
}
|
||||
|
||||
@@ -100,7 +101,7 @@ export class RateLimitService {
|
||||
};
|
||||
} else {
|
||||
// Some other error occurred, rethrow it
|
||||
console.log("Internal Rate limit error", err);
|
||||
logger.error("Internal Rate limit error", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -134,9 +135,7 @@ export class RateLimitHelper {
|
||||
|
||||
sendRestResponseIfLimited(nextResponse: NextApiResponse) {
|
||||
if (!this.res || !this.isRateLimited()) {
|
||||
console.error(
|
||||
"Trying to send rate limit response without being limited.",
|
||||
);
|
||||
logger.error("Trying to send rate limit response without being limited.");
|
||||
throw new Error(
|
||||
"Trying to send rate limit response without being limited.",
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type AuthHeaderVerificationResult,
|
||||
CachedApiKey,
|
||||
OrgEnrichedApiKey,
|
||||
logger,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import {
|
||||
type PrismaClient,
|
||||
@@ -35,10 +36,18 @@ export class ApiAuthService {
|
||||
private async invalidate(apiKeys: ApiKey[], identifier: string) {
|
||||
const hashKeys = apiKeys.map((key) => key.fastHashedSecretKey);
|
||||
|
||||
const filteredHashKeys = hashKeys.filter((hash): hash is string =>
|
||||
Boolean(hash),
|
||||
);
|
||||
if (filteredHashKeys.length === 0) {
|
||||
logger.info("No valid keys to invalidate");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.redis) {
|
||||
console.log(`Invalidating API keys in redis for ${identifier}`);
|
||||
logger.info(`Invalidating API keys in redis for ${identifier}`);
|
||||
await this.redis.del(
|
||||
hashKeys
|
||||
filteredHashKeys
|
||||
.filter((hash): hash is string => Boolean(hash))
|
||||
.map((hash) => this.createRedisKey(hash)),
|
||||
);
|
||||
@@ -81,9 +90,7 @@ export class ApiAuthService {
|
||||
|
||||
// if redis is available, delete the key from there as well
|
||||
// delete from redis even if caching is disabled via env for consistency
|
||||
if (this.redis && apiKey.fastHashedSecretKey) {
|
||||
await this.redis.del(this.createRedisKey(apiKey.fastHashedSecretKey));
|
||||
}
|
||||
this.invalidate([apiKey], `key ${id}`);
|
||||
|
||||
await this.prisma.apiKey.delete({
|
||||
where: {
|
||||
@@ -97,7 +104,7 @@ export class ApiAuthService {
|
||||
authHeader: string | undefined,
|
||||
): Promise<AuthHeaderVerificationResult> {
|
||||
if (!authHeader) {
|
||||
console.error("No authorization header");
|
||||
logger.error("No authorization header");
|
||||
return {
|
||||
validKey: false,
|
||||
error: "No authorization header",
|
||||
@@ -126,9 +133,9 @@ export class ApiAuthService {
|
||||
});
|
||||
|
||||
if (!slowKey) {
|
||||
console.error("No key found for public key", publicKey);
|
||||
logger.error("No key found for public key", publicKey);
|
||||
if (this.redis) {
|
||||
console.log(
|
||||
logger.info(
|
||||
`No key found, storing ${API_KEY_NON_EXISTENT} in redis`,
|
||||
);
|
||||
await this.addApiKeyToRedis(
|
||||
@@ -145,7 +152,7 @@ export class ApiAuthService {
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
console.log("Old key is invalid", publicKey);
|
||||
logger.info("Old key is invalid", publicKey);
|
||||
throw new Error("Invalid credentials");
|
||||
}
|
||||
|
||||
@@ -164,7 +171,7 @@ export class ApiAuthService {
|
||||
}
|
||||
|
||||
if (!finalApiKey) {
|
||||
console.log("No project id found for key", publicKey);
|
||||
logger.info("No project id found for key", publicKey);
|
||||
throw new Error("Invalid credentials");
|
||||
}
|
||||
|
||||
@@ -173,7 +180,7 @@ export class ApiAuthService {
|
||||
const plan = finalApiKey.plan;
|
||||
|
||||
if (!isPlan(plan)) {
|
||||
console.error("Invalid plan type for key", finalApiKey.plan);
|
||||
logger.error("Invalid plan type for key", finalApiKey.plan);
|
||||
throw new Error("Invalid credentials");
|
||||
}
|
||||
|
||||
@@ -212,7 +219,7 @@ export class ApiAuthService {
|
||||
};
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error(
|
||||
logger.error(
|
||||
`Error verifying auth header: ${error instanceof Error ? error.message : null}`,
|
||||
error,
|
||||
);
|
||||
@@ -252,7 +259,7 @@ export class ApiAuthService {
|
||||
include: { project: { include: { organization: true } } },
|
||||
});
|
||||
if (!dbKey) {
|
||||
console.log("No api key found for public key:", publicKey);
|
||||
logger.info("No api key found for public key:", publicKey);
|
||||
throw new Error("Invalid public key");
|
||||
}
|
||||
return dbKey;
|
||||
@@ -310,7 +317,7 @@ export class ApiAuthService {
|
||||
env.LANGFUSE_CACHE_API_KEY_TTL_SECONDS, // redis API is in seconds
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
console.error("Error adding key to redis", error);
|
||||
logger.error("Error adding key to redis", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,14 +344,11 @@ export class ApiAuthService {
|
||||
}
|
||||
|
||||
if (!parsedApiKey.success) {
|
||||
console.error(
|
||||
"Failed to parse API key from Redis:",
|
||||
parsedApiKey.error,
|
||||
);
|
||||
logger.error("Failed to parse API key from Redis:", parsedApiKey.error);
|
||||
}
|
||||
return null;
|
||||
} catch (error: unknown) {
|
||||
console.error("Error fetching key from redis", error);
|
||||
logger.error("Error fetching key from redis", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -387,7 +391,7 @@ export const convertToRedisRepresentation = (
|
||||
});
|
||||
|
||||
if (!orgId) {
|
||||
console.error("No organization found for key");
|
||||
logger.error("No organization found for key");
|
||||
throw new Error("Invalid credentials");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { auditLog } from "@/src/features/audit-logs/auditLog";
|
||||
import { createShaHash, generateKeySet } from "@langfuse/shared/src/server";
|
||||
import { throwIfNoProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import {
|
||||
createTRPCRouter,
|
||||
@@ -8,7 +7,7 @@ import {
|
||||
import * as z from "zod";
|
||||
import { ApiAuthService } from "@/src/features/public-api/server/apiAuth";
|
||||
import { redis } from "@langfuse/shared/src/server";
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { createAndAddApiKeysToDb } from "@langfuse/shared/src/server/auth/apiKeys";
|
||||
|
||||
export const apiKeysRouter = createTRPCRouter({
|
||||
byProjectId: protectedProjectProcedure
|
||||
@@ -56,37 +55,20 @@ export const apiKeysRouter = createTRPCRouter({
|
||||
scope: "apiKeys:create",
|
||||
});
|
||||
|
||||
const { pk, sk, hashedSk, displaySk } = await generateKeySet();
|
||||
|
||||
const salt = env.SALT;
|
||||
const hashFromProvidedKey = createShaHash(sk, salt);
|
||||
|
||||
const apiKey = await ctx.prisma.apiKey.create({
|
||||
data: {
|
||||
projectId: input.projectId,
|
||||
publicKey: pk,
|
||||
hashedSecretKey: hashedSk,
|
||||
displaySecretKey: displaySk,
|
||||
fastHashedSecretKey: hashFromProvidedKey,
|
||||
note: input.note,
|
||||
},
|
||||
const apiKeyMeta = await createAndAddApiKeysToDb({
|
||||
prisma: ctx.prisma,
|
||||
projectId: input.projectId,
|
||||
note: input.note,
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
session: ctx.session,
|
||||
resourceType: "apiKey",
|
||||
resourceId: apiKey.id,
|
||||
resourceId: apiKeyMeta.id,
|
||||
action: "create",
|
||||
});
|
||||
|
||||
return {
|
||||
id: apiKey.id,
|
||||
createdAt: apiKey.createdAt,
|
||||
note: input.note,
|
||||
publicKey: apiKey.publicKey,
|
||||
secretKey: sk,
|
||||
displaySecretKey: displaySk,
|
||||
};
|
||||
return apiKeyMeta;
|
||||
}),
|
||||
delete: protectedProjectProcedure
|
||||
.input(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { type ApiAccessScope } from "@langfuse/shared/src/server";
|
||||
import { type ApiAccessScope, logger } from "@langfuse/shared/src/server";
|
||||
|
||||
type Resource = {
|
||||
type: "project" | "trace" | "observation" | "score";
|
||||
@@ -28,7 +28,7 @@ async function isResourceInProject(resource: Resource, projectId: string) {
|
||||
case "project":
|
||||
const projectCheck = resource.id === projectId;
|
||||
if (!projectCheck)
|
||||
console.log("project check", projectCheck, resource.id, projectId);
|
||||
logger.warn("project check", projectCheck, resource.id, projectId);
|
||||
return projectCheck;
|
||||
|
||||
case "trace":
|
||||
@@ -37,7 +37,7 @@ async function isResourceInProject(resource: Resource, projectId: string) {
|
||||
where: { id: resource.id, projectId },
|
||||
})) === 1;
|
||||
if (!traceCheck)
|
||||
console.log("trace check", traceCheck, resource.id, projectId);
|
||||
logger.warn("trace check", traceCheck, resource.id, projectId);
|
||||
return traceCheck;
|
||||
|
||||
case "observation":
|
||||
@@ -46,7 +46,7 @@ async function isResourceInProject(resource: Resource, projectId: string) {
|
||||
where: { id: resource.id, projectId },
|
||||
})) === 1;
|
||||
if (!observationCheck)
|
||||
console.log(
|
||||
logger.warn(
|
||||
"observation check",
|
||||
observationCheck,
|
||||
resource.id,
|
||||
@@ -63,7 +63,7 @@ async function isResourceInProject(resource: Resource, projectId: string) {
|
||||
},
|
||||
})) === 1;
|
||||
if (!scoreCheck)
|
||||
console.log("score check", scoreCheck, resource.id, projectId);
|
||||
logger.warn("score check", scoreCheck, resource.id, projectId);
|
||||
return scoreCheck;
|
||||
default:
|
||||
return false;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
redis,
|
||||
type AuthHeaderValidVerificationResult,
|
||||
traceException,
|
||||
logger,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { type RateLimitResource } from "@langfuse/shared";
|
||||
import { RateLimitService } from "@/src/features/public-api/server/RateLimitService";
|
||||
@@ -64,15 +65,8 @@ export const createAuthedAPIRoute = <
|
||||
return rateLimitResponse.sendRestResponseIfLimited(res);
|
||||
}
|
||||
|
||||
console.log(
|
||||
"Request to route ",
|
||||
routeConfig.name,
|
||||
"projectId ",
|
||||
auth.scope.projectId,
|
||||
"with query ",
|
||||
req.query,
|
||||
"and body ",
|
||||
req.body,
|
||||
logger.info(
|
||||
`Request to route ${routeConfig.name} projectId ${auth.scope.projectId} with query ${req.query} and body ${req.body}`,
|
||||
);
|
||||
|
||||
const query = routeConfig.querySchema
|
||||
@@ -93,7 +87,7 @@ export const createAuthedAPIRoute = <
|
||||
if (routeConfig.responseSchema) {
|
||||
const parsingResult = routeConfig.responseSchema.safeParse(response);
|
||||
if (!parsingResult.success) {
|
||||
console.error("Response validation failed:", parsingResult.error);
|
||||
logger.error("Response validation failed:", parsingResult.error);
|
||||
traceException(parsingResult.error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { cors, runMiddleware } from "@/src/features/public-api/server/cors";
|
||||
import { type NextApiRequest, type NextApiResponse } from "next";
|
||||
import { type ZodError } from "zod";
|
||||
import { BaseError, MethodNotAllowedError } from "@langfuse/shared";
|
||||
import { traceException } from "@langfuse/shared/src/server";
|
||||
import { logger, traceException } from "@langfuse/shared/src/server";
|
||||
|
||||
const httpMethods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const;
|
||||
export type HttpMethod = (typeof httpMethods)[number];
|
||||
@@ -39,7 +39,7 @@ export function withMiddlewares(handlers: Handlers) {
|
||||
|
||||
return await finalHandlers[method](req, res);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
logger.error(error);
|
||||
|
||||
if (error instanceof BaseError) {
|
||||
if (error.httpCode >= 500 && error.httpCode < 600) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { VERSION } from "@/src/constants";
|
||||
import { ServerPosthog } from "@/src/features/posthog-analytics/ServerPosthog";
|
||||
import { Prisma, prisma } from "@langfuse/shared/src/db";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { logger } from "@langfuse/shared/src/server";
|
||||
|
||||
// Interval between jobs in milliseconds
|
||||
const JOB_INTERVAL_MINUTES = Prisma.raw("60");
|
||||
@@ -13,7 +14,7 @@ export async function telemetry() {
|
||||
try {
|
||||
// Only run in prod
|
||||
if (process.env.NODE_ENV !== "production") return;
|
||||
// Do not run in Lanfuse cloud, separate telemetry is used
|
||||
// Do not run in Langfuse cloud, separate telemetry is used
|
||||
if (process.env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== undefined) return;
|
||||
// Check if telemetry is not disabled, except for EE
|
||||
if (
|
||||
@@ -45,7 +46,7 @@ export async function telemetry() {
|
||||
}
|
||||
} catch (error) {
|
||||
// Catch all errors to be sure telemetry does not break the application
|
||||
console.error("Telemetry, unexpected error:", error);
|
||||
logger.error("Telemetry, unexpected error:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +86,7 @@ async function jobScheduler(): Promise<
|
||||
) AS status;`;
|
||||
// Return if job should not run
|
||||
if (checkNoLock.length !== 1) {
|
||||
console.error("Telemetry failed to check if job should run");
|
||||
logger.error("Telemetry failed to check if job should run");
|
||||
return { shouldRunJob: false };
|
||||
}
|
||||
if (!checkNoLock[0]!.status) return { shouldRunJob: false };
|
||||
@@ -119,7 +120,7 @@ async function jobScheduler(): Promise<
|
||||
|
||||
// Other job was created in the meantime
|
||||
if (createJobLocked.length !== 1) {
|
||||
console.error("Telemetry job is locked");
|
||||
logger.error("Telemetry job is locked");
|
||||
return { shouldRunJob: false };
|
||||
}
|
||||
|
||||
@@ -127,7 +128,7 @@ async function jobScheduler(): Promise<
|
||||
|
||||
// should not happen
|
||||
if (!jobStartedAt) {
|
||||
console.error("Telemetry failed to create job_started_at");
|
||||
logger.error("Telemetry failed to create job_started_at");
|
||||
return { shouldRunJob: false };
|
||||
}
|
||||
|
||||
@@ -263,6 +264,6 @@ async function posthogTelemetry({
|
||||
|
||||
await posthog.shutdownAsync();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
logger.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { createUserEmailPassword } from "@/src/features/auth-credentials/lib/credentialsServerUtils";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { createAndAddApiKeysToDb } from "@langfuse/shared/src/server/auth/apiKeys";
|
||||
|
||||
// Create Organization
|
||||
if (env.LANGFUSE_INIT_ORG_ID) {
|
||||
const org = await prisma.organization.upsert({
|
||||
where: { id: env.LANGFUSE_INIT_ORG_ID },
|
||||
update: {},
|
||||
create: {
|
||||
id: env.LANGFUSE_INIT_ORG_ID,
|
||||
name: env.LANGFUSE_INIT_ORG_NAME ?? "Provisioned Org",
|
||||
},
|
||||
});
|
||||
|
||||
// Create Project: Org -> Project
|
||||
if (env.LANGFUSE_INIT_PROJECT_ID) {
|
||||
await prisma.project.upsert({
|
||||
where: { id: env.LANGFUSE_INIT_PROJECT_ID },
|
||||
update: {},
|
||||
create: {
|
||||
id: env.LANGFUSE_INIT_PROJECT_ID,
|
||||
name: env.LANGFUSE_INIT_PROJECT_NAME ?? "Provisioned Project",
|
||||
orgId: org.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Add API Keys: Project -> API Key
|
||||
if (
|
||||
env.LANGFUSE_INIT_PROJECT_SECRET_KEY &&
|
||||
env.LANGFUSE_INIT_PROJECT_PUBLIC_KEY
|
||||
) {
|
||||
const existingApiKey = await prisma.apiKey.findUnique({
|
||||
where: { publicKey: env.LANGFUSE_INIT_PROJECT_PUBLIC_KEY },
|
||||
});
|
||||
|
||||
// Delete key if project changed
|
||||
if (
|
||||
existingApiKey &&
|
||||
existingApiKey.projectId !== env.LANGFUSE_INIT_PROJECT_ID
|
||||
) {
|
||||
await prisma.apiKey.delete({
|
||||
where: { publicKey: env.LANGFUSE_INIT_PROJECT_PUBLIC_KEY },
|
||||
});
|
||||
}
|
||||
|
||||
// Create new key if it doesn't exist or project changed
|
||||
if (
|
||||
!existingApiKey ||
|
||||
existingApiKey.projectId !== env.LANGFUSE_INIT_PROJECT_ID
|
||||
) {
|
||||
await createAndAddApiKeysToDb({
|
||||
prisma,
|
||||
projectId: env.LANGFUSE_INIT_PROJECT_ID,
|
||||
note: "Provisioned API Key",
|
||||
predefinedKeys: {
|
||||
secretKey: env.LANGFUSE_INIT_PROJECT_SECRET_KEY,
|
||||
publicKey: env.LANGFUSE_INIT_PROJECT_PUBLIC_KEY,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create User: Org -> User
|
||||
if (env.LANGFUSE_INIT_USER_EMAIL && env.LANGFUSE_INIT_USER_PASSWORD) {
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email: env.LANGFUSE_INIT_USER_EMAIL },
|
||||
});
|
||||
|
||||
let userId = existingUser?.id;
|
||||
|
||||
// Create user if it doesn't exist yet
|
||||
if (!userId) {
|
||||
userId = await createUserEmailPassword(
|
||||
env.LANGFUSE_INIT_USER_EMAIL,
|
||||
env.LANGFUSE_INIT_USER_PASSWORD,
|
||||
env.LANGFUSE_INIT_USER_NAME ?? "Provisioned User",
|
||||
);
|
||||
}
|
||||
|
||||
// Create OrgMembership: Org -> OrgMembership <- User
|
||||
await prisma.organizationMembership.upsert({
|
||||
where: {
|
||||
orgId_userId: { userId, orgId: org.id },
|
||||
},
|
||||
update: { role: "OWNER" },
|
||||
create: {
|
||||
userId,
|
||||
orgId: org.id,
|
||||
role: "OWNER",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,5 +3,6 @@
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
await import("./datadog.server.config");
|
||||
await import("./initialize");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { env } from "@/src/env.mjs";
|
||||
import { ServerPosthog } from "@/src/features/posthog-analytics/ServerPosthog";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { type NextApiRequest, type NextApiResponse } from "next";
|
||||
import { logger } from "@langfuse/shared/src/server";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
@@ -59,7 +60,7 @@ export default async function handler(
|
||||
|
||||
await posthog.shutdownAsync();
|
||||
|
||||
console.log(
|
||||
logger.info(
|
||||
"Updated ingestion_metrics in PostHog from startTimeframe:",
|
||||
startTimeframe?.toISOString(),
|
||||
"to endTimeframe:",
|
||||
@@ -77,7 +78,7 @@ export default async function handler(
|
||||
|
||||
return res.status(200).json({ message: "OK" });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
logger.error(error);
|
||||
return res.status(500).json({ message: "Internal server error" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { VERSION } from "@/src/constants";
|
||||
import { cors, runMiddleware } from "@/src/features/public-api/server/cors";
|
||||
import { telemetry } from "@/src/features/telemetry";
|
||||
import { isSigtermReceived } from "@/src/utils/shutdown";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { traceException } from "@langfuse/shared/src/server";
|
||||
import { logger, traceException } from "@langfuse/shared/src/server";
|
||||
import { type NextApiRequest, type NextApiResponse } from "next";
|
||||
|
||||
export default async function handler(
|
||||
@@ -16,15 +15,6 @@ export default async function handler(
|
||||
const failIfNoRecentEvents = req.query.failIfNoRecentEvents === "true";
|
||||
|
||||
try {
|
||||
if (isSigtermReceived()) {
|
||||
console.log(
|
||||
"Health check failed: SIGTERM / SIGINT received, shutting down.",
|
||||
);
|
||||
return res.status(500).json({
|
||||
status: "SIGTERM / SIGINT received, shutting down",
|
||||
version: VERSION.replace("v", ""),
|
||||
});
|
||||
}
|
||||
await prisma.$queryRaw`SELECT 1;`;
|
||||
|
||||
if (failIfNoRecentEvents) {
|
||||
@@ -65,7 +55,7 @@ export default async function handler(
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Health check failed: db not available", e);
|
||||
logger.error("Health check failed: db not available", e);
|
||||
traceException(e);
|
||||
return res.status(503).json({
|
||||
status: "Database not available",
|
||||
@@ -74,7 +64,7 @@ export default async function handler(
|
||||
}
|
||||
} catch (e) {
|
||||
traceException(e);
|
||||
console.log("Health check failed: ", e);
|
||||
logger.error("Health check failed: ", e);
|
||||
return res.status(503).json({
|
||||
status: "Health check failed",
|
||||
version: VERSION.replace("v", ""),
|
||||
|
||||
@@ -3,16 +3,17 @@ import { type NextApiRequest, type NextApiResponse } from "next";
|
||||
import { z } from "zod";
|
||||
import { LangfuseNotFoundError, InternalServerError } from "@langfuse/shared";
|
||||
import {
|
||||
getLegacyIngestionQueue,
|
||||
eventTypes,
|
||||
ingestionEvent,
|
||||
traceException,
|
||||
redis,
|
||||
logger,
|
||||
type AuthHeaderValidVerificationResult,
|
||||
type ingestionBatchEvent,
|
||||
handleBatch,
|
||||
recordIncrement,
|
||||
getCurrentSpan,
|
||||
LegacyIngestionQueue,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import {
|
||||
SdkLogProcessor,
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
import {
|
||||
sendToWorkerIfEnvironmentConfigured,
|
||||
QueueJobs,
|
||||
instrumentSync,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { randomUUID } from "crypto";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
@@ -80,7 +82,10 @@ export default async function handler(
|
||||
metadata: jsonSchema.nullish(),
|
||||
});
|
||||
|
||||
const parsedSchema = batchType.safeParse(req.body);
|
||||
const parsedSchema = instrumentSync(
|
||||
{ name: "ingestion-zod-parse-unknown-batch-event" },
|
||||
() => batchType.safeParse(req.body),
|
||||
);
|
||||
|
||||
recordIncrement(
|
||||
"ingestion_event",
|
||||
@@ -105,7 +110,7 @@ export default async function handler(
|
||||
: undefined;
|
||||
|
||||
if (!parsedSchema.success) {
|
||||
console.log("Invalid request data", parsedSchema.error);
|
||||
logger.info("Invalid request data", parsedSchema.error);
|
||||
return res.status(400).json({
|
||||
message: "Invalid request data",
|
||||
errors: parsedSchema.error.issues.map((issue) => issue.message),
|
||||
@@ -116,7 +121,10 @@ export default async function handler(
|
||||
|
||||
const batch: (z.infer<typeof ingestionEvent> | undefined)[] =
|
||||
parsedSchema.data.batch.map((event) => {
|
||||
const parsed = ingestionEvent.safeParse(event);
|
||||
const parsed = instrumentSync(
|
||||
{ name: "ingestion-zod-parse-individual-event" },
|
||||
() => ingestionEvent.safeParse(event),
|
||||
);
|
||||
if (!parsed.success) {
|
||||
validationErrors.push({
|
||||
id:
|
||||
@@ -141,45 +149,56 @@ export default async function handler(
|
||||
|
||||
if (env.LANGFUSE_ASYNC_INGESTION_PROCESSING === "true" && redis) {
|
||||
// this function MUST NOT return but send the HTTP response directly
|
||||
const queue = getLegacyIngestionQueue();
|
||||
const queue = LegacyIngestionQueue.getInstance();
|
||||
|
||||
if (queue) {
|
||||
// still need to check auth scope for all events individually
|
||||
|
||||
const failedAccessScope = accessCheckPerEvent(sortedBatch, authCheck);
|
||||
|
||||
await queue.add(
|
||||
QueueJobs.LegacyIngestionJob,
|
||||
{
|
||||
payload: { data: sortedBatch, authCheck: authCheck },
|
||||
id: randomUUID(),
|
||||
timestamp: new Date(),
|
||||
name: QueueJobs.LegacyIngestionJob as const,
|
||||
},
|
||||
{
|
||||
removeOnFail: 1_000_000,
|
||||
removeOnComplete: true,
|
||||
attempts: 5,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 1000,
|
||||
let addToQueueFailed = false;
|
||||
try {
|
||||
await queue.add(
|
||||
QueueJobs.LegacyIngestionJob,
|
||||
{
|
||||
payload: { data: sortedBatch, authCheck: authCheck },
|
||||
id: randomUUID(),
|
||||
timestamp: new Date(),
|
||||
name: QueueJobs.LegacyIngestionJob as const,
|
||||
},
|
||||
},
|
||||
);
|
||||
{
|
||||
removeOnFail: 1_000_000,
|
||||
removeOnComplete: true,
|
||||
attempts: 5,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 1000,
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
logger.warn(
|
||||
"Failed to add batch to queue, falling back to sync processing",
|
||||
e,
|
||||
);
|
||||
addToQueueFailed = true;
|
||||
}
|
||||
|
||||
return handleBatchResult(
|
||||
[
|
||||
...validationErrors,
|
||||
...failedAccessScope.map((e) => ({
|
||||
id: e.id,
|
||||
error: "Access Scope Denied",
|
||||
})),
|
||||
], // we are not sending additional server errors to the client in case of early return
|
||||
sortedBatch.map((event) => ({ id: event.id, result: event })),
|
||||
res,
|
||||
);
|
||||
if (!addToQueueFailed) {
|
||||
return handleBatchResult(
|
||||
[
|
||||
...validationErrors,
|
||||
...failedAccessScope.map((e) => ({
|
||||
id: e.id,
|
||||
error: "Access Scope Denied",
|
||||
})),
|
||||
], // we are not sending additional server errors to the client in case of early return
|
||||
sortedBatch.map((event) => ({ id: event.id, result: event })),
|
||||
res,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
logger.error(
|
||||
"Ingestion queue not initialized, falling back to sync processing",
|
||||
);
|
||||
}
|
||||
@@ -201,7 +220,7 @@ export default async function handler(
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof UnauthorizedError)) {
|
||||
console.error("error_handling_ingestion_event", error);
|
||||
logger.error("error_handling_ingestion_event", error);
|
||||
traceException(error);
|
||||
}
|
||||
|
||||
@@ -218,7 +237,7 @@ export default async function handler(
|
||||
});
|
||||
}
|
||||
if (error instanceof z.ZodError) {
|
||||
console.log(`Zod exception`, error.errors);
|
||||
logger.info(`Zod exception`, error.errors);
|
||||
return res.status(400).json({
|
||||
message: "Invalid request data",
|
||||
error: error.errors,
|
||||
@@ -365,7 +384,7 @@ export const handleBatchResult = (
|
||||
|
||||
if (returnedErrors.length > 0) {
|
||||
traceException(errors);
|
||||
console.log("Error processing events", returnedErrors);
|
||||
logger.info("Error processing events", returnedErrors);
|
||||
}
|
||||
|
||||
results.forEach((result) => {
|
||||
@@ -446,7 +465,7 @@ export const parseSingleTypedIngestionApiResponse = <T extends z.ZodTypeAny>(
|
||||
|
||||
const parsedObj = object.safeParse(results[0].result);
|
||||
if (!parsedObj.success) {
|
||||
console.error("Error parsing response", parsedObj.error);
|
||||
logger.error("Error parsing response", parsedObj.error);
|
||||
traceException(parsedObj.error);
|
||||
}
|
||||
// should not fail in prod but just log an exception, see above
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ApiAuthService } from "@/src/features/public-api/server/apiAuth";
|
||||
import { cors, runMiddleware } from "@/src/features/public-api/server/cors";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { isPrismaException } from "@/src/utils/exceptions";
|
||||
import { redis } from "@langfuse/shared/src/server";
|
||||
import { logger, redis } from "@langfuse/shared/src/server";
|
||||
|
||||
import { type NextApiRequest, type NextApiResponse } from "next";
|
||||
import { RateLimitService } from "@/src/features/public-api/server/RateLimitService";
|
||||
@@ -48,7 +48,7 @@ export default async function handler(
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
logger.error(error);
|
||||
if (isPrismaException(error)) {
|
||||
return res.status(500).json({
|
||||
error: "Internal Server Error",
|
||||
@@ -57,7 +57,7 @@ export default async function handler(
|
||||
return res.status(500).json({ message: "Internal server error" });
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
logger.error(
|
||||
`Method not allowed for ${req.method} on /api/public/projects`,
|
||||
);
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
redis,
|
||||
recordIncrement,
|
||||
traceException,
|
||||
logger,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { PRODUCTION_LABEL } from "@/src/features/prompts/constants";
|
||||
import { RateLimitService } from "@/src/features/public-api/server/RateLimitService";
|
||||
@@ -111,7 +112,7 @@ export default async function handler(
|
||||
|
||||
throw new MethodNotAllowedError();
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
logger.error(error);
|
||||
traceException(error);
|
||||
|
||||
if (error instanceof BaseError) {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { VERSION } from "@/src/constants";
|
||||
import { cors, runMiddleware } from "@/src/features/public-api/server/cors";
|
||||
import { telemetry } from "@/src/features/telemetry";
|
||||
import { isSigtermReceived } from "@/src/utils/shutdown";
|
||||
import { logger, traceException } from "@langfuse/shared/src/server";
|
||||
import { type NextApiRequest, type NextApiResponse } from "next";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
try {
|
||||
await runMiddleware(req, res, cors);
|
||||
await telemetry();
|
||||
|
||||
if (isSigtermReceived()) {
|
||||
logger.info(
|
||||
"Readiness check failed: SIGTERM / SIGINT received, shutting down.",
|
||||
);
|
||||
return res.status(500).json({
|
||||
status: "SIGTERM / SIGINT received, shutting down",
|
||||
version: VERSION.replace("v", ""),
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
traceException(e);
|
||||
logger.warn("Readiness check failed: ", e);
|
||||
return res.status(503).json({
|
||||
status: "Readiness check failed",
|
||||
version: VERSION.replace("v", ""),
|
||||
});
|
||||
}
|
||||
return res.status(200).json({
|
||||
status: "OK",
|
||||
version: VERSION.replace("v", ""),
|
||||
});
|
||||
}
|
||||
@@ -10,11 +10,15 @@ import { createAuthedAPIRoute } from "@/src/features/public-api/server/createAut
|
||||
import { Prisma } from "@langfuse/shared/src/db";
|
||||
import { parseSingleTypedIngestionApiResponse } from "@/src/pages/api/public/ingestion";
|
||||
import { type Trace } from "@langfuse/shared";
|
||||
import { eventTypes, handleBatch } from "@langfuse/shared/src/server";
|
||||
import {
|
||||
eventTypes,
|
||||
handleBatch,
|
||||
orderByToPrismaSql,
|
||||
} from "@langfuse/shared/src/server";
|
||||
|
||||
import { v4 } from "uuid";
|
||||
import { telemetry } from "@/src/features/telemetry";
|
||||
import { tracesTableCols, orderByToPrismaSql } from "@langfuse/shared";
|
||||
import { tracesTableCols } from "@langfuse/shared";
|
||||
import { tokenCount } from "@/src/features/ingest/usage";
|
||||
|
||||
export default withMiddlewares({
|
||||
|
||||
@@ -10,7 +10,7 @@ import { prisma } from "@langfuse/shared/src/db";
|
||||
import { ApiAuthService } from "@/src/features/public-api/server/apiAuth";
|
||||
import { paginationZod } from "@langfuse/shared";
|
||||
import { isPrismaException } from "@/src/utils/exceptions";
|
||||
import { redis } from "@langfuse/shared/src/server";
|
||||
import { logger, redis } from "@langfuse/shared/src/server";
|
||||
import { RateLimitService } from "@/src/features/public-api/server/RateLimitService";
|
||||
|
||||
const GetUsersSchema = z.object({
|
||||
@@ -131,11 +131,11 @@ export default async function handler(
|
||||
},
|
||||
});
|
||||
} else {
|
||||
console.error(req.method, req.body);
|
||||
logger.error(`Invalid request method ${req.method}`, req.body);
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
logger.error(error);
|
||||
if (isPrismaException(error)) {
|
||||
return res.status(500).json({
|
||||
errors: ["Internal Server Error"],
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createNextApiHandler } from "@trpc/server/adapters/next";
|
||||
import { createTRPCContext } from "@/src/server/api/trpc";
|
||||
import { appRouter } from "@/src/server/api/root";
|
||||
import { traceException } from "@langfuse/shared/src/server";
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { logger, traceException } from "@langfuse/shared/src/server";
|
||||
|
||||
export const config = {
|
||||
maxDuration: 240,
|
||||
@@ -12,7 +13,17 @@ export default createNextApiHandler({
|
||||
router: appRouter,
|
||||
createContext: createTRPCContext,
|
||||
onError: ({ path, error }) => {
|
||||
console.error(`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`);
|
||||
logger.error(
|
||||
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`,
|
||||
error,
|
||||
);
|
||||
traceException(error);
|
||||
},
|
||||
responseMeta() {
|
||||
return {
|
||||
headers: {
|
||||
"x-build-id": env.NEXT_PUBLIC_BUILD_ID,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { FullScreenPage } from "@/src/components/layouts/full-screen-page";
|
||||
import Header from "@/src/components/layouts/header";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/src/components/ui/resizable";
|
||||
import { DatasetRunItemsTable } from "@/src/features/datasets/components/DatasetRunItemsTable";
|
||||
import { EditDatasetItem } from "@/src/features/datasets/components/EditDatasetItem";
|
||||
import { DetailPageNav } from "@/src/features/navigate-detail-pages/DetailPageNav";
|
||||
@@ -68,13 +73,28 @@ export default function Dataset() {
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<EditDatasetItem projectId={projectId} datasetItem={item.data ?? null} />
|
||||
<Header title="Runs" level="h3" />
|
||||
<DatasetRunItemsTable
|
||||
projectId={projectId}
|
||||
datasetItemId={itemId}
|
||||
datasetId={datasetId}
|
||||
/>
|
||||
|
||||
<ResizablePanelGroup direction="vertical">
|
||||
<ResizablePanel
|
||||
minSize={10}
|
||||
defaultSize={50}
|
||||
className="!overflow-y-auto"
|
||||
>
|
||||
<EditDatasetItem
|
||||
projectId={projectId}
|
||||
datasetItem={item.data ?? null}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle className="bg-border" />
|
||||
<ResizablePanel minSize={10} className="flex flex-col space-y-4">
|
||||
<Header title="Runs" level="h3" />
|
||||
<DatasetRunItemsTable
|
||||
projectId={projectId}
|
||||
datasetItemId={itemId}
|
||||
datasetId={datasetId}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</FullScreenPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { FullScreenPage } from "@/src/components/layouts/full-screen-page";
|
||||
import Header from "@/src/components/layouts/header";
|
||||
import { JSONView } from "@/src/components/ui/CodeJsonViewer";
|
||||
import { DatasetRunItemsTable } from "@/src/features/datasets/components/DatasetRunItemsTable";
|
||||
import { DeleteDatasetRunButton } from "@/src/features/datasets/components/DeleteDatasetRunButton";
|
||||
import { DetailPageNav } from "@/src/features/navigate-detail-pages/DetailPageNav";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { useRouter } from "next/router";
|
||||
@@ -36,13 +37,20 @@ export default function Dataset() {
|
||||
{ name: run.data?.name ?? "" },
|
||||
]}
|
||||
actionButtons={
|
||||
<DetailPageNav
|
||||
currentId={runId}
|
||||
path={(id) =>
|
||||
`/project/${projectId}/datasets/${datasetId}/runs/${id}`
|
||||
}
|
||||
listKey="datasetRuns"
|
||||
/>
|
||||
<>
|
||||
<DeleteDatasetRunButton
|
||||
projectId={projectId}
|
||||
datasetRunId={runId}
|
||||
redirectUrl={`/project/${projectId}/datasets/${datasetId}`}
|
||||
/>
|
||||
<DetailPageNav
|
||||
currentId={runId}
|
||||
path={(id) =>
|
||||
`/project/${projectId}/datasets/${datasetId}/runs/${id}`
|
||||
}
|
||||
listKey="datasetRuns"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { aggregateScores } from "@/src/features/scores/lib/aggregateScores";
|
||||
import {
|
||||
datetimeFilterToPrismaSql,
|
||||
filterAndValidateDbScoreList,
|
||||
observationsTableCols,
|
||||
orderByToPrismaSql,
|
||||
tableColumnsToSqlFilterAndPrefix,
|
||||
} from "@langfuse/shared";
|
||||
import { type ObservationView, Prisma, prisma } from "@langfuse/shared/src/db";
|
||||
|
||||
import { type GetAllGenerationsInput } from "../getAllQueries";
|
||||
import { traceException } from "@langfuse/shared/src/server";
|
||||
import {
|
||||
datetimeFilterToPrismaSql,
|
||||
orderByToPrismaSql,
|
||||
tableColumnsToSqlFilterAndPrefix,
|
||||
traceException,
|
||||
} from "@langfuse/shared/src/server";
|
||||
|
||||
type AdditionalObservationFields = {
|
||||
traceName: string | null;
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { timeFilter, type ObservationOptions } from "@langfuse/shared";
|
||||
import { protectedProjectProcedure } from "@/src/server/api/trpc";
|
||||
import { Prisma } from "@langfuse/shared/src/db";
|
||||
import {
|
||||
datetimeFilterToPrisma,
|
||||
datetimeFilterToPrismaSql,
|
||||
timeFilter,
|
||||
type ObservationOptions,
|
||||
} from "@langfuse/shared";
|
||||
import { protectedProjectProcedure } from "@/src/server/api/trpc";
|
||||
import { Prisma } from "@langfuse/shared/src/db";
|
||||
} from "@langfuse/shared/src/server";
|
||||
|
||||
export const filterOptionsQuery = protectedProjectProcedure
|
||||
.input(
|
||||
|
||||
@@ -17,18 +17,20 @@ import {
|
||||
} from "@/src/server/api/trpc";
|
||||
import {
|
||||
CreateAnnotationScoreData,
|
||||
datetimeFilterToPrismaSql,
|
||||
datetimeFilterToPrisma,
|
||||
orderBy,
|
||||
orderByToPrismaSql,
|
||||
paginationZod,
|
||||
singleFilter,
|
||||
tableColumnsToSqlFilterAndPrefix,
|
||||
timeFilter,
|
||||
UpdateAnnotationScoreData,
|
||||
validateDbScore,
|
||||
} from "@langfuse/shared";
|
||||
import { Prisma, type Score } from "@langfuse/shared/src/db";
|
||||
import {
|
||||
datetimeFilterToPrisma,
|
||||
datetimeFilterToPrismaSql,
|
||||
orderByToPrismaSql,
|
||||
tableColumnsToSqlFilterAndPrefix,
|
||||
} from "@langfuse/shared/src/server";
|
||||
|
||||
const ScoreFilterOptions = z.object({
|
||||
projectId: z.string(), // Required for protectedProjectProcedure
|
||||
|
||||
@@ -9,19 +9,21 @@ import {
|
||||
} from "@/src/server/api/trpc";
|
||||
import {
|
||||
filterAndValidateDbScoreList,
|
||||
createSessionsAllQuery,
|
||||
orderBy,
|
||||
paginationZod,
|
||||
type SessionOptions,
|
||||
singleFilter,
|
||||
timeFilter,
|
||||
datetimeFilterToPrismaSql,
|
||||
} from "@langfuse/shared";
|
||||
import { Prisma } from "@langfuse/shared/src/db";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type Decimal from "decimal.js";
|
||||
import { traceException } from "@langfuse/shared/src/server";
|
||||
import {
|
||||
createSessionsAllQuery,
|
||||
datetimeFilterToPrismaSql,
|
||||
traceException,
|
||||
} from "@langfuse/shared/src/server";
|
||||
const SessionFilterOptions = z.object({
|
||||
projectId: z.string(), // Required for protectedProjectProcedure
|
||||
filter: z.array(singleFilter).nullable(),
|
||||
|
||||
@@ -9,14 +9,10 @@ import {
|
||||
protectedProjectProcedure,
|
||||
} from "@/src/server/api/trpc";
|
||||
import {
|
||||
datetimeFilterToPrisma,
|
||||
datetimeFilterToPrismaSql,
|
||||
filterAndValidateDbScoreList,
|
||||
orderBy,
|
||||
orderByToPrismaSql,
|
||||
paginationZod,
|
||||
singleFilter,
|
||||
tableColumnsToSqlFilterAndPrefix,
|
||||
timeFilter,
|
||||
type TraceOptions,
|
||||
tracesTableCols,
|
||||
@@ -27,7 +23,13 @@ import {
|
||||
Prisma,
|
||||
type Trace,
|
||||
} from "@langfuse/shared/src/db";
|
||||
import { traceException } from "@langfuse/shared/src/server";
|
||||
import {
|
||||
datetimeFilterToPrisma,
|
||||
datetimeFilterToPrismaSql,
|
||||
orderByToPrismaSql,
|
||||
tableColumnsToSqlFilterAndPrefix,
|
||||
traceException,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type Decimal from "decimal.js";
|
||||
|
||||
@@ -4,14 +4,11 @@ import {
|
||||
createTRPCRouter,
|
||||
protectedProjectProcedure,
|
||||
} from "@/src/server/api/trpc";
|
||||
import { paginationZod } from "@langfuse/shared";
|
||||
import {
|
||||
singleFilter,
|
||||
tableColumnsToSqlFilterAndPrefix,
|
||||
} from "@langfuse/shared";
|
||||
import { paginationZod, singleFilter } from "@langfuse/shared";
|
||||
import { Prisma } from "@langfuse/shared/src/db";
|
||||
import { usersTableCols } from "@/src/server/api/definitions/usersTable";
|
||||
import { type LastUserScore } from "@/src/features/scores/lib/types";
|
||||
import { tableColumnsToSqlFilterAndPrefix } from "@langfuse/shared/src/server";
|
||||
|
||||
const UserFilterOptions = z.object({
|
||||
projectId: z.string(), // Required for protectedProjectProcedure
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
type singleFilter,
|
||||
type timeFilter,
|
||||
type ColumnDefinition,
|
||||
tableColumnsToSqlFilter,
|
||||
} from "@langfuse/shared";
|
||||
import { Prisma, type PrismaClient } from "@langfuse/shared/src/db";
|
||||
import Decimal from "decimal.js";
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
filterInterface,
|
||||
} from "./sqlInterface";
|
||||
import { tableDefinitions } from "./tableDefinitions";
|
||||
import { tableColumnsToSqlFilter } from "@langfuse/shared/src/server";
|
||||
|
||||
export type InternalDatabaseRow = {
|
||||
[key: string]: bigint | number | Decimal | string | Date;
|
||||
|
||||
+13
-7
@@ -35,6 +35,7 @@ import {
|
||||
traceException,
|
||||
sendResetPasswordVerificationRequest,
|
||||
instrumentAsync,
|
||||
logger,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { getOrganizationPlan } from "@/src/features/entitlements/server/getOrganizationPlan";
|
||||
import { projectRoleAccessRights } from "@/src/features/rbac/constants/projectAccessRights";
|
||||
@@ -104,8 +105,9 @@ const staticProviders: Provider[] = [
|
||||
}
|
||||
|
||||
// EE: Check custom SSO enforcement
|
||||
const customSsoProvider = await getSsoAuthProviderIdForDomain(domain);
|
||||
if (customSsoProvider) {
|
||||
const multiTenantSsoProvider =
|
||||
await getSsoAuthProviderIdForDomain(domain);
|
||||
if (multiTenantSsoProvider) {
|
||||
throw new Error(`You must sign in via SSO for this domain.`);
|
||||
}
|
||||
|
||||
@@ -291,7 +293,7 @@ export async function getAuthOptions(): Promise<NextAuthOptions> {
|
||||
try {
|
||||
dynamicSsoProviders = await loadSsoProviders();
|
||||
} catch (e) {
|
||||
console.error("Error loading dynamic SSO providers", e);
|
||||
logger.error("Error loading dynamic SSO providers", e);
|
||||
traceException(e);
|
||||
}
|
||||
const providers = [...staticProviders, ...dynamicSsoProviders];
|
||||
@@ -405,19 +407,23 @@ export async function getAuthOptions(): Promise<NextAuthOptions> {
|
||||
// Block sign in without valid user.email
|
||||
const email = user.email?.toLowerCase();
|
||||
if (!email) {
|
||||
console.error("No email found in user object");
|
||||
logger.error("No email found in user object");
|
||||
throw new Error("No email found in user object");
|
||||
}
|
||||
if (z.string().email().safeParse(email).success === false) {
|
||||
console.error("Invalid email found in user object");
|
||||
logger.error("Invalid email found in user object");
|
||||
throw new Error("Invalid email found in user object");
|
||||
}
|
||||
|
||||
// EE: Check custom SSO enforcement, enforce the specific SSO provider on email domain
|
||||
// This also blocks setting a password for an email that is enforced to use SSO via password reset flow
|
||||
const domain = email.split("@")[1];
|
||||
const customSsoProvider = await getSsoAuthProviderIdForDomain(domain);
|
||||
if (customSsoProvider && account?.provider !== customSsoProvider) {
|
||||
const multiTenantSsoProvider =
|
||||
await getSsoAuthProviderIdForDomain(domain);
|
||||
if (
|
||||
multiTenantSsoProvider &&
|
||||
account?.provider !== multiTenantSsoProvider
|
||||
) {
|
||||
console.log(
|
||||
"Custom SSO provider enforced for domain, user signed in with other provider",
|
||||
);
|
||||
|
||||
+59
-2
@@ -10,7 +10,10 @@ import {
|
||||
httpLink,
|
||||
loggerLink,
|
||||
splitLink,
|
||||
TRPCClientError,
|
||||
type TRPCLink,
|
||||
} from "@trpc/client";
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import { createTRPCNext } from "@trpc/next";
|
||||
import { type inferRouterInputs, type inferRouterOutputs } from "@trpc/server";
|
||||
import superjson from "superjson";
|
||||
@@ -18,6 +21,7 @@ import superjson from "superjson";
|
||||
import { type AppRouter } from "@/src/server/api/root";
|
||||
import { setUpSuperjson } from "@/src/utils/superjson";
|
||||
import { trpcErrorToast } from "@/src/utils/trpcErrorToast";
|
||||
import { showVersionUpdateToast } from "@/src/features/notifications/showVersionUpdateToast";
|
||||
|
||||
setUpSuperjson();
|
||||
|
||||
@@ -27,6 +31,58 @@ const getBaseUrl = () => {
|
||||
return `http://localhost:${process.env.PORT ?? 3000}`; // dev SSR should use localhost
|
||||
};
|
||||
|
||||
// global build id used to compare versions to show refresh toast on stale cache hit serving deprecated files
|
||||
let buildId: string | null = null;
|
||||
|
||||
const CLIENT_STALE_CACHE_CODES = [404, 400];
|
||||
|
||||
const handleTrpcError = (error: unknown) => {
|
||||
if (error instanceof TRPCClientError) {
|
||||
const httpStatus: number =
|
||||
typeof error.data?.httpStatus === "number" ? error.data.httpStatus : 500;
|
||||
|
||||
if (CLIENT_STALE_CACHE_CODES.includes(httpStatus)) {
|
||||
if (
|
||||
!!buildId &&
|
||||
!!process.env.NEXT_PUBLIC_BUILD_ID &&
|
||||
buildId !== process.env.NEXT_PUBLIC_BUILD_ID
|
||||
) {
|
||||
showVersionUpdateToast();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trpcErrorToast(error);
|
||||
};
|
||||
|
||||
// onError update build id to compare versions
|
||||
const buildIdLink = (): TRPCLink<AppRouter> => () => {
|
||||
return ({ next, op }) => {
|
||||
return observable((observer) => {
|
||||
const unsubscribe = next(op).subscribe({
|
||||
next(value) {
|
||||
observer.next(value);
|
||||
},
|
||||
error(err) {
|
||||
if (
|
||||
err.meta &&
|
||||
err.meta.response &&
|
||||
err.meta.response instanceof Response
|
||||
) {
|
||||
buildId = err.meta.response.headers.get("x-build-id");
|
||||
}
|
||||
observer.error(err);
|
||||
},
|
||||
complete() {
|
||||
observer.complete();
|
||||
},
|
||||
});
|
||||
return unsubscribe;
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
/** A set of type-safe react-query hooks for your tRPC API. */
|
||||
export const api = createTRPCNext<AppRouter>({
|
||||
config() {
|
||||
@@ -44,6 +100,7 @@ export const api = createTRPCNext<AppRouter>({
|
||||
* @see https://trpc.io/docs/links
|
||||
*/
|
||||
links: [
|
||||
buildIdLink(),
|
||||
loggerLink({
|
||||
enabled: (opts) =>
|
||||
process.env.NODE_ENV === "development" ||
|
||||
@@ -73,12 +130,12 @@ export const api = createTRPCNext<AppRouter>({
|
||||
queryClientConfig: {
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
onError: (error) => trpcErrorToast(error),
|
||||
onError: (error) => handleTrpcError(error),
|
||||
// react query defaults to `online`, but we want to disable it as it caused issues for some users
|
||||
networkMode: "always",
|
||||
},
|
||||
mutations: {
|
||||
onError: (error) => trpcErrorToast(error),
|
||||
onError: (error) => handleTrpcError(error),
|
||||
// react query defaults to `online`, but we want to disable it as it caused issues for some users
|
||||
networkMode: "always",
|
||||
},
|
||||
|
||||
+5
-9
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "worker",
|
||||
"version": "2.75.2",
|
||||
"version": "2.78.0",
|
||||
"description": "",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
@@ -22,7 +22,6 @@
|
||||
"@appsignal/opentelemetry-instrumentation-bullmq": "^0.7.1",
|
||||
"@clickhouse/client": "^1.4.0",
|
||||
"@langfuse/shared": "workspace:*",
|
||||
"@logtail/pino": "^0.5.0",
|
||||
"@opentelemetry/api": "^1.8.0",
|
||||
"@opentelemetry/auto-instrumentations-node": "^0.44.0",
|
||||
"@opentelemetry/context-async-hooks": "^1.25.1",
|
||||
@@ -30,8 +29,8 @@
|
||||
"@opentelemetry/instrumentation-express": "^0.41.1",
|
||||
"@opentelemetry/instrumentation-http": "^0.52.1",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.42.0",
|
||||
"@opentelemetry/instrumentation-pino": "^0.41.0",
|
||||
"@opentelemetry/instrumentation-undici": "^0.4.0",
|
||||
"@opentelemetry/instrumentation-winston": "^0.40.0",
|
||||
"@prisma/instrumentation": "^5.13.0",
|
||||
"backoff": "^2.5.0",
|
||||
"bullmq": "^5.12.10",
|
||||
@@ -48,9 +47,6 @@
|
||||
"kysely": "^0.27.4",
|
||||
"lodash": "^4.17.21",
|
||||
"pg": "^8.11.5",
|
||||
"pino": "^9.2.0",
|
||||
"pino-http": "^10.2.0",
|
||||
"pino-pretty": "^10.3.1",
|
||||
"stripe": "^16.8.0",
|
||||
"tiktoken": "^1.0.15",
|
||||
"uuid": "^9.0.1",
|
||||
@@ -62,7 +58,7 @@
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/express-serve-static-core": "^4.19.3",
|
||||
"@types/lodash": "^4.17.5",
|
||||
"@types/lodash": "^4.17.7",
|
||||
"@types/node": "^20.11.19",
|
||||
"@types/pg": "^8.11.6",
|
||||
"@types/uuid": "^9.0.8",
|
||||
@@ -72,12 +68,12 @@
|
||||
"eslint-config-standard": "^17.1.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"kysely-codegen": "^0.11.0",
|
||||
"msw": "^2.3.1",
|
||||
"msw": "^2.4.1",
|
||||
"nodemon": "^3.1.3",
|
||||
"prettier": "^3.2.5",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsc-watch": "^6.2.0",
|
||||
"tsx": "^4.18.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.13",
|
||||
"vitest": "^1.5.3"
|
||||
|
||||
@@ -17,14 +17,14 @@ import {
|
||||
import { encrypt } from "@langfuse/shared/encryption";
|
||||
import { OpenAIServer } from "./network";
|
||||
import { afterEach } from "node:test";
|
||||
import { getEvalQueue } from "../queues/evalQueue";
|
||||
import { logger } from "@langfuse/shared/src/server";
|
||||
|
||||
vi.mock("../redis/consumer", () => ({
|
||||
evalQueue: {
|
||||
add: vi.fn().mockImplementation((jobName, jobData) => {
|
||||
console.log(
|
||||
logger.info(
|
||||
`Mock evalQueue.add called with jobName: ${jobName} and jobData:`,
|
||||
jobData
|
||||
jobData,
|
||||
);
|
||||
// Simulate the job being processed immediately by calling the job's processing function
|
||||
// Note: You would replace `processJobFunction` with the actual function that processes the job
|
||||
@@ -391,9 +391,6 @@ describe("create eval jobs", () => {
|
||||
expect(jobs.length).toBe(1);
|
||||
expect(jobs[0].project_id).toBe("7a88fb47-b4e2-43b8-a06c-a5ce950dc53a");
|
||||
expect(jobs[0].job_input_trace_id).toBe(traceId);
|
||||
console.log(jobs[0]);
|
||||
const j = await getEvalQueue()?.getJob(jobs[0].id);
|
||||
console.log(j);
|
||||
expect(jobs[0].status.toString()).toBe("CANCELLED");
|
||||
expect(jobs[0].start_time).not.toBeNull();
|
||||
expect(jobs[0].end_time).not.toBeNull();
|
||||
@@ -594,8 +591,8 @@ describe("execute evals", () => {
|
||||
|
||||
await expect(evaluate({ event: payload })).rejects.toThrowError(
|
||||
new LangfuseNotFoundError(
|
||||
"API key for provider openai and project 7a88fb47-b4e2-43b8-a06c-a5ce950dc53a not found."
|
||||
)
|
||||
"API key for provider openai and project 7a88fb47-b4e2-43b8-a06c-a5ce950dc53a not found.",
|
||||
),
|
||||
);
|
||||
|
||||
const jobs = await kyselyPrisma.$kysely
|
||||
@@ -730,7 +727,7 @@ describe("test variable extraction", () => {
|
||||
"7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
["input", "output"],
|
||||
traceId,
|
||||
variableMapping
|
||||
variableMapping,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
@@ -792,7 +789,7 @@ describe("test variable extraction", () => {
|
||||
"7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
["input", "output"],
|
||||
traceId,
|
||||
variableMapping
|
||||
variableMapping,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
@@ -842,12 +839,12 @@ describe("test variable extraction", () => {
|
||||
"7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
["input", "output"],
|
||||
traceId,
|
||||
variableMapping
|
||||
)
|
||||
variableMapping,
|
||||
),
|
||||
).rejects.toThrowError(
|
||||
new LangfuseNotFoundError(
|
||||
`Observation great-llm-name for trace ${traceId} not found. Please ensure the mapped data exists and consider extending the job delay.`
|
||||
)
|
||||
`Observation great-llm-name for trace ${traceId} not found. Please ensure the mapped data exists and consider extending the job delay.`,
|
||||
),
|
||||
);
|
||||
}, 10_000);
|
||||
|
||||
@@ -897,7 +894,7 @@ describe("test variable extraction", () => {
|
||||
"7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
["input", "output"],
|
||||
traceId,
|
||||
variableMapping
|
||||
variableMapping,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
@@ -973,7 +970,7 @@ describe("test variable extraction", () => {
|
||||
"7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
["input", "output"],
|
||||
traceId,
|
||||
variableMapping
|
||||
variableMapping,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { setupServer } from "msw/node";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { logger } from "@langfuse/shared/src/server";
|
||||
|
||||
const DEFAULT_RESPONSE = {
|
||||
id: "chatcmpl-9MhZ73aGSmhfAtjU9DwoL4om73hJ7",
|
||||
@@ -32,7 +33,7 @@ const DEFAULT_RESPONSE = {
|
||||
|
||||
function CompletionHandler(response: HttpResponse) {
|
||||
return http.post("https://api.openai.com/v1/chat/completions", async () => {
|
||||
console.log("handler");
|
||||
logger.info("handler");
|
||||
return response;
|
||||
});
|
||||
}
|
||||
@@ -46,7 +47,7 @@ function ErrorCompletionHandler(status: number, statusText: string) {
|
||||
new HttpResponse(null, {
|
||||
status,
|
||||
statusText,
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,15 +65,15 @@ export class OpenAIServer {
|
||||
hasActiveKey?: boolean;
|
||||
useDefaultResponse?: boolean;
|
||||
}) {
|
||||
console.log("openai", { hasActiveKey, useDefaultResponse });
|
||||
logger.info("openai", { hasActiveKey, useDefaultResponse });
|
||||
|
||||
this.hasActiveKey = hasActiveKey;
|
||||
this.internalServer = setupServer(
|
||||
...(useDefaultResponse ? [JsonCompletionHandler(DEFAULT_RESPONSE)] : [])
|
||||
...(useDefaultResponse ? [JsonCompletionHandler(DEFAULT_RESPONSE)] : []),
|
||||
);
|
||||
if (hasActiveKey) {
|
||||
this.internalServer.events.on("response:bypass", async ({ response }) => {
|
||||
console.log(response);
|
||||
logger.info(response);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,30 +1,19 @@
|
||||
import { expect, test, describe, vi } from "vitest";
|
||||
import { expect, test, describe, vi, afterEach } from "vitest";
|
||||
import { randomUUID } from "crypto";
|
||||
import { z } from "zod";
|
||||
import logger from "../logger";
|
||||
import { evalJobCreator } from "../queues/evalQueue";
|
||||
import {
|
||||
getTraceUpsertQueue,
|
||||
QueueJobs,
|
||||
TraceUpsertEventSchema,
|
||||
QueueName,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { WorkerManager } from "../queues/workerManager";
|
||||
|
||||
describe.sequential("handle redis events", () => {
|
||||
test("handle redis job succeeding", async () => {
|
||||
vi.mock("../eval-service", () => ({
|
||||
createEvalJobs: async ({
|
||||
data,
|
||||
}: {
|
||||
data: z.infer<typeof TraceUpsertEventSchema>;
|
||||
}) => {
|
||||
return true;
|
||||
},
|
||||
}));
|
||||
afterEach(async () => {
|
||||
await WorkerManager.closeWorkers();
|
||||
});
|
||||
|
||||
// this activates the consumer
|
||||
evalJobCreator?.on("completed", (job, err) => {
|
||||
logger.info(`Eval Job with id ${job?.id} completed`);
|
||||
});
|
||||
test("handle redis job succeeding", async () => {
|
||||
WorkerManager.register(QueueName.TraceUpsert, async () => true);
|
||||
|
||||
const traceUpsertQueue = getTraceUpsertQueue();
|
||||
|
||||
@@ -47,10 +36,36 @@ describe.sequential("handle redis events", () => {
|
||||
},
|
||||
{
|
||||
timeout: 20_000,
|
||||
}
|
||||
},
|
||||
);
|
||||
}, 20_000);
|
||||
|
||||
test("handle no matching queue worker", async () => {
|
||||
// IngestionFlushQueue worker vs TraceUpsert producer
|
||||
WorkerManager.register(QueueName.IngestionFlushQueue, async () => true);
|
||||
|
||||
const traceUpsertQueue = getTraceUpsertQueue();
|
||||
|
||||
expect(traceUpsertQueue).toBeDefined();
|
||||
|
||||
const job = await traceUpsertQueue?.add(QueueJobs.TraceUpsert, {
|
||||
id: randomUUID(),
|
||||
timestamp: new Date(),
|
||||
payload: {
|
||||
projectId: "project-id",
|
||||
traceId: "trace-id",
|
||||
},
|
||||
name: QueueJobs.TraceUpsert as const,
|
||||
});
|
||||
|
||||
// Wait for 2s
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
// Job should still be waiting as there is no listener
|
||||
const jobState = await traceUpsertQueue?.getJobState(job!.id!);
|
||||
expect(jobState).toEqual("waiting");
|
||||
}, 5000);
|
||||
|
||||
// test("handle redis job failing", async () => {
|
||||
// vi.mock("../eval-service", () => ({
|
||||
// createEvalJobs: async ({
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
|
||||
import { env } from "../env";
|
||||
import logger from "../logger";
|
||||
|
||||
import { logger } from "@langfuse/shared/src/server";
|
||||
export const pruneDatabase = async () => {
|
||||
if (!env.DATABASE_URL.includes("localhost:5432")) {
|
||||
throw new Error("You cannot prune database unless running on localhost.");
|
||||
|
||||
+22
-11
@@ -13,8 +13,7 @@ import {
|
||||
|
||||
import { env } from "../env";
|
||||
import { checkContainerHealth } from "../features/health";
|
||||
import logger from "../logger";
|
||||
|
||||
import { logger } from "@langfuse/shared/src/server";
|
||||
const router = express.Router();
|
||||
|
||||
type EventsResponse = {
|
||||
@@ -23,10 +22,22 @@ type EventsResponse = {
|
||||
|
||||
router.get<{}, { status: string }>("/health", async (_req, res) => {
|
||||
try {
|
||||
await checkContainerHealth(res);
|
||||
await checkContainerHealth(res, false);
|
||||
} catch (e) {
|
||||
traceException(e);
|
||||
logger.error(e, "Health check failed");
|
||||
logger.error("Health check failed", e);
|
||||
res.status(500).json({
|
||||
status: "error",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get<{}, { status: string }>("/ready", async (_req, res) => {
|
||||
try {
|
||||
await checkContainerHealth(res, true);
|
||||
} catch (e) {
|
||||
traceException(e);
|
||||
logger.error("Readiness check failed", e);
|
||||
res.status(500).json({
|
||||
status: "error",
|
||||
});
|
||||
@@ -48,16 +59,16 @@ router
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`Clickhouse health check response: ${JSON.stringify(await response.text())}`
|
||||
`Clickhouse health check response: ${JSON.stringify(await response.text())}`,
|
||||
);
|
||||
|
||||
res.json({ status: "success" });
|
||||
} catch (e) {
|
||||
logger.error(e, "Clickhouse health check failed");
|
||||
logger.error("Clickhouse health check failed", e);
|
||||
res.status(500).json({ status: "error", message: JSON.stringify(e) });
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(e, "Unexpected error during Clickhouse health check");
|
||||
logger.error("Unexpected error during Clickhouse health check", e);
|
||||
res.status(500).json({ status: "error", message: JSON.stringify(e) });
|
||||
}
|
||||
});
|
||||
@@ -66,7 +77,7 @@ router
|
||||
.use(
|
||||
basicAuth({
|
||||
users: { admin: env.LANGFUSE_WORKER_PASSWORD },
|
||||
})
|
||||
}),
|
||||
)
|
||||
.post<{}, EventsResponse>("/events", async (req, res) => {
|
||||
try {
|
||||
@@ -93,7 +104,7 @@ router
|
||||
if (traceUpsertQueue) {
|
||||
logger.info(
|
||||
`Added ${jobs.length} trace upsert jobs to the queue`,
|
||||
jobs
|
||||
jobs,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -117,7 +128,7 @@ router
|
||||
|
||||
return res.status(400).send();
|
||||
} catch (e) {
|
||||
logger.error(e, "Error processing events");
|
||||
logger.error("Error processing events", e);
|
||||
traceException(e);
|
||||
return res.status(500).json({
|
||||
status: "error",
|
||||
@@ -129,7 +140,7 @@ router
|
||||
.use(
|
||||
basicAuth({
|
||||
users: { admin: env.LANGFUSE_WORKER_PASSWORD },
|
||||
})
|
||||
}),
|
||||
)
|
||||
.post("/ingestion", async (req, res) => {
|
||||
return res.status(200).send(); // Not implemented, Send 200 to acknowledge the request for web containers to not throw
|
||||
|
||||
+57
-34
@@ -6,18 +6,21 @@ import MessageResponse from "./interfaces/MessageResponse";
|
||||
|
||||
require("dotenv").config();
|
||||
|
||||
import logger from "./logger";
|
||||
|
||||
import { evalJobCreator, evalJobExecutor } from "./queues/evalQueue";
|
||||
import { batchExportJobExecutor } from "./queues/batchExportQueue";
|
||||
import { ingestionQueueExecutor } from "./queues/ingestionFlushQueueExecutor";
|
||||
import { repeatQueueExecutor } from "./queues/repeatQueue";
|
||||
import { logQueueWorkerError } from "./utils/logQueueWorkerError";
|
||||
import {
|
||||
evalJobCreatorQueueProcessor,
|
||||
evalJobExecutorQueueProcessor,
|
||||
} from "./queues/evalQueue";
|
||||
import { batchExportQueueProcessor } from "./queues/batchExportQueue";
|
||||
import { ingestionFlushQueueProcessor } from "./queues/ingestionFlushQueueExecutor";
|
||||
import { repeatQueueProcessor } from "./queues/repeatQueue";
|
||||
import { onShutdown } from "./utils/shutdown";
|
||||
|
||||
import helmet from "helmet";
|
||||
import { legacyIngestionExecutor } from "./queues/legacyIngestionQueue";
|
||||
import { cloudUsageMeteringJobExecutor } from "./queues/cloudUsageMeteringQueue";
|
||||
import { legacyIngestionQueueProcessor } from "./queues/legacyIngestionQueue";
|
||||
import { cloudUsageMeteringQueueProcessor } from "./queues/cloudUsageMeteringQueue";
|
||||
import { WorkerManager } from "./queues/workerManager";
|
||||
import { QueueName } from "@langfuse/shared/src/server";
|
||||
import { env } from "./env";
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -35,34 +38,54 @@ app.use("/api", api);
|
||||
app.use(middlewares.notFound);
|
||||
app.use(middlewares.errorHandler);
|
||||
|
||||
logger.info("Eval Job Creator started", evalJobCreator?.isRunning());
|
||||
WorkerManager.register(QueueName.RepeatQueue, repeatQueueProcessor);
|
||||
|
||||
logger.info("Eval Job Executor started", evalJobExecutor?.isRunning());
|
||||
logger.info(
|
||||
"Batch Export Job Executor started",
|
||||
batchExportJobExecutor?.isRunning()
|
||||
);
|
||||
logger.info("Repeat Queue Executor started", repeatQueueExecutor?.isRunning());
|
||||
logger.info(
|
||||
"Flush Ingestion Queue Executor started",
|
||||
ingestionQueueExecutor?.isRunning()
|
||||
);
|
||||
logger.info(
|
||||
"Legacy Ingestion Executor started",
|
||||
legacyIngestionExecutor?.isRunning()
|
||||
);
|
||||
logger.info(
|
||||
"Cloud Usage Metering Job Executor started",
|
||||
cloudUsageMeteringJobExecutor?.isRunning()
|
||||
WorkerManager.register(QueueName.TraceUpsert, evalJobCreatorQueueProcessor, {
|
||||
concurrency: env.LANGFUSE_EVAL_CREATOR_WORKER_CONCURRENCY,
|
||||
});
|
||||
|
||||
WorkerManager.register(
|
||||
QueueName.EvaluationExecution,
|
||||
evalJobExecutorQueueProcessor,
|
||||
{
|
||||
concurrency: env.LANGFUSE_EVAL_EXECUTION_WORKER_CONCURRENCY,
|
||||
},
|
||||
);
|
||||
|
||||
evalJobCreator?.on("failed", logQueueWorkerError);
|
||||
evalJobExecutor?.on("failed", logQueueWorkerError);
|
||||
batchExportJobExecutor?.on("failed", logQueueWorkerError);
|
||||
repeatQueueExecutor?.on("failed", logQueueWorkerError);
|
||||
ingestionQueueExecutor?.on("failed", logQueueWorkerError);
|
||||
legacyIngestionExecutor?.on("failed", logQueueWorkerError);
|
||||
cloudUsageMeteringJobExecutor?.on("failed", logQueueWorkerError);
|
||||
WorkerManager.register(QueueName.BatchExport, batchExportQueueProcessor, {
|
||||
concurrency: 1, // only 1 job at a time
|
||||
limiter: {
|
||||
// execute 1 batch export in 5 seconds to avoid overloading the DB
|
||||
max: 1,
|
||||
duration: 5_000,
|
||||
},
|
||||
});
|
||||
|
||||
WorkerManager.register(
|
||||
QueueName.IngestionFlushQueue,
|
||||
ingestionFlushQueueProcessor,
|
||||
{
|
||||
concurrency: env.LANGFUSE_INGESTION_FLUSH_PROCESSING_CONCURRENCY,
|
||||
},
|
||||
);
|
||||
|
||||
if (env.STRIPE_SECRET_KEY) {
|
||||
WorkerManager.register(
|
||||
QueueName.CloudUsageMeteringQueue,
|
||||
cloudUsageMeteringQueueProcessor,
|
||||
{
|
||||
concurrency: 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (env.QUEUE_CONSUMER_LEGACY_INGESTION_QUEUE_IS_ENABLED === "true") {
|
||||
WorkerManager.register(
|
||||
QueueName.LegacyIngestionQueue,
|
||||
legacyIngestionQueueProcessor,
|
||||
{ concurrency: env.LANGFUSE_LEGACY_INGESTION_WORKER_CONCURRENCY }, // n ingestion batches at a time
|
||||
);
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => onShutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => onShutdown("SIGTERM"));
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user