Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86816e569d | ||
|
|
0fe2176930 | ||
|
|
43f7c524dc | ||
|
|
2bcfab130d | ||
|
|
51bb1c3374 | ||
|
|
302bb065b7 | ||
|
|
e6a8fb84f0 | ||
|
|
b12f3df933 | ||
|
|
159b5f87fe | ||
|
|
73fe2dc1a7 | ||
|
|
9daf348d61 | ||
|
|
8a9d0b01af | ||
|
|
315d8c7619 | ||
|
|
b23eaf3cd4 | ||
|
|
eea1e8d054 | ||
|
|
06a7a4bfb2 | ||
|
|
7c8a30d48f | ||
|
|
1971adda18 | ||
|
|
d9b4e3a7dd | ||
|
|
f1aa884cbc | ||
|
|
0ed9acddb6 | ||
|
|
61e3470a93 |
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"name": "langfuse dev",
|
||||
"image": "mcr.microsoft.com/devcontainers/javascript-node:20-bookworm",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
||||
"version": "latest",
|
||||
"moby": true
|
||||
}
|
||||
},
|
||||
"forwardPorts": [3000],
|
||||
|
||||
"onCreateCommand": "npm install -g pnpm@9.5.0",
|
||||
"postCreateCommand": "cp .env.dev.example .env && pnpm run dx-f"
|
||||
}
|
||||
@@ -174,6 +174,10 @@ ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000
|
||||
# LANGFUSE_CACHE_PROMPT_ENABLED=
|
||||
# LANGFUSE_CACHE_PROMPT_TTL_SECONDS=
|
||||
|
||||
# Stripe
|
||||
# STRIPE_SECRET_KEY=
|
||||
# STRIPE_WEBHOOK_SIGNING_SECRET=
|
||||
|
||||
### END Langfuse Cloud Config
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||

