Compare commits

...
22 Commits
Author SHA1 Message Date
Marlies Mayerhofer 86816e569d chore: release v2.72.0
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web (node20, pg12) (push) Waiting to run
CI/CD / tests-web (node20, pg15) (push) Waiting to run
CI/CD / tests-worker (node20, pg12) (push) Waiting to run
CI/CD / tests-worker (node20, pg15) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / e2e-server-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2024-08-21 09:54:34 +02:00
marliessophieandGitHub 0fe2176930 style(ui): align styling of comment button on sessions page and adjust dashboard and session overflow for mobile (#2985)
* style(ui): align styling of comment button on sessions page

* style(dashboard): score analytics charts full width for mobile

* style(markdown): fix overflow behavior on sessions page for mobile
2024-08-21 09:29:17 +02:00
Max DeichmannandGitHub 43f7c524dc fix: drop failed eval jobs (#2986) 2024-08-20 23:07:51 +00:00
Marc Klingen 2bcfab130d chore(cloud): update stripe product ids 2024-08-20 19:10:56 +02:00
Marc Klingen 51bb1c3374 chore(cloud): add more details to upgrade page 2024-08-20 16:58:10 +02:00
Marc KlingenandGitHub 302bb065b7 feat(cloud): add billing period and next invoiced amount to billing settings (#2982) 2024-08-20 16:35:13 +02:00
Max DeichmannandGitHub e6a8fb84f0 fix: remove last backend sentry logic (#2981) 2024-08-20 14:07:43 +00:00
Marc KlingenandGitHub b12f3df933 fix: admin role (#2980) 2024-08-20 15:40:32 +02:00
marliessophieandGitHub 159b5f87fe feat(comments): add comments on objects (#2971) 2024-08-20 15:39:23 +02:00
Marc Klingen 73fe2dc1a7 fix: use test mode stripe products also in staging 2024-08-19 22:52:03 +02:00
Marc KlingenandGitHub 9daf348d61 feat(cloud): add stripe checkout, customer portal, and usage metering (#2962) 2024-08-19 20:35:46 +00:00
Marc KlingenandGitHub 8a9d0b01af chore(cloud): reduce posthog event count (#2973)
optimize how often we use identify with posthog
2024-08-19 16:49:01 +02:00
Marc Klingen 315d8c7619 chore(ui): include NEXT_PUBLIC_DEMO_ORG_ID in demo banner check 2024-08-19 12:18:04 +02:00
Max DeichmannandGitHub b23eaf3cd4 infra: record exceptions of health endpoint (#2970) 2024-08-19 11:54:43 +02:00
Max DeichmannandGitHub eea1e8d054 fix: only openai adapter for evals (#2960) 2024-08-19 08:00:54 +00:00
Max DeichmannandGitHub 06a7a4bfb2 infra: add ingestion api attributes to span (#2968) 2024-08-19 07:46:43 +00:00
Max DeichmannandGitHub 7c8a30d48f infra: add ingestion pipeline processing time (#2965) 2024-08-17 21:13:41 +00:00
Max DeichmannandGitHub 1971adda18 feat: improve dd ressource names for http requests (#2964) 2024-08-17 21:57:33 +02:00
Max DeichmannandGitHub d9b4e3a7dd feat: add dd instrumentation to web container (#2727) 2024-08-17 20:21:37 +02:00
ClemoandGitHub f1aa884cbc docs: update readme 2024-08-17 14:33:46 +02:00
ClemoandGitHub 0ed9acddb6 docs: update careers 2024-08-17 14:28:58 +02:00
Marc KlingenandGitHub 61e3470a93 feat(datasets): add duplicate dataset button (#2957)
* feat(datasets): add duplicate dataset button

* named export
2024-08-17 01:12:32 +02:00
135 changed files with 5498 additions and 1510 deletions
-14
View File
@@ -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"
}
+4
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
![langfuse_logo_1024](https://github.com/user-attachments/assets/f17d8fc1-8206-4afa-a5ec-774826be88a8)
<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>
-1
View File
@@ -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
+1
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "2.71.0",
"version": "2.72.0",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
+4 -2
View File
@@ -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",
+18
View File
@@ -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;
+23
View File
@@ -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")
+64 -11
View File
@@ -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
*/
+5
View File
@@ -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";
@@ -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>;
@@ -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;
+1
View File
@@ -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);
};
+3
View File
@@ -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
) {
+2255 -587
View File
File diff suppressed because it is too large Load Diff
+21 -17
View File
@@ -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
View File
@@ -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;
+16
View File
@@ -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>
)
}
+5 -2
View File
@@ -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">
+1 -1
View File
@@ -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",
+9
View File
@@ -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}
+9 -7
View File
@@ -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">
+8 -4
View File
@@ -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 (
+19 -1
View File
@@ -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}
+40 -20
View File
@@ -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}
+44 -3
View File
@@ -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}
+41 -1
View File
@@ -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>
+9 -3
View File
@@ -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
View File
@@ -1 +1 @@
export const VERSION = "v2.71.0";
export const VERSION = "v2.72.0";
+62
View File
@@ -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();
}
-13
View File
@@ -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();
+61
View File
@@ -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;
+21 -1
View File
@@ -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,
+6
View File
@@ -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
+2
View File
@@ -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>
);
}
+238
View File
@@ -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 />
+8 -10
View File
@@ -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;
}),
});
+1 -5
View File
@@ -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
View File
@@ -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;
}
+1
View File
@@ -0,0 +1 @@
These APIs are implemented via the NextJS App router in src/app/api/billing.
+3
View File
@@ -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",
+25 -7
View File
@@ -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>;
+8 -9
View File
@@ -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 }[]>(
+2 -2
View File
@@ -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");
}
+2 -2
View File
@@ -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;
+3 -9
View File
@@ -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}`);
},
});
+1 -1
View File
@@ -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}
+26 -27
View File
@@ -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
});
-19
View File
@@ -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