|
||||
|
||||
<div align="center"><h1> Langfuse: Open Source LLM Engineering Platform</h4></div>
|
||||
<div align="center"><h1> Langfuse: Open Source LLM Observability & Engineering Platform</h4></div>
|
||||
<div align="center"><h2> Debug and improve your LLM app</h4></div>
|
||||
<div align="center"><h3> LLM Observability, Prompt Management, LLM Evaluations, Datasets, LLM Metrics and Prompt Playground</h4></div>
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ Join us in scaling Langfuse in Berlin, Germany. We are an open source company, w
|
||||
|
||||
_Open Roles_
|
||||
|
||||
- Backend Engineer, 70-130k EUR, 0.25-0.75% Equity, https://www.ycombinator.com/companies/langfuse/jobs/mnrdwla-backend-engineer
|
||||
- Product Engineer, 70-130k EUR, 0.25-0.75% Equity, https://www.ycombinator.com/companies/langfuse/jobs/aAvmoFB-product-engineer
|
||||
- Developer Advocate, 60-110k EUR, 0.25-0.5% Equity, https://www.ycombinator.com/companies/langfuse/jobs/uHysbKH-developer-advocate-devrel
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@langfuse/shared": "workspace:*",
|
||||
"@opentelemetry/api": ">=1.0.0 <1.9.0",
|
||||
"next": "^14.2.3",
|
||||
"next-auth": "^4.24.7",
|
||||
"zod": "^3.23.8"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "langfuse",
|
||||
"version": "2.71.0",
|
||||
"version": "2.72.0",
|
||||
"author": "engineering@langfuse.com",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
|
||||
@@ -57,7 +57,8 @@
|
||||
"@langchain/anthropic": "^0.1.21",
|
||||
"@langchain/core": "^0.1.61",
|
||||
"@langchain/openai": "^0.0.33",
|
||||
"@prisma/client": "^5.13.0",
|
||||
"@opentelemetry/api": ">=1.0.0 <1.9.0",
|
||||
"@prisma/client": "^5.17.0",
|
||||
"@react-email/components": "^0.0.19",
|
||||
"@react-email/render": "^0.0.15",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
@@ -65,6 +66,7 @@
|
||||
"bullmq": "^5.4.2",
|
||||
"decimal.js": "^10.4.3",
|
||||
"exponential-backoff": "^3.1.1",
|
||||
"dd-trace": "^5.19.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"kysely": "^0.27.3",
|
||||
"langchain": "^0.2.6",
|
||||
@@ -92,7 +94,7 @@
|
||||
"kysely-codegen": "^0.11.0",
|
||||
"nodemon": "^3.1.3",
|
||||
"prettier": "^3.2.5",
|
||||
"prisma": "^5.13.0",
|
||||
"prisma": "^5.17.0",
|
||||
"prisma-erd-generator": "^1.11.2",
|
||||
"prisma-kysely": "^1.8.0",
|
||||
"ts-node": "^10.9.2",
|
||||
|
||||
@@ -42,6 +42,13 @@ export const DatasetStatus = {
|
||||
ARCHIVED: "ARCHIVED"
|
||||
} as const;
|
||||
export type DatasetStatus = (typeof DatasetStatus)[keyof typeof DatasetStatus];
|
||||
export const CommentObjectType = {
|
||||
TRACE: "TRACE",
|
||||
OBSERVATION: "OBSERVATION",
|
||||
SESSION: "SESSION",
|
||||
PROMPT: "PROMPT"
|
||||
} as const;
|
||||
export type CommentObjectType = (typeof CommentObjectType)[keyof typeof CommentObjectType];
|
||||
export const JobType = {
|
||||
EVAL: "EVAL"
|
||||
} as const;
|
||||
@@ -116,6 +123,16 @@ export type BatchExport = {
|
||||
url: string | null;
|
||||
log: string | null;
|
||||
};
|
||||
export type Comment = {
|
||||
id: string;
|
||||
project_id: string;
|
||||
object_type: CommentObjectType;
|
||||
object_id: string;
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
content: string;
|
||||
author_user_id: string | null;
|
||||
};
|
||||
export type CronJobs = {
|
||||
name: string;
|
||||
last_run: Timestamp | null;
|
||||
@@ -491,6 +508,7 @@ export type DB = {
|
||||
api_keys: ApiKey;
|
||||
audit_logs: AuditLog;
|
||||
batch_exports: BatchExport;
|
||||
comments: Comment;
|
||||
cron_jobs: CronJobs;
|
||||
dataset_items: DatasetItem;
|
||||
dataset_run_items: DatasetRunItems;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CommentObjectType" AS ENUM ('TRACE', 'OBSERVATION', 'SESSION', 'PROMPT');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "comments" (
|
||||
"id" TEXT NOT NULL,
|
||||
"project_id" TEXT NOT NULL,
|
||||
"object_type" "CommentObjectType" NOT NULL,
|
||||
"object_id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"content" TEXT NOT NULL,
|
||||
"author_user_id" TEXT,
|
||||
|
||||
CONSTRAINT "comments_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "comments_project_id_object_type_object_id_idx" ON "comments"("project_id", "object_type", "object_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "comments" ADD CONSTRAINT "comments_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -129,6 +129,7 @@ model Project {
|
||||
Score Score[]
|
||||
scoreConfig ScoreConfig[]
|
||||
BatchExport BatchExport[]
|
||||
comment Comment[]
|
||||
|
||||
@@index([orgId])
|
||||
@@map("projects")
|
||||
@@ -635,6 +636,28 @@ model Events {
|
||||
@@map("events")
|
||||
}
|
||||
|
||||
model Comment {
|
||||
id String @id @default(cuid())
|
||||
projectId String @map("project_id")
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
objectType CommentObjectType @map("object_type")
|
||||
objectId String @map("object_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
content String
|
||||
authorUserId String? @map("author_user_id") // no fk constraint, user can be deleted
|
||||
|
||||
@@index([projectId, objectType, objectId])
|
||||
@@map("comments")
|
||||
}
|
||||
|
||||
enum CommentObjectType {
|
||||
TRACE
|
||||
OBSERVATION
|
||||
SESSION
|
||||
PROMPT
|
||||
}
|
||||
|
||||
model Prompt {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@ -257,21 +257,29 @@ async function main() {
|
||||
|
||||
const traceVolume = environment === "load" ? LOAD_TRACE_VOLUME : 100;
|
||||
|
||||
const { traces, observations, scores, sessions, events } = createObjects(
|
||||
traceVolume,
|
||||
envTags,
|
||||
colorTags,
|
||||
project1,
|
||||
project2,
|
||||
promptIds,
|
||||
configIdsAndNames
|
||||
);
|
||||
const { traces, observations, scores, sessions, events, comments } =
|
||||
createObjects(
|
||||
traceVolume,
|
||||
envTags,
|
||||
colorTags,
|
||||
project1,
|
||||
project2,
|
||||
promptIds,
|
||||
configIdsAndNames
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Seeding ${traces.length} traces, ${observations.length} observations, and ${scores.length} scores`
|
||||
);
|
||||
|
||||
await uploadObjects(traces, observations, scores, sessions, events);
|
||||
await uploadObjects(
|
||||
traces,
|
||||
observations,
|
||||
scores,
|
||||
sessions,
|
||||
events,
|
||||
comments
|
||||
);
|
||||
|
||||
// If openai key is in environment, add it to the projects LLM API keys
|
||||
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
|
||||
@@ -464,7 +472,8 @@ async function uploadObjects(
|
||||
observations: Prisma.ObservationCreateManyInput[],
|
||||
scores: Prisma.ScoreCreateManyInput[],
|
||||
sessions: Prisma.TraceSessionCreateManyInput[],
|
||||
events: Prisma.ObservationCreateManyInput[]
|
||||
events: Prisma.ObservationCreateManyInput[],
|
||||
comments: Prisma.CommentCreateManyInput[]
|
||||
) {
|
||||
let promises: Prisma.PrismaPromise<unknown>[] = [];
|
||||
|
||||
@@ -556,6 +565,22 @@ async function uploadObjects(
|
||||
);
|
||||
await promises[i];
|
||||
}
|
||||
|
||||
promises = [];
|
||||
chunk(comments, chunkSize).forEach((chunk) => {
|
||||
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`
|
||||
);
|
||||
await promises[i];
|
||||
}
|
||||
}
|
||||
|
||||
function createObjects(
|
||||
@@ -581,6 +606,7 @@ function createObjects(
|
||||
const sessions: Prisma.TraceSessionCreateManyInput[] = [];
|
||||
const events: Prisma.ObservationCreateManyInput[] = [];
|
||||
const configs: Prisma.ScoreConfigCreateManyInput[] = [];
|
||||
const comments: Prisma.CommentCreateManyInput[] = [];
|
||||
|
||||
for (let i = 0; i < traceVolume; i++) {
|
||||
// print progress to console with a progress bar that refreshes every 10 iterations
|
||||
@@ -611,6 +637,7 @@ function createObjects(
|
||||
const trace = {
|
||||
id: `trace-${v4()}`,
|
||||
timestamp: traceTs,
|
||||
createdAt: traceTs,
|
||||
projectId: projectId,
|
||||
name: ["generate-outreach", "label-inbound", "draft-response"][
|
||||
i % 3
|
||||
@@ -669,6 +696,7 @@ function createObjects(
|
||||
traceId: trace.id,
|
||||
name: annotationScoreName,
|
||||
timestamp: traceTs,
|
||||
createdAt: traceTs,
|
||||
source: ScoreSource.ANNOTATION,
|
||||
projectId,
|
||||
authorUserId: `user-${i}`,
|
||||
@@ -685,6 +713,7 @@ function createObjects(
|
||||
name: "sentiment",
|
||||
value: Math.floor(Math.random() * 10) - 5,
|
||||
timestamp: traceTs,
|
||||
createdAt: traceTs,
|
||||
source: ScoreSource.API,
|
||||
projectId,
|
||||
dataType: ScoreDataType.NUMERIC,
|
||||
@@ -697,6 +726,7 @@ function createObjects(
|
||||
traceId: trace.id,
|
||||
name: "Completeness",
|
||||
timestamp: traceTs,
|
||||
createdAt: traceTs,
|
||||
source: ScoreSource.API,
|
||||
projectId,
|
||||
dataType: ScoreDataType.CATEGORICAL,
|
||||
@@ -707,6 +737,15 @@ function createObjects(
|
||||
: []),
|
||||
];
|
||||
|
||||
if (Math.random() > 0.9)
|
||||
comments.push({
|
||||
projectId: trace.projectId,
|
||||
objectId: trace.id,
|
||||
objectType: "TRACE",
|
||||
content: "Trace comment content",
|
||||
...(Math.random() > 0.5 ? { authorUserId: `user-${i}` } : {}),
|
||||
});
|
||||
|
||||
scores.push(...traceScores);
|
||||
|
||||
const existingSpanIds: string[] = [];
|
||||
@@ -725,6 +764,7 @@ function createObjects(
|
||||
type: ObservationType.SPAN,
|
||||
id: `span-${v4()}`,
|
||||
startTime: spanTsStart,
|
||||
createdAt: spanTsStart,
|
||||
endTime: spanTsEnd,
|
||||
name: `span-${i}-${j}`,
|
||||
metadata: {
|
||||
@@ -796,6 +836,7 @@ function createObjects(
|
||||
type: ObservationType.GENERATION,
|
||||
id: `generation-${v4()}`,
|
||||
startTime: generationTsStart,
|
||||
createdAt: generationTsStart,
|
||||
endTime: generationTsEnd,
|
||||
completionStartTime:
|
||||
Math.random() > 0.5 ? generationTsCompletionStart : undefined,
|
||||
@@ -867,6 +908,7 @@ function createObjects(
|
||||
source: ScoreSource.API,
|
||||
projectId: trace.projectId,
|
||||
timestamp: generationTsEnd,
|
||||
createdAt: traceTs,
|
||||
});
|
||||
if (Math.random() > 0.6)
|
||||
scores.push({
|
||||
@@ -877,6 +919,15 @@ function createObjects(
|
||||
source: ScoreSource.API,
|
||||
projectId: trace.projectId,
|
||||
timestamp: generationTsEnd,
|
||||
createdAt: traceTs,
|
||||
});
|
||||
|
||||
if (Math.random() > 0.8)
|
||||
comments.push({
|
||||
projectId: trace.projectId,
|
||||
objectId: generation.id,
|
||||
objectType: "OBSERVATION",
|
||||
content: "Observation comment content",
|
||||
});
|
||||
|
||||
for (let l = 0; l < Math.floor(Math.random() * 2); l++) {
|
||||
@@ -892,6 +943,7 @@ function createObjects(
|
||||
type: ObservationType.EVENT,
|
||||
id: `event-${v4()}`,
|
||||
startTime: eventTs,
|
||||
createdAt: eventTs,
|
||||
name: `event-${i}-${j}-${k}-${l}`,
|
||||
metadata: {
|
||||
user: `user-${i}@langfuse.com`,
|
||||
@@ -916,6 +968,7 @@ function createObjects(
|
||||
configs,
|
||||
sessions: uniqueSessions,
|
||||
events,
|
||||
comments,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const COMMENT_OBJECT_TYPES = [
|
||||
"TRACE",
|
||||
"OBSERVATION",
|
||||
"SESSION",
|
||||
"PROMPT",
|
||||
] as const;
|
||||
|
||||
export const CreateCommentData = z.object({
|
||||
projectId: z.string(),
|
||||
content: z.string().trim().min(1).max(500),
|
||||
objectId: z.string(),
|
||||
objectType: z.enum(COMMENT_OBJECT_TYPES),
|
||||
});
|
||||
|
||||
export const DeleteCommentData = z.object({
|
||||
projectId: z.string(),
|
||||
commentId: z.string(),
|
||||
objectId: z.string(),
|
||||
objectType: z.enum(COMMENT_OBJECT_TYPES),
|
||||
});
|
||||
@@ -144,7 +144,7 @@ const ValidatedScoreConfigSchema = z
|
||||
|
||||
/**
|
||||
* Use this function when pulling a list of score configs from the database before using in the application to ensure type safety.
|
||||
* All score configs are expected to pass the validation. If a score fails validation, it will be logged to Sentry.
|
||||
* All score configs are expected to pass the validation. If a score fails validation, it will be logged to Otel.
|
||||
* @param scoreConfigs
|
||||
* @returns list of validated score configs
|
||||
*/
|
||||
|
||||
@@ -155,7 +155,7 @@ export const ScorePropsAgainstConfig = z.union([
|
||||
|
||||
/**
|
||||
* Use this function when pulling a list of scores from the database before using in the application to ensure type safety.
|
||||
* All scores are expected to pass the validation. If a score fails validation, it will be logged to Sentry.
|
||||
* All scores are expected to pass the validation. If a score fails validation, it will be logged to Otel.
|
||||
* @param scores
|
||||
* @returns list of validated scores
|
||||
*/
|
||||
|
||||
@@ -2,6 +2,8 @@ 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";
|
||||
@@ -30,6 +32,9 @@ export * from "./features/annotation/types";
|
||||
export * from "./features/scores/scoreConfigTypes";
|
||||
export * from "./features/scores/scoreTypes";
|
||||
|
||||
// comments
|
||||
export * from "./features/comments/types";
|
||||
|
||||
// export db types only
|
||||
export * from "@prisma/client";
|
||||
export { type DB } from "../prisma/generated/types";
|
||||
|
||||
+8
@@ -5,5 +5,13 @@ export const CloudConfigSchema = z.object({
|
||||
monthlyObservationLimit: z.number().int().positive().optional(),
|
||||
// used for table and dashboard queries
|
||||
defaultLookBackDays: z.number().int().positive().optional(),
|
||||
// need to update stripe webhook if you change this, it fetches from db via these fields
|
||||
stripe: z
|
||||
.object({
|
||||
customerId: z.string().optional(),
|
||||
activeSubscriptionId: z.string().optional(),
|
||||
activeProductId: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
export type CloudConfigSchema = z.infer<typeof CloudConfigSchema>;
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { CloudConfigSchema } from "@/src/features/organizations/utils/cloudConfigSchema";
|
||||
import { type Organization } from "@langfuse/shared";
|
||||
import { type Organization } from "@prisma/client";
|
||||
import { CloudConfigSchema } from "./cloudConfigSchema";
|
||||
|
||||
type parsedOrg = Omit<Organization, "cloudConfig"> & {
|
||||
cloudConfig: CloudConfigSchema | null;
|
||||
@@ -24,3 +24,4 @@ export * from "./auth/types";
|
||||
export * from "./ingestion/legacy/index";
|
||||
export * from "./queues";
|
||||
export * from "./ingestion/legacy/EventProcessor";
|
||||
export * from "./instrumentation";
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instrumentation at Langfuse
|
||||
|
||||
Throughout our applications we want to use as much Otel as possible. This helps us to be flexible choosing our observability backend, and we will benefit from features and packages built by the Otel community.
|
||||
|
||||
## How to use Otel in your application
|
||||
|
||||
- Use the `instrument` or `instrument` functions to wrap your functions with Otel instrumentation. This will automatically create spans for your functions and send them to the Otel collector. If an instrumented function throws, exceptions will be added to the span and the span will be marked as failed.
|
||||
- Use `recordGauge`, `recordCounter`, `recordHistogram` to record metrics. These will be sent to the Otel collector.
|
||||
|
||||
## Configuration options
|
||||
|
||||
- `web` and `worker` have an instrumentation.ts file, which configures otel for the application.
|
||||
- For trpc, we use `@baselime/trpc-opentelemetry-middleware` to enrich spans with trpc inputs and outputs.
|
||||
- When building adding new infrastructure, we should search for auto instumentations for our code base.
|
||||
|
||||
## Library support
|
||||
|
||||
- `dd-trace` has a direct dependency of `@opentelemetry/api` version < `1.9` [GH](https://github.com/DataDog/dd-trace-js/blob/ed9b0b30f7b0283579a9bf8c18e1f9deab18fecf/package.json#L81).
|
||||
- `@prisma/instrumentation` requires at least `@opentelemetry/api` version `1.8` [GH](https://github.com/prisma/prisma/blob/d780290b13754420abcfa5d7592f02049c6cc005/packages/instrumentation/package.json#L25)
|
||||
- We need to ensure that all other libraries we use are compatible with `@opentelemetry/api` version `1.8`. If we have a package using a later version, this will break the instrumentation and crash the container. We can check the versions with the following command:
|
||||
|
||||
```bash
|
||||
pnpm --filter=web why @opentelemetry/api
|
||||
```
|
||||
@@ -0,0 +1,135 @@
|
||||
import * as opentelemetry from "@opentelemetry/api";
|
||||
import * as dd from "dd-trace";
|
||||
|
||||
// type CallbackFn<T> = () => T;
|
||||
|
||||
export type SpanCtx = {
|
||||
name: string;
|
||||
spanKind?: opentelemetry.SpanKind; // https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/api.md#spankind
|
||||
rootSpan?: boolean; // https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/overview.md#traces
|
||||
traceScope?: string;
|
||||
};
|
||||
|
||||
type CallbackFn<T> = () => T | Promise<T>;
|
||||
|
||||
export function instrument<T>(
|
||||
ctx: SpanCtx,
|
||||
callback: CallbackFn<T>
|
||||
): T extends Promise<any> ? Promise<T> : T {
|
||||
return getTracer(ctx.traceScope ?? callback.name).startActiveSpan(
|
||||
ctx.name,
|
||||
{
|
||||
root: ctx.rootSpan,
|
||||
kind: ctx.spanKind,
|
||||
},
|
||||
(span) => {
|
||||
const handleResult = (result: T) => {
|
||||
span.end();
|
||||
return result;
|
||||
};
|
||||
|
||||
const handleError = (ex: unknown) => {
|
||||
traceException(ex as opentelemetry.Exception, span);
|
||||
span.end();
|
||||
throw ex;
|
||||
};
|
||||
|
||||
try {
|
||||
const result = callback();
|
||||
if (result instanceof Promise) {
|
||||
return result
|
||||
.then(handleResult)
|
||||
.catch(handleError) as T extends Promise<any> ? Promise<T> : T;
|
||||
} else {
|
||||
return handleResult(result) as T extends Promise<any>
|
||||
? Promise<T>
|
||||
: T;
|
||||
}
|
||||
} catch (ex) {
|
||||
return handleError(ex) as T extends Promise<any> ? Promise<T> : T;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export const getCurrentSpan = () => opentelemetry.trace.getActiveSpan();
|
||||
|
||||
export const traceException = (
|
||||
ex: unknown,
|
||||
span?: opentelemetry.Span,
|
||||
code?: string
|
||||
) => {
|
||||
const activeSpan = span ?? getCurrentSpan();
|
||||
|
||||
if (!activeSpan) {
|
||||
return;
|
||||
}
|
||||
|
||||
const exception = {
|
||||
code: code,
|
||||
message: ex instanceof Error ? ex.message : String(ex),
|
||||
name: ex instanceof Error ? ex.name : "Error",
|
||||
stack: ex instanceof Error ? ex.stack : undefined,
|
||||
};
|
||||
|
||||
// adds an otel event
|
||||
activeSpan.recordException(exception);
|
||||
|
||||
//adds tags for datadog error tracking
|
||||
activeSpan.setAttributes({
|
||||
"error.stack": exception.stack,
|
||||
"error.message": exception.message,
|
||||
"error.type": exception.name,
|
||||
});
|
||||
|
||||
activeSpan.setStatus({
|
||||
code: opentelemetry.SpanStatusCode.ERROR,
|
||||
message: exception.message,
|
||||
});
|
||||
};
|
||||
|
||||
export const addUserToSpan = (
|
||||
attributes: { userId?: string; projectId?: string; email?: string },
|
||||
span?: opentelemetry.Span
|
||||
) => {
|
||||
const activeSpan = span ?? getCurrentSpan();
|
||||
|
||||
if (!activeSpan) {
|
||||
return;
|
||||
}
|
||||
|
||||
attributes.userId && activeSpan.setAttribute("user.id", attributes.userId);
|
||||
attributes.email && activeSpan.setAttribute("user.email", attributes.email);
|
||||
attributes.projectId &&
|
||||
activeSpan.setAttribute("project.id", attributes.projectId);
|
||||
};
|
||||
|
||||
export const getTracer = (name: string) => opentelemetry.trace.getTracer(name);
|
||||
|
||||
export const recordGauge = (
|
||||
stat: string,
|
||||
value?: number | undefined,
|
||||
tags?:
|
||||
| {
|
||||
[tag: string]: string | number;
|
||||
}
|
||||
| undefined
|
||||
) => {
|
||||
dd.dogstatsd.gauge(stat, value, tags);
|
||||
};
|
||||
|
||||
export const recordIncrement = (
|
||||
stat: string,
|
||||
value?: number | undefined,
|
||||
tags?: { [tag: string]: string | number } | undefined
|
||||
) => {
|
||||
dd.dogstatsd.increment(stat, value, tags);
|
||||
};
|
||||
|
||||
export const recordHistogram = (
|
||||
stat: string,
|
||||
value?: number | undefined,
|
||||
tags?: { [tag: string]: string | number } | undefined
|
||||
) => {
|
||||
dd.dogstatsd.histogram(stat, value, tags);
|
||||
};
|
||||
@@ -6,6 +6,7 @@ export enum EventName {
|
||||
BatchExport = "BatchExport",
|
||||
EvaluationExecution = "EvaluationExecution",
|
||||
LegacyIngestion = "LegacyIngestion",
|
||||
CloudUsageMetering = "CloudUsageMetering",
|
||||
}
|
||||
|
||||
export const LegacyIngestionEvent = z.object({
|
||||
@@ -60,6 +61,7 @@ export enum QueueName {
|
||||
RepeatQueue = "repeat-queue",
|
||||
IngestionFlushQueue = "ingestion-flush-queue",
|
||||
LegacyIngestionQueue = "legacy-ingestion-queue",
|
||||
CloudUsageMeteringQueue = "cloud-usage-metering-queue",
|
||||
}
|
||||
|
||||
export enum QueueJobs {
|
||||
@@ -69,6 +71,7 @@ export enum QueueJobs {
|
||||
EnqueueBatchExportJobs = "enqueue-batch-export-jobs",
|
||||
FlushIngestionEntity = "flush-ingestion-entity",
|
||||
LegacyIngestionJob = "legacy-ingestion-job",
|
||||
CloudUsageMeteringJob = "cloud-usage-metering-job",
|
||||
}
|
||||
|
||||
export type TQueueJobTypes = {
|
||||
|
||||
@@ -17,6 +17,10 @@ export const getTraceUpsertQueue = () => {
|
||||
traceUpsertQueue = redis
|
||||
? new Queue<TQueueJobTypes[QueueName.TraceUpsert]>(QueueName.TraceUpsert, {
|
||||
connection: redis,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: 100, // Important: If not true, new jobs for that ID would be ignored as jobs in the complete set are still considered as part of the queue
|
||||
removeOnFail: 1000,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ export class PromptService {
|
||||
constructor(
|
||||
private prisma: PrismaClient,
|
||||
private redis: Redis | null,
|
||||
private metricIncrementer?: // used for Sentry metrics
|
||||
private metricIncrementer?: // used for otel metrics
|
||||
(name: string, value?: number) => void,
|
||||
cacheEnabled?: boolean // used for testing
|
||||
) {
|
||||
|
||||
Generated
+2255
-587
File diff suppressed because it is too large
Load Diff
+21
-17
@@ -80,22 +80,22 @@ const nextConfig = {
|
||||
// Required to check authentication status from langfuse.com
|
||||
...(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== undefined
|
||||
? [
|
||||
{
|
||||
source: "/api/auth/session",
|
||||
headers: [
|
||||
{
|
||||
key: "Access-Control-Allow-Origin",
|
||||
value: "https://langfuse.com",
|
||||
},
|
||||
{ key: "Access-Control-Allow-Credentials", value: "true" },
|
||||
{ key: "Access-Control-Allow-Methods", value: "GET,POST" },
|
||||
{
|
||||
key: "Access-Control-Allow-Headers",
|
||||
value: "Content-Type, Authorization",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
{
|
||||
source: "/api/auth/session",
|
||||
headers: [
|
||||
{
|
||||
key: "Access-Control-Allow-Origin",
|
||||
value: "https://langfuse.com",
|
||||
},
|
||||
{ key: "Access-Control-Allow-Credentials", value: "true" },
|
||||
{ key: "Access-Control-Allow-Methods", value: "GET,POST" },
|
||||
{
|
||||
key: "Access-Control-Allow-Headers",
|
||||
value: "Content-Type, Authorization",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
// all files in /public/generated are public and can be accessed from any origin, e.g. to render an API reference based on our openapi schema
|
||||
{
|
||||
@@ -115,7 +115,11 @@ const nextConfig = {
|
||||
},
|
||||
|
||||
// webassembly support for @dqbd/tiktoken
|
||||
webpack(config) {
|
||||
webpack(config, { isServer }) {
|
||||
if (isServer) {
|
||||
// https://github.com/open-telemetry/opentelemetry-js/issues/4173
|
||||
config.ignoreWarnings = [{ module: /opentelemetry/ }];
|
||||
}
|
||||
config.experiments = {
|
||||
asyncWebAssembly: true,
|
||||
layers: true,
|
||||
|
||||
+20
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "2.71.0",
|
||||
"version": "2.72.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -23,6 +23,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/tokenizer": "^0.0.4",
|
||||
"@appsignal/opentelemetry-instrumentation-bullmq": "^0.7.1",
|
||||
"@baselime/trpc-opentelemetry-middleware": "^0.1.2",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lint": "^6.8.0",
|
||||
"@headlessui/react": "1.7.18",
|
||||
@@ -37,6 +39,21 @@
|
||||
"@marsidev/react-turnstile": "^0.5.4",
|
||||
"@mui/x-tree-view": "^7.6.2",
|
||||
"@next-auth/prisma-adapter": "^1.0.7",
|
||||
"@opentelemetry/api": ">=1.0.0 <1.9.0",
|
||||
"@opentelemetry/auto-instrumentations-node": "^0.44.0",
|
||||
"@opentelemetry/context-async-hooks": "^1.25.1",
|
||||
"@opentelemetry/core": "^1.14.0",
|
||||
"@opentelemetry/instrumentation": "^0.52.1",
|
||||
"@opentelemetry/instrumentation-http": "^0.52.1",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.42.0",
|
||||
"@opentelemetry/instrumentation-undici": "^0.4.0",
|
||||
"@opentelemetry/resources": "^1.25.1",
|
||||
"@opentelemetry/sdk-metrics": "1.23.0",
|
||||
"@opentelemetry/sdk-node": "^0.50.0",
|
||||
"@opentelemetry/sdk-trace-base": "^1.25.1",
|
||||
"@opentelemetry/sdk-trace-node": "^1.25.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.25.1",
|
||||
"@prisma/instrumentation": "^5.13.0",
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.5",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
@@ -63,7 +80,6 @@
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@sentry/nextjs": "8.13.0",
|
||||
"@sentry/node": "8.13.0",
|
||||
"@sentry/profiling-node": "8.13.0",
|
||||
"@t3-oss/env-nextjs": "^0.10.1",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tanstack/react-query": "^4.36.1",
|
||||
@@ -85,6 +101,7 @@
|
||||
"core-js": "^3.36.1",
|
||||
"cors": "^2.8.5",
|
||||
"date-fns": "^3.3.1",
|
||||
"dd-trace": "^5.21.0",
|
||||
"decimal.js": "^10.4.3",
|
||||
"dompurify": "^3.1.5",
|
||||
"graphql": "^16.9.0",
|
||||
@@ -115,6 +132,7 @@
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remark-math": "^6.0.0",
|
||||
"sonner": "^1.4.41",
|
||||
"stripe": "^16.8.0",
|
||||
"superjson": "2.2.1",
|
||||
"tailwind-merge": "^2.2.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { stripeWebhookApiHandler } from "@/src/ee/features/billing/server/stripeWebhookApiHandler";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = stripeWebhookApiHandler;
|
||||
@@ -0,0 +1,16 @@
|
||||
export const metadata = {
|
||||
title: 'Next.js',
|
||||
description: 'Generated by Next.js',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -48,8 +48,11 @@ export const ModelParameters: React.FC<ModelParamsContext> = ({
|
||||
<p className="font-semibold">Model</p>
|
||||
{availableProviders.length === 0 ? (
|
||||
<>
|
||||
<p className="text-sm">No LLM API key set in project.</p>
|
||||
<CreateLLMApiKeyDialog />
|
||||
<p className="text-sm">
|
||||
No LLM API key set in project. For evals, we only support OpenAI so
|
||||
far.
|
||||
</p>
|
||||
<CreateLLMApiKeyDialog evalModelsOnly={true} />
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -2,9 +2,9 @@ import { Button } from "@/src/components/ui/button";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
import { captureException } from "@sentry/nextjs";
|
||||
import { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { captureException } from "@sentry/nextjs";
|
||||
|
||||
export const ErrorPage = ({
|
||||
title = "Error",
|
||||
|
||||
@@ -43,6 +43,10 @@ export default function Header({
|
||||
title: string;
|
||||
breadcrumb?: { name: string; href?: string }[];
|
||||
status?: Status;
|
||||
label?: {
|
||||
text: string;
|
||||
href: string;
|
||||
};
|
||||
help?: { description: string; href?: string };
|
||||
featureBetaURL?: string;
|
||||
actionButtons?: React.ReactNode;
|
||||
@@ -91,6 +95,11 @@ export default function Header({
|
||||
) : null}
|
||||
</div>
|
||||
{props.status && <StatusBadge type={props.status} />}
|
||||
{props.label && (
|
||||
<Link href={props.label.href}>
|
||||
<StatusBadge type={props.label.text} />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{props.actionButtons ?? null}
|
||||
|
||||
@@ -132,24 +132,25 @@ export default function Layout(props: PropsWithChildren) {
|
||||
|
||||
// Feature Flags
|
||||
if (
|
||||
!(
|
||||
route.featureFlag === undefined ||
|
||||
enableExperimentalFeatures ||
|
||||
session.data?.user?.featureFlags[route.featureFlag]
|
||||
)
|
||||
route.featureFlag !== undefined &&
|
||||
!enableExperimentalFeatures &&
|
||||
session.data?.user?.admin !== true &&
|
||||
session.data?.user?.featureFlags[route.featureFlag] !== true
|
||||
)
|
||||
return null;
|
||||
|
||||
// check entitlements
|
||||
if (
|
||||
route.entitlement !== undefined &&
|
||||
!entitlements.includes(route.entitlement)
|
||||
!entitlements.includes(route.entitlement) &&
|
||||
session.data?.user?.admin !== true
|
||||
)
|
||||
return null;
|
||||
|
||||
// RBAC
|
||||
if (
|
||||
route.projectRbacScope !== undefined &&
|
||||
session.data?.user?.admin !== true &&
|
||||
(!project ||
|
||||
!organization ||
|
||||
!hasProjectAccess({
|
||||
@@ -511,7 +512,8 @@ export default function Layout(props: PropsWithChildren) {
|
||||
</Menu>
|
||||
</div>
|
||||
<div className="lg:pl-56">
|
||||
{env.NEXT_PUBLIC_DEMO_PROJECT_ID &&
|
||||
{env.NEXT_PUBLIC_DEMO_ORG_ID &&
|
||||
env.NEXT_PUBLIC_DEMO_PROJECT_ID &&
|
||||
routerProjectId === env.NEXT_PUBLIC_DEMO_PROJECT_ID &&
|
||||
Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION) ? (
|
||||
<div className="flex w-full items-center border-b border-dark-yellow bg-light-yellow px-4 py-2 lg:sticky lg:top-0 lg:z-40">
|
||||
|
||||
@@ -11,28 +11,32 @@ const statusCategories = {
|
||||
export type Status =
|
||||
(typeof statusCategories)[keyof typeof statusCategories][number];
|
||||
|
||||
export const StatusBadge = (props: { className?: string; type: Status }) => {
|
||||
export const StatusBadge = (props: {
|
||||
className?: string;
|
||||
type: Status | (string & {});
|
||||
}) => {
|
||||
let badgeColor = "bg-muted-gray text-primary";
|
||||
let dotColor = "bg-muted-foreground";
|
||||
let dotPingColor = "bg-muted-foreground";
|
||||
let showDot = true;
|
||||
let showDot = false;
|
||||
|
||||
if (statusCategories.active.includes(props.type)) {
|
||||
badgeColor = "bg-light-green text-dark-green";
|
||||
dotColor = "animate-ping bg-dark-green";
|
||||
dotPingColor = "bg-dark-green";
|
||||
showDot = true;
|
||||
} else if (statusCategories.pending.includes(props.type)) {
|
||||
badgeColor = "bg-light-yellow text-dark-yellow";
|
||||
dotColor = "animate-ping bg-dark-yellow";
|
||||
dotPingColor = "bg-dark-yellow";
|
||||
showDot = true;
|
||||
} else if (statusCategories.error.includes(props.type)) {
|
||||
badgeColor = "bg-light-red text-dark-red";
|
||||
dotColor = "animate-ping bg-dark-red";
|
||||
dotPingColor = "bg-dark-red";
|
||||
showDot = false;
|
||||
showDot = true;
|
||||
} else if (statusCategories.completed.includes(props.type)) {
|
||||
badgeColor = "bg-light-green text-dark-green";
|
||||
showDot = false;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useEffect, useState } from "react";
|
||||
import { AnnotateDrawer } from "@/src/features/scores/components/AnnotateDrawer";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import useLocalStorage from "@/src/components/useLocalStorage";
|
||||
import { CommentDrawerButton } from "@/src/features/comments/CommentDrawerButton";
|
||||
|
||||
// some projects have thousands of traces in a sessions, paginate to avoid rendering all at once
|
||||
const PAGE_SIZE = 50;
|
||||
@@ -52,6 +53,15 @@ export const SessionPage: React.FC<{
|
||||
string[]
|
||||
>("emptySelectedConfigIds", []);
|
||||
|
||||
const commentCounts = api.comments.getCountsByObjectIds.useQuery(
|
||||
{
|
||||
projectId,
|
||||
objectIds: [sessionId],
|
||||
objectType: "SESSION",
|
||||
},
|
||||
{ enabled: session.isSuccess },
|
||||
);
|
||||
|
||||
if (session.error?.data?.code === "UNAUTHORIZED")
|
||||
return <ErrorPage message="You do not have access to this session." />;
|
||||
|
||||
@@ -87,6 +97,14 @@ export const SessionPage: React.FC<{
|
||||
}
|
||||
listKey="sessions"
|
||||
/>,
|
||||
<CommentDrawerButton
|
||||
key="comment"
|
||||
variant="outline"
|
||||
projectId={projectId}
|
||||
objectId={sessionId}
|
||||
objectType="SESSION"
|
||||
count={commentCounts.data?.get(sessionId)}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -178,7 +196,7 @@ const SessionIO = ({
|
||||
},
|
||||
);
|
||||
return (
|
||||
<div className="col-span-2 flex flex-col gap-2 p-0">
|
||||
<div className="col-span-2 grid grid-flow-row gap-2 p-0">
|
||||
{!trace.data ? (
|
||||
<JsonSkeleton
|
||||
className="h-full w-full overflow-hidden px-2 py-1"
|
||||
|
||||
@@ -23,6 +23,7 @@ import { ScoresPreview } from "@/src/components/trace/ScoresPreview";
|
||||
import { JumpToPlaygroundButton } from "@/src/ee/features/playground/page/components/JumpToPlaygroundButton";
|
||||
import { AnnotateDrawer } from "@/src/features/scores/components/AnnotateDrawer";
|
||||
import useLocalStorage from "@/src/components/useLocalStorage";
|
||||
import { CommentDrawerButton } from "@/src/features/comments/CommentDrawerButton";
|
||||
|
||||
export const ObservationPreview = (props: {
|
||||
observations: Array<ObservationReturnType>;
|
||||
@@ -30,6 +31,7 @@ export const ObservationPreview = (props: {
|
||||
scores: APIScore[];
|
||||
currentObservationId: string;
|
||||
traceId: string;
|
||||
commentCounts?: Map<string, number>;
|
||||
}) => {
|
||||
const [selectedTab, setSelectedTab] = useQueryParam(
|
||||
"view",
|
||||
@@ -163,6 +165,12 @@ export const ObservationPreview = (props: {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<CommentDrawerButton
|
||||
projectId={preloadedObservation.projectId}
|
||||
objectId={preloadedObservation.id}
|
||||
objectType="OBSERVATION"
|
||||
count={props.commentCounts?.get(preloadedObservation.id)}
|
||||
/>
|
||||
<AnnotateDrawer
|
||||
projectId={props.projectId}
|
||||
traceId={preloadedObservation.traceId}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
nestObservations,
|
||||
treeItemColors,
|
||||
} from "@/src/components/trace/lib/helpers";
|
||||
import { CommentCountIcon } from "@/src/features/comments/CommentCountIcon";
|
||||
|
||||
export const ObservationTree = (props: {
|
||||
observations: ObservationReturnType[];
|
||||
@@ -27,6 +28,8 @@ export const ObservationTree = (props: {
|
||||
setCurrentObservationId: (id: string | undefined) => void;
|
||||
showMetrics: boolean;
|
||||
showScores: boolean;
|
||||
observationCommentCounts?: Map<string, number>;
|
||||
traceCommentCounts?: Map<string, number>;
|
||||
className?: string;
|
||||
}) => {
|
||||
const nestedObservations = nestObservations(props.observations);
|
||||
@@ -37,6 +40,7 @@ export const ObservationTree = (props: {
|
||||
collapseAll={props.collapseAll}
|
||||
trace={props.trace}
|
||||
scores={props.scores}
|
||||
comments={props.traceCommentCounts}
|
||||
currentObservationId={props.currentObservationId}
|
||||
setCurrentObservationId={props.setCurrentObservationId}
|
||||
showMetrics={props.showMetrics}
|
||||
@@ -47,6 +51,7 @@ export const ObservationTree = (props: {
|
||||
collapsedObservations={props.collapsedObservations}
|
||||
toggleCollapsedObservation={props.toggleCollapsedObservation}
|
||||
scores={props.scores}
|
||||
comments={props.observationCommentCounts}
|
||||
indentationLevel={1}
|
||||
currentObservationId={props.currentObservationId}
|
||||
setCurrentObservationId={props.setCurrentObservationId}
|
||||
@@ -62,6 +67,7 @@ const ObservationTreeTraceNode = (props: {
|
||||
expandAll: () => void;
|
||||
collapseAll: () => void;
|
||||
scores: APIScore[];
|
||||
comments: Map<string, number> | undefined;
|
||||
currentObservationId: string | undefined;
|
||||
setCurrentObservationId: (id: string | undefined) => void;
|
||||
showMetrics?: boolean;
|
||||
@@ -79,23 +85,28 @@ const ObservationTreeTraceNode = (props: {
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<span className={cn("rounded-sm bg-input p-1 text-xs")}>TRACE</span>
|
||||
<span className="flex-1 break-all text-sm">{props.trace.name}</span>
|
||||
<Button
|
||||
onClick={(ev) => (ev.stopPropagation(), props.expandAll())}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
title="Expand all"
|
||||
>
|
||||
<PlusCircleIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={(ev) => (ev.stopPropagation(), props.collapseAll())}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
title="Collapse all"
|
||||
>
|
||||
<MinusCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="break-all text-sm">{props.trace.name}</span>
|
||||
{props.comments ? (
|
||||
<CommentCountIcon count={props.comments.get(props.trace.id)} />
|
||||
) : null}
|
||||
<div className="flex flex-1 justify-end">
|
||||
<Button
|
||||
onClick={(ev) => (ev.stopPropagation(), props.expandAll())}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
title="Expand all"
|
||||
>
|
||||
<PlusCircleIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={(ev) => (ev.stopPropagation(), props.collapseAll())}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
title="Collapse all"
|
||||
>
|
||||
<MinusCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{props.showMetrics && props.trace.latency ? (
|
||||
@@ -120,6 +131,7 @@ const ObservationTreeNode = (props: {
|
||||
collapsedObservations: string[];
|
||||
toggleCollapsedObservation: (id: string) => void;
|
||||
scores: APIScore[];
|
||||
comments?: Map<string, number> | undefined;
|
||||
indentationLevel: number;
|
||||
currentObservationId: string | undefined;
|
||||
setCurrentObservationId: (id: string | undefined) => void;
|
||||
@@ -154,9 +166,16 @@ const ObservationTreeNode = (props: {
|
||||
<ColorCodedObservationType
|
||||
observationType={observation.type}
|
||||
/>
|
||||
<span className="flex-1 break-all text-sm">
|
||||
{observation.name}
|
||||
</span>
|
||||
<div className="grid flex-1 grid-cols-[auto,1fr] gap-2">
|
||||
<span className="break-all text-sm">
|
||||
{observation.name}
|
||||
</span>
|
||||
{props.comments ? (
|
||||
<CommentCountIcon
|
||||
count={props.comments.get(observation.id)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{observation.children.length === 0 ? null : (
|
||||
<Toggle
|
||||
onClick={(ev) => (
|
||||
@@ -244,6 +263,7 @@ const ObservationTreeNode = (props: {
|
||||
collapsedObservations={props.collapsedObservations}
|
||||
toggleCollapsedObservation={props.toggleCollapsedObservation}
|
||||
scores={props.scores}
|
||||
comments={props.comments}
|
||||
indentationLevel={props.indentationLevel + 1}
|
||||
currentObservationId={props.currentObservationId}
|
||||
setCurrentObservationId={props.setCurrentObservationId}
|
||||
|
||||
@@ -20,15 +20,18 @@ import ScoresTable from "@/src/components/table/use-cases/scores";
|
||||
import { ScoresPreview } from "@/src/components/trace/ScoresPreview";
|
||||
import { AnnotateDrawer } from "@/src/features/scores/components/AnnotateDrawer";
|
||||
import useLocalStorage from "@/src/components/useLocalStorage";
|
||||
import { CommentDrawerButton } from "@/src/features/comments/CommentDrawerButton";
|
||||
|
||||
export const TracePreview = ({
|
||||
trace,
|
||||
observations,
|
||||
scores,
|
||||
commentCounts,
|
||||
}: {
|
||||
trace: Trace & { latency?: number };
|
||||
observations: ObservationReturnType[];
|
||||
scores: APIScore[];
|
||||
commentCounts?: Map<string, number>;
|
||||
}) => {
|
||||
const [selectedTab, setSelectedTab] = useQueryParam(
|
||||
"view",
|
||||
@@ -99,6 +102,12 @@ export const TracePreview = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<CommentDrawerButton
|
||||
projectId={trace.projectId}
|
||||
objectId={trace.id}
|
||||
objectType="TRACE"
|
||||
count={commentCounts?.get(trace.id)}
|
||||
/>
|
||||
<AnnotateDrawer
|
||||
projectId={trace.projectId}
|
||||
traceId={trace.id}
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { TracePreview } from "@/src/components/trace/TracePreview";
|
||||
import { ObservationPreview } from "@/src/components/trace/ObservationPreview";
|
||||
import useSessionStorage from "@/src/components/useSessionStorage";
|
||||
import { api } from "@/src/utils/api";
|
||||
|
||||
// Fixed widths for styling for v1
|
||||
const SCALE_WIDTH = 800;
|
||||
@@ -44,12 +45,14 @@ const PREDEFINED_STEP_SIZES = [
|
||||
|
||||
const getNestedObservationKeys = (
|
||||
observations: NestedObservation[],
|
||||
): string[] => {
|
||||
): { keys: string[]; ids: string[] } => {
|
||||
const keys: string[] = [];
|
||||
const ids: string[] = [];
|
||||
|
||||
const collectKeys = (obs: NestedObservation[]) => {
|
||||
obs.forEach((observation) => {
|
||||
keys.push(`observation-${observation.id}`);
|
||||
ids.push(observation.id);
|
||||
if (observation.children) {
|
||||
collectKeys(observation.children);
|
||||
}
|
||||
@@ -57,7 +60,7 @@ const getNestedObservationKeys = (
|
||||
};
|
||||
|
||||
collectKeys(observations);
|
||||
return keys;
|
||||
return { keys, ids };
|
||||
};
|
||||
|
||||
const calculateStepSize = (latency: number, scaleWidth: number) => {
|
||||
@@ -181,6 +184,7 @@ function TraceTreeItem({
|
||||
scores,
|
||||
observations,
|
||||
cardWidth,
|
||||
commentCounts,
|
||||
}: {
|
||||
observation: NestedObservation;
|
||||
level: number;
|
||||
@@ -190,6 +194,7 @@ function TraceTreeItem({
|
||||
scores: APIScore[];
|
||||
observations: Array<ObservationReturnType>;
|
||||
cardWidth: number;
|
||||
commentCounts?: Map<string, number>;
|
||||
}) {
|
||||
const { startTime, endTime } = observation || {};
|
||||
const [backgroundColor, setBackgroundColor] = useState("");
|
||||
@@ -232,6 +237,7 @@ function TraceTreeItem({
|
||||
projectId={projectId}
|
||||
currentObservationId={observation.id}
|
||||
traceId={observation.traceId}
|
||||
commentCounts={commentCounts}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -250,6 +256,7 @@ function TraceTreeItem({
|
||||
scores={scores}
|
||||
observations={observations}
|
||||
cardWidth={cardWidth}
|
||||
commentCounts={commentCounts}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
@@ -298,11 +305,43 @@ export function TraceTimelineView({
|
||||
() => nestObservations(observations),
|
||||
[observations],
|
||||
);
|
||||
const nestedObservationKeys = useMemo(
|
||||
const { keys: nestedObservationKeys, ids: nestedObservationIds } = useMemo(
|
||||
() => getNestedObservationKeys(nestedObservations),
|
||||
[nestedObservations],
|
||||
);
|
||||
|
||||
const observationCommentCounts = api.comments.getCountsByObjectIds.useQuery(
|
||||
{
|
||||
projectId: trace.projectId,
|
||||
objectIds: nestedObservationIds,
|
||||
objectType: "OBSERVATION",
|
||||
},
|
||||
{
|
||||
trpc: {
|
||||
context: {
|
||||
skipBatch: true,
|
||||
},
|
||||
},
|
||||
refetchOnMount: false, // prevents refetching loops
|
||||
},
|
||||
);
|
||||
|
||||
const traceCommentCounts = api.comments.getCountsByObjectIds.useQuery(
|
||||
{
|
||||
projectId: trace.projectId,
|
||||
objectIds: [trace.id],
|
||||
objectType: "TRACE",
|
||||
},
|
||||
{
|
||||
trpc: {
|
||||
context: {
|
||||
skipBatch: true,
|
||||
},
|
||||
},
|
||||
refetchOnMount: false, // prevents refetching loops
|
||||
},
|
||||
);
|
||||
|
||||
if (!latency) return null;
|
||||
|
||||
const stepSize = calculateStepSize(latency, SCALE_WIDTH);
|
||||
@@ -414,6 +453,7 @@ export function TraceTimelineView({
|
||||
trace={trace}
|
||||
observations={observations}
|
||||
scores={scores}
|
||||
commentCounts={traceCommentCounts.data}
|
||||
/>
|
||||
</div>
|
||||
</TreeItemInner>
|
||||
@@ -431,6 +471,7 @@ export function TraceTimelineView({
|
||||
scores={scores}
|
||||
observations={observations}
|
||||
cardWidth={cardWidth}
|
||||
commentCounts={observationCommentCounts.data}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { usdFormatter } from "@/src/utils/numbers";
|
||||
import Decimal from "decimal.js";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { DeleteButton } from "@/src/components/deleteButton";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/src/components/ui/tabs";
|
||||
@@ -57,6 +57,42 @@ export function Trace(props: {
|
||||
[],
|
||||
);
|
||||
|
||||
const observationObjectIds: string[] = useMemo(() => {
|
||||
return props.observations.map(({ id }) => id);
|
||||
}, [props.observations]);
|
||||
|
||||
const observationCommentCounts = api.comments.getCountsByObjectIds.useQuery(
|
||||
{
|
||||
projectId: props.trace.projectId,
|
||||
objectIds: observationObjectIds,
|
||||
objectType: "OBSERVATION",
|
||||
},
|
||||
{
|
||||
trpc: {
|
||||
context: {
|
||||
skipBatch: true,
|
||||
},
|
||||
},
|
||||
refetchOnMount: false, // prevents refetching loops
|
||||
},
|
||||
);
|
||||
|
||||
const traceCommentCounts = api.comments.getCountsByObjectIds.useQuery(
|
||||
{
|
||||
projectId: props.trace.projectId,
|
||||
objectIds: [props.trace.id],
|
||||
objectType: "TRACE",
|
||||
},
|
||||
{
|
||||
trpc: {
|
||||
context: {
|
||||
skipBatch: true,
|
||||
},
|
||||
},
|
||||
refetchOnMount: false, // prevents refetching loops
|
||||
},
|
||||
);
|
||||
|
||||
const toggleCollapsedObservation = useCallback(
|
||||
(id: string) => {
|
||||
if (collapsedObservations.includes(id)) {
|
||||
@@ -114,6 +150,7 @@ export function Trace(props: {
|
||||
trace={props.trace}
|
||||
observations={props.observations}
|
||||
scores={props.scores}
|
||||
commentCounts={traceCommentCounts.data}
|
||||
/>
|
||||
) : (
|
||||
<ObservationPreview
|
||||
@@ -122,6 +159,7 @@ export function Trace(props: {
|
||||
projectId={props.projectId}
|
||||
currentObservationId={currentObservationId}
|
||||
traceId={props.trace.id}
|
||||
commentCounts={observationCommentCounts.data}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -171,6 +209,8 @@ export function Trace(props: {
|
||||
setCurrentObservationId={setCurrentObservationId}
|
||||
showMetrics={metricsOnObservationTree}
|
||||
showScores={scoresOnObservationTree}
|
||||
observationCommentCounts={observationCommentCounts.data}
|
||||
traceCommentCounts={traceCommentCounts.data}
|
||||
className="flex w-full flex-col overflow-y-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,12 @@ import { Drawer as DrawerPrimitive } from "vaul";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { useMediaQuery } from "react-responsive";
|
||||
|
||||
type DrawerContentProps = React.ComponentPropsWithoutRef<
|
||||
typeof DrawerPrimitive.Content
|
||||
> & {
|
||||
overlayClassName?: string;
|
||||
};
|
||||
|
||||
// https://tailwindcss.com/docs/responsive-design
|
||||
const TAILWIND_MD_MEDIA_QUERY = 768;
|
||||
|
||||
@@ -48,10 +54,10 @@ DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
|
||||
|
||||
const DrawerContent = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
DrawerContentProps
|
||||
>(({ className, children, overlayClassName, ...props }, ref) => (
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay />
|
||||
<DrawerOverlay className={overlayClassName} />
|
||||
<DrawerPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const VERSION = "v2.71.0";
|
||||
export const VERSION = "v2.72.0";
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { registerInstrumentations } from "@opentelemetry/instrumentation";
|
||||
import { IORedisInstrumentation } from "@opentelemetry/instrumentation-ioredis";
|
||||
import { HttpInstrumentation } from "@opentelemetry/instrumentation-http";
|
||||
import { PrismaInstrumentation } from "@prisma/instrumentation";
|
||||
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
|
||||
import { AsyncHooksContextManager } from "@opentelemetry/context-async-hooks";
|
||||
import dd from "dd-trace";
|
||||
import opentelemetry from "@opentelemetry/api";
|
||||
// import { BullMQInstrumentation } from "@appsignal/opentelemetry-instrumentation-bullmq";
|
||||
import { UndiciInstrumentation } from "@opentelemetry/instrumentation-undici";
|
||||
|
||||
if (!process.env.VERCEL && process.env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION) {
|
||||
console.log("Initializing otel tracing");
|
||||
const contextManager = new AsyncHooksContextManager().enable();
|
||||
|
||||
opentelemetry.context.setGlobalContextManager(contextManager);
|
||||
|
||||
const tracer = dd.init({
|
||||
profiling: false,
|
||||
runtimeMetrics: true,
|
||||
});
|
||||
|
||||
const { TracerProvider } = tracer;
|
||||
|
||||
const provider = new TracerProvider();
|
||||
|
||||
// correct the ressrouce name for http requests
|
||||
tracer.use("http", {
|
||||
hooks: {
|
||||
request(span, req) {
|
||||
if (span && req) {
|
||||
let url = "path" in req ? req.path : req.url;
|
||||
if (url) {
|
||||
// Remove URL parameters
|
||||
url = url.split("?")[0];
|
||||
// Add wildcard for /_next/static
|
||||
if (url.startsWith("/_next/static")) {
|
||||
url = "/_next/static/*";
|
||||
}
|
||||
}
|
||||
if (url) {
|
||||
const method = req.method;
|
||||
span.setTag("resource.name", method ? `${method} ${url}` : url);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
registerInstrumentations({
|
||||
instrumentations: [
|
||||
new IORedisInstrumentation(),
|
||||
new HttpInstrumentation(),
|
||||
new PrismaInstrumentation(),
|
||||
getNodeAutoInstrumentations(),
|
||||
new UndiciInstrumentation(),
|
||||
// new BullMQInstrumentation(),
|
||||
],
|
||||
});
|
||||
|
||||
provider.register();
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
// import { registerOTel } from "@vercel/otel";
|
||||
|
||||
// const { TracerProvider } = (await import("dd-trace")).default.init({
|
||||
// runtimeMetrics: true,
|
||||
// profiling: true,
|
||||
// });
|
||||
|
||||
// const provider = new TracerProvider();
|
||||
|
||||
// registerOTel({
|
||||
// // instrumentations: [new PrismaInstrumentation()],
|
||||
// });
|
||||
// provider.register();
|
||||
@@ -0,0 +1,61 @@
|
||||
# Billing
|
||||
|
||||
## Overview
|
||||
|
||||
This page outlines our integration with **Stripe billing**. The integration supports **usage-based billing** for each organization and project within our product.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **Organizations**: Entities within the product that can have multiple projects.
|
||||
- **Projects**: Sub-entities under organizations where usage is tracked.
|
||||
- **Observations**: Units of usage, essentially API calls, tracked at the project level and aggregated for billing on the organization level.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Checkout and Subscription Management
|
||||
|
||||
1. **Upgrade Process**:
|
||||
|
||||
- Organizations can upgrade their plan via the **billing settings**.
|
||||
- On the **select plans popup**, only product listed in `stripeProducts.ts` are included.
|
||||
- A new **Stripe Checkout session** is created via the API.
|
||||
- The **organization ID** is passed to Stripe as a reference.
|
||||
- The default price in stripe for each product is used.
|
||||
|
||||
2. **Stripe WebHooks**:
|
||||
|
||||
- **Checkout Session Completion**:
|
||||
- On completion, the `customerID` and `activeSubscriptionId` are added to the cloud config of the organization's object.
|
||||
- Linked based on the stripe client reference (`stripeClientReference.ts`).
|
||||
- **Subscription Changes**:
|
||||
- On creation, update, or deletion of a subscription, the **active product ID** is updated in the cloud config of the organization which includes the `activeSubscriptionId`.
|
||||
|
||||
3. **Cloud Config**:
|
||||
- Contains the **customer ID**, **activeSubscriptionId**, and **active product ID**.
|
||||
- All entitlements within the application are based on the **active product ID** and it's mapping to `Plan` in Langfuse (`stripeProducts.ts`).
|
||||
- If a `plan` is included in the cloud config (legacy), no new subscription can be created for the organization. Organizations need to be migrated to the new system.
|
||||
|
||||
### Usage-Based Pricing
|
||||
|
||||
1. **Usage Meter in Stripe**:
|
||||
|
||||
- A meter with the ID `trace_observations` is used to track usage.
|
||||
- This meter tracks observations at the _customer_ level within Stripe.
|
||||
|
||||
2. **Hourly Job**:
|
||||
|
||||
- An hourly job runs in the **worker container** (`cloudUsageMeteringQueue`).
|
||||
- Logs the number of observations within the last hour to Stripe for all organizations which include a customer ID in the cloud config.
|
||||
- The job is managed via
|
||||
- BullMQ repeatable jobs: trigger run each hour + potential backfill job on startup of worker container
|
||||
- `cron_jobs` postgres table to track the last run time of the job and to prevent multiple instances of the job running.
|
||||
|
||||
## Webhook development
|
||||
|
||||
You can test webhooks via the `stripe` CLI. Step by step guide: https://dashboard.stripe.com/test/webhooks/create?endpoint_location=local
|
||||
|
||||
1. `stripe login`
|
||||
2. `stripe listen --forward-to localhost:3000/api/billing/stripe-webhook`
|
||||
3. Add the API key from the Stripe test environment and the webhook signing secret from the CLI to the `.env` file.
|
||||
|
||||
Upgrade/downgrade projects via the Langfuse billing settings.
|
||||
@@ -0,0 +1,244 @@
|
||||
// Langfuse Cloud only
|
||||
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { Flex, MarkerBar, Metric, Text } from "@tremor/react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTrigger,
|
||||
} from "@/src/components/ui/dialog";
|
||||
import Header from "@/src/components/layouts/header";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { useQueryOrganization } from "@/src/features/organizations/hooks";
|
||||
import { Card } from "@/src/components/ui/card";
|
||||
import { numberFormatter, compactNumberFormatter } from "@/src/utils/numbers";
|
||||
import { useHasOrgEntitlement } from "@/src/features/entitlements/hooks";
|
||||
import {
|
||||
type Plan,
|
||||
planLabels,
|
||||
} from "@/src/features/entitlements/constants/plans";
|
||||
import { stripeProducts } from "@/src/ee/features/billing/utils/stripeProducts";
|
||||
import { useRouter } from "next/router";
|
||||
import {
|
||||
chatAvailable,
|
||||
sendUserChatMessage,
|
||||
} from "@/src/features/support-chat/chat";
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { useHasOrganizationAccess } from "@/src/features/rbac/utils/checkOrganizationAccess";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/src/components/ui/alert";
|
||||
|
||||
export const BillingSettings = () => {
|
||||
const router = useRouter();
|
||||
const orgId = router.query.organizationId as string | undefined;
|
||||
const hasAccess = useHasOrganizationAccess({
|
||||
organizationId: orgId,
|
||||
scope: "langfuseCloudBilling:CRUD",
|
||||
});
|
||||
|
||||
const entitled = useHasOrgEntitlement("cloud-billing");
|
||||
if (!entitled) return null;
|
||||
|
||||
if (!hasAccess)
|
||||
return (
|
||||
<Alert>
|
||||
<AlertTitle>Access Denied</AlertTitle>
|
||||
<AlertDescription>
|
||||
You do not have permission to view the billing settings of this
|
||||
organization.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Header title="Usage & Billing" level="h3" />
|
||||
<OrganizationUsageChart />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const OrganizationUsageChart = () => {
|
||||
const organization = useQueryOrganization();
|
||||
const usage = api.cloudBilling.getUsage.useQuery(
|
||||
{
|
||||
orgId: organization?.id as string,
|
||||
},
|
||||
{
|
||||
enabled: organization !== undefined,
|
||||
trpc: {
|
||||
context: {
|
||||
skipBatch: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
const planLimit =
|
||||
organization?.cloudConfig?.monthlyObservationLimit ?? 50_000;
|
||||
const plan: Plan = organization?.plan ?? "cloud:hobby";
|
||||
const planLabel = planLabels[plan];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card className="p-4">
|
||||
{usage.data !== undefined ? (
|
||||
<>
|
||||
<Text>
|
||||
{usage.data.billingPeriod
|
||||
? `Observations in billing period`
|
||||
: "Observations / last 30d"}
|
||||
</Text>
|
||||
<Metric>{numberFormatter(usage.data.countObservations, 0)}</Metric>
|
||||
{plan === "cloud:hobby" && (
|
||||
<>
|
||||
<Flex className="mt-4">
|
||||
<Text>{`${numberFormatter((usage.data.countObservations / planLimit) * 100)}%`}</Text>
|
||||
<Text>Plan limit: {compactNumberFormatter(planLimit)}</Text>
|
||||
</Flex>
|
||||
<MarkerBar
|
||||
value={Math.min(
|
||||
(usage.data.countObservations / planLimit) * 100,
|
||||
100,
|
||||
)}
|
||||
className="mt-3"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
"Loading (might take a moment) ..."
|
||||
)}
|
||||
</Card>
|
||||
<div className="mt-2 flex flex-col gap-1 text-sm text-muted-foreground">
|
||||
<p>Current plan: {planLabel}</p>
|
||||
{usage.data?.billingPeriod && (
|
||||
<p>
|
||||
{`Billing period: ${usage.data.billingPeriod.start.toLocaleDateString()} - ${usage.data.billingPeriod.end.toLocaleDateString()}`}
|
||||
</p>
|
||||
)}
|
||||
{usage.data?.upcomingInvoice && (
|
||||
<p>
|
||||
{`Next invoice (current usage): ${usage.data.upcomingInvoice.usdAmount} USD`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-row items-center gap-2">
|
||||
<BillingPortalOrPricingPageButton />
|
||||
<Button variant="secondary" asChild>
|
||||
<Link href={"https://langfuse.com/pricing"} target="_blank">
|
||||
Compare plans
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const BillingPortalOrPricingPageButton = () => {
|
||||
const organization = useQueryOrganization();
|
||||
const billingPortalUrl = api.cloudBilling.getStripeCustomerPortalUrl.useQuery(
|
||||
{
|
||||
orgId: organization?.id as string,
|
||||
},
|
||||
{
|
||||
enabled: organization !== undefined,
|
||||
},
|
||||
);
|
||||
if (billingPortalUrl.isLoading) return null;
|
||||
if (!billingPortalUrl.data) return <PricingPageButton />;
|
||||
|
||||
return (
|
||||
<Button asChild>
|
||||
<Link href={billingPortalUrl.data}>Billing portal</Link>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const PricingPageButton = () => {
|
||||
const capture = usePostHogClientCapture();
|
||||
const organization = useQueryOrganization();
|
||||
const router = useRouter();
|
||||
const mutCreateCheckoutSession =
|
||||
api.cloudBilling.createStripeCheckoutSession.useMutation({
|
||||
onSuccess: (url) => {
|
||||
router.push(url);
|
||||
},
|
||||
});
|
||||
if (!organization) return null;
|
||||
|
||||
// Do not show checkout or customer portal if manual plan is set in cloud config
|
||||
if (organization.cloudConfig?.plan) {
|
||||
if (chatAvailable)
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
sendUserChatMessage(
|
||||
`I'd like to change my current plan, region ${env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION}, organization id ${organization.id}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
Change plan
|
||||
</Button>
|
||||
);
|
||||
else return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
capture("project_settings:pricing_dialog_opened");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button>Change plan</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<Header
|
||||
title="Plans"
|
||||
level="h3"
|
||||
actionButtons={
|
||||
<Button variant="secondary" asChild>
|
||||
<Link href="https://langfuse.com/pricing" target="_blank">
|
||||
Comparison of plans ↗
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</DialogHeader>
|
||||
<div className="mb-3 flex flex-col justify-center gap-10 md:flex-row">
|
||||
{stripeProducts
|
||||
.filter((product) => Boolean(product.checkout))
|
||||
.map((product) => (
|
||||
<div
|
||||
key={product.stripeProductId}
|
||||
className="flex flex-1 flex-col"
|
||||
>
|
||||
<div className="mb-2 text-lg font-semibold">
|
||||
{product.checkout?.title}
|
||||
</div>
|
||||
<div>{product.checkout?.description}</div>
|
||||
<div className="mb-6 mt-2">{product.checkout?.price}</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (organization)
|
||||
mutCreateCheckoutSession.mutate({
|
||||
orgId: organization.id,
|
||||
stripeProductId: product.stripeProductId,
|
||||
});
|
||||
}}
|
||||
className="mt-auto"
|
||||
>
|
||||
Select plan
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,283 @@
|
||||
import { createStripeClientReference } from "@/src/ee/features/billing/stripeClientReference";
|
||||
import { stripeClient } from "@/src/ee/features/billing/utils/stripe";
|
||||
import { stripeProducts } from "@/src/ee/features/billing/utils/stripeProducts";
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { throwIfNoEntitlement } from "@/src/features/entitlements/server/hasEntitlement";
|
||||
import { parseDbOrg } from "@langfuse/shared";
|
||||
import {
|
||||
createTRPCRouter,
|
||||
protectedOrganizationProcedure,
|
||||
} from "@/src/server/api/trpc";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import * as z from "zod";
|
||||
import { throwIfNoOrganizationAccess } from "@/src/features/rbac/utils/checkOrganizationAccess";
|
||||
import { auditLog } from "@/src/features/audit-logs/auditLog";
|
||||
|
||||
export const cloudBillingRouter = createTRPCRouter({
|
||||
createStripeCheckoutSession: protectedOrganizationProcedure
|
||||
.input(
|
||||
z.object({
|
||||
orgId: z.string(),
|
||||
stripeProductId: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
throwIfNoOrganizationAccess({
|
||||
organizationId: input.orgId,
|
||||
scope: "langfuseCloudBilling:CRUD",
|
||||
session: ctx.session,
|
||||
});
|
||||
throwIfNoEntitlement({
|
||||
entitlement: "cloud-billing",
|
||||
sessionUser: ctx.session.user,
|
||||
orgId: input.orgId,
|
||||
});
|
||||
|
||||
const org = await ctx.prisma.organization.findUnique({
|
||||
where: {
|
||||
id: input.orgId,
|
||||
},
|
||||
});
|
||||
if (!org) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Organization not found",
|
||||
});
|
||||
}
|
||||
|
||||
const parsedOrg = parseDbOrg(org);
|
||||
if (parsedOrg.cloudConfig?.plan)
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message:
|
||||
"Cannot initialize stripe checkout for orgs that have a manual/legacy plan",
|
||||
});
|
||||
|
||||
if (!stripeClient)
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Stripe client not initialized",
|
||||
});
|
||||
|
||||
const stripeCustomerId = parsedOrg.cloudConfig?.stripe?.customerId;
|
||||
const stripeActiveSubscriptionId =
|
||||
parsedOrg.cloudConfig?.stripe?.activeSubscriptionId;
|
||||
if (stripeActiveSubscriptionId) {
|
||||
// If the org has a customer ID, do not return checkout options, should use the billing portal instead
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Organization already has an active subscription",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!stripeProducts.some(
|
||||
(product) =>
|
||||
Boolean(product.checkout) &&
|
||||
product.stripeProductId === input.stripeProductId,
|
||||
)
|
||||
)
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Invalid stripe product id",
|
||||
});
|
||||
|
||||
const product = await stripeClient.products.retrieve(
|
||||
input.stripeProductId,
|
||||
);
|
||||
if (!product.default_price) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Product does not have a default price in Stripe",
|
||||
});
|
||||
}
|
||||
|
||||
const returnUrl = `${env.NEXTAUTH_URL}/organization/${input.orgId}/settings`;
|
||||
const session = await stripeClient.checkout.sessions.create({
|
||||
customer: stripeCustomerId,
|
||||
line_items: [
|
||||
{
|
||||
price: product.default_price as string,
|
||||
},
|
||||
],
|
||||
client_reference_id:
|
||||
createStripeClientReference(input.orgId) ?? undefined,
|
||||
allow_promotion_codes: true,
|
||||
tax_id_collection: {
|
||||
enabled: true,
|
||||
},
|
||||
consent_collection: {
|
||||
terms_of_service: "required",
|
||||
},
|
||||
...(stripeCustomerId
|
||||
? {
|
||||
customer_update: {
|
||||
name: "auto",
|
||||
address: "auto",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
billing_address_collection: "required",
|
||||
success_url: returnUrl,
|
||||
cancel_url: returnUrl,
|
||||
mode: "subscription",
|
||||
metadata: {
|
||||
orgId: input.orgId,
|
||||
cloudRegion: env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
if (!session.url)
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to create checkout session",
|
||||
});
|
||||
|
||||
auditLog({
|
||||
session: ctx.session,
|
||||
orgId: input.orgId,
|
||||
resourceType: "stripeCheckoutSession",
|
||||
resourceId: session.id,
|
||||
action: "create",
|
||||
});
|
||||
|
||||
return session.url;
|
||||
}),
|
||||
getStripeCustomerPortalUrl: protectedOrganizationProcedure
|
||||
.input(
|
||||
z.object({
|
||||
orgId: z.string(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
throwIfNoEntitlement({
|
||||
entitlement: "cloud-billing",
|
||||
sessionUser: ctx.session.user,
|
||||
orgId: input.orgId,
|
||||
});
|
||||
throwIfNoOrganizationAccess({
|
||||
organizationId: input.orgId,
|
||||
scope: "langfuseCloudBilling:CRUD",
|
||||
session: ctx.session,
|
||||
});
|
||||
|
||||
const org = await ctx.prisma.organization.findUnique({
|
||||
where: {
|
||||
id: input.orgId,
|
||||
},
|
||||
});
|
||||
if (!org) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Organization not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (!stripeClient)
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Stripe client not initialized",
|
||||
});
|
||||
|
||||
const parsedOrg = parseDbOrg(org);
|
||||
let stripeCustomerId = parsedOrg.cloudConfig?.stripe?.customerId;
|
||||
let stripeSubscriptionId =
|
||||
parsedOrg.cloudConfig?.stripe?.activeSubscriptionId;
|
||||
if (!stripeCustomerId || !stripeSubscriptionId) {
|
||||
// Do not create a new customer if the org is on a plan (assigned manually)
|
||||
return null;
|
||||
}
|
||||
|
||||
const billingPortalSession =
|
||||
await stripeClient.billingPortal.sessions.create({
|
||||
customer: stripeCustomerId,
|
||||
return_url: `${env.NEXTAUTH_URL}/organization/${input.orgId}/settings/billing`,
|
||||
});
|
||||
|
||||
return billingPortalSession.url;
|
||||
}),
|
||||
getUsage: protectedOrganizationProcedure
|
||||
.input(
|
||||
z.object({
|
||||
orgId: z.string(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
throwIfNoEntitlement({
|
||||
entitlement: "cloud-billing",
|
||||
sessionUser: ctx.session.user,
|
||||
orgId: input.orgId,
|
||||
});
|
||||
throwIfNoOrganizationAccess({
|
||||
organizationId: input.orgId,
|
||||
scope: "langfuseCloudBilling:CRUD",
|
||||
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,
|
||||
},
|
||||
});
|
||||
if (!organization) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Organization not found",
|
||||
});
|
||||
}
|
||||
const parsedOrg = parseDbOrg(organization);
|
||||
|
||||
// For Stripe subscriptions, we can get usage from the Stripe Metered Billing API
|
||||
if (
|
||||
stripeClient &&
|
||||
parsedOrg.cloudConfig?.stripe?.customerId &&
|
||||
parsedOrg.cloudConfig?.stripe?.activeSubscriptionId
|
||||
) {
|
||||
const subscription = await stripeClient.subscriptions.retrieve(
|
||||
parsedOrg.cloudConfig.stripe.activeSubscriptionId,
|
||||
);
|
||||
if (subscription) {
|
||||
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 = {
|
||||
usdAmount: stripeInvoice.amount_due / 100,
|
||||
date: new Date(stripeInvoice.period_end * 1000),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const usage = await ctx.prisma.observation.count({
|
||||
where: {
|
||||
project: {
|
||||
orgId: input.orgId,
|
||||
},
|
||||
startTime: {
|
||||
gte: billingPeriod?.start ?? thirtyDaysAgo,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
countObservations: usage,
|
||||
billingPeriod,
|
||||
upcomingInvoice,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import {
|
||||
getOrgIdFromStripeClientReference,
|
||||
isStripeClientReferenceFromCurrentCloudRegion,
|
||||
} from "@/src/ee/features/billing/stripeClientReference";
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { stripeClient } from "@/src/ee/features/billing/utils/stripe";
|
||||
import type Stripe from "stripe";
|
||||
import { CloudConfigSchema, parseDbOrg } from "@langfuse/shared";
|
||||
import { traceException } from "@langfuse/shared/src/server";
|
||||
|
||||
/*
|
||||
* Sign-up endpoint (email/password users), creates user in database.
|
||||
* SSO users are created by the NextAuth adapters.
|
||||
*/
|
||||
export async function stripeWebhookApiHandler(req: NextRequest) {
|
||||
if (req.method !== "POST")
|
||||
return NextResponse.json(
|
||||
{ message: "Method not allowed" },
|
||||
{ status: 405 },
|
||||
);
|
||||
|
||||
if (!env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION || !stripeClient) {
|
||||
console.error("[Stripe Webhook] Endpoint only available in Langfuse Cloud");
|
||||
return NextResponse.json(
|
||||
{ message: "Stripe webhook endpoint only available in Langfuse Cloud" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
if (!env.STRIPE_WEBHOOK_SIGNING_SECRET) {
|
||||
console.error("[Stripe Webhook] Stripe webhook signing key not found");
|
||||
return NextResponse.json(
|
||||
{ message: "Stripe secret key not found" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// check if the request is signed by stripe
|
||||
const sig = req.headers.get("stripe-signature");
|
||||
console.log("[Stripe Webhook] Signature", sig);
|
||||
if (!sig) {
|
||||
console.error("[Stripe Webhook] No signature");
|
||||
return NextResponse.json({ message: "No signature" }, { status: 400 });
|
||||
}
|
||||
let event: Stripe.Event;
|
||||
try {
|
||||
event = stripeClient.webhooks.constructEvent(
|
||||
await req.text(),
|
||||
sig,
|
||||
env.STRIPE_WEBHOOK_SIGNING_SECRET,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("[Stripe Webhook] Error verifying signature", err);
|
||||
return NextResponse.json(
|
||||
{ message: `Webhook Error: ${err}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Handle the event
|
||||
switch (event.type) {
|
||||
case "customer.subscription.created":
|
||||
// update the active product id on the organization linked to the subscription + customer and subscription id (if null or same)
|
||||
const subscription = event.data.object;
|
||||
console.log(
|
||||
"[Stripe Webhook] Start customer.subscription.created",
|
||||
subscription,
|
||||
);
|
||||
await handleSubscriptionChanged(subscription, "created");
|
||||
break;
|
||||
case "customer.subscription.updated":
|
||||
// update the active product id on the organization linked to the subscription + customer and subscription id (if null or same)
|
||||
const updatedSubscription = event.data.object;
|
||||
console.log(
|
||||
"[Stripe Webhook] Start customer.subscription.updated",
|
||||
updatedSubscription,
|
||||
);
|
||||
await handleSubscriptionChanged(updatedSubscription, "updated");
|
||||
break;
|
||||
case "customer.subscription.deleted":
|
||||
// remove the active product id on the organization linked to the subscription + subscription, keep customer id
|
||||
const deletedSubscription = event.data.object;
|
||||
console.log(
|
||||
"[Stripe Webhook] Start customer.subscription.deleted",
|
||||
deletedSubscription,
|
||||
);
|
||||
await handleSubscriptionChanged(deletedSubscription, "deleted");
|
||||
break;
|
||||
default:
|
||||
console.log(`Unhandled event type ${event.type}`);
|
||||
}
|
||||
|
||||
return NextResponse.json({ received: true }, { status: 200 });
|
||||
}
|
||||
|
||||
async function handleSubscriptionChanged(
|
||||
subscription: Stripe.Subscription,
|
||||
action: "created" | "deleted" | "updated",
|
||||
) {
|
||||
const subscriptionId = subscription.id;
|
||||
|
||||
// get the checkout session from the subscription to retrieve the client reference for this subscription
|
||||
const checkoutSessionsResponse = await stripeClient?.checkout.sessions.list({
|
||||
subscription: subscriptionId,
|
||||
limit: 1,
|
||||
});
|
||||
if (!checkoutSessionsResponse || checkoutSessionsResponse.data.length !== 1) {
|
||||
console.error("[Stripe Webhook] No checkout session found");
|
||||
return;
|
||||
}
|
||||
const checkoutSession = checkoutSessionsResponse.data[0];
|
||||
|
||||
// the client reference is passed to the stripe checkout session via the pricing page
|
||||
const clientReference = checkoutSession.client_reference_id;
|
||||
if (!clientReference) {
|
||||
console.error("[Stripe Webhook] No client reference");
|
||||
return NextResponse.json(
|
||||
{ message: "No client reference" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (!isStripeClientReferenceFromCurrentCloudRegion(clientReference)) {
|
||||
console.log(
|
||||
"[Stripe Webhook] Client reference not from current cloud region",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const orgId = getOrgIdFromStripeClientReference(clientReference);
|
||||
|
||||
// find the org with the customer ID
|
||||
const organization = await prisma.organization.findUnique({
|
||||
where: {
|
||||
id: orgId,
|
||||
},
|
||||
});
|
||||
if (!organization) {
|
||||
console.error("[Stripe Webhook] No organization not found");
|
||||
return;
|
||||
}
|
||||
const parsedOrg = parseDbOrg(organization);
|
||||
|
||||
// assert that no other stripe customer id is already set on the org
|
||||
const customerId = subscription.customer;
|
||||
if (!customerId || typeof customerId !== "string") {
|
||||
console.error("[Stripe Webhook] Product ID not found");
|
||||
traceException("[Stripe Webhook] Product ID not found");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
parsedOrg.cloudConfig?.stripe?.customerId &&
|
||||
parsedOrg.cloudConfig?.stripe?.customerId !== customerId
|
||||
) {
|
||||
traceException("[Stripe Webhook] Another customer id already set on org");
|
||||
return;
|
||||
}
|
||||
|
||||
// check subscription items
|
||||
console.log("subscription.items.data", subscription.items.data);
|
||||
|
||||
if (!subscription.items.data || subscription.items.data.length !== 1) {
|
||||
console.error(
|
||||
"[Stripe Webhook] Subscription items not found or more than one",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const subscriptionItem = subscription.items.data[0];
|
||||
const productId = subscriptionItem.price.product;
|
||||
|
||||
if (!productId || typeof productId !== "string") {
|
||||
console.error("[Stripe Webhook] Product ID not found");
|
||||
traceException("[Stripe Webhook] Product ID not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// assert that no other product is already set on the org if this is not an update
|
||||
if (
|
||||
action !== "updated" &&
|
||||
parsedOrg.cloudConfig?.stripe?.activeProductId &&
|
||||
parsedOrg.cloudConfig?.stripe?.activeProductId !== productId
|
||||
) {
|
||||
traceException(
|
||||
"[Stripe Webhook] Another active product id already set on (one of the) org with this active subscription id",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// update the cloud config with the product ID
|
||||
if (action === "created" || action === "updated") {
|
||||
await prisma.organization.update({
|
||||
where: {
|
||||
id: parsedOrg.id,
|
||||
},
|
||||
data: {
|
||||
cloudConfig: {
|
||||
...parsedOrg.cloudConfig,
|
||||
stripe: {
|
||||
...parsedOrg.cloudConfig?.stripe,
|
||||
...CloudConfigSchema.shape.stripe.parse({
|
||||
activeProductId: productId,
|
||||
activeSubscriptionId: subscriptionId,
|
||||
customerId: customerId,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} else if (action === "deleted") {
|
||||
await prisma.organization.update({
|
||||
where: {
|
||||
id: parsedOrg.id,
|
||||
},
|
||||
data: {
|
||||
cloudConfig: {
|
||||
...parsedOrg.cloudConfig,
|
||||
stripe: {
|
||||
...parsedOrg.cloudConfig?.stripe,
|
||||
...CloudConfigSchema.shape.stripe.parse({
|
||||
activeProductId: undefined,
|
||||
activeSubscriptionId: undefined,
|
||||
customerId: customerId,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { env } from "@/src/env.mjs";
|
||||
|
||||
// used client-side to create a stripe customer reference
|
||||
export const createStripeClientReference = (orgId: string) => {
|
||||
if (!env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION) {
|
||||
console.error(
|
||||
"Returning null stripeCustomerReference, you cannot run the checkout page outside of Langfuse Cloud",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return `${env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION}-${orgId}`;
|
||||
};
|
||||
|
||||
// used server-side to check if the stripe customer reference is valid and parse the orgId
|
||||
export const isStripeClientReferenceFromCurrentCloudRegion = (
|
||||
clientReference: string,
|
||||
) =>
|
||||
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION &&
|
||||
clientReference.startsWith(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION);
|
||||
|
||||
export const getOrgIdFromStripeClientReference = (clientReference: string) =>
|
||||
clientReference.replace(`${env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION}-`, "");
|
||||
@@ -0,0 +1,6 @@
|
||||
import { env } from "@/src/env.mjs";
|
||||
import Stripe from "stripe";
|
||||
|
||||
export const stripeClient = env.STRIPE_SECRET_KEY
|
||||
? new Stripe(env.STRIPE_SECRET_KEY)
|
||||
: undefined;
|
||||
@@ -0,0 +1,49 @@
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { type Plan } from "@/src/features/entitlements/constants/plans";
|
||||
|
||||
type StripeProduct = {
|
||||
stripeProductId: string;
|
||||
mappedPlan: Plan;
|
||||
// include checkout if product can be subscribed to by new users
|
||||
checkout: {
|
||||
title: string;
|
||||
description: string;
|
||||
price: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
// map of planid to plan name
|
||||
export const stripeProducts: StripeProduct[] = [
|
||||
{
|
||||
stripeProductId:
|
||||
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "DEV" ||
|
||||
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "STAGING"
|
||||
? "prod_QgDNYKXcBfvUQ3" // test
|
||||
: "prod_QhK7UMhrkVeF6R", // live
|
||||
mappedPlan: "cloud:pro",
|
||||
checkout: {
|
||||
title: "Pro",
|
||||
description:
|
||||
"For serious projects. Includes access to full history and higher usage.",
|
||||
price: "$59 / month + $10/100k observations",
|
||||
},
|
||||
},
|
||||
{
|
||||
stripeProductId:
|
||||
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "DEV" ||
|
||||
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "STAGING"
|
||||
? "prod_QgDOxTD64U6KDv" // test
|
||||
: "prod_QhK9qKGH25BTcS", // live
|
||||
mappedPlan: "cloud:team",
|
||||
checkout: {
|
||||
title: "Team",
|
||||
description:
|
||||
"Dedicated solutions and support for your team. Contact us for additional add-ons listed on the pricing page.",
|
||||
price: "$499 / month + $10/100k observations",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const mapStripeProductIdToPlan = (productId: string): Plan | null =>
|
||||
stripeProducts.find((product) => product.stripeProductId === productId)
|
||||
?.mappedPlan ?? null;
|
||||
@@ -6,7 +6,11 @@ import {
|
||||
} from "@/src/server/api/trpc";
|
||||
import { throwIfNoProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import { auditLog } from "@/src/features/audit-logs/auditLog";
|
||||
import { DEFAULT_TRACE_JOB_DELAY, EvalTargetObject } from "@langfuse/shared";
|
||||
import {
|
||||
DEFAULT_TRACE_JOB_DELAY,
|
||||
EvalTargetObject,
|
||||
LLMAdapter,
|
||||
} from "@langfuse/shared";
|
||||
import {
|
||||
ZodModelConfig,
|
||||
singleFilter,
|
||||
@@ -333,6 +337,22 @@ export const evalRouter = createTRPCRouter({
|
||||
scope: "evalTemplate:create",
|
||||
});
|
||||
|
||||
const matchingLLMKey = await ctx.prisma.llmApiKeys.findFirst({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
provider: input.provider,
|
||||
},
|
||||
});
|
||||
|
||||
if (!matchingLLMKey) {
|
||||
throw new Error("No matching LLM key found for provider");
|
||||
}
|
||||
|
||||
// check that the adapter on the api key is openai for evals
|
||||
if (matchingLLMKey.adapter !== LLMAdapter.OpenAI) {
|
||||
throw new Error("Only OpenAI models are supported for evals");
|
||||
}
|
||||
|
||||
const latestTemplate = await ctx.prisma.evalTemplate.findFirst({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
|
||||
@@ -9,8 +9,7 @@ import { isEeEnabled } from "@/src/ee/utils/isEeEnabled";
|
||||
import { type SsoConfig, prisma } from "@langfuse/shared/src/db";
|
||||
import { decrypt } from "@langfuse/shared/encryption";
|
||||
import { SsoProviderSchema } from "./types";
|
||||
import { CustomSSOProvider } from "@langfuse/shared/src/server";
|
||||
import * as Sentry from "@sentry/node";
|
||||
import { CustomSSOProvider, traceException } from "@langfuse/shared/src/server";
|
||||
|
||||
// Local cache for SSO configurations
|
||||
let cachedSsoConfigs: {
|
||||
@@ -52,8 +51,7 @@ async function getSsoConfigs(): Promise<SsoProviderSchema[]> {
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Failed to load SSO configs from the database", e);
|
||||
Sentry.captureException(e);
|
||||
|
||||
traceException(e);
|
||||
// empty array will be cached to prevent repeated DB queries
|
||||
failedToFetch = true;
|
||||
}
|
||||
@@ -69,7 +67,8 @@ async function getSsoConfigs(): Promise<SsoProviderSchema[]> {
|
||||
`Failed to parse SSO provider config for domain ${v.domain}`,
|
||||
e,
|
||||
);
|
||||
Sentry.captureException(e);
|
||||
|
||||
traceException(e);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
@@ -194,7 +193,7 @@ const dbToNextAuthProvider = (provider: SsoProviderSchema): Provider | null => {
|
||||
console.error(
|
||||
`Unrecognized SSO provider for domain ${(provider as any).domain}`,
|
||||
);
|
||||
Sentry.captureException(
|
||||
traceException(
|
||||
new Error(
|
||||
`Unrecognized SSO provider for domain ${(provider as any).domain}`,
|
||||
),
|
||||
|
||||
@@ -31,8 +31,15 @@ export const useModelParams = ({
|
||||
);
|
||||
|
||||
const availableProviders = useMemo(() => {
|
||||
return availableLLMApiKeys.data?.data.map((key) => key.provider) ?? [];
|
||||
}, [availableLLMApiKeys]);
|
||||
const adapter =
|
||||
(evalModelsOnly
|
||||
? availableLLMApiKeys.data?.data.filter(
|
||||
(p) => p.adapter === LLMAdapter.OpenAI,
|
||||
) // for evals, we only support OpenAI
|
||||
: availableLLMApiKeys.data?.data) ?? [];
|
||||
|
||||
return adapter.map((key) => key.provider) ?? [];
|
||||
}, [availableLLMApiKeys.data?.data, evalModelsOnly]);
|
||||
|
||||
const selectedProviderApiKey = availableLLMApiKeys.data?.data.find(
|
||||
(key) => key.provider === modelParams.provider.value,
|
||||
|
||||
@@ -166,6 +166,8 @@ export const env = createEnv({
|
||||
.number()
|
||||
.positive()
|
||||
.default(60 * 10),
|
||||
STRIPE_SECRET_KEY: z.string().optional(),
|
||||
STRIPE_WEBHOOK_SIGNING_SECRET: z.string().optional(),
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -176,6 +178,8 @@ export const env = createEnv({
|
||||
* WARNING: They do not work when used in Docker builds as NEXT_PUBLIC variables are not runtime but compile-time.
|
||||
*/
|
||||
client: {
|
||||
// WARNING: Also add these to web/Dockerfile
|
||||
|
||||
// NEXT_PUBLIC_CLIENTVAR: z.string().min(1),
|
||||
NEXT_PUBLIC_LANGFUSE_CLOUD_REGION: z
|
||||
.enum(["US", "EU", "STAGING", "DEV"])
|
||||
@@ -309,6 +313,8 @@ export const env = createEnv({
|
||||
process.env.LANGFUSE_ALLOWED_ORGANIZATION_CREATORS,
|
||||
LANGFUSE_INGESTION_BUFFER_TTL_SECONDS:
|
||||
process.env.LANGFUSE_INGESTION_BUFFER_TTL_SECONDS,
|
||||
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
|
||||
STRIPE_WEBHOOK_SIGNING_SECRET: process.env.STRIPE_WEBHOOK_SIGNING_SECRET,
|
||||
},
|
||||
// Skip validation in Docker builds
|
||||
// DOCKER_BUILD is set in Dockerfile
|
||||
|
||||
@@ -5,6 +5,7 @@ export type AuditableResource =
|
||||
| "orgMembership"
|
||||
| "projectMembership"
|
||||
| "membershipInvitation"
|
||||
| "comment"
|
||||
| "datasetItem"
|
||||
| "dataset"
|
||||
| "trace"
|
||||
@@ -21,6 +22,7 @@ export type AuditableResource =
|
||||
| "posthogIntegration"
|
||||
| "llmApiKey"
|
||||
| "batchExport"
|
||||
| "stripeCheckoutSession"
|
||||
// legacy resources
|
||||
| "membership";
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { MessageCircleMore } from "lucide-react";
|
||||
|
||||
export function CommentCountIcon({ count }: { count?: number }) {
|
||||
if (!count) return null;
|
||||
|
||||
return (
|
||||
<span className="relative mr-1 text-xs">
|
||||
<MessageCircleMore className="h-5 w-5" />
|
||||
<span className="absolute -top-0.5 left-3 flex max-h-[1rem] min-w-[1rem] items-center justify-center rounded-full border border-muted-foreground bg-accent-light-blue px-0.5 text-[9px]">
|
||||
{count > 99 ? "99+" : count}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import Header from "@/src/components/layouts/header";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTrigger,
|
||||
} from "@/src/components/ui/drawer";
|
||||
import { CommentCountIcon } from "@/src/features/comments/CommentCountIcon";
|
||||
import { CommentList } from "@/src/features/comments/CommentList";
|
||||
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import { type CommentObjectType } from "@langfuse/shared";
|
||||
import { MessageCircleIcon, MessageCircleOff } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
export function CommentDrawerButton({
|
||||
projectId,
|
||||
objectId,
|
||||
objectType,
|
||||
count,
|
||||
variant = "secondary",
|
||||
}: {
|
||||
projectId: string;
|
||||
objectId: string;
|
||||
objectType: CommentObjectType;
|
||||
count?: number;
|
||||
variant?: "secondary" | "outline";
|
||||
}) {
|
||||
const hasReadAccess = useHasProjectAccess({
|
||||
projectId,
|
||||
scope: "comments:read",
|
||||
});
|
||||
const hasWriteAccess = useHasProjectAccess({
|
||||
projectId,
|
||||
scope: "comments:CUD",
|
||||
});
|
||||
|
||||
if (!hasReadAccess || (!hasWriteAccess && !count))
|
||||
return (
|
||||
<Button type="button" variant="secondary" disabled>
|
||||
<MessageCircleOff className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>
|
||||
<Button type="button" variant={variant} size="icon">
|
||||
{!!count ? (
|
||||
<CommentCountIcon count={count} />
|
||||
) : (
|
||||
<MessageCircleIcon className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent className="h-1/3" overlayClassName="bg-primary/10">
|
||||
<div className="mx-auto w-full overflow-y-auto md:max-h-full">
|
||||
<DrawerHeader className="sticky top-0 z-10 rounded-sm bg-background">
|
||||
<Header title="Comments" level="h3"></Header>
|
||||
</DrawerHeader>
|
||||
<div data-vaul-no-drag className="px-4">
|
||||
<CommentList
|
||||
projectId={projectId}
|
||||
objectId={objectId}
|
||||
objectType={objectType}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@/src/components/ui/avatar";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@/src/components/ui/form";
|
||||
import { Textarea } from "@/src/components/ui/textarea";
|
||||
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { getRelativeTimestampFromNow } from "@/src/utils/dates";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { type CommentObjectType, CreateCommentData } from "@langfuse/shared";
|
||||
import { ArrowUpToLine, LoaderCircle, Trash } from "lucide-react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { type z } from "zod";
|
||||
|
||||
export function CommentList({
|
||||
projectId,
|
||||
objectId,
|
||||
objectType,
|
||||
cardView = false,
|
||||
className,
|
||||
}: {
|
||||
projectId: string;
|
||||
objectId: string;
|
||||
objectType: CommentObjectType;
|
||||
cardView?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const session = useSession();
|
||||
const hasReadAccess = useHasProjectAccess({
|
||||
projectId,
|
||||
scope: "comments:read",
|
||||
});
|
||||
|
||||
const hasWriteAccess = useHasProjectAccess({
|
||||
projectId,
|
||||
scope: "comments:CUD",
|
||||
});
|
||||
|
||||
const comments = api.comments.getByObjectId.useQuery(
|
||||
{
|
||||
projectId,
|
||||
objectId,
|
||||
objectType,
|
||||
},
|
||||
{ enabled: hasReadAccess },
|
||||
);
|
||||
|
||||
const form = useForm<z.infer<typeof CreateCommentData>>({
|
||||
resolver: zodResolver(CreateCommentData),
|
||||
defaultValues: {
|
||||
content: "",
|
||||
projectId,
|
||||
objectId,
|
||||
objectType,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({ content: "", projectId, objectId, objectType });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [objectId, objectType]);
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const createCommentMutation = api.comments.create.useMutation({
|
||||
onSuccess: async () => {
|
||||
await Promise.all([utils.comments.invalidate()]);
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
|
||||
const deleteCommentMutation = api.comments.delete.useMutation({
|
||||
onSuccess: async () => {
|
||||
await Promise.all([utils.comments.invalidate()]);
|
||||
},
|
||||
});
|
||||
|
||||
const commentsWithFormattedTimestamp = useMemo(() => {
|
||||
return comments.data?.map((comment) => ({
|
||||
...comment,
|
||||
timestamp: getRelativeTimestampFromNow(comment.createdAt),
|
||||
}));
|
||||
}, [comments.data]);
|
||||
|
||||
if (!hasReadAccess || (!hasWriteAccess && comments.data?.length === 0))
|
||||
return null;
|
||||
|
||||
function onSubmit(values: z.infer<typeof CreateCommentData>) {
|
||||
createCommentMutation.mutateAsync({
|
||||
...values,
|
||||
});
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key === "Enter" && event.metaKey) {
|
||||
event.preventDefault(); // Prevent the default newline behavior
|
||||
form.handleSubmit(onSubmit)(); // Submit the form on cmd+enter
|
||||
}
|
||||
};
|
||||
|
||||
if (comments.isLoading)
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-[9rem] items-center justify-center rounded border border-dashed p-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<LoaderCircle className="mr-1.5 h-4 w-4 animate-spin text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground opacity-60">
|
||||
Loading comments...
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn(cardView && "rounded-md border", className)}>
|
||||
{cardView && (
|
||||
<div className="border-b px-3 py-1 text-sm font-medium">Comments</div>
|
||||
)}
|
||||
{hasWriteAccess && (
|
||||
<div className="mx-2 mb-2 mt-2 rounded-md border">
|
||||
<div className="border-b px-3 py-1 text-xs font-medium">
|
||||
New comment
|
||||
</div>
|
||||
<Form {...form}>
|
||||
<form className="relative">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Add comment..."
|
||||
{...field}
|
||||
onKeyDown={handleKeyDown} // cmd+enter to submit
|
||||
className="border-none text-xs focus:outline-none focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0 active:ring-0"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className="ml-2 text-xs" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
loading={createCommentMutation.isLoading}
|
||||
onClick={() => {
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
className="absolute bottom-2 right-2"
|
||||
>
|
||||
<ArrowUpToLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-2">
|
||||
{commentsWithFormattedTimestamp?.map((comment) => (
|
||||
<div
|
||||
key={comment.id}
|
||||
className="group grid grid-cols-[auto,1fr] gap-1 p-2"
|
||||
>
|
||||
<Avatar className="mt-1 h-7 w-7">
|
||||
<AvatarImage src={comment.authorUserImage ?? undefined} />
|
||||
<AvatarFallback>
|
||||
{comment.authorUserName
|
||||
? comment.authorUserName
|
||||
.split(" ")
|
||||
.map((word) => word[0])
|
||||
.slice(0, 2)
|
||||
.concat("")
|
||||
: comment.authorUserId ?? "U"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="relative rounded-md border">
|
||||
<div className="flex h-8 flex-row items-center justify-between border-b px-3 py-1 text-xs font-medium">
|
||||
<div>
|
||||
{comment.authorUserName ?? comment.authorUserId ?? "User"}
|
||||
</div>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<div>{comment.timestamp}</div>
|
||||
<div className="hidden min-h-6 justify-end group-hover:flex">
|
||||
{session.data?.user?.id === comment.authorUserId && (
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="destructive-secondary"
|
||||
title="Delete comment"
|
||||
loading={deleteCommentMutation.isLoading}
|
||||
className="-mr-2"
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
"Are you sure you want to delete this comment?",
|
||||
)
|
||||
)
|
||||
deleteCommentMutation.mutateAsync({
|
||||
commentId: comment.id,
|
||||
projectId,
|
||||
objectId,
|
||||
objectType,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-3 my-3 select-text whitespace-pre-wrap text-xs">
|
||||
{comment.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -108,7 +108,7 @@ export function ScoreAnalytics(props: {
|
||||
return (
|
||||
<div key={scoreKey}>
|
||||
<div>{`${getScoreDataTypeIcon(dataType)} ${name} (${source.toLowerCase()})`}</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-4">
|
||||
<div className="mt-2 grid gap-2 lg:grid-cols-2 lg:gap-4">
|
||||
{/* aggregate */}
|
||||
<div>
|
||||
<div className="mb-2 text-sm text-muted-foreground">
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { Copy } from "lucide-react";
|
||||
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
|
||||
export const DuplicateDatasetButton: React.FC<{
|
||||
projectId: string;
|
||||
datasetId: string;
|
||||
}> = ({ projectId, datasetId }) => {
|
||||
const router = useRouter();
|
||||
const hasAccess = useHasProjectAccess({
|
||||
projectId,
|
||||
scope: "datasets:CUD",
|
||||
});
|
||||
const duplicateDataset = api.datasets.duplicateDataset.useMutation({
|
||||
onSuccess: ({ id }) => {
|
||||
router.push(`/project/${projectId}/datasets/${id}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleDuplicate = () => {
|
||||
if (
|
||||
confirm(
|
||||
"Are you sure you want to duplicate this dataset and all of its items?",
|
||||
)
|
||||
) {
|
||||
duplicateDataset.mutate({ projectId, datasetId });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleDuplicate}
|
||||
variant="outline"
|
||||
title="Duplicate dataset"
|
||||
loading={duplicateDataset.isLoading}
|
||||
size="icon"
|
||||
disabled={!hasAccess}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -15,7 +15,7 @@ import { DB } from "@/src/server/db";
|
||||
import { filterAndValidateDbScoreList, paginationZod } from "@langfuse/shared";
|
||||
import { aggregateScores } from "@/src/features/scores/lib/aggregateScores";
|
||||
import { type ScoreSimplified } from "@/src/features/scores/lib/types";
|
||||
import * as Sentry from "@sentry/node";
|
||||
import { traceException } from "@langfuse/shared/src/server";
|
||||
|
||||
export const datasetRouter = createTRPCRouter({
|
||||
allDatasetMeta: protectedProjectProcedure
|
||||
@@ -473,6 +473,98 @@ export const datasetRouter = createTRPCRouter({
|
||||
});
|
||||
return deletedDataset;
|
||||
}),
|
||||
duplicateDataset: protectedProjectProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
datasetId: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
throwIfNoProjectAccess({
|
||||
session: ctx.session,
|
||||
projectId: input.projectId,
|
||||
scope: "datasets:CUD",
|
||||
});
|
||||
const dataset = await ctx.prisma.dataset.findUnique({
|
||||
where: {
|
||||
id_projectId: {
|
||||
id: input.datasetId,
|
||||
projectId: input.projectId,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
datasetItems: {
|
||||
orderBy: {
|
||||
createdAt: "asc",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!dataset) {
|
||||
throw new Error("Dataset not found");
|
||||
}
|
||||
|
||||
// find a unique name for the new dataset
|
||||
// by appending a counter to the name in case of the name already exists
|
||||
// e.g. "Copy of dataset" -> "Copy of dataset (1)"
|
||||
const existingDatasetNames = (
|
||||
await ctx.prisma.dataset.findMany({
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
name: {
|
||||
startsWith: "Copy of " + dataset.name,
|
||||
},
|
||||
},
|
||||
})
|
||||
).map((d) => d.name);
|
||||
let counter: number = 0;
|
||||
const duplicateDatasetName = (pCounter: number) =>
|
||||
pCounter === 0
|
||||
? `Copy of ${dataset.name}`
|
||||
: `Copy of ${dataset.name} (${counter})`;
|
||||
while (true) {
|
||||
if (!existingDatasetNames.includes(duplicateDatasetName(counter))) {
|
||||
break;
|
||||
}
|
||||
counter++;
|
||||
}
|
||||
|
||||
const newDataset = await ctx.prisma.dataset.create({
|
||||
data: {
|
||||
name: duplicateDatasetName(counter),
|
||||
description: dataset.description,
|
||||
projectId: input.projectId,
|
||||
metadata: dataset.metadata ?? undefined,
|
||||
datasetItems: {
|
||||
createMany: {
|
||||
data: dataset.datasetItems.map((item) => ({
|
||||
// the items get new ids as they need to be unique on project level
|
||||
input: item.input ?? undefined,
|
||||
expectedOutput: item.expectedOutput ?? undefined,
|
||||
metadata: item.metadata ?? undefined,
|
||||
sourceTraceId: item.sourceTraceId,
|
||||
sourceObservationId: item.sourceObservationId,
|
||||
status: item.status,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
session: ctx.session,
|
||||
resourceType: "dataset",
|
||||
resourceId: newDataset.id,
|
||||
action: "create",
|
||||
after: newDataset,
|
||||
});
|
||||
|
||||
return { id: newDataset.id };
|
||||
}),
|
||||
createDatasetItem: protectedProjectProcedure
|
||||
.input(
|
||||
z.object({
|
||||
@@ -647,11 +739,11 @@ export const datasetRouter = createTRPCRouter({
|
||||
|
||||
const validatedTraceScores = filterAndValidateDbScoreList(
|
||||
traceScores,
|
||||
Sentry.captureException,
|
||||
traceException,
|
||||
);
|
||||
const validatedObservationScores = filterAndValidateDbScoreList(
|
||||
observationScores,
|
||||
Sentry.captureException,
|
||||
traceException,
|
||||
);
|
||||
|
||||
const items = runItems.map((ri) => {
|
||||
|
||||
@@ -5,7 +5,7 @@ const entitlements = [
|
||||
"playground",
|
||||
"model-based-evaluations",
|
||||
"rbac-project-roles",
|
||||
"cloud-usage-metering",
|
||||
"cloud-billing",
|
||||
"integration-posthog",
|
||||
"batch-export",
|
||||
] as const;
|
||||
@@ -15,7 +15,7 @@ export type Entitlement = (typeof entitlements)[number];
|
||||
const cloudAllPlansEntitlements: Entitlement[] = [
|
||||
"playground",
|
||||
"model-based-evaluations",
|
||||
"cloud-usage-metering",
|
||||
"cloud-billing",
|
||||
"integration-posthog",
|
||||
"batch-export",
|
||||
];
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
const plans = [
|
||||
"oss",
|
||||
"cloud:hobby",
|
||||
"cloud:pro",
|
||||
"cloud:team",
|
||||
"self-hosted:enterprise",
|
||||
] as const;
|
||||
export type Plan = (typeof plans)[number];
|
||||
export const planLabels = {
|
||||
oss: "OSS",
|
||||
"cloud:hobby": "Hobby",
|
||||
"cloud:pro": "Pro",
|
||||
"cloud:team": "Team",
|
||||
"self-hosted:enterprise": "Enterprise",
|
||||
} as const;
|
||||
|
||||
export type Plan = keyof typeof planLabels;
|
||||
|
||||
export const plans = Object.keys(planLabels) as Plan[];
|
||||
|
||||
// This function is kept here to ensure consistency when updating plan names in the future.
|
||||
export const isCloudPlan = (plan: Plan) => plan.startsWith("cloud");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mapStripeProductIdToPlan } from "@/src/ee/features/billing/utils/stripeProducts";
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { type Plan } from "@/src/features/entitlements/constants/plans";
|
||||
import { type CloudConfigSchema } from "@/src/features/organizations/utils/cloudConfigSchema";
|
||||
import { type CloudConfigSchema } from "@langfuse/shared";
|
||||
|
||||
/**
|
||||
* Get the plan of the organization based on the cloud configuration. Used to add this plan to the organization object in JWT via NextAuth.
|
||||
@@ -8,18 +9,30 @@ import { type CloudConfigSchema } from "@/src/features/organizations/utils/cloud
|
||||
export function getOrganizationPlan(cloudConfig?: CloudConfigSchema): Plan {
|
||||
if (process.env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION) {
|
||||
// in dev, grant team plan to all organizations
|
||||
if (process.env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "DEV") {
|
||||
return "cloud:team";
|
||||
}
|
||||
// if (process.env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "DEV") {
|
||||
// return "cloud:team";
|
||||
// }
|
||||
if (cloudConfig) {
|
||||
switch (cloudConfig.plan) {
|
||||
case "Hobby":
|
||||
return "cloud:hobby";
|
||||
case "Pro":
|
||||
return "cloud:pro";
|
||||
case "Team":
|
||||
case "Enterprise":
|
||||
return "cloud:team";
|
||||
// manual plan override
|
||||
if (cloudConfig.plan) {
|
||||
switch (cloudConfig.plan) {
|
||||
case "Hobby":
|
||||
return "cloud:hobby";
|
||||
case "Pro":
|
||||
return "cloud:pro";
|
||||
case "Team":
|
||||
case "Enterprise":
|
||||
return "cloud:team";
|
||||
}
|
||||
}
|
||||
// stripe plan via product id
|
||||
if (cloudConfig.stripe?.activeProductId) {
|
||||
const stripePlan = mapStripeProductIdToPlan(
|
||||
cloudConfig.stripe.activeProductId,
|
||||
);
|
||||
if (stripePlan) {
|
||||
return stripePlan;
|
||||
}
|
||||
}
|
||||
}
|
||||
return "cloud:hobby";
|
||||
|
||||
@@ -23,6 +23,10 @@ import {
|
||||
createOrganizationRoute,
|
||||
createProjectRoute,
|
||||
} from "@/src/features/setup/setupRoutes";
|
||||
import {
|
||||
isCloudPlan,
|
||||
planLabels,
|
||||
} from "@/src/features/entitlements/constants/plans";
|
||||
|
||||
const SingleOrganizationProjectOverview = ({
|
||||
orgId,
|
||||
@@ -82,6 +86,14 @@ const SingleOrganizationProjectOverview = ({
|
||||
title={org.name}
|
||||
level={level}
|
||||
status={orgId === env.NEXT_PUBLIC_DEMO_ORG_ID ? "Demo Org" : undefined}
|
||||
label={
|
||||
isCloudPlan(org.plan)
|
||||
? {
|
||||
text: planLabels[org.plan],
|
||||
href: `/organization/${org.id}/settings/billing`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
actionButtons={
|
||||
<>
|
||||
<Button asChild variant="ghost">
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { useQueryOrganization } from "@/src/features/organizations/hooks";
|
||||
import Script from "next/script";
|
||||
|
||||
export function PricingPage(props: { className?: string }) {
|
||||
const org = useQueryOrganization();
|
||||
return (
|
||||
<>
|
||||
<Script async src="https://js.stripe.com/v3/pricing-table.js" />
|
||||
<div className={props.className}>
|
||||
<stripe-pricing-table
|
||||
client-reference-id={org?.id}
|
||||
pricing-table-id="prctbl_1OjTUyAWilt2EAVVMCMAMDgB"
|
||||
publishable-key="pk_live_51MPW00AWilt2EAVVFWfPTQhgmLA0EeacLSzAs6e3vECCcMBvwcMse81XgXO6k1bdBHbPBdpOmrXE8P1gBrxE7yhH00RPHQ8SyG"
|
||||
></stripe-pricing-table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
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();
|
||||
@@ -226,6 +227,13 @@ export const PromptDetail = () => {
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
<CommentList
|
||||
projectId={prompt.projectId}
|
||||
objectId={prompt.id}
|
||||
objectType="PROMPT"
|
||||
className="mt-10"
|
||||
cardView
|
||||
/>
|
||||
</div>
|
||||
<div className="flex h-screen flex-col">
|
||||
<div className="text-m px-3 font-medium">
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { PRODUCTION_LABEL } from "@/src/features/prompts/constants";
|
||||
import { InvalidRequestError, type Prompt } from "@langfuse/shared";
|
||||
import { PromptService, redis } from "@langfuse/shared/src/server";
|
||||
import {
|
||||
PromptService,
|
||||
redis,
|
||||
recordIncrement,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
type GetPromptByNameParams = {
|
||||
promptName: string;
|
||||
@@ -15,11 +18,7 @@ export const getPromptByName = async (
|
||||
params: GetPromptByNameParams,
|
||||
): Promise<Prompt | null> => {
|
||||
const { promptName, projectId, version, label } = params;
|
||||
const promptService = new PromptService(
|
||||
prisma,
|
||||
redis,
|
||||
Sentry.metrics.increment,
|
||||
);
|
||||
const promptService = new PromptService(prisma, redis, recordIncrement);
|
||||
|
||||
if (version && label)
|
||||
throw new InvalidRequestError("Cannot specify both version and label");
|
||||
|
||||
@@ -54,7 +54,11 @@ const formSchema = z
|
||||
path: ["withDefaultModels"],
|
||||
});
|
||||
|
||||
export function CreateLLMApiKeyDialog() {
|
||||
export function CreateLLMApiKeyDialog({
|
||||
evalModelsOnly,
|
||||
}: {
|
||||
evalModelsOnly?: boolean;
|
||||
}) {
|
||||
const projectId = useProjectIdFromURL();
|
||||
const capture = usePostHogClientCapture();
|
||||
const utils = api.useUtils();
|
||||
@@ -216,11 +220,16 @@ export function CreateLLMApiKeyDialog() {
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{Object.values(LLMAdapter).map((provider) => (
|
||||
<SelectItem value={provider} key={provider}>
|
||||
{provider}
|
||||
</SelectItem>
|
||||
))}
|
||||
{Object.values(LLMAdapter)
|
||||
.filter(
|
||||
(provider) =>
|
||||
!evalModelsOnly || provider === LLMAdapter.OpenAI,
|
||||
)
|
||||
.map((provider) => (
|
||||
<SelectItem value={provider} key={provider}>
|
||||
{provider}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { env } from "@/src/env.mjs";
|
||||
import {
|
||||
addUserToSpan,
|
||||
createShaHash,
|
||||
recordIncrement,
|
||||
verifySecretKey,
|
||||
type AuthHeaderVerificationResult,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { type PrismaClient, type ApiKey } from "@langfuse/shared/src/db";
|
||||
import { isPrismaException } from "@/src/utils/exceptions";
|
||||
import * as Sentry from "@sentry/node";
|
||||
import { type Redis } from "ioredis";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -135,9 +136,7 @@ export class ApiAuthService {
|
||||
throw new Error("Invalid credentials");
|
||||
}
|
||||
|
||||
Sentry.setUser({
|
||||
id: projectId,
|
||||
});
|
||||
addUserToSpan({ projectId });
|
||||
|
||||
return {
|
||||
validKey: true,
|
||||
@@ -152,9 +151,8 @@ export class ApiAuthService {
|
||||
const publicKey = authHeader.replace("Bearer ", "");
|
||||
|
||||
const dbKey = await this.findDbKeyOrThrow(publicKey);
|
||||
Sentry.setUser({
|
||||
id: dbKey.projectId,
|
||||
});
|
||||
|
||||
addUserToSpan({ projectId: dbKey.projectId });
|
||||
|
||||
return {
|
||||
validKey: true,
|
||||
@@ -214,17 +212,17 @@ export class ApiAuthService {
|
||||
const redisApiKey = await this.fetchApiKeyFromRedis(hash);
|
||||
|
||||
if (redisApiKey === API_KEY_NON_EXISTENT) {
|
||||
Sentry.metrics.increment("api_key_cache_hit");
|
||||
recordIncrement("api_key_cache_hit", 1);
|
||||
throw new Error("Invalid credentials");
|
||||
}
|
||||
|
||||
// if we found something, return the object.
|
||||
if (redisApiKey) {
|
||||
Sentry.metrics.increment("api_key_cache_hit");
|
||||
recordIncrement("api_key_cache_hit", 1);
|
||||
return redisApiKey;
|
||||
}
|
||||
|
||||
Sentry.metrics.increment("api_key_cache_miss");
|
||||
recordIncrement("api_key_cache_miss", 1);
|
||||
|
||||
// if redis not available or object not found, try the database
|
||||
const apiKey = await this.prisma.apiKey.findUnique({
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { type NextApiRequest, type NextApiResponse } from "next";
|
||||
import { type ZodType, type z } from "zod";
|
||||
import * as Sentry from "@sentry/node";
|
||||
import { ApiAuthService } from "@/src/features/public-api/server/apiAuth";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import {
|
||||
redis,
|
||||
type AuthHeaderValidVerificationResult,
|
||||
traceException,
|
||||
} from "@langfuse/shared/src/server";
|
||||
|
||||
type RouteConfig<
|
||||
@@ -80,7 +80,7 @@ export const createAuthedAPIRoute = <
|
||||
const parsingResult = routeConfig.responseSchema.safeParse(response);
|
||||
if (!parsingResult.success) {
|
||||
console.error("Response validation failed:", parsingResult.error);
|
||||
Sentry.captureException(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 * as Sentry from "@sentry/node";
|
||||
import { traceException } from "@langfuse/shared/src/server";
|
||||
|
||||
const httpMethods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const;
|
||||
export type HttpMethod = (typeof httpMethods)[number];
|
||||
@@ -43,7 +43,7 @@ export function withMiddlewares(handlers: Handlers) {
|
||||
|
||||
if (error instanceof BaseError) {
|
||||
if (error.httpCode >= 500 && error.httpCode < 600) {
|
||||
Sentry.captureException(error);
|
||||
traceException(error);
|
||||
}
|
||||
return res.status(error.httpCode).json({
|
||||
message: error.message,
|
||||
@@ -52,7 +52,7 @@ export function withMiddlewares(handlers: Handlers) {
|
||||
}
|
||||
|
||||
if (isPrismaException(error)) {
|
||||
Sentry.captureException(error);
|
||||
traceException(error);
|
||||
return res.status(500).json({
|
||||
message: "Internal Server Error",
|
||||
error: "An unknown error occurred",
|
||||
@@ -67,7 +67,7 @@ export function withMiddlewares(handlers: Handlers) {
|
||||
});
|
||||
}
|
||||
|
||||
Sentry.captureException(error);
|
||||
traceException(error);
|
||||
return res.status(500).json({
|
||||
message: "Internal Server Error",
|
||||
error:
|
||||
|
||||
@@ -11,11 +11,11 @@ import { api } from "@/src/utils/api";
|
||||
import type { RouterOutput } from "@/src/utils/types";
|
||||
import { Trash } from "lucide-react";
|
||||
import { useQueryParams, withDefault, NumberParam } from "use-query-params";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/src/components/ui/alert";
|
||||
import { type Role } from "@langfuse/shared";
|
||||
import { type Organization, type Role } from "@langfuse/shared";
|
||||
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import Header from "@/src/components/layouts/header";
|
||||
|
||||
export type tmp = Organization;
|
||||
export type InvitesTableRow = {
|
||||
email: string;
|
||||
createdAt: Date;
|
||||
@@ -192,14 +192,7 @@ export function MembershipInvitesPage({
|
||||
};
|
||||
|
||||
if (projectId ? !hasProjectViewAccess : !hasOrgViewAccess) {
|
||||
return (
|
||||
<Alert>
|
||||
<AlertTitle>Access Denied</AlertTitle>
|
||||
<AlertDescription>
|
||||
You do not have permission to view invites of this organization.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (totalCount === 0) return null;
|
||||
|
||||
@@ -7,6 +7,7 @@ const organizationScopes = [
|
||||
"organization:delete",
|
||||
"organizationMembers:read",
|
||||
"organizationMembers:CUD",
|
||||
"langfuseCloudBilling:CRUD",
|
||||
] as const;
|
||||
|
||||
// type string of all Resource:Action, e.g. "organizationMembers:read"
|
||||
@@ -20,6 +21,7 @@ export const organizationRoleAccessRights: Record<Role, OrganizationScope[]> = {
|
||||
"organization:delete",
|
||||
"organizationMembers:CUD",
|
||||
"organizationMembers:read",
|
||||
"langfuseCloudBilling:CRUD",
|
||||
],
|
||||
ADMIN: [
|
||||
"projects:create",
|
||||
|
||||
@@ -42,6 +42,9 @@ const projectScopes = [
|
||||
"llmApiKeys:read",
|
||||
"llmApiKeys:create",
|
||||
"llmApiKeys:delete",
|
||||
|
||||
"comments:CUD",
|
||||
"comments:read",
|
||||
] as const;
|
||||
|
||||
// type string of all Resource:Action, e.g. "members:read"
|
||||
@@ -78,6 +81,8 @@ export const projectRoleAccessRights: Record<Role, ProjectScope[]> = {
|
||||
"llmApiKeys:create",
|
||||
"llmApiKeys:delete",
|
||||
"batchExport:create",
|
||||
"comments:CUD",
|
||||
"comments:read",
|
||||
],
|
||||
ADMIN: [
|
||||
"project:read",
|
||||
@@ -108,6 +113,8 @@ export const projectRoleAccessRights: Record<Role, ProjectScope[]> = {
|
||||
"llmApiKeys:create",
|
||||
"llmApiKeys:delete",
|
||||
"batchExport:create",
|
||||
"comments:CUD",
|
||||
"comments:read",
|
||||
],
|
||||
MEMBER: [
|
||||
"project:read",
|
||||
@@ -129,6 +136,8 @@ export const projectRoleAccessRights: Record<Role, ProjectScope[]> = {
|
||||
"evalJobExecution:read",
|
||||
"llmApiKeys:read",
|
||||
"batchExport:create",
|
||||
"comments:CUD",
|
||||
"comments:read",
|
||||
],
|
||||
VIEWER: [
|
||||
"project:read",
|
||||
@@ -138,6 +147,7 @@ export const projectRoleAccessRights: Record<Role, ProjectScope[]> = {
|
||||
"evalJob:read",
|
||||
"evalJobExecution:read",
|
||||
"llmApiKeys:read",
|
||||
"comments:read",
|
||||
],
|
||||
NONE: [],
|
||||
};
|
||||
|
||||
@@ -43,6 +43,7 @@ export const useHasOrganizationAccess = (p: {
|
||||
const { scope, organizationId } = p;
|
||||
const session = useSession();
|
||||
|
||||
if (session.data?.user?.admin) return true;
|
||||
if (!organizationId) return false;
|
||||
|
||||
return hasOrganizationAccess({
|
||||
|
||||
@@ -43,6 +43,7 @@ export const useHasProjectAccess = (p: {
|
||||
const { scope, projectId } = p;
|
||||
const session = useSession();
|
||||
|
||||
if (session.data?.user?.admin) return true;
|
||||
if (!projectId) return false;
|
||||
|
||||
return hasProjectAccess({ session: session.data, scope, projectId });
|
||||
|
||||
@@ -85,7 +85,9 @@ export function AnnotateDrawer({
|
||||
) : (
|
||||
<Button
|
||||
className="h-6 rounded-full px-3 text-xs"
|
||||
onClick={() =>
|
||||
disabled={!hasAccess}
|
||||
onClick={() => {
|
||||
setIsDrawerOpen(true);
|
||||
capture(
|
||||
Boolean(scores.length)
|
||||
? "score:update_form_open"
|
||||
@@ -94,8 +96,8 @@ export function AnnotateDrawer({
|
||||
type: type,
|
||||
source: source,
|
||||
},
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
Annotate
|
||||
</Button>
|
||||
|
||||
@@ -37,6 +37,7 @@ export function SetupPage() {
|
||||
const { project, organization } = useQueryProjectOrOrganization();
|
||||
const router = useRouter();
|
||||
const [orgStep] = useQueryParam("orgstep", StringParam); // "invite-members" | "create-project"
|
||||
const queryProjectId = router.query.projectId as string | undefined;
|
||||
|
||||
// starts at 1 to align with breadcrumb
|
||||
const stepInt = !organization
|
||||
@@ -49,9 +50,9 @@ export function SetupPage() {
|
||||
|
||||
const hasAnyTrace =
|
||||
api.traces.hasAny.useQuery(
|
||||
{ projectId: project?.id ?? "no-project" },
|
||||
{ projectId: queryProjectId as string },
|
||||
{
|
||||
enabled: !!project && stepInt === 4,
|
||||
enabled: queryProjectId !== undefined && stepInt === 4,
|
||||
refetchInterval: 5000,
|
||||
trpc: {
|
||||
context: {
|
||||
|
||||
@@ -5,14 +5,18 @@ import { api } from "@/src/utils/api";
|
||||
import { setupTracingRoute } from "@/src/features/setup/setupRoutes";
|
||||
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import { LockIcon } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
const SetupTracingButton = () => {
|
||||
const { project } = useQueryProjectOrOrganization();
|
||||
|
||||
const router = useRouter();
|
||||
const queryProjectId = router.query.projectId as string | undefined;
|
||||
|
||||
const { data: hasAnyTrace, isLoading } = api.traces.hasAny.useQuery(
|
||||
{ projectId: project?.id ?? "no-project" },
|
||||
{ projectId: queryProjectId as string },
|
||||
{
|
||||
enabled: project !== undefined,
|
||||
enabled: queryProjectId !== undefined,
|
||||
trpc: {
|
||||
context: {
|
||||
skipBatch: true,
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
// Langfuse Cloud only
|
||||
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { Flex, MarkerBar, Metric, Text } from "@tremor/react";
|
||||
import Link from "next/link";
|
||||
import { PricingPage } from "@/src/features/pricing-page/PricingPage";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTrigger,
|
||||
} from "@/src/components/ui/dialog";
|
||||
import Header from "@/src/components/layouts/header";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { useQueryOrganization } from "@/src/features/organizations/hooks";
|
||||
import { Card } from "@/src/components/ui/card";
|
||||
import { numberFormatter, compactNumberFormatter } from "@/src/utils/numbers";
|
||||
import { useHasOrgEntitlement } from "@/src/features/entitlements/hooks";
|
||||
|
||||
export const OrganizationUsageChart = () => {
|
||||
const organization = useQueryOrganization();
|
||||
const entitled = useHasOrgEntitlement("cloud-usage-metering");
|
||||
const usage = api.usageMetering.last30d.useQuery(
|
||||
{
|
||||
orgId: organization!.id,
|
||||
},
|
||||
{
|
||||
enabled: organization !== undefined && entitled,
|
||||
trpc: {
|
||||
context: {
|
||||
skipBatch: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
const capture = usePostHogClientCapture();
|
||||
const planLimit =
|
||||
organization?.cloudConfig?.monthlyObservationLimit ?? 50_000;
|
||||
const plan = organization?.cloudConfig?.plan ?? "Hobby";
|
||||
|
||||
if (!entitled) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header title="Usage & Billing" level="h3" />
|
||||
<Card className="p-4">
|
||||
{usage.data !== undefined ? (
|
||||
<>
|
||||
<Text>Observations / last 30d</Text>
|
||||
<Metric>{numberFormatter(usage.data, 0)}</Metric>
|
||||
{plan === "Hobby" && (
|
||||
<>
|
||||
<Flex className="mt-4">
|
||||
<Text>{`${numberFormatter((usage.data / planLimit) * 100)}%`}</Text>
|
||||
<Text>Plan limit: {compactNumberFormatter(planLimit)}</Text>
|
||||
</Flex>
|
||||
<MarkerBar
|
||||
value={Math.min((usage.data / planLimit) * 100, 100)}
|
||||
className="mt-3"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
"Loading (might take a moment) ..."
|
||||
)}
|
||||
</Card>
|
||||
<div className="mt-4 flex flex-row items-center gap-2">
|
||||
{plan === "Hobby" ? (
|
||||
<Dialog
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
capture("project_settings:pricing_dialog_opened");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="secondary">Change plan</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<Header
|
||||
title="Select plan"
|
||||
level="h3"
|
||||
actionButtons={
|
||||
<Button variant="secondary" asChild>
|
||||
<Link href="https://langfuse.com/pricing" target="_blank">
|
||||
Pricing page ↗
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</DialogHeader>
|
||||
<PricingPage className="mb-5 mt-5" />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
<Button variant="secondary">
|
||||
<Link href="https://billing.stripe.com/p/login/6oE9BXd4u8PR2aYaEE">
|
||||
Billing settings
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="secondary" asChild>
|
||||
<Link href="https://langfuse.com/pricing" target="_blank">
|
||||
Pricing page ↗
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="inline-block text-sm text-muted-foreground">
|
||||
Current plan: {plan}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
# Usage Metering
|
||||
|
||||
This feature is used on Langfuse cloud to track project usage and bill customers accordingly.
|
||||
@@ -1,46 +0,0 @@
|
||||
import { hasEntitlement } from "@/src/features/entitlements/server/hasEntitlement";
|
||||
import {
|
||||
createTRPCRouter,
|
||||
protectedOrganizationProcedure,
|
||||
} from "@/src/server/api/trpc";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import * as z from "zod";
|
||||
|
||||
export const usageMeteringRouter = createTRPCRouter({
|
||||
last30d: protectedOrganizationProcedure
|
||||
.input(
|
||||
z.object({
|
||||
orgId: z.string(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
if (
|
||||
!hasEntitlement({
|
||||
entitlement: "cloud-usage-metering",
|
||||
sessionUser: ctx.session.user,
|
||||
orgId: input.orgId,
|
||||
})
|
||||
)
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Missing cloud-usage-metering entitlement",
|
||||
});
|
||||
|
||||
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: thirtyDaysAgo,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return usage;
|
||||
}),
|
||||
});
|
||||
@@ -2,10 +2,6 @@
|
||||
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
await import("./sentry.server.config");
|
||||
}
|
||||
|
||||
if (process.env.NEXT_RUNTIME === "edge") {
|
||||
await import("./sentry.edge.config");
|
||||
await import("./datadog.server.config");
|
||||
}
|
||||
}
|
||||
|
||||
+21
-17
@@ -123,17 +123,19 @@ export default api.withTRPC(MyApp);
|
||||
function UserTracking() {
|
||||
const session = useSession();
|
||||
|
||||
const sessionUser = session.data?.user;
|
||||
|
||||
useEffect(() => {
|
||||
if (session.status === "authenticated") {
|
||||
if (sessionUser) {
|
||||
// PostHog
|
||||
if (env.NEXT_PUBLIC_POSTHOG_KEY && env.NEXT_PUBLIC_POSTHOG_HOST)
|
||||
posthog.identify(session.data.user?.id ?? undefined, {
|
||||
posthog.identify(sessionUser.id ?? undefined, {
|
||||
environment: process.env.NODE_ENV,
|
||||
email: session.data.user?.email ?? undefined,
|
||||
name: session.data.user?.name ?? undefined,
|
||||
featureFlags: session.data.user?.featureFlags ?? undefined,
|
||||
email: sessionUser.email ?? undefined,
|
||||
name: sessionUser.name ?? undefined,
|
||||
featureFlags: sessionUser.featureFlags ?? undefined,
|
||||
projects:
|
||||
session.data.user?.organizations.flatMap((org) =>
|
||||
sessionUser.organizations.flatMap((org) =>
|
||||
org.projects.map((project) => ({
|
||||
...project,
|
||||
organization: org,
|
||||
@@ -141,7 +143,7 @@ function UserTracking() {
|
||||
) ?? undefined,
|
||||
LANGFUSE_CLOUD_REGION: env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION,
|
||||
});
|
||||
const emailDomain = session.data.user?.email?.split("@")[1];
|
||||
const emailDomain = sessionUser.email?.split("@")[1];
|
||||
if (emailDomain)
|
||||
posthog.group("emailDomain", emailDomain, {
|
||||
domain: emailDomain,
|
||||
@@ -149,20 +151,21 @@ function UserTracking() {
|
||||
|
||||
// Sentry
|
||||
setUser({
|
||||
email: session.data.user?.email ?? undefined,
|
||||
id: session.data.user?.id ?? undefined,
|
||||
email: sessionUser.email ?? undefined,
|
||||
id: sessionUser.id ?? undefined,
|
||||
});
|
||||
|
||||
// Chat
|
||||
chatSetUser({
|
||||
name: session.data.user?.name ?? "undefined",
|
||||
email: session.data.user?.email ?? "undefined",
|
||||
name: sessionUser.name ?? "undefined",
|
||||
email: sessionUser.email ?? "undefined",
|
||||
data: {
|
||||
userId: session.data.user?.id ?? "undefined",
|
||||
organizations: session.data.user?.organizations
|
||||
? JSON.stringify(session.data.user.organizations)
|
||||
userId: sessionUser.id ?? "undefined",
|
||||
organizations: sessionUser.organizations
|
||||
? JSON.stringify(sessionUser.organizations)
|
||||
: "undefined",
|
||||
featureFlags: session.data.user?.featureFlags
|
||||
? JSON.stringify(session.data.user.featureFlags)
|
||||
featureFlags: sessionUser.featureFlags
|
||||
? JSON.stringify(sessionUser.featureFlags)
|
||||
: "undefined",
|
||||
},
|
||||
});
|
||||
@@ -175,7 +178,8 @@ function UserTracking() {
|
||||
// Sentry
|
||||
setUser(null);
|
||||
}
|
||||
}, [session]);
|
||||
}, [sessionUser]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
These APIs are implemented via the NextJS App router in src/app/api/billing.
|
||||
@@ -3,6 +3,7 @@ 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 { type NextApiRequest, type NextApiResponse } from "next";
|
||||
|
||||
export default async function handler(
|
||||
@@ -65,12 +66,14 @@ export default async function handler(
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Health check failed: db not available", e);
|
||||
traceException(e);
|
||||
return res.status(503).json({
|
||||
status: "Database not available",
|
||||
version: VERSION.replace("v", ""),
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
traceException(e);
|
||||
console.log("Health check failed: ", e);
|
||||
return res.status(503).json({
|
||||
status: "Health check failed",
|
||||
|
||||
@@ -7,10 +7,13 @@ import {
|
||||
getLegacyIngestionQueue,
|
||||
eventTypes,
|
||||
ingestionEvent,
|
||||
traceException,
|
||||
redis,
|
||||
type AuthHeaderValidVerificationResult,
|
||||
type ingestionBatchEvent,
|
||||
handleBatch,
|
||||
recordIncrement,
|
||||
getCurrentSpan,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import {
|
||||
SdkLogProcessor,
|
||||
@@ -22,7 +25,6 @@ import {
|
||||
import { isNotNullOrUndefined } from "@/src/utils/types";
|
||||
import { telemetry } from "@/src/features/telemetry";
|
||||
import { jsonSchema } from "@langfuse/shared";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { isPrismaException } from "@/src/utils/exceptions";
|
||||
import { env } from "@/src/env.mjs";
|
||||
import {
|
||||
@@ -70,11 +72,28 @@ export default async function handler(
|
||||
|
||||
const parsedSchema = batchType.safeParse(req.body);
|
||||
|
||||
Sentry.metrics.increment(
|
||||
recordIncrement(
|
||||
"ingestion_event",
|
||||
parsedSchema.success ? parsedSchema.data.batch.length : 0,
|
||||
);
|
||||
|
||||
// add context of api call to the span
|
||||
const currentSpan = getCurrentSpan();
|
||||
|
||||
// get x-langfuse-xxx headers and add them to the span
|
||||
Object.keys(req.headers).forEach((header) => {
|
||||
if (header.toLowerCase().startsWith("x-langfuse")) {
|
||||
currentSpan?.setAttributes({
|
||||
[header]: req.headers[header],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// add number of events to the span
|
||||
parsedSchema.data
|
||||
? currentSpan?.setAttribute("event_count", parsedSchema.data.batch.length)
|
||||
: undefined;
|
||||
|
||||
await gaugePrismaStats();
|
||||
|
||||
if (!parsedSchema.success) {
|
||||
@@ -141,6 +160,7 @@ export default async function handler(
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return handleBatchResult(
|
||||
[
|
||||
...validationErrors,
|
||||
@@ -176,7 +196,7 @@ export default async function handler(
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof UnauthorizedError)) {
|
||||
console.error("error_handling_ingestion_event", error);
|
||||
Sentry.captureException(error);
|
||||
traceException(error);
|
||||
}
|
||||
|
||||
if (error instanceof BaseError) {
|
||||
@@ -329,9 +349,6 @@ export const handleBatchResult = (
|
||||
error: error.error.message,
|
||||
});
|
||||
} else {
|
||||
if (process.env.NEXT_PUBLIC_SENTRY_DSN) {
|
||||
Sentry.captureException(error.error);
|
||||
}
|
||||
returnedErrors.push({
|
||||
id: error.id,
|
||||
status: 500,
|
||||
@@ -341,6 +358,7 @@ export const handleBatchResult = (
|
||||
});
|
||||
|
||||
if (returnedErrors.length > 0) {
|
||||
traceException(errors);
|
||||
console.log("Error processing events", returnedErrors);
|
||||
}
|
||||
|
||||
@@ -423,7 +441,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);
|
||||
Sentry.captureException(parsedObj.error);
|
||||
traceException(parsedObj.error);
|
||||
}
|
||||
// should not fail in prod but just log an exception, see above
|
||||
return results[0].result as z.infer<T>;
|
||||
|
||||
@@ -17,9 +17,13 @@ import {
|
||||
ForbiddenError,
|
||||
type Prompt,
|
||||
} from "@langfuse/shared";
|
||||
import { PromptService, redis } from "@langfuse/shared/src/server";
|
||||
import {
|
||||
PromptService,
|
||||
redis,
|
||||
recordIncrement,
|
||||
traceException,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { PRODUCTION_LABEL } from "@/src/features/prompts/constants";
|
||||
import * as Sentry from "@sentry/node";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
@@ -47,11 +51,7 @@ export default async function handler(
|
||||
const promptName = searchParams.name;
|
||||
const version = searchParams.version ?? undefined;
|
||||
|
||||
const promptService = new PromptService(
|
||||
prisma,
|
||||
redis,
|
||||
Sentry.metrics.increment,
|
||||
);
|
||||
const promptService = new PromptService(prisma, redis, recordIncrement);
|
||||
|
||||
let prompt: Prompt | null = null;
|
||||
|
||||
@@ -102,8 +102,7 @@ export default async function handler(
|
||||
throw new MethodNotAllowedError();
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
|
||||
Sentry.captureException(error);
|
||||
traceException(error);
|
||||
|
||||
if (error instanceof BaseError) {
|
||||
return res.status(error.httpCode).json({
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
LangfuseNotFoundError,
|
||||
} from "@langfuse/shared";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import * as Sentry from "@sentry/node";
|
||||
import { traceException } from "@langfuse/shared/src/server";
|
||||
|
||||
export default withMiddlewares({
|
||||
GET: createAuthedAPIRoute({
|
||||
@@ -30,7 +30,7 @@ export default withMiddlewares({
|
||||
|
||||
const parsedConfig = GetScoreConfigResponse.safeParse(config);
|
||||
if (!parsedConfig.success) {
|
||||
Sentry.captureException(parsedConfig.error);
|
||||
traceException(parsedConfig.error);
|
||||
throw new InternalServerError("Requested score config is corrupted");
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
validateDbScoreConfig,
|
||||
} from "@langfuse/shared";
|
||||
import { Prisma, prisma } from "@langfuse/shared/src/db";
|
||||
import * as Sentry from "@sentry/node";
|
||||
import { traceException } from "@langfuse/shared/src/server";
|
||||
|
||||
const inflateConfigBody = (body: z.infer<typeof PostScoreConfigBody>) => {
|
||||
if (isBooleanDataType(body.dataType)) {
|
||||
@@ -67,7 +67,7 @@ export default withMiddlewares({
|
||||
|
||||
const configs = filterAndValidateDbScoreConfigList(
|
||||
rawConfigs,
|
||||
Sentry.captureException,
|
||||
traceException,
|
||||
);
|
||||
|
||||
const totalItemsRes = await prisma.$queryRaw<{ count: bigint }[]>(
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
LangfuseNotFoundError,
|
||||
} from "@langfuse/shared";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import * as Sentry from "@sentry/node";
|
||||
import { traceException } from "@langfuse/shared/src/server";
|
||||
|
||||
export default withMiddlewares({
|
||||
GET: createAuthedAPIRoute({
|
||||
@@ -33,7 +33,7 @@ export default withMiddlewares({
|
||||
const parsedScore = GetScoreResponse.safeParse(score);
|
||||
|
||||
if (!parsedScore.success) {
|
||||
Sentry.captureException(parsedScore.error);
|
||||
traceException(parsedScore.error);
|
||||
throw new InternalServerError("Requested score is corrupted");
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
LangfuseNotFoundError,
|
||||
} from "@langfuse/shared";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import * as Sentry from "@sentry/node";
|
||||
import { traceException } from "@langfuse/shared/src/server";
|
||||
|
||||
export default withMiddlewares({
|
||||
GET: createAuthedAPIRoute({
|
||||
@@ -53,7 +53,7 @@ export default withMiddlewares({
|
||||
const outObservations = observations.map(transformDbToApiObservation);
|
||||
const validatedScores = filterAndValidateDbScoreList(
|
||||
scores,
|
||||
Sentry.captureException,
|
||||
traceException,
|
||||
);
|
||||
|
||||
const { duration, ...restOfTrace } = trace;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createNextApiHandler } from "@trpc/server/adapters/next";
|
||||
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { createTRPCContext } from "@/src/server/api/trpc";
|
||||
import { appRouter } from "@/src/server/api/root";
|
||||
|
||||
@@ -15,12 +14,7 @@ export default createNextApiHandler({
|
||||
// batching: {
|
||||
// enabled: false,
|
||||
// },
|
||||
onError:
|
||||
env.NODE_ENV === "development"
|
||||
? ({ path, error }) => {
|
||||
console.error(
|
||||
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
onError: ({ path, error }) => {
|
||||
console.error(`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ export default function AuthError() {
|
||||
const router = useRouter();
|
||||
const { error } = router.query;
|
||||
const errorMessage = error
|
||||
? decodeURIComponent(String(error))
|
||||
? decodeURIComponent(String(error))
|
||||
: "An authentication error occurred. Please reach out to support.";
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,18 +3,19 @@ import Header from "@/src/components/layouts/header";
|
||||
import { MembershipInvitesPage } from "@/src/features/rbac/components/MembershipInvitesPage";
|
||||
import { MembersTable } from "@/src/features/rbac/components/MembersTable";
|
||||
import { JSONView } from "@/src/components/ui/CodeJsonViewer";
|
||||
import { env } from "@/src/env.mjs";
|
||||
import RenameOrganization from "@/src/features/organizations/components/RenameOrganization";
|
||||
import { useQueryOrganization } from "@/src/features/organizations/hooks";
|
||||
import { OrganizationUsageChart } from "@/src/features/usage-metering/OrganizationUsageChart";
|
||||
import { useRouter } from "next/router";
|
||||
import { SettingsDangerZone } from "@/src/components/SettingsDangerZone";
|
||||
import { DeleteOrganizationButton } from "@/src/features/organizations/components/DeleteOrganizationButton";
|
||||
import { BillingSettings } from "@/src/ee/features/billing/components/BillingSettings";
|
||||
import { useHasOrgEntitlement } from "@/src/features/entitlements/hooks";
|
||||
|
||||
const OrgSettingsPage = () => {
|
||||
const organization = useQueryOrganization();
|
||||
const router = useRouter();
|
||||
const { page } = router.query;
|
||||
const showBillingSettings = useHasOrgEntitlement("cloud-billing");
|
||||
|
||||
if (!organization) return null;
|
||||
|
||||
@@ -68,8 +69,8 @@ const OrgSettingsPage = () => {
|
||||
{
|
||||
title: "Billing",
|
||||
slug: "billing",
|
||||
content: <OrganizationUsageChart />,
|
||||
show: env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== undefined,
|
||||
content: <BillingSettings />,
|
||||
show: showBillingSettings,
|
||||
},
|
||||
{
|
||||
title: "Projects",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { DatasetActionButton } from "@/src/features/datasets/components/DatasetA
|
||||
import { DeleteButton } from "@/src/components/deleteButton";
|
||||
import { JSONView } from "@/src/components/ui/CodeJsonViewer";
|
||||
import { FullScreenPage } from "@/src/components/layouts/full-screen-page";
|
||||
import { DuplicateDatasetButton } from "@/src/features/datasets/components/DuplicateDatasetButton";
|
||||
|
||||
export default function Dataset() {
|
||||
const router = useRouter();
|
||||
@@ -52,6 +53,10 @@ export default function Dataset() {
|
||||
datasetMetadata={dataset.data?.metadata}
|
||||
icon
|
||||
/>
|
||||
<DuplicateDatasetButton
|
||||
datasetId={datasetId}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<DeleteButton
|
||||
itemId={datasetId}
|
||||
projectId={projectId}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { DeleteButton } from "@/src/components/deleteButton";
|
||||
import { NewDatasetItemButton } from "@/src/features/datasets/components/NewDatasetItemButton";
|
||||
import { JSONView } from "@/src/components/ui/CodeJsonViewer";
|
||||
import { FullScreenPage } from "@/src/components/layouts/full-screen-page";
|
||||
import { DuplicateDatasetButton } from "@/src/features/datasets/components/DuplicateDatasetButton";
|
||||
|
||||
export default function DatasetItems() {
|
||||
const router = useRouter();
|
||||
@@ -60,6 +61,10 @@ export default function DatasetItems() {
|
||||
datasetMetadata={dataset.data?.metadata}
|
||||
icon
|
||||
/>
|
||||
<DuplicateDatasetButton
|
||||
datasetId={datasetId}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<DeleteButton
|
||||
itemId={datasetId}
|
||||
projectId={projectId}
|
||||
|
||||
@@ -1,34 +1,33 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
if (process.env.NEXT_PUBLIC_SENTRY_DSN)
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
// Replay may only be enabled for the client-side
|
||||
integrations: [Sentry.replayIntegration()],
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
// Replay may only be enabled for the client-side
|
||||
integrations: [Sentry.replayIntegration()],
|
||||
|
||||
// Set tracesSampleRate to 1.0 to capture 100%
|
||||
// of transactions for performance monitoring.
|
||||
// We recommend adjusting this value in production
|
||||
tracesSampleRate: process.env.LANGFUSE_TRACING_SAMPLE_RATE
|
||||
? Number(process.env.LANGFUSE_TRACING_SAMPLE_RATE)
|
||||
: 0.1,
|
||||
// Set tracesSampleRate to 1.0 to capture 100%
|
||||
// of transactions for performance monitoring.
|
||||
// We recommend adjusting this value in production
|
||||
tracesSampleRate: process.env.NEXT_LANGFUSE_TRACING_SAMPLE_RATE
|
||||
? Number(process.env.NEXT_LANGFUSE_TRACING_SAMPLE_RATE)
|
||||
: 0.1,
|
||||
|
||||
// Capture Replay for 100% of all sessions,
|
||||
// plus for 100% of sessions with an error
|
||||
replaysSessionSampleRate: 0,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
// Capture Replay for 100% of all sessions,
|
||||
// plus for 100% of sessions with an error
|
||||
replaysSessionSampleRate: 0,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
|
||||
beforeSend(event, _hint) {
|
||||
// Check if it is an exception, and if so, show the report dialog
|
||||
if (event.exception) {
|
||||
Sentry.showReportDialog({ eventId: event.event_id });
|
||||
}
|
||||
return event;
|
||||
},
|
||||
beforeSend(event, _hint) {
|
||||
// Check if it is an exception, and if so, show the report dialog
|
||||
if (event.exception) {
|
||||
Sentry.showReportDialog({ eventId: event.event_id });
|
||||
}
|
||||
return event;
|
||||
},
|
||||
|
||||
// ...
|
||||
// ...
|
||||
|
||||
// Note: if you want to override the automatic release value, do not set a
|
||||
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
|
||||
// that it will also get attached to your source maps
|
||||
});
|
||||
// Note: if you want to override the automatic release value, do not set a
|
||||
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
|
||||
// that it will also get attached to your source maps
|
||||
});
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
if (process.env.NEXT_PUBLIC_SENTRY_DSN)
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
|
||||
// Set tracesSampleRate to 1.0 to capture 100%
|
||||
// of transactions for performance monitoring.
|
||||
// We recommend adjusting this value in production
|
||||
tracesSampleRate: process.env.LANGFUSE_TRACING_SAMPLE_RATE
|
||||
? Number(process.env.LANGFUSE_TRACING_SAMPLE_RATE)
|
||||
: 0.1,
|
||||
|
||||
// ...
|
||||
|
||||
// Note: if you want to override the automatic release value, do not set a
|
||||
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
|
||||
// that it will also get attached to your source maps
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user