Compare commits

...
34 Commits
Author SHA1 Message Date
Marc Klingen d54cbc6c9b chore: release v3.17.0 2025-01-27 13:38:42 +01:00
Marc KlingenandGitHub 332f6658d9 ci: run codespell also on v2 branch and prs (#5224) 2025-01-27 13:20:58 +01:00
Hassieb PakzadandGitHub 25a559293b fix(media): base id gen on project ID + content hash (#5222) 2025-01-27 11:57:47 +01:00
Max DeichmannandGitHub 9ff8643489 chore: add client side logging for auth errors (#5221)
push
2025-01-27 10:03:02 +00:00
Steffen SchmitzandGitHub dd7e4f344e fix: align input/output and metadata parsing between GET /traces and /traces/:id (#5218)
* fix: align input/output and metadata parsing between GET /traces and /traces/:id

* chore: lint

* chore: lint
2025-01-27 09:53:33 +00:00
Marc KlingenandGitHub fe089e15f2 fix(openapi): upgrade openapi spec generator to correctly specify /ingestion 207 status code (#5217) 2025-01-27 10:44:33 +01:00
Marc KlingenandGitHub 9f8793e26a feat(auth): make checks and auth method configurable across SSO providers (#5203)
push
2025-01-27 09:12:58 +00:00
Hassieb PakzadandGitHub a1cb4c727b fix(media): remove transactions (#5211) 2025-01-25 23:44:34 +01:00
Hassieb PakzadandGitHub b39eb5445a fix(media): reduce tx level to RepeateableRead (#5210) 2025-01-25 21:09:14 +01:00
Hassieb PakzadandGitHub b611301658 fix(media-api): increase tx maxWait (#5208) 2025-01-25 16:12:34 +01:00
Hassieb PakzadandGitHub c3fd8a74dc fix(trace-timeline): first token label (#5202) 2025-01-24 16:38:47 +01:00
steffen911 aff8421dde chore: release v3.16.0 2025-01-24 16:12:52 +01:00
Steffen SchmitzandGitHub c442c4290e feat: add proxy support for oauth flows (#5198) 2025-01-24 14:32:46 +00:00
Max Deichmann 01bc983b97 chore: release v3.15.0 2025-01-24 15:02:59 +01:00
Max DeichmannandGitHub e2015bc52b feat: update prompt versions (#5106)
* feat: update prompt versions

* push

* fix: return 404 error instead of 500 for not found observations trpc route

* fix

* fix

* fix

* fix

* fix

* feat: update prompts and invalidate cache

* feat: update prompts and invalidate cache

* feat: update prompts and invalidate cache

* feat: update prompts and invalidate cache
2025-01-24 13:40:59 +00:00
Max DeichmannandGitHub 91589d20c6 chore: add error logs for trpc add prompts label (#5195)
push
2025-01-24 12:45:00 +00:00
50f9444c36 feat(auth): Add AUTH_CUSTOM_ID_TOKEN environment variable (#5193)
* Add AUTH_CUSTOM_ID_TOKEN environment variable

* add to multi-tenant-sso (ee)

* add comment

---------

Co-authored-by: Marc Klingen <git@marcklingen.com>
2025-01-24 11:14:39 +00:00
marliessophieandGitHub 4432223fd2 fix(comments-api): increase comment character limit to 3,000 (#5194) 2025-01-24 10:59:25 +00:00
marliessophieandGitHub e3d58ff920 feat(traces): display number of all log kinds on trace table (#5165)
* feat(traces): display number of all log kinds on trace table

* chore: lint

* tests: adjust

* push

* chore: rename `computed_level` to `aggregated_level`

* chore: adjust ui labels

* chore: include level counts in export
2025-01-24 09:57:21 +00:00
Hassieb PakzadandGitHub ded00e6fdf feat(trace-timeline): add time to first token (#5185) 2025-01-24 10:02:05 +01:00
Max DeichmannandGitHub 3fd1cc021c perf: add timestamp filter to observations join in eval queue (#5192)
* push

* chore: disable secondary queue
2025-01-23 23:50:40 +00:00
Steffen SchmitzandGitHub 4b2e68017b feat: add support for media asset deletion based on retention settings (#5181)
* feat: allow data retention configuration in project settings

* chore: remove prefix

* chore: hide data retention on cloud

* chore: lint

* feat: add support for media asset deletion based on retention settings

* chore: fix naming

* chore: increase expiry schedule

* chore: add logging

* chore: revert to intended schedule
2025-01-23 16:54:33 +00:00
Marc KlingenandGitHub a52f87c64a chore(cloud): add login issue banner, allow to open support chat (#5183) 2025-01-23 16:07:03 +00:00
Hassieb Pakzad 7500575a96 chore: release v3.14.0 2025-01-23 16:24:10 +01:00
Steffen SchmitzandGitHub 105284d756 feat: allow data retention configuration in project settings (#5178)
* feat: allow data retention configuration in project settings

* chore: remove prefix

* chore: hide data retention on cloud

* chore: lint

* chore: disable box on missing entitlement and add integer enforcement
2025-01-23 14:42:24 +00:00
Hassieb PakzadandGitHub 29f7ace2ce feat(llm-api-keys): allow for extra headers (#5167) 2025-01-23 14:59:00 +01:00
Hassieb PakzadandGitHub 8fd17b52d1 fix(prompt-diff-dialog): keep open on cursor leaving window (#5176) 2025-01-23 10:44:07 +01:00
marliessophieandGitHub 484af10463 feat(prompts): support in UI prompt duplication (#5110)
* feat(prompts): support in UI prompt duplication

* chore(ui-ds): make `children` in `ActionButton` optional

* fix: extract custom hook for prompt name validation

* eslint

* fix: insert correct prompt count

* chore: validate prompt name uniqueness; attach latest label for single version

* chore: refactor duplication approach to use createMany and save on network-roundtrips

* chore: revert changes on action button

* push
2025-01-22 17:53:09 +00:00
Hassieb PakzadandGitHub 33d43ca9bd fix(diff-view-ui): add line breaks (#5168) 2025-01-22 17:27:58 +01:00
Hassieb PakzadandGitHub 3658620540 feat(prompts): add diff view on new prompt version (#5163) 2025-01-22 15:54:06 +01:00
Steffen SchmitzandGitHub 83e1c5a3df chore: use a distribution metric for langfuse.ingestion.count_files (#5164)
* chore: use a distribution metric for langfuse.ingestion.count_files

* chore: update name

* chore: update name

* chore: remove outdated import
2025-01-22 11:53:31 +00:00
Steffen SchmitzandGitHub 5efcef593f chore: add additional metrics into ingestion pipeline (#5160)
* chore: add additional metrics into ingestion pipeline

* chore: spellcheck
2025-01-22 11:03:56 +00:00
Marc KlingenandGitHub 6c84c54a4a feat(cloud): switch between available plans (#5153)
* feat(cloud): switch between available plans

* push

* inline comments

* entitlement check

* improve error message

* remove unused ids

* fix spellcheck
2025-01-22 10:38:25 +00:00
Max DeichmannandGitHub 9b3d77e1de chore: disable secondary queue (#5156) 2025-01-21 23:25:56 +01:00
99 changed files with 3519 additions and 562 deletions
+2 -1
View File
@@ -103,6 +103,7 @@ OTEL_SERVICE_NAME="langfuse"
# AUTH_CUSTOM_SCOPE="openid email profile" # optional
# AUTH_CUSTOM_CLIENT_AUTH_METHOD="client_secret_basic" # optional
# AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING=false
# AUTH_CUSTOM_ID_TOKEN=false # optional, default is true
# Transactional email, optional
# Defines the email address to use as the from address.
@@ -253,4 +254,4 @@ OTEL_SERVICE_NAME="langfuse"
# LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS=
# LANGFUSE_INGESTION_CLICKHOUSE_MAX_ATTEMPTS=
## END Langfuse V3 Ingestion
## END Langfuse V3 Ingestion
+6 -2
View File
@@ -3,9 +3,13 @@ name: Codespell
on:
push:
branches: [main]
branches:
- "main"
tags:
- "v*"
pull_request:
branches: [main]
branches:
- "**"
merge_group:
permissions:
+1
View File
@@ -27,6 +27,7 @@
"@langfuse/shared": "workspace:*",
"@opentelemetry/api": ">=1.0.0 <1.10.0",
"axios": "^1.7.7",
"https-proxy-agent": "^7.0.6",
"next": "^14.2.21",
"next-auth": "^4.24.11",
"zod": "^3.23.8"
+1 -1
View File
@@ -12,7 +12,7 @@ groups:
# namespaceExport: Langfuse
# allowCustomFetcher: true
- name: fernapi/fern-openapi
version: 0.0.26
version: 0.1.7
output:
location: local-file-system
path: ../../../web/public/generated/api-client
+2 -2
View File
@@ -128,7 +128,7 @@ types:
modelParameters: optional<map<string, commons.MapValue>>
usage: optional<IngestionUsage>
usageDetails: optional<UsageDetails>
costDetails: optional<map<string, float>>
costDetails: optional<map<string, double>>
promptName: optional<string>
promptVersion: optional<integer>
@@ -141,7 +141,7 @@ types:
usage: optional<IngestionUsage>
promptName: optional<string>
usageDetails: optional<UsageDetails>
costDetails: optional<map<string, float>>
costDetails: optional<map<string, double>>
promptVersion: optional<integer>
ObservationBody:
@@ -0,0 +1,27 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/fern-api/fern/main/fern.schema.json
imports:
prompts: ./prompts.yml
pagination: ./utils/pagination.yml
service:
auth: true
base-path: /api/public/v2
endpoints:
update:
docs: Update labels for a specific prompt version
method: PATCH
path: /prompts/{promptName}/version/{version}
path-parameters:
promptName:
type: string
docs: The name of the prompt
version:
type: integer
docs: Version of the prompt to update
request:
name: UpdatePromptRequest
body:
properties:
newLabels:
type: list<string>
docs: New labels for the prompt version. Labels are unique across versions. The "latest" label is reserved and managed by Langfuse.
response: prompts.Prompt
-1
View File
@@ -102,7 +102,6 @@ types:
tags:
type: optional<list<string>>
docs: List of tags to apply to all versions of this prompt.
Prompt:
union:
chat: ChatPrompt
+1 -1
View File
@@ -59,7 +59,7 @@ service:
type: optional<commons.ScoreDataType>
docs: Retrieve only scores with a specific dataType.
traceTags:
type: optional<list<string>>
type: optional<string>
allow-multiple: true
docs: Only scores linked to traces that include all of these tags will be returned.
response: GetScoresResponse
+1 -1
View File
@@ -3,7 +3,7 @@ groups:
local:
generators:
- name: fernapi/fern-openapi
version: 0.0.31
version: 0.1.7
output:
location: local-file-system
path: ../../../web/public/generated/api
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "3.13.0",
"version": "3.17.0",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
@@ -86,6 +86,9 @@
"jsonpath-plus": "10.0.7",
"nanoid": "^3.3.8",
"katex": "^0.16.21"
},
"patchedDependencies": {
"next-auth@4.24.11": "patches/next-auth@4.24.11.patch"
}
}
}
+1
View File
@@ -77,6 +77,7 @@
"dd-trace": "^5.23.1",
"decimal.js": "^10.4.3",
"exponential-backoff": "^3.1.1",
"https-proxy-agent": "^7.0.6",
"ioredis": "^5.4.1",
"kysely": "^0.27.4",
"langchain": "^0.3.6",
@@ -304,6 +304,8 @@ export type LlmApiKeys = {
base_url: string | null;
custom_models: Generated<string[]>;
with_default_models: Generated<boolean>;
extra_headers: string | null;
extra_header_keys: Generated<string[]>;
config: unknown | null;
project_id: string;
};
@@ -466,6 +468,7 @@ export type Project = {
updated_at: Generated<Timestamp>;
deleted_at: Timestamp | null;
name: string;
retention_days: number | null;
};
export type ProjectMembership = {
org_membership_id: string;
@@ -0,0 +1,3 @@
ALTER TABLE "llm_api_keys"
ADD COLUMN "extra_headers" TEXT,
ADD COLUMN "extra_header_keys" TEXT[] NOT NULL DEFAULT '{}'::TEXT[];
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "projects"
ADD COLUMN "retention_days" INTEGER;
+3
View File
@@ -115,6 +115,7 @@ model Project {
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
name String
retentionDays Int? @map("retention_days")
projectMembers ProjectMembership[]
organization Organization @relation(fields: [orgId], references: [id], onUpdate: Cascade, onDelete: Cascade)
traces Trace[]
@@ -192,6 +193,8 @@ model LlmApiKeys {
baseURL String? @map("base_url")
customModels String[] @default([]) @map("custom_models")
withDefaultModels Boolean @default(true) @map("with_default_models")
extraHeaders String? @map("extra_headers")
extraHeaderKeys String[] @default([]) @map("extra_header_keys")
config Json?
projectId String @map("project_id")
@@ -17,7 +17,6 @@ export function CustomSSOProvider<P extends CustomSSOUser>(
wellKnown: `${options.issuer}/.well-known/openid-configuration`,
authorization: { params: { scope: "openid email profile" } }, // overridden by options.authorization to be able to set custom scopes, deep merged with this default
checks: ["pkce", "state"],
idToken: true,
profile(profile) {
return {
id: profile.sub,
+3
View File
@@ -8,6 +8,7 @@ export * from "./auth/apiKeys";
export * from "./auth/customSsoProvider";
export * from "./auth/gitHubEnterpriseProvider";
export * from "./llm/fetchLLMCompletion";
export * from "./llm/utils";
export * from "./llm/types";
export * from "./utils/DatabaseReadStream";
export * from "./utils/transforms";
@@ -31,6 +32,8 @@ export * from "./redis/batchExport";
export * from "./redis/ingestionQueue";
export * from "./redis/postHogIntegrationQueue";
export * from "./redis/postHogIntegrationProcessingQueue";
export * from "./redis/dataRetentionQueue";
export * from "./redis/dataRetentionProcessingQueue";
export * from "./redis/coreDataS3ExportQueue";
export * from "./redis/meteringDataPostgresExportQueue";
export * from "./redis/experimentCreateQueue";
@@ -226,6 +226,14 @@ export const recordHistogram = (
dd.dogstatsd.histogram(stat, value, tags);
};
export const recordDistribution = (
stat: string,
value?: number | undefined,
tags?: { [tag: string]: string | number } | undefined,
) => {
dd.dogstatsd.distribution(stat, value, tags);
};
/**
* Converts a queue name to the matching datadog metric name.
* Consumer only needs to append the relevant suffix.
@@ -49,6 +49,7 @@ type LLMCompletionParams = {
callbacks?: BaseCallbackHandler[];
baseURL?: string;
apiKey: string;
extraHeaders?: Record<string, string>;
maxRetries?: number;
config?: Record<string, string> | null;
traceParams?: TraceParams;
@@ -100,6 +101,7 @@ export async function fetchLLMCompletion(
maxRetries,
config,
traceParams,
extraHeaders,
} = params;
let finalCallbacks: BaseCallbackHandler[] | undefined = callbacks ?? [];
@@ -176,6 +178,7 @@ export async function fetchLLMCompletion(
maxRetries,
configuration: {
baseURL,
defaultHeaders: extraHeaders,
},
timeout: 1000 * 60 * 2, // 2 minutes timeout
});
+2
View File
@@ -148,6 +148,8 @@ export const LLMApiKeySchema = z
provider: z.string(),
displaySecretKey: z.string(),
secretKey: z.string(),
extraHeaders: z.string().nullish(),
extraHeaderKeys: z.array(z.string()),
baseURL: z.string().nullable(),
customModels: z.array(z.string()),
withDefaultModels: z.boolean(),
+13
View File
@@ -0,0 +1,13 @@
import { z } from "zod";
import { decrypt } from "../../encryption";
const ExtraHeaderSchema = z.record(z.string(), z.string());
export function decryptAndParseExtraHeaders(
extraHeaders: string | null | undefined,
) {
if (!extraHeaders) return;
return ExtraHeaderSchema.parse(decrypt(extraHeaders));
}
+17
View File
@@ -51,6 +51,10 @@ export const ExperimentCreateEventSchema = z.object({
runId: z.string(),
description: z.string().optional(),
});
export const DataRetentionProcessingEventSchema = z.object({
projectId: z.string(),
retention: z.number(),
});
export type BatchExportJobType = z.infer<typeof BatchExportJobSchema>;
export type TraceQueueEventType = z.infer<typeof TraceQueueEventSchema>;
@@ -67,6 +71,9 @@ export type ExperimentCreateEventType = z.infer<
export type PostHogIntegrationProcessingEventType = z.infer<
typeof PostHogIntegrationProcessingEventSchema
>;
export type DataRetentionProcessingEventType = z.infer<
typeof DataRetentionProcessingEventSchema
>;
export enum QueueName {
TraceUpsert = "trace-upsert", // Ingestion pipeline adds events on each Trace upsert
@@ -83,6 +90,8 @@ export enum QueueName {
PostHogIntegrationProcessingQueue = "posthog-integration-processing-queue",
CoreDataS3ExportQueue = "core-data-s3-export-queue",
MeteringDataPostgresExportQueue = "metering-data-postgres-export-queue",
DataRetentionQueue = "data-retention-queue",
DataRetentionProcessingQueue = "data-retention-processing-queue",
}
export enum QueueJobs {
@@ -100,6 +109,8 @@ export enum QueueJobs {
PostHogIntegrationProcessingJob = "posthog-integration-processing-job",
CoreDataS3ExportJob = "core-data-s3-export-job",
MeteringDataPostgresExportJob = "metering-data-postgres-export-job",
DataRetentionJob = "data-retention-job",
DataRetentionProcessingJob = "data-retention-processing-job",
}
export type TQueueJobTypes = {
@@ -163,4 +174,10 @@ export type TQueueJobTypes = {
payload: PostHogIntegrationProcessingEventType;
name: QueueJobs.PostHogIntegrationProcessingJob;
};
[QueueName.DataRetentionProcessingQueue]: {
timestamp: Date;
id: string;
payload: DataRetentionProcessingEventType;
name: QueueJobs.DataRetentionProcessingJob;
};
};
@@ -0,0 +1,40 @@
import { Queue } from "bullmq";
import { QueueName } from "../queues";
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
import { logger } from "../logger";
export class DataRetentionProcessingQueue {
private static instance: Queue | null = null;
public static getInstance(): Queue | null {
if (DataRetentionProcessingQueue.instance) {
return DataRetentionProcessingQueue.instance;
}
const newRedis = createNewRedisInstance({
enableOfflineQueue: false,
...redisQueueRetryOptions,
});
DataRetentionProcessingQueue.instance = newRedis
? new Queue(QueueName.DataRetentionProcessingQueue, {
connection: newRedis,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 10000,
attempts: 5,
backoff: {
type: "exponential",
delay: 5000,
},
},
})
: null;
DataRetentionProcessingQueue.instance?.on("error", (err) => {
logger.error("DataRetentionProcessingQueue error", err);
});
return DataRetentionProcessingQueue.instance;
}
}
@@ -0,0 +1,55 @@
import { Queue } from "bullmq";
import { QueueName, QueueJobs } from "../queues";
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
import { logger } from "../logger";
export class DataRetentionQueue {
private static instance: Queue | null = null;
public static getInstance(): Queue | null {
if (DataRetentionQueue.instance) {
return DataRetentionQueue.instance;
}
const newRedis = createNewRedisInstance({
enableOfflineQueue: false,
...redisQueueRetryOptions,
});
DataRetentionQueue.instance = newRedis
? new Queue(QueueName.DataRetentionQueue, {
connection: newRedis,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100,
attempts: 5,
backoff: {
type: "exponential",
delay: 5000,
},
},
})
: null;
DataRetentionQueue.instance?.on("error", (err) => {
logger.error("DataRetentionQueue error", err);
});
if (DataRetentionQueue.instance) {
logger.debug("Scheduling jobs for DataRetentionQueue");
DataRetentionQueue.instance
.add(
QueueJobs.DataRetentionJob,
{},
{
repeat: { pattern: "15 3 * * *" }, // every day at 3:15am
},
)
.catch((err) => {
logger.error("Error adding DataRetentionQueue schedule", err);
});
}
return DataRetentionQueue.instance;
}
}
@@ -13,6 +13,8 @@ import { PostHogIntegrationQueue } from "./postHogIntegrationQueue";
import { PostHogIntegrationProcessingQueue } from "./postHogIntegrationProcessingQueue";
import { CoreDataS3ExportQueue } from "./coreDataS3ExportQueue";
import { MeteringDataPostgresExportQueue } from "./meteringDataPostgresExportQueue";
import { DataRetentionQueue } from "./dataRetentionQueue";
import { DataRetentionProcessingQueue } from "./dataRetentionProcessingQueue";
export function getQueue(queueName: QueueName): Queue | null {
switch (queueName) {
@@ -44,6 +46,10 @@ export function getQueue(queueName: QueueName): Queue | null {
return CoreDataS3ExportQueue.getInstance();
case QueueName.MeteringDataPostgresExportQueue:
return MeteringDataPostgresExportQueue.getInstance();
case QueueName.DataRetentionQueue:
return DataRetentionQueue.getInstance();
case QueueName.DataRetentionProcessingQueue:
return DataRetentionProcessingQueue.getInstance();
default:
const exhaustiveCheckDefault: never = queueName;
throw new Error(`Queue ${queueName} not found`);
@@ -30,7 +30,7 @@ import { env } from "../../env";
export const checkTraceExists = async (
projectId: string,
traceId: string,
timestamp: Date | undefined,
timestamp: Date,
filter: FilterState,
): Promise<boolean> => {
const { tracesFilter } = getProjectIdDefaultFilter(projectId, {
@@ -61,18 +61,22 @@ export const checkTraceExists = async (
const query = `
WITH observations_agg AS (
SELECT
multiIf(
arrayExists(x -> x = 'ERROR', groupArray(level)), 'ERROR',
arrayExists(x -> x = 'WARNING', groupArray(level)), 'WARNING',
arrayExists(x -> x = 'DEFAULT', groupArray(level)), 'DEFAULT',
'DEBUG'
) AS level,
) AS aggregated_level,
countIf(level = 'ERROR') as error_count,
countIf(level = 'WARNING') as warning_count,
countIf(level = 'DEFAULT') as default_count,
countIf(level = 'DEBUG') as debug_count,
trace_id,
project_id
FROM observations o FINAL
WHERE o.project_id = {projectId: String}
${timeStampFilter ? `AND o.start_time >= {traceTimestamp: DateTime64(3)} - ${OBSERVATIONS_TO_TRACE_INTERVAL}` : ""}
AND o.start_time >= {timestamp: DateTime64(3)} - ${OBSERVATIONS_TO_TRACE_INTERVAL}
GROUP BY trace_id, project_id
)
SELECT
@@ -82,7 +86,7 @@ export const checkTraceExists = async (
${observationFilterRes ? `INNER JOIN observations_agg o ON t.id = o.trace_id AND t.project_id = o.project_id` : ""}
WHERE ${tracesFilterRes.query}
AND t.project_id = {projectId: String}
${timestamp ? `AND timestamp >= {timestamp: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}` : ""}
AND timestamp >= {timestamp: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}
GROUP BY t.id, t.project_id
`;
@@ -1,5 +1,6 @@
import { Readable } from "stream";
import {
DeleteObjectsCommand,
GetObjectCommand,
ListObjectsV2Command,
PutObjectCommand,
@@ -45,6 +46,8 @@ export interface StorageService {
contentType: string;
contentLength: number;
}): Promise<string>;
deleteFiles(paths: string[]): Promise<void>;
}
export class StorageServiceFactory {
@@ -201,6 +204,25 @@ class AzureBlobStorageService implements StorageService {
}
}
public async deleteFiles(paths: string[]): Promise<void> {
try {
await this.createContainerIfNotExists();
await Promise.all(
paths.map(async (path) => {
const blobClient = this.client.getBlobClient(path);
await blobClient.deleteIfExists();
}),
);
} catch (err) {
logger.error(
`Failed to delete files from Azure Blob Storage ${paths}`,
err,
);
throw Error("Failed to delete files from Azure Blob Storage");
}
}
public async listFiles(
prefix: string,
): Promise<{ file: string; createdAt: Date }[]> {
@@ -415,6 +437,37 @@ class S3StorageService implements StorageService {
}
}
public async deleteFiles(paths: string[]): Promise<void> {
const chunkSize = 900;
const chunks = [];
for (let i = 0; i < paths.length; i += chunkSize) {
chunks.push(paths.slice(i, i + chunkSize));
}
try {
for (const chunk of chunks) {
const command = new DeleteObjectsCommand({
Bucket: this.bucketName,
Delete: {
Objects: chunk.map((path) => ({ Key: path })),
Quiet: true,
},
});
const result = await this.client.send(command);
if (result?.Errors && result?.Errors?.length > 0) {
logger.error("Failed to delete files from S3", {
errors: result.Errors,
});
throw new Error("Failed to delete files from S3");
}
}
} catch (err) {
logger.error(`Failed to delete files from S3`, err);
throw new Error("Failed to delete files from S3");
}
}
public async getSignedUploadUrl(params: {
path: string;
ttlSeconds: number;
@@ -70,6 +70,10 @@ export type TracesMetricsUiReturnType = {
scores: ScoreAggregate;
usageDetails: Record<string, number>;
costDetails: Record<string, number>;
errorCount: bigint;
warningCount: bigint;
defaultCount: bigint;
debugCount: bigint;
};
export const convertToUiTableRows = (
@@ -125,6 +129,10 @@ export const convertToUITableMetrics = (
? new Decimal(row.cost_details.output)
: null,
level: row.level,
debugCount: BigInt(row.debug_count ?? 0),
warningCount: BigInt(row.warning_count ?? 0),
errorCount: BigInt(row.error_count ?? 0),
defaultCount: BigInt(row.default_count ?? 0),
};
};
@@ -138,6 +146,10 @@ export type TracesTableMetricsClickhouseReturnType = {
usage_details: Record<string, number>;
cost_details: Record<string, number>;
scores_avg: Array<{ name: string; avg_value: number }>;
error_count: number | null;
warning_count: number | null;
default_count: number | null;
debug_count: number | null;
};
export type FetchTracesTableProps = {
@@ -225,7 +237,11 @@ const getTracesTableGeneric = async <T>(props: FetchTracesTableProps) => {
os.latency_milliseconds / 1000 as latency,
os.cost_details as cost_details,
os.usage_details as usage_details,
os.level as level,
os.aggregated_level as level,
os.error_count as error_count,
os.warning_count as warning_count,
os.default_count as default_count,
os.debug_count as debug_count,
os.observation_count as observation_count,
s.scores_avg as scores_avg,
t.public as public`;
@@ -245,7 +261,6 @@ const getTracesTableGeneric = async <T>(props: FetchTracesTableProps) => {
t.public as public`;
break;
default:
const exhaustiveCheckDefault: never = select;
throw new Error(`Unknown select type: ${select}`);
}
@@ -361,12 +376,16 @@ const getTracesTableGeneric = async <T>(props: FetchTracesTableProps) => {
sumMap(usage_details) as usage_details,
SUM(total_cost) AS total_cost,
date_diff('millisecond', least(min(start_time), min(end_time)), greatest(max(start_time), max(end_time))) as latency_milliseconds,
countIf(level = 'ERROR') as error_count,
countIf(level = 'WARNING') as warning_count,
countIf(level = 'DEFAULT') as default_count,
countIf(level = 'DEBUG') as debug_count,
multiIf(
arrayExists(x -> x = 'ERROR', groupArray(level)), 'ERROR',
arrayExists(x -> x = 'WARNING', groupArray(level)), 'WARNING',
arrayExists(x -> x = 'DEFAULT', groupArray(level)), 'DEFAULT',
'DEBUG'
) AS level,
) AS aggregated_level,
sumMap(cost_details) as cost_details,
trace_id,
project_id
@@ -12,7 +12,7 @@ export const tracesTableUiColumnDefinitions: UiColumnMapping[] = [
uiTableName: "Level",
uiTableId: "level",
clickhouseTableName: "observations",
clickhouseSelect: "level",
clickhouseSelect: "aggregated_level",
},
{
uiTableName: "ID",
@@ -68,6 +68,30 @@ export const tracesTableUiColumnDefinitions: UiColumnMapping[] = [
clickhouseTableName: "traces",
clickhouseSelect: "tags",
},
{
uiTableName: "Warning Level Count",
uiTableId: "warningCount",
clickhouseTableName: "observations",
clickhouseSelect: "warning_count",
},
{
uiTableName: "Error Level Count",
uiTableId: "errorCount",
clickhouseTableName: "observations",
clickhouseSelect: "error_count",
},
{
uiTableName: "Default Level Count",
uiTableId: "defaultCount",
clickhouseTableName: "observations",
clickhouseSelect: "default_count",
},
{
uiTableName: "Debug Level Count",
uiTableId: "debugCount",
clickhouseTableName: "observations",
clickhouseSelect: "debug_count",
},
{
uiTableName: "Input Tokens",
uiTableId: "inputTokens",
@@ -88,6 +88,30 @@ export const tracesTableCols: ColumnDefinition[] = [
internal: 'generation_metrics."completionTokens"',
nullable: true,
},
{
name: "Error Level Count",
id: "errorCount",
type: "number",
internal: 'generation_metrics."errorCount"',
},
{
name: "Warning Level Count",
id: "warningCount",
type: "number",
internal: 'generation_metrics."warningCount"',
},
{
name: "Default Level Count",
id: "defaultCount",
type: "number",
internal: 'generation_metrics."defaultCount"',
},
{
name: "Debug Level Count",
id: "debugCount",
type: "number",
internal: 'generation_metrics."debugCount"',
},
{
name: "Total Tokens",
id: "totalTokens",
@@ -102,7 +126,6 @@ export const tracesTableCols: ColumnDefinition[] = [
internal: 'generation_metrics."totalTokens"',
nullable: true,
},
{
name: "Scores",
id: "scores_avg",
+26
View File
@@ -0,0 +1,26 @@
diff --git a/core/lib/oauth/client.js b/core/lib/oauth/client.js
index 52c51eb6ff422dc0899ccec31baf3fa39e42eeae..bc50c35bb617d0e86b68ca42f64d44b475ba4abb 100644
--- a/core/lib/oauth/client.js
+++ b/core/lib/oauth/client.js
@@ -1,5 +1,7 @@
"use strict";
+var HttpsProxyAgent = require('https-proxy-agent').HttpsProxyAgent;
+
Object.defineProperty(exports, "__esModule", {
value: true
});
@@ -7,7 +9,12 @@ exports.openidClient = openidClient;
var _openidClient = require("openid-client");
async function openidClient(options) {
const provider = options.provider;
- if (provider.httpOptions) _openidClient.custom.setHttpOptionsDefaults(provider.httpOptions);
+ let httpOptions = {};
+ if (provider.httpOptions) httpOptions = { ...provider.httpOptions };
+ if (process.env.AUTH_HTTPS_PROXY || process.env.AUTH_HTTP_PROXY) {
+ httpOptions.agent = new HttpsProxyAgent(process.env.AUTH_HTTPS_PROXY || process.env.AUTH_HTTP_PROXY);
+ }
+ _openidClient.custom.setHttpOptionsDefaults(httpOptions);
let issuer;
if (provider.wellKnown) {
issuer = await _openidClient.Issuer.discover(provider.wellKnown);
+352 -234
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "3.13.0",
"version": "3.17.0",
"private": true,
"license": "MIT",
"engines": {
@@ -109,9 +109,11 @@
"date-fns": "^3.3.1",
"dd-trace": "^5.23.1",
"decimal.js": "^10.4.3",
"diff": "^7.0.0",
"dompurify": "^3.1.5",
"fastest-levenshtein": "^1.0.16",
"graphql": "^16.9.0",
"https-proxy-agent": "^7.0.6",
"ioredis": "^5.4.1",
"ip-address": "^9.0.5",
"kysely": "^0.27.4",
@@ -158,6 +160,7 @@
"@testing-library/react": "^15.0.7",
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/diff": "^7.0.0",
"@types/dompurify": "^3.0.5",
"@types/eslint": "^8.56.7",
"@types/jest": "^29.5.12",
@@ -53,6 +53,7 @@ components:
properties:
id:
type: string
nullable: true
traceId:
type: string
example: cdef-1234-5678-90ab
@@ -67,15 +68,19 @@ components:
values must equal either 1 or 0 (true or false)
observationId:
type: string
nullable: true
comment:
type: string
nullable: true
dataType:
$ref: '#/components/schemas/ScoreDataType'
nullable: true
description: >-
When set, must match the score value's type. If not set, will be
inferred from the score value or config
configId:
type: string
nullable: true
description: >-
Reference a score config on a score. When set, the score name must
equal the config name and scores must comply with the config's range
@@ -100,6 +105,7 @@ components:
$ref: '#/components/schemas/ScoreSource'
observationId:
type: string
nullable: true
timestamp:
type: string
format: date-time
@@ -111,10 +117,13 @@ components:
format: date-time
authorUserId:
type: string
nullable: true
comment:
type: string
nullable: true
configId:
type: string
nullable: true
description: >-
Reference a score config on a score. When set, config and score name
must be equal and value must comply to optionally defined numerical
@@ -166,6 +175,7 @@ components:
value:
type: number
format: double
nullable: true
description: >-
Only defined if a config is linked. Represents the numeric category
mapping of the stringValue
+71 -1
View File
@@ -737,7 +737,7 @@ paths:
- Ingestion
parameters: []
responses:
'200':
'207':
description: ''
content:
application/json:
@@ -1426,6 +1426,76 @@ paths:
schema: {}
security:
- BasicAuth: []
/api/public/v2/prompts/{promptName}/version/{version}:
patch:
description: Update labels for a specific prompt version
operationId: promptVersion_update
tags:
- PromptVersion
parameters:
- name: promptName
in: path
description: The name of the prompt
required: true
schema:
type: string
- name: version
in: path
description: Version of the prompt to update
required: true
schema:
type: integer
responses:
'200':
description: ''
content:
application/json:
schema:
$ref: '#/components/schemas/Prompt'
'400':
description: ''
content:
application/json:
schema: {}
'401':
description: ''
content:
application/json:
schema: {}
'403':
description: ''
content:
application/json:
schema: {}
'404':
description: ''
content:
application/json:
schema: {}
'405':
description: ''
content:
application/json:
schema: {}
security:
- BasicAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
newLabels:
type: array
items:
type: string
description: >-
New labels for the prompt version. Labels are unique across
versions. The "latest" label is reserved and managed by
Langfuse.
required:
- newLabels
/api/public/v2/prompts/{promptName}:
get:
description: Get a prompt
@@ -1057,6 +1057,61 @@
}
]
},
{
"_type": "container",
"description": null,
"name": "Prompt Version",
"item": [
{
"_type": "endpoint",
"name": "Update",
"request": {
"description": "Update labels for a specific prompt version",
"url": {
"raw": "{{baseUrl}}/api/public/v2/prompts/:promptName/version/:version",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"public",
"v2",
"prompts",
":promptName",
"version",
":version"
],
"query": [],
"variable": [
{
"key": "promptName",
"value": "",
"description": "The name of the prompt"
},
{
"key": "version",
"value": "",
"description": "Version of the prompt to update"
}
]
},
"header": [],
"method": "PATCH",
"auth": null,
"body": {
"mode": "raw",
"raw": "{\n \"newLabels\": [\n \"example\"\n ]\n}",
"options": {
"raw": {
"language": "json"
}
}
}
},
"response": []
}
]
},
{
"_type": "container",
"description": null,
@@ -312,4 +312,52 @@ describe("Clickhouse Traces Repository Test", () => {
const exists = await checkTraceExists(projectId, traceId, new Date(), []);
expect(exists).toBe(true);
});
it("should check if trace exists with error level count > 0", async () => {
const traceId = v4();
const trace = createTrace({
id: traceId,
user_id: "user-1",
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
metadata: { key: "value" },
release: "1.0.0",
version: "2.0.0",
});
const observations = [
createObservation({
trace_id: trace.id,
project_id: trace.project_id,
name: "observation-name",
end_time: new Date().getTime(),
start_time: new Date().getTime() - 1000,
input: "input",
output: "output",
provided_model_name: "model-1",
level: "ERROR",
}),
createObservation({
trace_id: trace.id,
project_id: trace.project_id,
name: "observation-name-2",
end_time: new Date().getTime(),
start_time: new Date().getTime() - 100000,
input: "input-2",
output: "output-2",
provided_model_name: "model-2",
}),
];
await createTracesCh([trace]);
await createObservationsCh(observations);
const exists = await checkTraceExists(projectId, traceId, new Date(), [
{
type: "number",
column: "errorCount",
operator: ">",
value: 0,
},
]);
expect(exists).toBe(true);
});
});
@@ -271,4 +271,76 @@ describe("/api/public/traces API Endpoint", () => {
expect(response.status).toBe(400);
});
it("LFE-3699: should fetch a single trace with unescaped metadata via traces list", async () => {
const traceId = randomUUID();
const trace = createTrace({
id: traceId,
name: "trace-name1",
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
metadata: { key: JSON.stringify({ foo: "bar" }) },
input: JSON.stringify({
args: [
{
foo: "bar",
},
],
}),
});
await createTracesCh([trace]);
const traces = await makeZodVerifiedAPICall(
GetTracesV1Response,
"GET",
`/api/public/traces`,
);
const traceResponse = traces.body.data.find((t) => t.id === traceId);
expect(traceResponse).toBeDefined();
expect(traceResponse!.name).toBe("trace-name1");
expect(traceResponse!.metadata).toEqual({ key: { foo: "bar" } });
expect(traceResponse!.input).toEqual({
args: [
{
foo: "bar",
},
],
});
});
it("LFE-3699: should fetch a single trace with unescaped metadata via single trace endpoint", async () => {
const traceId = randomUUID();
const trace = createTrace({
id: traceId,
name: "trace-name1",
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
metadata: { key: JSON.stringify({ foo: "bar" }) },
input: JSON.stringify({
args: [
{
foo: "bar",
},
],
}),
});
await createTracesCh([trace]);
const traceResponse = await makeZodVerifiedAPICall(
GetTraceV1Response,
"GET",
`/api/public/traces/${traceId}`,
);
expect(traceResponse.body.name).toBe("trace-name1");
expect(traceResponse.body.metadata).toEqual({ key: { foo: "bar" } });
expect(traceResponse.body.input).toEqual({
args: [
{
foo: "bar",
},
],
});
});
});
+149 -1
View File
@@ -13,7 +13,10 @@ import {
import { nanoid } from "ai";
import { type PromptsMetaResponse } from "@/src/features/prompts/server/actions/getPromptsMeta";
import { getObservationById } from "@langfuse/shared/src/server";
import {
createOrgProjectAndApiKey,
getObservationById,
} from "@langfuse/shared/src/server";
const projectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
const baseURI = "/api/public/v2/prompts";
@@ -1113,6 +1116,151 @@ describe("/api/public/v2/prompts API Endpoint", () => {
});
});
describe("PATCH api/public/v2/prompts/[promptName]/version/[version]", () => {
it("should update the labels of a prompt", async () => {
const { projectId: newProjectId, auth: newAuth } =
await createOrgProjectAndApiKey();
const originalPrompt = await prisma.prompt.create({
data: {
name: "prompt-1",
projectId: newProjectId,
version: 1,
labels: ["production"],
createdBy: "user-test",
prompt: "prompt-1",
},
});
const response = await makeAPICall(
"PATCH",
`${baseURI}/prompt-1/version/1`,
{
newLabels: ["new-label"],
},
newAuth,
);
expect(response.status).toBe(200);
const updatedPrompt = await prisma.prompt.findUnique({
where: {
id: originalPrompt.id,
},
});
expect(updatedPrompt?.labels).toContain("production");
expect(updatedPrompt?.labels).toContain("new-label");
expect(updatedPrompt?.labels).toHaveLength(2);
});
it("should remove label from previous version when adding to new version", async () => {
const { projectId: newProjectId, auth: newAuth } =
await createOrgProjectAndApiKey();
// Create version 1 with "production" label
await prisma.prompt.create({
data: {
name: "prompt-1",
projectId: newProjectId,
version: 1,
labels: ["production"],
createdBy: "user-test",
prompt: "prompt-1",
},
});
// Create version 2 initially without the label
await prisma.prompt.create({
data: {
name: "prompt-1",
projectId: newProjectId,
version: 2,
labels: [],
createdBy: "user-test",
prompt: "prompt-1",
},
});
// Add "production" label to version 2
const response = await makeAPICall(
"PATCH",
`${baseURI}/prompt-1/version/2`,
{
newLabels: ["production"],
},
newAuth,
);
expect(response.status).toBe(200);
const responseBody = response.body as unknown as Prompt;
expect(responseBody.labels).toEqual(["production"]);
// Check version 2 got the label
const promptV2 = await prisma.prompt.findFirst({
where: {
projectId: newProjectId,
name: "prompt-1",
version: 2,
},
});
expect(promptV2?.labels).toEqual(["production"]);
// Check version 1 had the label removed
const promptV1 = await prisma.prompt.findFirst({
where: {
projectId: newProjectId,
name: "prompt-1",
version: 1,
},
});
expect(promptV1?.labels).toEqual([]);
});
it("trying to set 'latest' label results in 400 error", async () => {
const { projectId: newProjectId, auth: newAuth } =
await createOrgProjectAndApiKey();
// Create initial prompt version
await prisma.prompt.create({
data: {
name: "prompt-1",
projectId: newProjectId,
version: 1,
labels: [],
createdBy: "user-test",
prompt: "prompt-1",
},
});
// Try to set "latest" label
const response = await makeAPICall(
"PATCH",
`${baseURI}/prompt-1/version/1`,
{
newLabels: ["latest"],
},
newAuth,
);
expect(response.status).toBe(400);
});
it("updating non existing prompt results in 404", async () => {
const { auth: newAuth } = await createOrgProjectAndApiKey();
// Try to update non-existing prompt
const response = await makeAPICall(
"PATCH",
`${baseURI}/non-existing-prompt/version/1`,
{
newLabels: ["production"],
},
newAuth,
);
expect(response.status).toBe(404);
});
});
const isPrompt = (x: unknown): x is Prompt => {
if (typeof x !== "object" || x === null) return false;
const prompt = x as Prompt;
+193
View File
@@ -0,0 +1,193 @@
import React, { useEffect, useState } from "react";
import { Card, CardContent } from "@/src/components/ui/card";
import { cn } from "@/src/utils/tailwind";
import { diffChars } from "diff";
type DiffLine = {
text: string;
type: "unchanged" | "removed" | "added" | "empty";
parts?: {
value: string;
type?: "removed" | "added";
}[];
};
type DiffViewerProps = {
oldString: string;
newString: string;
oldLabel?: string;
newLabel?: string;
className?: string;
};
const DIFF_COLORS = {
added: {
text: "bg-green-500/30",
line: "bg-green-500/10",
},
removed: {
text: "bg-destructive/60",
line: "bg-destructive/10",
},
empty: "bg-muted",
} as const;
const DiffViewer: React.FC<DiffViewerProps> = ({
oldString,
newString,
oldLabel = "Original Version",
newLabel = "New Version",
className,
}) => {
const [diffLines, setDiffLines] = useState<{
left: DiffLine[];
right: DiffLine[];
}>({ left: [], right: [] });
useEffect(() => {
const left: DiffLine[] = [];
const right: DiffLine[] = [];
// Get the complete diff first
const changes = diffChars(oldString, newString, {});
// Group changes by line
const oldParts: { value: string; type?: "removed" }[][] = [[]];
const newParts: { value: string; type?: "added" }[][] = [[]];
changes.forEach((part) => {
const lines = part.value.split("\n");
lines.forEach((line, idx) => {
if (idx > 0) {
if (!part.added) oldParts.push([]);
if (!part.removed) newParts.push([]);
}
if (!part.added) {
oldParts[oldParts.length - 1].push({
value: line,
type: part.removed ? "removed" : undefined,
});
}
if (!part.removed) {
newParts[newParts.length - 1].push({
value: line,
type: part.added ? "added" : undefined,
});
}
});
});
// Convert parts to DiffLines
const maxLength = Math.max(oldParts.length, newParts.length);
for (let i = 0; i < maxLength; i++) {
const oldLineParts = oldParts[i] || [];
const newLineParts = newParts[i] || [];
if (oldLineParts.length === 0 && newLineParts.length === 0) {
left.push({ text: "", type: "empty" });
right.push({ text: "", type: "empty" });
continue;
}
const oldText = oldLineParts.map((p) => p.value).join("");
const newText = newLineParts.map((p) => p.value).join("");
if (oldText === newText) {
left.push({ text: oldText, type: "unchanged" });
right.push({ text: newText, type: "unchanged" });
} else {
left.push({
text: oldText,
type: oldText ? "removed" : "empty",
parts: oldLineParts.length ? oldLineParts : undefined,
});
right.push({
text: newText,
type: newText ? "added" : "empty",
parts: newLineParts.length ? newLineParts : undefined,
});
}
}
setDiffLines({ left, right });
}, [oldString, newString]);
const DiffRow: React.FC<{ leftLine: DiffLine; rightLine: DiffLine }> = ({
leftLine,
rightLine,
}) => {
const typeClasses = {
unchanged: "",
removed: DIFF_COLORS.removed.line,
added: DIFF_COLORS.added.line,
empty: DIFF_COLORS.empty,
};
const renderContent = (line: DiffLine) =>
line.parts
? line.parts.map((part, idx) => (
<span
key={idx}
className={part.type ? DIFF_COLORS[part.type].text : undefined}
>
{part.value}
</span>
))
: line.text || "\u00A0";
return (
<div className="grid grid-cols-2">
<div
className={cn(
"whitespace-pre-wrap break-words border-r px-4 py-1 font-mono text-xs",
typeClasses[leftLine.type],
)}
>
{renderContent(leftLine)}
</div>
<div
className={cn(
"whitespace-pre-wrap break-words px-4 py-1 font-mono text-xs",
typeClasses[rightLine.type],
)}
>
{renderContent(rightLine)}
</div>
</div>
);
};
if (oldString === newString) {
return <div className="text-sm text-muted-foreground">No changes</div>;
}
return (
<div className={cn("w-full", className)}>
<Card>
<CardContent className="p-0">
<div className="grid grid-cols-2">
<div className="border-b border-r bg-muted px-4 py-2 text-xs font-semibold">
{oldLabel}
</div>
<div className="border-b bg-muted px-4 py-2 text-xs font-semibold">
{newLabel}
</div>
</div>
<div>
{diffLines.left.map((leftLine, idx) => (
<DiffRow
key={idx}
leftLine={leftLine}
rightLine={diffLines.right[idx]}
/>
))}
</div>
</CardContent>
</Card>
</div>
);
};
export default DiffViewer;
+3
View File
@@ -13,10 +13,12 @@ export function JsonEditor({
editable = true,
lineWrapping = true,
className,
onBlur,
}: {
defaultValue: string;
onChange?: (value: string) => void;
editable?: boolean;
onBlur?: () => void;
lineWrapping?: boolean;
className?: string;
}) {
@@ -45,6 +47,7 @@ export function JsonEditor({
if (onChange) onChange(c);
setLinterEnabled(c !== "");
}}
onBlur={onBlur}
className={cn("overflow-hidden rounded-md border text-xs", className)}
editable={editable}
/>
+11 -1
View File
@@ -68,7 +68,17 @@ function useSessionWithRetryOnUnauthenticated() {
useEffect(() => {
if (session.status === "unauthenticated" && retryCount < MAX_RETRIES) {
const fetchSession = async () => {
await getSession({ broadcast: true });
try {
await getSession({ broadcast: true });
} catch (error) {
console.error(
"Error fetching session:",
error,
"\nError details:",
JSON.stringify(error, null, 2),
);
throw error;
}
setRetryCount((prevCount) => prevCount + 1);
};
fetchSession();
+13
View File
@@ -1,6 +1,19 @@
import { type ObservationLevel } from "@langfuse/shared";
export const LevelColors = {
DEFAULT: { text: "", bg: "" },
DEBUG: { text: "text-muted-foreground", bg: "bg-primary-foreground" },
WARNING: { text: "text-dark-yellow", bg: "bg-light-yellow" },
ERROR: { text: "text-dark-red", bg: "bg-light-red" },
};
export const LevelSymbols = {
DEFAULT: "️",
DEBUG: "🔍",
WARNING: "⚠️",
ERROR: "🚨",
};
export const formatAsLabel = (countLabel: string) => {
return countLabel.replace(/Count$/, "").toUpperCase() as ObservationLevel;
};
+53 -1
View File
@@ -24,7 +24,11 @@ import {
import type Decimal from "decimal.js";
import { numberFormatter, usdFormatter } from "@/src/utils/numbers";
import { DeleteButton } from "@/src/components/deleteButton";
import { LevelColors } from "@/src/components/level-colors";
import {
formatAsLabel,
LevelColors,
LevelSymbols,
} from "@/src/components/level-colors";
import { cn } from "@/src/utils/tailwind";
import { useDetailPageLists } from "@/src/features/navigate-detail-pages/context";
import { useOrderByState } from "@/src/features/orderBy/hooks/useOrderByState";
@@ -52,6 +56,8 @@ import { BatchExportTableButton } from "@/src/components/BatchExportTableButton"
import { BreakdownTooltip } from "@/src/components/trace/BreakdownToolTip";
import { InfoIcon } from "lucide-react";
import { useHasEntitlement } from "@/src/features/entitlements/hooks";
import { Separator } from "@/src/components/ui/separator";
import React from "react";
export type TracesTableRow = {
bookmarked: boolean;
@@ -61,6 +67,12 @@ export type TracesTableRow = {
userId: string;
level?: ObservationLevel;
observationCount?: bigint;
levelCounts: {
errorCount?: bigint;
warningCount?: bigint;
debugCount?: bigint;
defaultCount?: bigint;
};
// scores holds grouped column with individual scores
scores?: ScoreAggregate;
latency?: number;
@@ -629,6 +641,40 @@ export default function TracesTable({
defaultHidden: true,
enableSorting: true,
},
{
accessorKey: "levelCounts",
id: "levelCounts",
header: "Observation Levels",
size: 75,
cell: ({ row }) => {
const value: TracesTableRow["levelCounts"] =
row.getValue("levelCounts");
if (!traceMetrics.data) return <Skeleton className="h-3 w-1/2" />;
const nonZeroCounts = Object.entries(value).filter(
([_, count]) => count > 0,
);
return (
<div className="flex min-h-6 flex-row gap-2 overflow-x-auto whitespace-nowrap">
{nonZeroCounts.map(([level, count], index) => (
<React.Fragment key={level}>
<div className="flex min-w-6 flex-row gap-2">
<span className="text-xs">
{LevelSymbols[formatAsLabel(level)]}{" "}
{numberFormatter(count, 0)}
</span>
</div>
{index < nonZeroCounts.length - 1 && (
<Separator orientation="vertical" className="h-5" />
)}
</React.Fragment>
))}
</div>
);
},
enableHiding: true,
},
{
accessorKey: "observationCount",
id: "observationCount",
@@ -754,6 +800,12 @@ export default function TracesTable({
completionTokens: trace.completionTokens,
totalTokens: trace.totalTokens,
},
levelCounts: {
errorCount: trace.errorCount,
warningCount: trace.warningCount,
defaultCount: trace.defaultCount,
debugCount: trace.debugCount,
},
usageDetails: trace.usageDetails,
costDetails: trace.costDetails,
scores: trace.scores
+81 -23
View File
@@ -76,6 +76,7 @@ function TreeItemInner({
totalScaleSpan,
type,
startOffset = 0,
firstTokenTimeOffset,
name,
children,
setBackgroundColor,
@@ -86,6 +87,7 @@ function TreeItemInner({
totalScaleSpan: number;
type: TreeItemType;
startOffset?: number;
firstTokenTimeOffset?: number;
name?: string | null;
children?: React.ReactNode;
setBackgroundColor: (color: string) => void;
@@ -147,31 +149,80 @@ function TreeItemInner({
<div className="flex items-center" style={{ width: `${SCALE_WIDTH}px` }}>
<div className={`relative w-[${SCALE_WIDTH}px]`}>
<div className="ml-4 mr-4 h-full border-r-2"></div>
<div
className={cn(
"flex h-5 items-center justify-end rounded-sm",
itemWidth
? treeItemColors.get(type)
: "border border-dashed bg-muted",
)}
style={{
width: `${itemWidth || 10}px`,
marginLeft: `${startOffset}px`,
}}
>
<span
{firstTokenTimeOffset ? (
<div className="flex" style={{ marginLeft: `${startOffset}px` }}>
<div
className={cn(
"flex h-5 items-center justify-end rounded-l-sm border-r border-gray-400 opacity-60",
itemWidth
? treeItemColors.get(type)
: "border border-dashed bg-muted",
)}
style={{
width: `${firstTokenTimeOffset - startOffset}px`,
}}
>
<span
className={cn(
"text text-xs font-light italic text-foreground group-hover:block",
firstTokenTimeOffset - startOffset < 30 ? "-mr-14" : "mr-2",
)}
>
First token
</span>
</div>
<div
className={cn(
"flex h-5 items-center justify-end rounded-r-sm",
itemWidth
? treeItemColors.get(type)
: "border border-dashed bg-muted",
)}
style={{
width: `${itemWidth - (firstTokenTimeOffset - startOffset)}px`,
}}
>
<span
className={cn(
"hidden justify-end text-xs text-muted-foreground group-hover:block",
itemOffsetLabelWidth > SCALE_WIDTH
? "mr-1"
: isPresent(latency)
? `-mr-9`
: "-mr-6",
)}
>
{isPresent(latency) ? `${latency.toFixed(2)}s` : "n/a"}
</span>
</div>
</div>
) : (
<div
className={cn(
"hidden justify-end text-xs text-muted-foreground group-hover:block",
itemOffsetLabelWidth > SCALE_WIDTH
? "mr-1"
: isPresent(latency)
? `-mr-9`
: "-mr-6",
"flex h-5 items-center justify-end rounded-sm",
itemWidth
? treeItemColors.get(type)
: "border border-dashed bg-muted",
)}
style={{
width: `${itemWidth || 10}px`,
marginLeft: `${startOffset}px`,
}}
>
{isPresent(latency) ? `${latency.toFixed(2)}s` : "n/a"}
</span>
</div>
<span
className={cn(
"hidden justify-end text-xs text-muted-foreground group-hover:block",
itemOffsetLabelWidth > SCALE_WIDTH
? "mr-1"
: isPresent(latency)
? `-mr-9`
: "-mr-6",
)}
>
{isPresent(latency) ? `${latency.toFixed(2)}s` : "n/a"}
</span>
</div>
)}
</div>
</div>
</div>
@@ -199,7 +250,7 @@ function TraceTreeItem({
cardWidth: number;
commentCounts?: Map<string, number>;
}) {
const { startTime, endTime } = observation || {};
const { startTime, completionStartTime, endTime } = observation || {};
const [backgroundColor, setBackgroundColor] = useState("");
const latency = endTime
@@ -208,6 +259,12 @@ function TraceTreeItem({
const startOffset =
((startTime.getTime() - traceStartTime.getTime()) / totalScaleSpan / 1000) *
SCALE_WIDTH;
const firstTokenTimeOffset = completionStartTime
? ((completionStartTime.getTime() - traceStartTime.getTime()) /
totalScaleSpan /
1000) *
SCALE_WIDTH
: undefined;
return (
<TreeItem
@@ -224,6 +281,7 @@ function TraceTreeItem({
type={observation.type}
name={observation.name}
startOffset={startOffset}
firstTokenTimeOffset={firstTokenTimeOffset}
totalScaleSpan={totalScaleSpan}
setBackgroundColor={setBackgroundColor}
level={level}
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v3.13.0";
export const VERSION = "v3.17.0";
@@ -21,12 +21,18 @@ import { Alert, AlertDescription, AlertTitle } from "@/src/components/ui/alert";
import { MAX_EVENTS_FREE_PLAN } from "@/src/ee/features/billing/constants";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/src/components/ui/dialog";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { stripeProducts } from "@/src/ee/features/billing/utils/stripeProducts";
import { toast } from "sonner";
import { ActionButton } from "@/src/components/ActionButton";
export const BillingSettings = () => {
const router = useRouter();
@@ -163,18 +169,19 @@ const BillingPortalOrPricingPageButton = () => {
router.push(url);
},
});
if (!organization) return null;
if (billingPortalUrl.isLoading) return null;
if (billingPortalUrl.data)
return (
<Button asChild>
<Link href={billingPortalUrl.data}>Billing portal</Link>
</Button>
);
const mutChangePlan =
api.cloudBilling.changeStripeSubscriptionProduct.useMutation({
onSuccess: () => {
toast.success("Plan changed successfully");
// wait 1 second before reloading
setTimeout(() => {
window.location.reload();
}, 500);
},
});
// Do not show checkout or customer portal if manual plan is set in cloud config
if (organization.cloudConfig?.plan) {
if (organization?.cloudConfig?.plan) {
if (chatAvailable)
return (
<Button
@@ -191,8 +198,7 @@ const BillingPortalOrPricingPageButton = () => {
else return null;
}
// Show pricing page button
return (
const switchPlan = (
<Dialog
onOpenChange={(open) => {
if (open) {
@@ -230,22 +236,89 @@ const BillingPortalOrPricingPageButton = () => {
</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>
{organization?.cloudConfig?.stripe?.activeProductId ? (
// Change plan
<Dialog>
<DialogTrigger asChild>
<Button
disabled={
organization?.cloudConfig?.stripe?.activeProductId ===
product.stripeProductId
}
className="mt-auto"
>
{organization?.cloudConfig?.stripe?.activeProductId ===
product.stripeProductId
? "Current plan"
: "Change plan"}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm Plan Change</DialogTitle>
<DialogDescription>
Changing your plan will immediately generate an
invoice for any usage on your current plan. Your new
plan and billing period will start today. Are you sure
you want to continue?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="secondary">Cancel</Button>
</DialogClose>
<ActionButton
onClick={() => {
if (organization) {
mutChangePlan.mutate({
orgId: organization.id,
stripeProductId: product.stripeProductId,
});
}
}}
loading={mutChangePlan.isLoading}
>
Confirm
</ActionButton>
</DialogFooter>
</DialogContent>
</Dialog>
) : (
// Upgrade, no plan yet
<ActionButton
onClick={() => {
if (organization)
mutCreateCheckoutSession.mutate({
orgId: organization.id,
stripeProductId: product.stripeProductId,
});
}}
disabled={
organization?.cloudConfig?.stripe?.activeProductId ===
product.stripeProductId
}
className="mt-auto"
loading={mutCreateCheckoutSession.isLoading}
>
Select plan
</ActionButton>
)}
</div>
))}
</div>
</DialogContent>
</Dialog>
);
// Show pricing page button
return (
<>
{switchPlan}
{billingPortalUrl.data && (
<Button asChild>
<Link href={billingPortalUrl.data}>Billing portal</Link>
</Button>
)}
</>
);
};
@@ -147,6 +147,130 @@ export const cloudBillingRouter = createTRPCRouter({
return session.url;
}),
changeStripeSubscriptionProduct: 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,
});
// check that product is valid
if (
!stripeProducts
.filter((i) => Boolean(i.checkout))
.map((i) => i.stripeProductId)
.includes(input.stripeProductId)
)
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Invalid stripe product id, product not available",
});
const org = await ctx.prisma.organization.findUnique({
where: {
id: input.orgId,
},
});
if (!org) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Organization not found",
});
}
const parsedOrg = parseDbOrg(org);
if (parsedOrg.cloudConfig?.plan)
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Cannot change plan for orgs that have a manual/legacy plan",
});
const stripeSubscriptionId =
parsedOrg.cloudConfig?.stripe?.activeSubscriptionId;
if (!stripeSubscriptionId)
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Organization does not have an active subscription",
});
if (!stripeClient)
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Stripe client not initialized",
});
const subscription =
await stripeClient.subscriptions.retrieve(stripeSubscriptionId);
if (
["canceled", "paused", "incomplete", "incomplete_expired"].includes(
subscription.status,
)
)
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message:
"Subscription is not active, current status: " +
subscription.status,
});
if (subscription.items.data.length !== 1)
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Subscription has multiple items",
});
const item = subscription.items.data[0];
if (
!stripeProducts
.map((i) => i.stripeProductId)
.includes(item.price.product as string)
)
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Current subscription product is not a valid product",
});
const newProduct = await stripeClient.products.retrieve(
input.stripeProductId,
);
if (!newProduct.default_price)
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "New product does not have a default price in Stripe",
});
await stripeClient.subscriptions.update(stripeSubscriptionId, {
items: [
// remove current product from subscription
{
id: item.id,
deleted: true,
},
// add new product to subscription
{
price: newProduct.default_price as string,
},
],
// reset billing cycle which causes immediate invoice for existing plan
billing_cycle_anchor: "now",
proration_behavior: "none",
});
}),
getStripeCustomerPortalUrl: protectedOrganizationProcedure
.input(
z.object({
@@ -69,7 +69,7 @@ import { showSuccessToast } from "@/src/features/notifications/showSuccessToast"
const formSchema = z.object({
scoreName: z.string(),
target: z.string(),
filter: z.array(singleFilter).nullable(), // re-using the filter type from the tables
filter: z.array(singleFilter).nullable(), // reusing the filter type from the tables
mapping: z.array(wipVariableMapping),
sampling: z.coerce.number().gt(0).lte(1),
delay: z.coerce.number().optional().default(10),
+6 -2
View File
@@ -20,6 +20,7 @@ import {
import { decrypt } from "@langfuse/shared/encryption";
import { throwIfNoEntitlement } from "@/src/features/entitlements/server/hasEntitlement";
import {
decryptAndParseExtraHeaders,
fetchLLMCompletion,
getScoresByIds,
LLMApiKeySchema,
@@ -37,7 +38,7 @@ const APIEvaluatorSchema = z.object({
evalTemplateId: z.string(),
scoreName: z.string(),
targetObject: z.string(),
filter: z.array(singleFilter).nullable(), // re-using the filter type from the tables
filter: z.array(singleFilter).nullable(), // reusing the filter type from the tables
variableMapping: z.array(variableMapping),
sampling: z.instanceof(Prisma.Decimal),
delay: z.number(),
@@ -93,7 +94,7 @@ const CreateEvalJobSchema = z.object({
evalTemplateId: z.string(),
scoreName: z.string().min(1),
target: z.string(),
filter: z.array(singleFilter).nullable(), // re-using the filter type from the tables
filter: z.array(singleFilter).nullable(), // reusing the filter type from the tables
mapping: z.array(variableMapping),
sampling: z.number().gt(0).lte(1),
delay: z.number().gte(0).default(DEFAULT_TRACE_JOB_DELAY), // 10 seconds default
@@ -536,6 +537,9 @@ export const evalRouter = createTRPCRouter({
await fetchLLMCompletion({
streaming: false,
apiKey: decrypt(parsedKey.data.secretKey), // decrypt the secret key
extraHeaders: decryptAndParseExtraHeaders(
parsedKey.data.extraHeaders,
),
baseURL: parsedKey.data.baseURL ?? undefined,
messages: [
{
+15 -12
View File
@@ -29,17 +29,17 @@ export const GithubProviderSchema = base.extend({
});
export const GithubEnterpriseProviderSchema = base.extend({
authProvider: z.literal("github-enterprise"),
authConfig: z
.object({
clientId: z.string(),
clientSecret: z.string(),
enterprise: z.object({
baseUrl: z.string().url(),
}),
allowDangerousEmailAccountLinking: z.boolean().optional().default(false),
})
.nullish(),
authProvider: z.literal("github-enterprise"),
authConfig: z
.object({
clientId: z.string(),
clientSecret: z.string(),
enterprise: z.object({
baseUrl: z.string().url(),
}),
allowDangerousEmailAccountLinking: z.boolean().optional().default(false),
})
.nullish(),
});
export const GitlabProviderSchema = base.extend({
@@ -123,6 +123,7 @@ export const CustomProviderSchema = base.extend({
clientSecret: z.string(),
issuer: z.string(),
scope: z.string().nullish(),
idToken: z.boolean().optional().default(true),
allowDangerousEmailAccountLinking: z.boolean().optional().default(false),
})
.nullish(),
@@ -130,7 +131,9 @@ export const CustomProviderSchema = base.extend({
export type GoogleProviderSchema = z.infer<typeof GoogleProviderSchema>;
export type GithubProviderSchema = z.infer<typeof GithubProviderSchema>;
export type GithubEnterpriseProviderSchema = z.infer<typeof GithubEnterpriseProviderSchema>;
export type GithubEnterpriseProviderSchema = z.infer<
typeof GithubEnterpriseProviderSchema
>;
export type GitlabProviderSchema = z.infer<typeof GitlabProviderSchema>;
export type Auth0ProviderSchema = z.infer<typeof Auth0ProviderSchema>;
export type OktaProviderSchema = z.infer<typeof OktaProviderSchema>;
@@ -17,6 +17,7 @@ import {
LLMApiKeySchema,
logger,
fetchLLMCompletion,
decryptAndParseExtraHeaders,
} from "@langfuse/shared/src/server";
export default async function chatCompletionHandler(req: NextRequest) {
@@ -51,6 +52,7 @@ export default async function chatCompletionHandler(req: NextRequest) {
streaming: true,
callbacks: [new PosthogCallbackHandler("playground", body, userId)],
apiKey: decrypt(parsedKey.data.secretKey),
extraHeaders: decryptAndParseExtraHeaders(parsedKey.data.extraHeaders),
baseURL: parsedKey.data.baseURL || undefined,
config: parsedKey.data.config,
});
+66 -11
View File
@@ -1,6 +1,26 @@
import { z } from "zod";
import { createEnv } from "@t3-oss/env-nextjs";
const zAuthMethod = z
.enum([
"client_secret_basic",
"client_secret_post",
"client_secret_jwt",
"private_key_jwt",
"tls_client_auth",
"self_signed_tls_client_auth",
"none",
])
.optional()
.default("client_secret_basic");
const zAuthChecks = z
.string()
.optional()
.transform((s) => s?.split(",").map((s) => s.trim()))
.pipe(z.array(z.enum(["nonce", "none", "pkce", "state"])).optional());
export const env = createEnv({
/**
* Specify your server-side environment variables schema here. This way you can ensure the app
@@ -58,56 +78,66 @@ export const env = createEnv({
AUTH_GOOGLE_CLIENT_SECRET: z.string().optional(),
AUTH_GOOGLE_ALLOWED_DOMAINS: z.string().optional(),
AUTH_GOOGLE_ALLOW_ACCOUNT_LINKING: z.enum(["true", "false"]).optional(),
AUTH_GOOGLE_CLIENT_AUTH_METHOD: zAuthMethod,
AUTH_GOOGLE_CHECKS: zAuthChecks,
AUTH_GITHUB_CLIENT_ID: z.string().optional(),
AUTH_GITHUB_CLIENT_SECRET: z.string().optional(),
AUTH_GITHUB_ALLOW_ACCOUNT_LINKING: z.enum(["true", "false"]).optional(),
AUTH_GITHUB_CLIENT_AUTH_METHOD: zAuthMethod,
AUTH_GITHUB_CHECKS: zAuthChecks,
AUTH_GITHUB_ENTERPRISE_CLIENT_ID: z.string().optional(),
AUTH_GITHUB_ENTERPRISE_CLIENT_SECRET: z.string().optional(),
AUTH_GITHUB_ENTERPRISE_BASE_URL: z.string().optional(),
AUTH_GITHUB_ENTERPRISE_ALLOW_ACCOUNT_LINKING: z
.enum(["true", "false"])
.optional(),
AUTH_GITHUB_ENTERPRISE_CLIENT_AUTH_METHOD: zAuthMethod,
AUTH_GITHUB_ENTERPRISE_CHECKS: zAuthChecks,
AUTH_GITLAB_CLIENT_ID: z.string().optional(),
AUTH_GITLAB_CLIENT_SECRET: z.string().optional(),
AUTH_GITLAB_ALLOW_ACCOUNT_LINKING: z.enum(["true", "false"]).optional(),
AUTH_GITLAB_ISSUER: z.string().optional(),
AUTH_GITLAB_CLIENT_AUTH_METHOD: zAuthMethod,
AUTH_GITLAB_CHECKS: zAuthChecks,
AUTH_AZURE_AD_CLIENT_ID: z.string().optional(),
AUTH_AZURE_AD_CLIENT_SECRET: z.string().optional(),
AUTH_AZURE_AD_TENANT_ID: z.string().optional(),
AUTH_AZURE_ALLOW_ACCOUNT_LINKING: z.enum(["true", "false"]).optional(),
AUTH_AZURE_CLIENT_AUTH_METHOD: zAuthMethod,
AUTH_AZURE_CHECKS: zAuthChecks,
AUTH_OKTA_CLIENT_ID: z.string().optional(),
AUTH_OKTA_CLIENT_SECRET: z.string().optional(),
AUTH_OKTA_ISSUER: z.string().optional(),
AUTH_OKTA_ALLOW_ACCOUNT_LINKING: z.enum(["true", "false"]).optional(),
AUTH_OKTA_CHECKS: zAuthChecks,
AUTH_OKTA_CLIENT_AUTH_METHOD: zAuthMethod,
AUTH_AUTH0_CLIENT_ID: z.string().optional(),
AUTH_AUTH0_CLIENT_SECRET: z.string().optional(),
AUTH_AUTH0_ISSUER: z.string().url().optional(),
AUTH_AUTH0_ALLOW_ACCOUNT_LINKING: z.enum(["true", "false"]).optional(),
AUTH_AUTH0_CLIENT_AUTH_METHOD: zAuthMethod,
AUTH_AUTH0_CHECKS: zAuthChecks,
AUTH_COGNITO_CLIENT_ID: z.string().optional(),
AUTH_COGNITO_CLIENT_SECRET: z.string().optional(),
AUTH_COGNITO_ISSUER: z.string().url().optional(),
AUTH_COGNITO_ALLOW_ACCOUNT_LINKING: z.enum(["true", "false"]).optional(),
AUTH_COGNITO_CLIENT_AUTH_METHOD: zAuthMethod,
AUTH_COGNITO_CHECKS: zAuthChecks,
AUTH_KEYCLOAK_CLIENT_ID: z.string().optional(),
AUTH_KEYCLOAK_CLIENT_SECRET: z.string().optional(),
AUTH_KEYCLOAK_ISSUER: z.string().optional(),
AUTH_KEYCLOAK_ALLOW_ACCOUNT_LINKING: z.enum(["true", "false"]).optional(),
AUTH_KEYCLOAK_CLIENT_AUTH_METHOD: zAuthMethod,
AUTH_KEYCLOAK_CHECKS: zAuthChecks,
AUTH_CUSTOM_CLIENT_ID: z.string().optional(),
AUTH_CUSTOM_CLIENT_SECRET: z.string().optional(),
AUTH_CUSTOM_ISSUER: z.string().url().optional(),
AUTH_CUSTOM_NAME: z.string().optional(),
AUTH_CUSTOM_SCOPE: z.string().optional(),
AUTH_CUSTOM_CLIENT_AUTH_METHOD: z
.enum([
"client_secret_basic",
"client_secret_post",
"client_secret_jwt",
"private_key_jwt",
"tls_client_auth",
"self_signed_tls_client_auth",
"none",
])
.optional(),
AUTH_CUSTOM_CLIENT_AUTH_METHOD: zAuthMethod,
AUTH_CUSTOM_CHECKS: zAuthChecks,
AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING: z.enum(["true", "false"]).optional(),
AUTH_CUSTOM_ID_TOKEN: z.enum(["true", "false"]).optional(),
AUTH_DOMAINS_WITH_SSO_ENFORCEMENT: z.string().optional(),
AUTH_IGNORE_ACCOUNT_FIELDS: z.string().optional(),
AUTH_DISABLE_USERNAME_PASSWORD: z.enum(["true", "false"]).optional(),
@@ -121,6 +151,8 @@ export const env = createEnv({
)
.optional()
.default(30 * 24 * 60), // default to 30 days
AUTH_HTTP_PROXY: z.string().url().optional(),
AUTH_HTTPS_PROXY: z.string().url().optional(),
// EMAIL
EMAIL_FROM_ADDRESS: z.string().optional(),
SMTP_CONNECTION_URL: z.string().optional(),
@@ -315,10 +347,14 @@ export const env = createEnv({
AUTH_GOOGLE_ALLOWED_DOMAINS: process.env.AUTH_GOOGLE_ALLOWED_DOMAINS,
AUTH_GOOGLE_ALLOW_ACCOUNT_LINKING:
process.env.AUTH_GOOGLE_ALLOW_ACCOUNT_LINKING,
AUTH_GOOGLE_CLIENT_AUTH_METHOD: process.env.AUTH_GOOGLE_CLIENT_AUTH_METHOD,
AUTH_GOOGLE_CHECKS: process.env.AUTH_GOOGLE_CHECKS,
AUTH_GITHUB_CLIENT_ID: process.env.AUTH_GITHUB_CLIENT_ID,
AUTH_GITHUB_CLIENT_SECRET: process.env.AUTH_GITHUB_CLIENT_SECRET,
AUTH_GITHUB_ALLOW_ACCOUNT_LINKING:
process.env.AUTH_GITHUB_ALLOW_ACCOUNT_LINKING,
AUTH_GITHUB_CLIENT_AUTH_METHOD: process.env.AUTH_GITHUB_CLIENT_AUTH_METHOD,
AUTH_GITHUB_CHECKS: process.env.AUTH_GITHUB_CHECKS,
AUTH_GITHUB_ENTERPRISE_CLIENT_ID:
process.env.AUTH_GITHUB_ENTERPRISE_CLIENT_ID,
AUTH_GITHUB_ENTERPRISE_CLIENT_SECRET:
@@ -327,50 +363,69 @@ export const env = createEnv({
process.env.AUTH_GITHUB_ENTERPRISE_BASE_URL,
AUTH_GITHUB_ENTERPRISE_ALLOW_ACCOUNT_LINKING:
process.env.AUTH_GITHUB_ENTERPRISE_ALLOW_ACCOUNT_LINKING,
AUTH_GITHUB_ENTERPRISE_CLIENT_AUTH_METHOD:
process.env.AUTH_GITHUB_ENTERPRISE_CLIENT_AUTH_METHOD,
AUTH_GITHUB_ENTERPRISE_CHECKS: process.env.AUTH_GITHUB_ENTERPRISE_CHECKS,
AUTH_GITLAB_ISSUER: process.env.AUTH_GITLAB_ISSUER,
AUTH_GITLAB_CLIENT_ID: process.env.AUTH_GITLAB_CLIENT_ID,
AUTH_GITLAB_CLIENT_SECRET: process.env.AUTH_GITLAB_CLIENT_SECRET,
AUTH_GITLAB_ALLOW_ACCOUNT_LINKING:
process.env.AUTH_GITLAB_ALLOW_ACCOUNT_LINKING,
AUTH_GITLAB_CLIENT_AUTH_METHOD: process.env.AUTH_GITLAB_CLIENT_AUTH_METHOD,
AUTH_GITLAB_CHECKS: process.env.AUTH_GITLAB_CHECKS,
AUTH_AZURE_AD_CLIENT_ID: process.env.AUTH_AZURE_AD_CLIENT_ID,
AUTH_AZURE_AD_CLIENT_SECRET: process.env.AUTH_AZURE_AD_CLIENT_SECRET,
AUTH_AZURE_AD_TENANT_ID: process.env.AUTH_AZURE_AD_TENANT_ID,
AUTH_AZURE_ALLOW_ACCOUNT_LINKING:
process.env.AUTH_AZURE_ALLOW_ACCOUNT_LINKING,
AUTH_AZURE_CLIENT_AUTH_METHOD: process.env.AUTH_AZURE_CLIENT_AUTH_METHOD,
AUTH_AZURE_CHECKS: process.env.AUTH_AZURE_CHECKS,
AUTH_OKTA_CLIENT_ID: process.env.AUTH_OKTA_CLIENT_ID,
AUTH_OKTA_CLIENT_SECRET: process.env.AUTH_OKTA_CLIENT_SECRET,
AUTH_OKTA_ISSUER: process.env.AUTH_OKTA_ISSUER,
AUTH_OKTA_ALLOW_ACCOUNT_LINKING:
process.env.AUTH_OKTA_ALLOW_ACCOUNT_LINKING,
AUTH_OKTA_CLIENT_AUTH_METHOD: process.env.AUTH_OKTA_CLIENT_AUTH_METHOD,
AUTH_OKTA_CHECKS: process.env.AUTH_OKTA_CHECKS,
AUTH_AUTH0_CLIENT_ID: process.env.AUTH_AUTH0_CLIENT_ID,
AUTH_AUTH0_CLIENT_SECRET: process.env.AUTH_AUTH0_CLIENT_SECRET,
AUTH_AUTH0_ISSUER: process.env.AUTH_AUTH0_ISSUER,
AUTH_AUTH0_ALLOW_ACCOUNT_LINKING:
process.env.AUTH_AUTH0_ALLOW_ACCOUNT_LINKING,
AUTH_AUTH0_CLIENT_AUTH_METHOD: process.env.AUTH_AUTH0_CLIENT_AUTH_METHOD,
AUTH_AUTH0_CHECKS: process.env.AUTH_AUTH0_CHECKS,
AUTH_COGNITO_CLIENT_ID: process.env.AUTH_COGNITO_CLIENT_ID,
AUTH_COGNITO_CLIENT_SECRET: process.env.AUTH_COGNITO_CLIENT_SECRET,
AUTH_COGNITO_ISSUER: process.env.AUTH_COGNITO_ISSUER,
AUTH_COGNITO_ALLOW_ACCOUNT_LINKING:
process.env.AUTH_COGNITO_ALLOW_ACCOUNT_LINKING,
AUTH_COGNITO_CLIENT_AUTH_METHOD: process.env.AUTH_COGNITO_CLIENT_AUTH_METHOD,
AUTH_COGNITO_CHECKS: process.env.AUTH_COGNITO_CHECKS,
AUTH_KEYCLOAK_CLIENT_ID: process.env.AUTH_KEYCLOAK_CLIENT_ID,
AUTH_KEYCLOAK_CLIENT_SECRET: process.env.AUTH_KEYCLOAK_CLIENT_SECRET,
AUTH_KEYCLOAK_ISSUER: process.env.AUTH_KEYCLOAK_ISSUER,
AUTH_KEYCLOAK_ALLOW_ACCOUNT_LINKING:
process.env.AUTH_KEYCLOAK_ALLOW_ACCOUNT_LINKING,
AUTH_KEYCLOAK_CLIENT_AUTH_METHOD: process.env.AUTH_KEYCLOAK_CLIENT_AUTH_METHOD,
AUTH_KEYCLOAK_CHECKS: process.env.AUTH_KEYCLOAK_CHECKS,
AUTH_CUSTOM_CLIENT_ID: process.env.AUTH_CUSTOM_CLIENT_ID,
AUTH_CUSTOM_CLIENT_SECRET: process.env.AUTH_CUSTOM_CLIENT_SECRET,
AUTH_CUSTOM_ISSUER: process.env.AUTH_CUSTOM_ISSUER,
AUTH_CUSTOM_NAME: process.env.AUTH_CUSTOM_NAME,
AUTH_CUSTOM_SCOPE: process.env.AUTH_CUSTOM_SCOPE,
AUTH_CUSTOM_CLIENT_AUTH_METHOD: process.env.AUTH_CUSTOM_CLIENT_AUTH_METHOD,
AUTH_CUSTOM_CHECKS: process.env.AUTH_CUSTOM_CHECKS,
AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING:
process.env.AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING,
AUTH_CUSTOM_ID_TOKEN: process.env.AUTH_CUSTOM_ID_TOKEN,
AUTH_IGNORE_ACCOUNT_FIELDS: process.env.AUTH_IGNORE_ACCOUNT_FIELDS,
AUTH_DOMAINS_WITH_SSO_ENFORCEMENT:
process.env.AUTH_DOMAINS_WITH_SSO_ENFORCEMENT,
AUTH_DISABLE_USERNAME_PASSWORD: process.env.AUTH_DISABLE_USERNAME_PASSWORD,
AUTH_DISABLE_SIGNUP: process.env.AUTH_DISABLE_SIGNUP,
AUTH_SESSION_MAX_AGE: process.env.AUTH_SESSION_MAX_AGE,
AUTH_HTTP_PROXY: process.env.AUTH_HTTP_PROXY,
AUTH_HTTPS_PROXY: process.env.AUTH_HTTPS_PROXY,
// Email
EMAIL_FROM_ADDRESS: process.env.EMAIL_FROM_ADDRESS,
SMTP_CONNECTION_URL: process.env.SMTP_CONNECTION_URL,
@@ -10,6 +10,7 @@ import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePos
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
@@ -123,15 +124,11 @@ const DataRegionInfo = () => (
<DialogHeader>
<DialogTitle>Data Regions</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-2">
<DialogDescription className="flex flex-col gap-2">
<p>Langfuse Cloud is available in two data regions:</p>
<ul className="list-disc pl-5">
<li>
US: Oregon (AWS us-west-2)
</li>
<li>
EU: Ireland (AWS eu-west-1)
</li>
<li>US: Oregon (AWS us-west-2)</li>
<li>EU: Ireland (AWS eu-west-1)</li>
</ul>
<p>
Regions are strictly separated, and no data is shared across regions.
@@ -154,7 +151,7 @@ const DataRegionInfo = () => (
</a>
.
</p>
</div>
</DialogDescription>
</DialogContent>
</Dialog>
);
@@ -0,0 +1,10 @@
import * as z from "zod";
export const projectRetentionSchema = z.object({
retention: z.coerce
.number()
.int("Must be an integer")
.refine((value) => value === 0 || value >= 7, {
message: "Value must be 0 or at least 7 days",
}),
});
@@ -14,6 +14,7 @@ const entitlements = [
"prompt-experiments",
"trace-deletion",
"audit-logs",
"data-retention",
] as const;
export type Entitlement = (typeof entitlements)[number];
@@ -76,6 +77,7 @@ export const entitlementAccess: Record<
...cloudAllPlansEntitlements,
"rbac-project-roles",
"audit-logs",
"data-retention",
],
entitlementLimits: {
"annotation-queue-count": false,
+15 -2
View File
@@ -41,6 +41,12 @@ export const llmApiKeyRouter = createTRPCRouter({
data: {
projectId: input.projectId,
secretKey: encrypt(input.secretKey),
extraHeaders: input.extraHeaders
? encrypt(JSON.stringify(input.extraHeaders))
: undefined,
extraHeaderKeys: input.extraHeaders
? Object.keys(input.extraHeaders)
: undefined,
adapter: input.adapter,
displaySecretKey: getDisplaySecretKey(input.secretKey),
provider: input.provider,
@@ -104,10 +110,15 @@ export const llmApiKeyRouter = createTRPCRouter({
});
const apiKeys = z
.array(LLMApiKeySchema.extend({ secretKey: z.undefined() }))
.array(
LLMApiKeySchema.extend({
secretKey: z.undefined(),
extraHeaders: z.undefined(),
}),
)
.parse(
await ctx.prisma.llmApiKeys.findMany({
// we must not return the secret key via the API, hence not selected
// we must not return the secret key AND extra headers via the API, hence not selected
select: {
id: true,
createdAt: true,
@@ -119,6 +130,7 @@ export const llmApiKeyRouter = createTRPCRouter({
baseURL: true,
customModels: true,
withDefaultModels: true,
extraHeaderKeys: true,
},
where: {
projectId: input.projectId,
@@ -169,6 +181,7 @@ export const llmApiKeyRouter = createTRPCRouter({
},
baseURL: input.baseURL,
apiKey: input.secretKey,
extraHeaders: input.extraHeaders,
messages: testMessages,
streaming: false,
maxRetries: 1,
+1
View File
@@ -10,4 +10,5 @@ export const CreateLlmApiKey = z.object({
withDefaultModels: z.boolean().optional(),
customModels: z.array(z.string().min(1)).optional(),
config: BedrockConfigSchema.optional(),
extraHeaders: z.record(z.string(), z.string()).optional(),
});
@@ -57,6 +57,8 @@ const events = {
"apply_labels",
"version_delete_open",
"version_delete_submit",
"duplicate_button_click",
"duplicate_form_submit",
],
session_detail: ["publish_button_click"],
eval_config: [
@@ -128,6 +130,7 @@ const events = {
project_settings: [
"project_delete",
"rename_form_submit",
"retention_form_submit",
"project_transfer",
"api_key_delete",
"api_key_create",
@@ -0,0 +1,145 @@
import { Card } from "@/src/components/ui/card";
import { Input } from "@/src/components/ui/input";
import { api } from "@/src/utils/api";
import type * as z from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/src/components/ui/form";
import Header from "@/src/components/layouts/header";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { LockIcon } from "lucide-react";
import { useQueryProject } from "@/src/features/projects/hooks";
import { useSession } from "next-auth/react";
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
import { projectRetentionSchema } from "@/src/features/auth/lib/projectRetentionSchema";
import { ActionButton } from "@/src/components/ActionButton";
import { useHasEntitlement } from "@/src/features/entitlements/hooks";
export default function ConfigureRetention() {
const { update: updateSession } = useSession();
const { project } = useQueryProject();
const capture = usePostHogClientCapture();
const hasAccess = useHasProjectAccess({
projectId: project?.id,
scope: "project:update",
});
const hasEntitlement = useHasEntitlement("data-retention");
const form = useForm<z.infer<typeof projectRetentionSchema>>({
resolver: zodResolver(projectRetentionSchema),
defaultValues: {
retention: project?.retentionDays ?? 0,
},
});
const setRetention = api.projects.setRetention.useMutation({
onSuccess: (_) => {
void updateSession();
},
onError: (error) => form.setError("retention", { message: error.message }),
});
function onSubmit(values: z.infer<typeof projectRetentionSchema>) {
if (!hasAccess || !project) return;
capture("project_settings:retention_form_submit");
setRetention
.mutateAsync({
projectId: project.id,
retention: values.retention || null, // Fallback to null for indefinite retention
})
.then(() => {
form.reset();
})
.catch((error) => {
console.error(error);
});
}
return (
<div>
<Header title="Data Retention (Beta)" level="h3" />
<Card className="mb-4 p-3">
<p className="mb-4 text-sm text-primary">
Data retention automatically deletes events older than the specified
number of days. The value must be an integer larger than 7. Set to 0
to retain data indefinitely. The deletion happens asynchronously, i.e.
event may be available for a while after they expired.
</p>
{Boolean(form.getValues().retention) &&
form.getValues().retention !== project?.retentionDays ? (
<p className="mb-4 text-sm text-primary">
Your Project&#39;s retention will be set from &quot;
{project?.retentionDays ?? "Indefinite"}
&quot; to &quot;
{Number(form.watch().retention) === 0
? "Indefinite"
: form.watch().retention}
&quot; days.
</p>
) : !Boolean(project?.retentionDays) ? (
<p className="mb-4 text-sm text-primary">
Your Project retains data indefinitely.
</p>
) : (
<p className="mb-4 text-sm text-primary">
Your Project&#39;s current retention is &quot;
{project?.retentionDays ?? ""}
&quot; days.
</p>
)}
<Form {...form}>
<form
// eslint-disable-next-line @typescript-eslint/no-misused-promises
onSubmit={form.handleSubmit(onSubmit)}
className="flex-1"
id="set-retention-project-form"
>
<FormField
control={form.control}
name="retention"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="relative">
<Input
type="number"
step="1"
placeholder={project?.retentionDays?.toString() ?? ""}
{...field}
value={field.value ?? ""}
className="flex-1"
disabled={!hasAccess || !hasEntitlement}
/>
{!hasAccess && (
<span title="No access">
<LockIcon className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 transform text-muted" />
</span>
)}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<ActionButton
variant="secondary"
hasAccess={hasAccess}
hasEntitlement={hasEntitlement}
loading={setRetention.isLoading}
disabled={form.getValues().retention === null}
className="mt-4"
type="submit"
>
Save
</ActionButton>
</form>
</Form>
</Card>
</div>
);
}
@@ -101,6 +101,39 @@ export const projectsRouter = createTRPCRouter({
return true;
}),
setRetention: protectedProjectProcedure
.input(
z.object({
projectId: z.string(),
retention: z.number().int().gte(7).nullable(),
}),
)
.mutation(async ({ input, ctx }) => {
throwIfNoProjectAccess({
session: ctx.session,
projectId: input.projectId,
scope: "project:update",
});
const project = await ctx.prisma.project.update({
where: {
id: input.projectId,
orgId: ctx.session.orgId,
},
data: {
retentionDays: input.retention,
},
});
await auditLog({
session: ctx.session,
resourceType: "project",
resourceId: input.projectId,
action: "update",
after: project,
});
return true;
}),
delete: protectedProjectProcedure
.input(
z.object({
@@ -0,0 +1,121 @@
import React from "react";
import { Button } from "@/src/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/src/components/ui/dialog";
import { type Prompt } from "@langfuse/shared";
import { type NewPromptFormSchemaType } from "./validation";
import DiffViewer from "@/src/components/DiffViewer";
type ReviewPromptDialogProps = {
initialPrompt: Prompt;
isLoading: boolean;
children: React.ReactNode;
onConfirm: () => void;
getNewPromptValues: () => NewPromptFormSchemaType;
};
export const ReviewPromptDialog: React.FC<ReviewPromptDialogProps> = (
props,
) => {
const { initialPrompt, children, getNewPromptValues, onConfirm, isLoading } =
props;
const [newPromptValue, setNewPromptValues] =
React.useState<NewPromptFormSchemaType | null>(null);
const [open, setOpen] = React.useState<boolean>(false);
React.useEffect(() => {
if (open) {
setNewPromptValues(getNewPromptValues());
}
}, [open, setNewPromptValues, getNewPromptValues]);
const initialPromptContent: string =
initialPrompt.type === "text"
? (initialPrompt.prompt as string)
: JSON.stringify(initialPrompt.prompt, null, 2);
const newPromptContent: string =
initialPrompt.type === "text"
? (newPromptValue?.textPrompt ?? "")
: JSON.stringify(
newPromptValue?.chatPrompt.map((m) =>
Object.fromEntries(
Object.entries(m).filter(([k, _]) => k !== "id"),
),
) ?? "{}",
null,
2,
);
const newConfig = JSON.stringify(
JSON.parse(newPromptValue?.config ?? "{}"),
null,
2,
);
return (
<Dialog open={open} onOpenChange={(open) => setOpen(open)}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="max-w-screen-xl">
<DialogHeader>
<DialogTitle>Review Prompt Changes</DialogTitle>
<DialogDescription className="flex items-center gap-2">
<span className="font-medium">{initialPrompt.name}</span>
</DialogDescription>
</DialogHeader>
<div className="max-h-[80vh] max-w-screen-xl space-y-6 overflow-y-auto">
<div className="space-y-6">
<div className="space-y-4">
<div>
<h3 className="mb-2 text-base font-medium">Content</h3>
<DiffViewer
oldString={initialPromptContent}
newString={newPromptContent}
oldLabel={`Previous content (v${initialPrompt.version})`}
newLabel="New content (draft)"
/>
</div>
<div>
<h3 className="mb-2 text-base font-medium">Config</h3>
<DiffViewer
oldString={JSON.stringify(initialPrompt.config, null, 2)}
newString={newConfig ?? "failed"}
oldLabel={`Previous config (v${initialPrompt.version})`}
newLabel="New config (draft)"
/>
</div>
</div>
</div>
</div>
<DialogFooter className="flex flex-row">
<Button
type="button"
variant="secondary"
onClick={() => setOpen(false)}
className="min-w-[8rem]"
>
Cancel
</Button>
<Button
onClick={onConfirm}
loading={isLoading}
variant={newPromptValue?.isActive ? "destructive" : "default"}
className="min-w-[8rem]"
>
Save new version
{newPromptValue?.isActive ? " and promote to production" : ""}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -33,6 +33,7 @@ import {
getIsCharOrUnderscore,
} from "@langfuse/shared";
import { PromptChatMessages } from "./PromptChatMessages";
import { ReviewPromptDialog } from "./ReviewPromptDialog";
import {
NewPromptFormSchema,
type NewPromptFormSchemaType,
@@ -49,6 +50,7 @@ import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePos
import usePlaygroundCache from "@/src/ee/features/playground/page/hooks/usePlaygroundCache";
import { useQueryParam } from "use-query-params";
import { Switch } from "@/src/components/ui/switch";
import { usePromptNameValidation } from "@/src/features/prompts/hooks/usePromptNameValidation";
type NewPromptFormProps = {
initialPrompt?: Prompt | null;
@@ -94,6 +96,7 @@ export const NewPromptForm: React.FC<NewPromptFormProps> = (props) => {
const form = useForm<NewPromptFormSchemaType>({
resolver: zodResolver(NewPromptFormSchema),
mode: "onTouched",
defaultValues,
});
@@ -184,24 +187,11 @@ export const NewPromptForm: React.FC<NewPromptFormProps> = (props) => {
}
}, [playgroundCache, initialPrompt, form, shouldLoadPlaygroundCache]);
useEffect(() => {
const isNewPrompt = !allPrompts
?.map((prompt) => prompt.value)
.includes(currentName);
if (!isNewPrompt) {
form.setError("name", { message: "Prompt name already exist." });
} else if (currentName === "new") {
form.setError("name", { message: "Prompt name cannot be 'new'" });
} else if (currentName && !/^[a-zA-Z0-9_\-.]+$/.test(currentName)) {
form.setError("name", {
message:
"Name must be alphanumeric with optional underscores, hyphens, or periods",
});
} else {
form.clearErrors("name");
}
}, [currentName, allPrompts, form]);
usePromptNameValidation({
currentName,
allPrompts,
form,
});
return (
<Form {...form}>
@@ -356,6 +346,7 @@ export const NewPromptForm: React.FC<NewPromptFormProps> = (props) => {
<JsonEditor
defaultValue={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
editable
/>
<FormDescription>
@@ -392,16 +383,44 @@ export const NewPromptForm: React.FC<NewPromptFormProps> = (props) => {
</FormItem>
)}
/>
<Button
type="submit"
loading={createPromptMutation.isLoading}
className="w-full"
disabled={Boolean(
!initialPrompt && form.formState.errors.name?.message,
)} // Disable button if prompt name already exists. Check is dynamic and not part of zod schema
>
{!initialPrompt ? "Create prompt" : "Save prompt version"}
</Button>
{initialPrompt ? (
<div className="flex flex-col gap-2">
<ReviewPromptDialog
initialPrompt={initialPrompt}
getNewPromptValues={form.getValues}
isLoading={createPromptMutation.isLoading}
onConfirm={form.handleSubmit(onSubmit)}
>
<Button
disabled={!form.formState.isValid}
variant="secondary"
className="w-full"
>
Review changes
</Button>
</ReviewPromptDialog>
<Button
type="submit"
loading={createPromptMutation.isLoading}
className="w-full"
disabled={!form.formState.isValid}
>
Save new prompt version
</Button>
</div>
) : (
<Button
type="submit"
loading={createPromptMutation.isLoading}
className="w-full"
disabled={Boolean(
!initialPrompt && form.formState.errors.name?.message,
)} // Disable button if prompt name already exists. Check is dynamic and not part of zod schema
>
Create prompt
</Button>
)}
</form>
{formError && (
<p className="text-red text-center">
@@ -0,0 +1,110 @@
import React from "react";
import { Button } from "@/src/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/src/components/ui/dialog";
import { type Prompt } from "@langfuse/shared";
import DiffViewer from "@/src/components/DiffViewer";
import { FileDiffIcon } from "lucide-react";
type PromptVersionDiffDialogProps = {
isOpen: boolean;
setIsOpen: (open: boolean) => void;
leftPrompt: Prompt;
rightPrompt: Prompt;
};
export const PromptVersionDiffDialog: React.FC<PromptVersionDiffDialogProps> = (
props,
) => {
const { leftPrompt, rightPrompt, isOpen, setIsOpen } = props;
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
setIsOpen(open);
}}
>
<DialogTrigger asChild>
<Button
variant="outline"
type="button"
size="icon"
className="h-7 w-7 px-0"
onClick={(event) => {
event.stopPropagation();
}}
title="Compare with selected prompt"
>
<FileDiffIcon className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent
className="max-w-screen-xl"
onClick={(event) => event.stopPropagation()}
>
<DialogHeader>
<DialogTitle>
Changes v{leftPrompt.version} v{rightPrompt.version}
</DialogTitle>
<DialogDescription className="flex items-center gap-2">
<span className="font-medium">Prompt {leftPrompt.name}</span>
</DialogDescription>
</DialogHeader>
<div className="max-h-[80vh] max-w-screen-xl space-y-6 overflow-y-auto">
<div className="space-y-6">
<div className="space-y-4">
<div>
<h3 className="mb-2 text-base font-medium">Content</h3>
<DiffViewer
oldString={
leftPrompt.type === "chat"
? JSON.stringify(leftPrompt.prompt, null, 2)
: (leftPrompt.prompt as string)
}
newString={
rightPrompt.type === "chat"
? JSON.stringify(rightPrompt.prompt, null, 2)
: (rightPrompt.prompt as string)
}
oldLabel={`v${leftPrompt.version}`}
newLabel={`v${rightPrompt.version}`}
/>
</div>
<div>
<h3 className="mb-2 text-base font-medium">Config</h3>
<DiffViewer
oldString={JSON.stringify(leftPrompt.config, null, 2)}
newString={JSON.stringify(rightPrompt.config, null, 2)}
oldLabel={`v${leftPrompt.version}`}
newLabel={`v${rightPrompt.version}`}
/>
</div>
</div>
</div>
</div>
<DialogFooter className="flex flex-row">
<Button
onClick={() => {
setIsOpen(false);
}}
className="w-full"
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,233 @@
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";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/src/components/ui/dialog";
import { ActionButton } from "@/src/components/ActionButton";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { useEntitlementLimit } from "@/src/features/entitlements/hooks";
import { useState } from "react";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/src/components/ui/form";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@/src/components/ui/input";
import { RadioGroup, RadioGroupItem } from "@/src/components/ui/radio-group";
import { usePromptNameValidation } from "@/src/features/prompts/hooks/usePromptNameValidation";
enum CopySettings {
SINGLE_VERSION = "single_version",
ALL_VERSIONS = "all_versions",
}
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
isCopySingleVersion: z.nativeEnum(CopySettings),
});
const DuplicatePromptForm: React.FC<{
projectId: string;
promptId: string;
promptName: string;
promptVersion: number;
onFormSuccess: () => void;
}> = ({ projectId, promptId, promptName, promptVersion, onFormSuccess }) => {
const capture = usePostHogClientCapture();
const router = useRouter();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: promptName,
isCopySingleVersion: CopySettings.SINGLE_VERSION,
},
});
const currentName = form.watch("name");
const utils = api.useUtils();
const duplicatePrompt = api.prompts.duplicatePrompt.useMutation({
onSuccess: ({ name }) => {
utils.prompts.invalidate();
void router.push(
`/project/${projectId}/prompts/${encodeURIComponent(name)}`,
);
},
});
function onSubmit(values: z.infer<typeof formSchema>) {
capture("prompt_detail:duplicate_form_submit");
duplicatePrompt
.mutateAsync({
...values,
projectId: projectId,
promptId: promptId,
isSingleVersion:
values.isCopySingleVersion === CopySettings.SINGLE_VERSION,
})
.then(() => {
onFormSuccess();
form.reset();
})
.catch((error: Error) => {
console.error(error);
});
}
const allPrompts = api.prompts.filterOptions.useQuery(
{
projectId: projectId,
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
staleTime: Infinity,
},
).data?.name;
usePromptNameValidation({
currentName,
allPrompts,
form,
});
return (
<Form {...form}>
<form
// eslint-disable-next-line @typescript-eslint/no-misused-promises
onSubmit={form.handleSubmit(onSubmit)}
className="mt-4 flex h-full flex-1 flex-col gap-4"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="flex flex-col gap-2">
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} type="text" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="isCopySingleVersion"
render={({ field }) => (
<FormItem>
<FormLabel>Settings</FormLabel>
<FormControl>
<RadioGroup
{...field}
onValueChange={field.onChange}
defaultValue={field.value}
className="flex flex-col space-y-1"
>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value={CopySettings.SINGLE_VERSION} />
</FormControl>
<FormLabel className="font-normal">
Copy only version {promptVersion}
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value={CopySettings.ALL_VERSIONS} />
</FormControl>
<FormLabel className="font-normal">
Copy all prompt versions and labels
</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
loading={duplicatePrompt.isLoading}
className="mb-4 mt-auto w-full"
>
Submit
</Button>
</form>
</Form>
);
};
export const DuplicatePromptButton: React.FC<{
projectId: string;
promptId: string;
promptName: string;
promptVersion: number;
}> = ({ projectId, promptId, promptName, promptVersion }) => {
const [open, setOpen] = useState(false);
const hasAccess = useHasProjectAccess({
projectId,
scope: "prompts:CUD",
});
const promptLimit = useEntitlementLimit("prompt-management-count-prompts");
const capture = usePostHogClientCapture();
const allPromptNames = api.prompts.allNames.useQuery(
{
projectId,
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
enabled: hasAccess,
},
);
return (
<Dialog open={hasAccess && open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<ActionButton
icon={<Copy className="mr-1 h-4 w-4" aria-hidden="true" />}
hasAccess={hasAccess}
variant="secondary"
limit={promptLimit}
title="Duplicate prompt"
limitValue={allPromptNames.data?.length ?? undefined}
onClick={() => {
capture("prompt_detail:duplicate_button_click");
}}
>
Duplicate
</ActionButton>
</DialogTrigger>
<DialogContent className="max-h-[90vh] min-h-0">
<DialogHeader>
<DialogTitle>Duplicate prompt</DialogTitle>
</DialogHeader>
<DuplicatePromptForm
projectId={projectId}
promptId={promptId}
promptName={promptName}
promptVersion={promptVersion}
onFormSuccess={() => setOpen(false)}
/>
</DialogContent>
</Dialog>
);
};
@@ -39,6 +39,7 @@ import { CreateExperimentsForm } from "@/src/ee/features/experiments/components/
import { useState } from "react";
import { useHasEntitlement } from "@/src/features/entitlements/hooks";
import { showSuccessToast } from "@/src/features/notifications/showSuccessToast";
import { DuplicatePromptButton } from "@/src/features/prompts/components/duplicate-prompt";
export const PromptDetail = () => {
const projectId = useProjectIdFromURL();
@@ -224,6 +225,14 @@ export const PromptDetail = () => {
</div>
</Button>
)}
{projectId && (
<DuplicatePromptButton
promptId={prompt.id}
projectId={projectId}
promptName={prompt.name}
promptVersion={prompt.version}
/>
)}
<DetailPageNav
key="nav"
currentId={promptName}
@@ -5,10 +5,12 @@ import { PRODUCTION_LABEL } from "@/src/features/prompts/constants";
import { type RouterOutputs } from "@/src/utils/api";
import { type NextRouter, useRouter } from "next/router";
import { useState } from "react";
import { PromptVersionDiffDialog } from "./PromptVersionDiffDialog";
const PromptHistoryTraceNode = (props: {
index: number;
prompt: RouterOutputs["prompts"]["allVersions"]["promptVersions"][number];
currentPrompt?: RouterOutputs["prompts"]["allVersions"]["promptVersions"][number];
currentPromptVersion: number | undefined;
setCurrentPromptVersion: (version: number | undefined) => void;
router: NextRouter;
@@ -17,6 +19,7 @@ const PromptHistoryTraceNode = (props: {
}) => {
const [isHovered, setIsHovered] = useState(false);
const [isLabelPopoverOpen, setIsLabelPopoverOpen] = useState(false);
const [isPromptDiffOpen, setIsPromptDiffOpen] = useState(false);
const { prompt } = props;
let badges: JSX.Element[] = prompt.labels
.sort((a, b) =>
@@ -70,8 +73,22 @@ const PromptHistoryTraceNode = (props: {
</span>
</div>
</div>
{(isHovered || props.currentPromptVersion === prompt.version) && (
{(isHovered ||
props.currentPromptVersion === prompt.version ||
isPromptDiffOpen) && (
<div className="flex flex-row justify-end space-x-1">
{props.currentPrompt &&
props.currentPromptVersion !== prompt.version ? (
<PromptVersionDiffDialog
isOpen={isPromptDiffOpen}
setIsOpen={(open) => {
setIsPromptDiffOpen(open);
if (!open) setIsHovered(false);
}}
leftPrompt={prompt}
rightPrompt={props.currentPrompt}
/>
) : null}
<SetPromptVersionLabels
prompt={prompt}
isOpen={isLabelPopoverOpen}
@@ -100,6 +117,10 @@ export const PromptHistoryNode = (props: {
}) => {
const router = useRouter();
const projectId = router.query.projectId as string;
const currentPrompt = props.prompts.find(
(p) => p.version === props.currentPromptVersion,
);
return (
<div className="w-full flex-1">
{props.prompts.map((prompt, index) => (
@@ -107,6 +128,7 @@ export const PromptHistoryNode = (props: {
key={prompt.id}
index={index}
prompt={prompt}
currentPrompt={currentPrompt}
currentPromptVersion={props.currentPromptVersion}
setCurrentPromptVersion={props.setCurrentPromptVersion}
router={router}
@@ -0,0 +1,35 @@
import { useEffect } from "react";
import { type UseFormReturn } from "react-hook-form";
interface UsePromptNameValidationProps {
currentName: string | undefined;
allPrompts: { value: string }[] | undefined;
form: UseFormReturn<any>;
}
export const usePromptNameValidation = ({
currentName,
allPrompts,
form,
}: UsePromptNameValidationProps) => {
useEffect(() => {
if (!currentName) return;
const isNewPrompt = !allPrompts
?.map((prompt) => prompt.value)
.includes(currentName);
if (!isNewPrompt) {
form.setError("name", { message: "Prompt name already exist." });
} else if (currentName === "new") {
form.setError("name", { message: "Prompt name cannot be 'new'" });
} else if (!/^[a-zA-Z0-9_\-.]+$/.test(currentName)) {
form.setError("name", {
message:
"Name must be alphanumeric with optional underscores, hyphens, or periods",
});
} else {
form.clearErrors("name");
}
}, [currentName, allPrompts, form]);
};
@@ -8,13 +8,26 @@ import { type PrismaClient } from "@langfuse/shared/src/db";
import { LATEST_PROMPT_LABEL } from "@/src/features/prompts/constants";
import { removeLabelsFromPreviousPromptVersions } from "@/src/features/prompts/server/utils/updatePromptLabels";
import { updatePromptTagsOnAllVersions } from "@/src/features/prompts/server/utils/updatePromptTags";
import { PromptService, redis } from "@langfuse/shared/src/server";
import {
PromptContentSchema,
PromptService,
redis,
} from "@langfuse/shared/src/server";
export type CreatePromptParams = CreatePromptTRPCType & {
createdBy: string;
prisma: PrismaClient;
};
type DuplicatePromptParams = {
projectId: string;
promptId: string;
name: string;
isSingleVersion: boolean;
createdBy: string;
prisma: PrismaClient;
};
export const createPrompt = async ({
projectId,
name,
@@ -96,3 +109,78 @@ export const createPrompt = async ({
return createdPrompt;
};
export const duplicatePrompt = async ({
projectId,
promptId,
name,
isSingleVersion,
createdBy,
prisma,
}: DuplicatePromptParams) => {
// validate that name is unique in project, uniqueness constraint too permissive as it includes version
const promptNameExists = await prisma.prompt.findFirst({
where: {
projectId,
name,
},
});
if (promptNameExists) {
throw new InvalidRequestError(
`Prompt name ${name} already exists in project ${projectId}`,
);
}
const existingPrompt = await prisma.prompt.findUnique({
where: {
id: promptId,
projectId: projectId,
},
});
if (!existingPrompt) {
throw new Error(`Existing prompt not found: ${promptId}`);
}
// if defined as single version, duplicate current prompt as new prompt v1
// else duplicate the entire prompt, should be all or nothing operation.
const promptsDb = await prisma.prompt.findMany({
where: {
projectId: projectId,
name: existingPrompt.name,
version: isSingleVersion ? existingPrompt.version : undefined,
},
});
// prepare createMany prompt records
const promptsToCreate = promptsDb.map((prompt) => ({
name,
version: isSingleVersion ? 1 : prompt.version,
labels: isSingleVersion
? [...new Set([LATEST_PROMPT_LABEL, ...prompt.labels])]
: prompt.labels,
type: prompt.type,
prompt: PromptContentSchema.parse(prompt.prompt),
config: jsonSchema.parse(prompt.config),
tags: prompt.tags,
projectId,
createdBy,
}));
// Create all prompts in a single operation
const result = await prisma.prompt.createMany({
data: promptsToCreate,
});
// If you need the created prompt, fetch it separately since createMany doesn't return created records
const createdPrompt = await prisma.prompt.findFirst({
where: {
name,
projectId,
version: isSingleVersion ? 1 : result.count,
},
});
return createdPrompt;
};
@@ -0,0 +1,74 @@
import { logger, PromptService } from "@langfuse/shared/src/server";
import { removeLabelsFromPreviousPromptVersions } from "@/src/features/prompts/server/utils/updatePromptLabels";
import { LangfuseNotFoundError } from "@langfuse/shared";
import { prisma } from "@langfuse/shared/src/db";
import { redis } from "@langfuse/shared/src/server";
export type UpdatePromptParams = {
promptName: string;
projectId: string;
promptVersion: number;
newLabels: string[];
};
export const updatePrompt = async (params: UpdatePromptParams) => {
const { promptName, projectId, promptVersion, newLabels } = params;
logger.info(
`Updating prompt ${promptName} in project ${projectId} version ${promptVersion} with labels ${newLabels}`,
);
const promptService = new PromptService(prisma, redis);
try {
await promptService.lockCache({ projectId, promptName: promptName });
const prompt = await promptService.getPrompt({
projectId,
promptName,
version: promptVersion,
label: undefined,
});
if (!prompt) {
throw new LangfuseNotFoundError(`Prompt not found: ${promptName}`);
}
const newLabelsSet = new Set([...newLabels, ...prompt.labels]);
logger.info(
`Setting labels for prompt: ${prompt.id}, ${prompt.name}, ${prompt.version}, ${JSON.stringify(
Array.from(newLabelsSet),
)}`,
);
const tx = [
...(await removeLabelsFromPreviousPromptVersions({
prisma,
projectId,
promptName,
labelsToRemove: [...new Set(newLabels)],
})),
prisma.prompt.update({
where: {
id: prompt.id,
projectId,
},
data: {
labels: {
set: Array.from(newLabelsSet),
},
},
}),
];
await promptService.invalidateCache({ projectId, promptName: promptName });
const res = await prisma.$transaction(tx);
await promptService.unlockCache({ projectId, promptName: promptName });
return res[res.length - 1];
} catch (e) {
await promptService.unlockCache({ projectId, promptName: promptName });
throw e;
}
};
@@ -48,4 +48,6 @@ const getPromptNameHandler = async (
return res.status(200).json(prompt);
};
export const promptNameHandler = withMiddlewares({ GET: getPromptNameHandler });
export const promptNameHandler = withMiddlewares({
GET: getPromptNameHandler,
});
@@ -0,0 +1,46 @@
import { logger } from "@langfuse/shared/src/server";
import { z } from "zod";
import { withMiddlewares } from "@/src/features/public-api/server/withMiddlewares";
import { createAuthedAPIRoute } from "@/src/features/public-api/server/createAuthedAPIRoute";
import { updatePrompt } from "@/src/features/prompts/server/actions/updatePrompts";
import { LangfuseNotFoundError } from "@langfuse/shared";
const UpdatePromptBodySchema = z.object({
newLabels: z
.array(z.string())
.refine((labels) => !labels.includes("latest"), {
message: "Label 'latest' is always assigned to the latest prompt version",
}),
});
export const promptVersionHandler = withMiddlewares({
PATCH: createAuthedAPIRoute({
name: "Update Prompt",
bodySchema: UpdatePromptBodySchema,
responseSchema: z.any(),
fn: async ({ body, res, req, auth }) => {
try {
const { newLabels } = UpdatePromptBodySchema.parse(body);
const { promptName, promptVersion } = req.query;
const prompt = await updatePrompt({
promptName: promptName as string,
projectId: auth.scope.projectId,
promptVersion: Number(promptVersion),
newLabels,
});
logger.info(`Prompt updated ${JSON.stringify(prompt)}`);
return res.status(200).json(prompt);
} catch (e) {
logger.error(e);
if (e instanceof LangfuseNotFoundError) {
return res.status(404).json({ message: e.message });
}
return res.status(500).json({ message: "Internal server error" });
}
},
}),
});
@@ -11,7 +11,7 @@ import {
protectedProjectProcedure,
} from "@/src/server/api/trpc";
import { type Prompt, Prisma } from "@langfuse/shared/src/db";
import { createPrompt } from "../actions/createPrompt";
import { createPrompt, duplicatePrompt } from "../actions/createPrompt";
import { promptsTableCols } from "@/src/server/api/definitions/promptsTable";
import { optionalPaginationZod, paginationZod } from "@langfuse/shared";
import { orderBy, singleFilter } from "@langfuse/shared";
@@ -172,6 +172,48 @@ export const promptRouter = createTRPCRouter({
throw e;
}
}),
duplicatePrompt: protectedProjectProcedure
.input(
z.object({
projectId: z.string(),
promptId: z.string(),
name: z.string(),
isSingleVersion: z.boolean(),
}),
)
.mutation(async ({ input, ctx }) => {
throwIfNoProjectAccess({
session: ctx.session,
projectId: input.projectId,
scope: "prompts:CUD",
});
const prompt = await duplicatePrompt({
projectId: input.projectId,
promptId: input.promptId,
name: input.name,
isSingleVersion: input.isSingleVersion,
createdBy: ctx.session.user.id,
prisma: ctx.prisma,
});
if (!prompt) {
throw new Error(`Failed to duplicate prompt: ${input.promptId}`);
}
await auditLog(
{
session: ctx.session,
resourceType: "prompt",
resourceId: prompt.id,
action: "create",
after: prompt,
},
ctx.prisma,
);
return prompt;
}),
filterOptions: protectedProjectProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ input, ctx }) => {
@@ -454,7 +496,7 @@ export const promptRouter = createTRPCRouter({
// Unlock cache
await promptService.unlockCache({ projectId, promptName });
} catch (e) {
logger.error(e);
logger.error(`Failed to set prompt labels: ${e}`, e);
throw e;
}
}),
@@ -44,6 +44,9 @@ const formSchema = z
awsAccessKeyId: z.string().optional(),
awsSecretAccessKey: z.string().optional(),
awsRegion: z.string().optional(),
extraHeaders: z.array(
z.object({ key: z.string().min(1), value: z.string().min(1) }),
),
})
.refine((data) => data.withDefaultModels || data.customModels.length > 0, {
message:
@@ -115,6 +118,7 @@ export function CreateLLMApiKeyForm({
baseURL: getCustomizedBaseURL(defaultAdapter),
withDefaultModels: true,
customModels: [],
extraHeaders: [],
},
});
@@ -125,6 +129,15 @@ export function CreateLLMApiKeyForm({
name: "customModels",
});
const {
fields: headerFields,
append: appendHeader,
remove: removeHeader,
} = useFieldArray({
control: form.control,
name: "extraHeaders",
});
async function onSubmit(values: z.infer<typeof formSchema>) {
if (!projectId) return console.error("No project ID found.");
if (
@@ -155,6 +168,17 @@ export function CreateLLMApiKeyForm({
};
}
const extraHeaders =
values.extraHeaders.length > 0
? values.extraHeaders.reduce(
(acc, header) => {
acc[header.key] = header.value;
return acc;
},
{} as Record<string, string>,
)
: undefined;
const newKey = {
projectId,
secretKey: secretKey ?? "",
@@ -166,6 +190,7 @@ export function CreateLLMApiKeyForm({
customModels: values.customModels
.map((m) => m.value.trim())
.filter(Boolean),
extraHeaders,
};
try {
@@ -383,6 +408,57 @@ export function CreateLLMApiKeyForm({
/>
)}
{/* Extra Headers */}
{currentAdapter === "openai" ? (
<FormField
control={form.control}
name="extraHeaders"
render={() => (
<FormItem>
<FormLabel>Extra Headers</FormLabel>
<FormDescription>
Optional additional HTTP headers to include with requests
towards LLM provider. All header values stored encrypted on
our servers.
</FormDescription>
{headerFields.map((header, index) => (
<div key={header.id} className="flex flex-row space-x-2">
<Input
{...form.register(`extraHeaders.${index}.key`)}
placeholder="Header name"
/>
<Input
{...form.register(`extraHeaders.${index}.value`)}
placeholder="Header value"
/>
<Button
type="button"
variant="ghost"
onClick={() => removeHeader(index)}
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="ghost"
onClick={() => appendHeader({ key: "", value: "" })}
className="w-full"
>
<PlusIcon
className="-ml-0.5 mr-1.5 h-5 w-5"
aria-hidden="true"
/>
Add Header
</Button>
</FormItem>
)}
/>
) : null}
{/* With default models */}
<FormField
control={form.control}
@@ -49,6 +49,10 @@ export function LlmApiKeyList(props: { projectId: string }) {
},
);
const hasExtraHeaderKeys = apiKeys.data?.data.some(
(key) => key.extraHeaderKeys.length > 0,
);
if (!isAvailable) return null;
if (!hasAccess) {
@@ -89,6 +93,9 @@ export function LlmApiKeyList(props: { projectId: string }) {
Base URL
</TableHead>
<TableHead className="text-primary">Secret Key</TableHead>
{hasExtraHeaderKeys ? (
<TableHead className="text-primary">Extra headers</TableHead>
) : null}
<TableHead />
</TableRow>
</TableHeader>
@@ -116,6 +123,9 @@ export function LlmApiKeyList(props: { projectId: string }) {
<TableCell className="font-mono">
{apiKey.displaySecretKey}
</TableCell>
{hasExtraHeaderKeys ? (
<TableCell> {apiKey.extraHeaderKeys.join(", ")} </TableCell>
) : null}
<TableCell>
<DeleteApiKeyButton
projectId={props.projectId}
+15 -22
View File
@@ -5,15 +5,11 @@ import {
TRACE_TO_OBSERVATIONS_INTERVAL,
orderByToClickhouseSql,
type DateTimeFilter,
parseClickhouseUTCDateTimeFormat,
convertClickhouseToDomain,
type TraceRecordReadType,
} from "@langfuse/shared/src/server";
import {
convertRecordToJsonSchema,
type OrderByState,
type Trace,
} from "@langfuse/shared";
import { type OrderByState } from "@langfuse/shared";
import { snakeCase } from "lodash";
import { type JsonValue } from "@prisma/client/runtime/binary";
type QueryType = {
page: number;
@@ -82,22 +78,21 @@ export const generateTracesForPublicApi = async (
SELECT
t.id as id,
CONCAT('/project/', t.project_id, '/traces/', t.id) as "htmlPath",
t.project_id as projectId,
t.project_id as project_id,
t.timestamp as timestamp,
t.name as name,
t.input as input,
t.output as output,
null as externalId,
t.session_id as sessionId,
t.session_id as session_id,
t.metadata as metadata,
t.user_id as userId,
t.user_id as user_id,
t.release as release,
t.version as version,
t.bookmarked as bookmarked,
t.public as public,
t.tags as tags,
t.created_at as createdAt,
t.updated_at as updatedAt,
t.created_at as created_at,
t.updated_at as updated_at,
s.score_ids as scores,
o.observation_ids as observations,
COALESCE(o.latency_milliseconds / 1000, 0) as latency,
@@ -113,7 +108,7 @@ export const generateTracesForPublicApi = async (
`;
const result = await queryClickhouse<
Trace & {
TraceRecordReadType & {
observations: string[];
scores: string[];
totalCost: number;
@@ -138,14 +133,12 @@ export const generateTracesForPublicApi = async (
});
return result.map((trace) => ({
...trace,
timestamp: parseClickhouseUTCDateTimeFormat(trace.timestamp.toString()),
createdAt: parseClickhouseUTCDateTimeFormat(trace.createdAt.toString()),
updatedAt: parseClickhouseUTCDateTimeFormat(trace.updatedAt.toString()),
// Parse metadata values to JSON and make TypeScript happy
metadata: convertRecordToJsonSchema(
(trace.metadata as Record<string, string>) || {},
) as JsonValue,
...convertClickhouseToDomain(trace),
observations: trace.observations,
scores: trace.scores,
totalCost: trace.totalCost,
latency: trace.latency,
htmlPath: trace.htmlPath,
}));
};
@@ -17,7 +17,7 @@ const APIComment = z
updatedAt: z.coerce.date(),
objectType: z.nativeEnum(CommentObjectType),
objectId: z.string(),
content: z.string().min(1).max(500),
content: z.string().min(1).max(3000),
authorUserId: z.string().nullish(),
})
.strict();
+122 -151
View File
@@ -1,4 +1,5 @@
import { randomUUID } from "crypto";
import crypto from "node:crypto";
import { env } from "@/src/env.mjs";
import { getFileExtensionFromContentType } from "@/src/features/media/server/getFileExtensionFromContentType";
@@ -14,10 +15,8 @@ import {
ForbiddenError,
InternalServerError,
InvalidRequestError,
Prisma,
} from "@langfuse/shared";
import { prisma } from "@langfuse/shared/src/db";
import { instrumentAsync } from "@langfuse/shared/src/server";
import { logger } from "@langfuse/shared/src/server";
export default withMiddlewares({
@@ -45,158 +44,120 @@ export default withMiddlewares({
`File size must be less than ${env.LANGFUSE_S3_MEDIA_MAX_CONTENT_LENGTH} bytes`,
);
// Retry up to 3 times to avoid race conditions for concurrent media uploads
// Multiple observations can reference the same media, so we need to make sure no deadlock occurs
const MAX_RETRIES = 6;
let retries = 0;
try {
const existingMedia = await prisma.media.findUnique({
where: {
projectId_sha256Hash: {
projectId,
sha256Hash,
},
},
});
return await instrumentAsync(
{ name: "media-create-upload-url" },
async (span) => {
span.setAttribute("projectId", projectId);
span.setAttribute("traceId", traceId);
span.setAttribute("observationId", observationId ?? "");
span.setAttribute("field", field);
span.setAttribute("sha256Hash", sha256Hash);
while (retries < MAX_RETRIES) {
try {
if (retries > 0) {
await new Promise((resolve) => setTimeout(resolve, 500));
}
return await prisma.$transaction<{
mediaId: string;
uploadUrl: string | null;
}>(
async (tx) => {
const existingMedia = await prisma.media.findUnique({
where: {
projectId_sha256Hash: {
projectId,
sha256Hash,
},
},
});
if (
existingMedia &&
existingMedia.uploadHttpStatus === 200 &&
existingMedia.contentType === contentType
) {
if (observationId) {
// Use raw upserts to avoid deadlocks
await tx.$queryRaw`
INSERT INTO "observation_media" ("id", "project_id", "trace_id", "observation_id", "media_id", "field")
VALUES (${randomUUID()}, ${projectId}, ${traceId}, ${observationId}, ${existingMedia.id}, ${field})
ON CONFLICT DO NOTHING;
`;
} else {
// Use raw upserts to avoid deadlocks
await tx.$queryRaw`
INSERT INTO "trace_media" ("id", "project_id", "trace_id", "media_id", "field")
VALUES (${randomUUID()}, ${projectId}, ${traceId}, ${existingMedia.id}, ${field})
ON CONFLICT DO NOTHING;
`;
}
return {
mediaId: existingMedia.id,
uploadUrl: null,
};
}
const mediaId = existingMedia?.id ?? randomUUID();
span.setAttribute("mediaId", mediaId);
if (!env.LANGFUSE_S3_MEDIA_UPLOAD_BUCKET)
throw new InternalServerError(
"Media upload to blob storage not enabled or no bucket configured",
);
const s3Client = getMediaStorageServiceClient(
env.LANGFUSE_S3_MEDIA_UPLOAD_BUCKET,
);
const bucketPath = getBucketPath({
projectId,
mediaId,
contentType,
});
const uploadUrl = await s3Client.getSignedUploadUrl({
path: bucketPath,
ttlSeconds: 60 * 60, // 1 hour
sha256Hash,
contentType,
contentLength,
});
await Promise.all([
// Use raw upserts to avoid deadlocks
tx.$queryRaw`
INSERT INTO "media" (
"id",
"project_id",
"sha_256_hash",
"bucket_path",
"bucket_name",
"content_type",
"content_length"
)
VALUES (
${mediaId},
${projectId},
${sha256Hash},
${bucketPath},
${env.LANGFUSE_S3_MEDIA_UPLOAD_BUCKET},
${contentType},
${contentLength}
)
ON CONFLICT ("project_id", "sha_256_hash")
DO UPDATE SET
"bucket_name" = ${env.LANGFUSE_S3_MEDIA_UPLOAD_BUCKET},
"bucket_path" = ${bucketPath},
"content_type" = ${contentType},
"content_length" = ${contentLength}
`,
observationId
? tx.$queryRaw`
INSERT INTO "observation_media" ("id", "project_id", "trace_id", "observation_id", "media_id", "field")
VALUES (${randomUUID()}, ${projectId}, ${traceId}, ${observationId}, ${mediaId}, ${field})
ON CONFLICT DO NOTHING;
`
: tx.$queryRaw`
INSERT INTO "trace_media" ("id", "project_id", "trace_id", "media_id", "field")
VALUES (${randomUUID()}, ${projectId}, ${traceId}, ${mediaId}, ${field})
ON CONFLICT DO NOTHING;
`,
]);
return {
mediaId,
uploadUrl,
};
},
{
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
},
);
} catch (error) {
logger.error(
`Failed to get media upload URL for trace ${traceId} and observation ${observationId}. Retrying...`,
error,
);
retries++;
continue;
}
if (
existingMedia &&
existingMedia.uploadHttpStatus === 200 &&
existingMedia.contentType === contentType
) {
if (observationId) {
// Use raw upserts to avoid deadlocks
await prisma.$queryRaw`
INSERT INTO "observation_media" ("id", "project_id", "trace_id", "observation_id", "media_id", "field")
VALUES (${randomUUID()}, ${projectId}, ${traceId}, ${observationId}, ${existingMedia.id}, ${field})
ON CONFLICT DO NOTHING;
`;
} else {
// Use raw upserts to avoid deadlocks
await prisma.$queryRaw`
INSERT INTO "trace_media" ("id", "project_id", "trace_id", "media_id", "field")
VALUES (${randomUUID()}, ${projectId}, ${traceId}, ${existingMedia.id}, ${field})
ON CONFLICT DO NOTHING;
`;
}
logger.error(
`Failed to get media upload URL for trace ${traceId} and observation ${observationId}.`,
return {
mediaId: existingMedia.id,
uploadUrl: null,
};
}
const mediaId = getMediaId({ projectId, sha256Hash });
if (!env.LANGFUSE_S3_MEDIA_UPLOAD_BUCKET)
throw new InternalServerError(
"Media upload to blob storage not enabled or no bucket configured",
);
throw new InternalServerError("Failed to get media upload URL");
},
);
const s3Client = getMediaStorageServiceClient(
env.LANGFUSE_S3_MEDIA_UPLOAD_BUCKET,
);
const bucketPath = getBucketPath({
projectId,
mediaId,
contentType,
});
const uploadUrl = await s3Client.getSignedUploadUrl({
path: bucketPath,
ttlSeconds: 60 * 60, // 1 hour
sha256Hash,
contentType,
contentLength,
});
// Create media record first to ensure fkey constraint is met on next queries
await prisma.$queryRaw`
INSERT INTO "media" (
"id",
"project_id",
"sha_256_hash",
"bucket_path",
"bucket_name",
"content_type",
"content_length"
)
VALUES (
${mediaId},
${projectId},
${sha256Hash},
${bucketPath},
${env.LANGFUSE_S3_MEDIA_UPLOAD_BUCKET},
${contentType},
${contentLength}
)
ON CONFLICT ("project_id", "sha_256_hash")
DO UPDATE SET
"bucket_name" = ${env.LANGFUSE_S3_MEDIA_UPLOAD_BUCKET},
"bucket_path" = ${bucketPath},
"content_type" = ${contentType},
"content_length" = ${contentLength}
`;
if (observationId) {
await prisma.$queryRaw`
INSERT INTO "observation_media" ("id", "project_id", "trace_id", "observation_id", "media_id", "field")
VALUES (${randomUUID()}, ${projectId}, ${traceId}, ${observationId}, ${mediaId}, ${field})
ON CONFLICT DO NOTHING;
`;
} else {
await prisma.$queryRaw`
INSERT INTO "trace_media" ("id", "project_id", "trace_id", "media_id", "field")
VALUES (${randomUUID()}, ${projectId}, ${traceId}, ${mediaId}, ${field})
ON CONFLICT DO NOTHING;
`;
}
return {
mediaId,
uploadUrl,
};
} catch (error) {
logger.error(
`Failed to get media upload URL for trace ${traceId} and observation ${observationId}.`,
);
throw new InternalServerError("Failed to get media upload URL");
}
},
}),
});
@@ -215,3 +176,13 @@ function getBucketPath(params: {
return `${prefix}${projectId}/${mediaId}.${fileExtension}`;
}
function getMediaId(params: { projectId: string; sha256Hash: string }) {
const { projectId, sha256Hash } = params;
return crypto
.createHash("sha256")
.update(projectId + sha256Hash, "utf8")
.digest("base64url")
.slice(0, 22);
}
@@ -0,0 +1 @@
export { promptVersionHandler as default } from "@/src/features/prompts/server/handlers/promptVersionHandler";
+15
View File
@@ -32,6 +32,7 @@ import { Shield } from "lucide-react";
import { useRouter } from "next/router";
import { captureException } from "@sentry/nextjs";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { openChat } from "@/src/features/support-chat/chat";
const credentialAuthForm = z.object({
email: z.string().email(),
@@ -435,6 +436,20 @@ export default function SignIn({
</h2>
</div>
{env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== undefined && (
<div className="-mb-4 mt-4 rounded-lg bg-card p-3 text-center text-sm sm:mx-auto sm:w-full sm:max-w-[480px] sm:rounded-lg sm:px-6">
If you are experiencing issues signing in, please force refresh this
page (CMD + SHIFT + R) or clear your browser cache. We are working
on a solution.{" "}
<span
className="cursor-pointer whitespace-nowrap text-xs font-medium text-primary-accent hover:text-hover-primary-accent"
onClick={openChat}
>
(contact us)
</span>
</div>
)}
<CloudRegionSwitch />
<div className="mt-14 bg-background px-6 py-10 shadow sm:mx-auto sm:w-full sm:max-w-[480px] sm:rounded-lg sm:px-10">
@@ -23,11 +23,14 @@ import { ActionButton } from "@/src/components/ActionButton";
import { BatchExportsSettingsPage } from "@/src/features/batch-exports/components/BatchExportsSettingsPage";
import { AuditLogsSettingsPage } from "@/src/ee/features/audit-log-viewer/AuditLogsSettingsPage";
import { ModelsSettings } from "@/src/features/models/components/ModelSettings";
import ConfigureRetention from "@/src/features/projects/components/ConfigureRetention";
import { env } from "@/src/env.mjs";
export default function SettingsPage() {
const { project, organization } = useQueryProject();
const router = useRouter();
const showBillingSettings = useHasEntitlement("cloud-billing");
const isLangfuseCloud = Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION);
if (!project || !organization) return null;
return (
<div className="lg:container">
@@ -42,6 +45,7 @@ export default function SettingsPage() {
<div className="flex flex-col gap-6">
<HostNameProject />
<RenameProject />
{isLangfuseCloud && <ConfigureRetention />}
<div>
<Header title="Debug Information" level="h3" />
<JSONView
+41 -3
View File
@@ -184,15 +184,16 @@ if (
clientId: env.AUTH_CUSTOM_CLIENT_ID,
clientSecret: env.AUTH_CUSTOM_CLIENT_SECRET,
issuer: env.AUTH_CUSTOM_ISSUER,
idToken: env.AUTH_CUSTOM_ID_TOKEN !== "false", // defaults to true
allowDangerousEmailAccountLinking:
env.AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING === "true",
authorization: {
params: { scope: env.AUTH_CUSTOM_SCOPE ?? "openid email profile" },
},
client: {
token_endpoint_auth_method:
env.AUTH_CUSTOM_CLIENT_AUTH_METHOD ?? "client_secret_basic",
token_endpoint_auth_method: env.AUTH_CUSTOM_CLIENT_AUTH_METHOD,
},
checks: env.AUTH_CUSTOM_CHECKS,
}),
);
@@ -203,6 +204,10 @@ if (env.AUTH_GOOGLE_CLIENT_ID && env.AUTH_GOOGLE_CLIENT_SECRET)
clientSecret: env.AUTH_GOOGLE_CLIENT_SECRET,
allowDangerousEmailAccountLinking:
env.AUTH_GOOGLE_ALLOW_ACCOUNT_LINKING === "true",
client: {
token_endpoint_auth_method: env.AUTH_GOOGLE_CLIENT_AUTH_METHOD,
},
checks: env.AUTH_GOOGLE_CHECKS,
}),
);
@@ -218,6 +223,10 @@ if (
issuer: env.AUTH_OKTA_ISSUER,
allowDangerousEmailAccountLinking:
env.AUTH_OKTA_ALLOW_ACCOUNT_LINKING === "true",
client: {
token_endpoint_auth_method: env.AUTH_OKTA_CLIENT_AUTH_METHOD,
},
checks: env.AUTH_OKTA_CHECKS,
}),
);
@@ -233,6 +242,10 @@ if (
issuer: env.AUTH_AUTH0_ISSUER,
allowDangerousEmailAccountLinking:
env.AUTH_AUTH0_ALLOW_ACCOUNT_LINKING === "true",
client: {
token_endpoint_auth_method: env.AUTH_AUTH0_CLIENT_AUTH_METHOD,
},
checks: env.AUTH_AUTH0_CHECKS,
}),
);
@@ -243,6 +256,10 @@ if (env.AUTH_GITHUB_CLIENT_ID && env.AUTH_GITHUB_CLIENT_SECRET)
clientSecret: env.AUTH_GITHUB_CLIENT_SECRET,
allowDangerousEmailAccountLinking:
env.AUTH_GITHUB_ALLOW_ACCOUNT_LINKING === "true",
client: {
token_endpoint_auth_method: env.AUTH_GITHUB_CLIENT_AUTH_METHOD,
},
checks: env.AUTH_GITHUB_CHECKS,
}),
);
@@ -258,6 +275,11 @@ if (
enterprise: { baseUrl: env.AUTH_GITHUB_ENTERPRISE_BASE_URL },
allowDangerousEmailAccountLinking:
env.AUTH_GITHUB_ENTERPRISE_ALLOW_ACCOUNT_LINKING === "true",
client: {
token_endpoint_auth_method:
env.AUTH_GITHUB_ENTERPRISE_CLIENT_AUTH_METHOD,
},
checks: env.AUTH_GITHUB_ENTERPRISE_CHECKS,
}),
);
}
@@ -270,6 +292,10 @@ if (env.AUTH_GITLAB_CLIENT_ID && env.AUTH_GITLAB_CLIENT_SECRET)
allowDangerousEmailAccountLinking:
env.AUTH_GITLAB_ALLOW_ACCOUNT_LINKING === "true",
issuer: env.AUTH_GITLAB_ISSUER,
client: {
token_endpoint_auth_method: env.AUTH_GITLAB_CLIENT_AUTH_METHOD,
},
checks: env.AUTH_GITLAB_CHECKS,
}),
);
@@ -285,6 +311,10 @@ if (
tenantId: env.AUTH_AZURE_AD_TENANT_ID,
allowDangerousEmailAccountLinking:
env.AUTH_AZURE_ALLOW_ACCOUNT_LINKING === "true",
client: {
token_endpoint_auth_method: env.AUTH_AZURE_CLIENT_AUTH_METHOD,
},
checks: env.AUTH_AZURE_CHECKS,
}),
);
@@ -298,9 +328,12 @@ if (
clientId: env.AUTH_COGNITO_CLIENT_ID,
clientSecret: env.AUTH_COGNITO_CLIENT_SECRET,
issuer: env.AUTH_COGNITO_ISSUER,
checks: "nonce",
checks: env.AUTH_COGNITO_CHECKS ?? "nonce",
allowDangerousEmailAccountLinking:
env.AUTH_COGNITO_ALLOW_ACCOUNT_LINKING === "true",
client: {
token_endpoint_auth_method: env.AUTH_COGNITO_CLIENT_AUTH_METHOD,
},
}),
);
@@ -316,6 +349,10 @@ if (
issuer: env.AUTH_KEYCLOAK_ISSUER,
allowDangerousEmailAccountLinking:
env.AUTH_KEYCLOAK_ALLOW_ACCOUNT_LINKING === "true",
client: {
token_endpoint_auth_method: env.AUTH_KEYCLOAK_CLIENT_AUTH_METHOD,
},
checks: env.AUTH_KEYCLOAK_CHECKS,
}),
);
@@ -469,6 +506,7 @@ export async function getAuthOptions(): Promise<NextAuthOptions> {
id: project.id,
name: project.name,
role: projectRole,
retentionDays: project.retentionDays,
deletedAt: project.deletedAt,
};
})
+1
View File
@@ -45,6 +45,7 @@ declare module "next-auth" {
id: PrismaProject["id"];
name: PrismaProject["name"];
deletedAt: PrismaProject["deletedAt"];
retentionDays: PrismaProject["retentionDays"];
role: Role; // include only projects where user has a role
}[];
}[];
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "worker",
"version": "3.13.0",
"version": "3.17.0",
"description": "",
"license": "MIT",
"private": true,
@@ -0,0 +1,84 @@
import { expect, test, describe, beforeAll } from "vitest";
import { env } from "../env";
import { randomUUID } from "crypto";
import {
StorageService,
StorageServiceFactory,
} from "@langfuse/shared/src/server";
import { prisma } from "@langfuse/shared/src/db";
import { handleDataRetentionProcessingJob } from "../ee/dataRetention/handleDataRetentionProcessingJob";
import { Job } from "bullmq";
describe("DataRetentionProcessingJob", () => {
let storageService: StorageService;
const projectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
beforeAll(() => {
storageService = StorageServiceFactory.getInstance({
accessKeyId: env.LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID,
secretAccessKey: env.LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY,
bucketName: env.LANGFUSE_S3_MEDIA_UPLOAD_BUCKET,
endpoint: env.LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT,
region: env.LANGFUSE_S3_MEDIA_UPLOAD_REGION,
forcePathStyle: env.LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE === "true",
});
});
test("should delete media files from cloud storage and database if expired", async () => {
// Setup
const fileName = `${randomUUID()}.txt`;
const fileType = "text/plain";
const data = "Hello, world!";
const expiresInSeconds = 3600;
await storageService.uploadFile({
fileName,
fileType,
data,
expiresInSeconds,
});
const mediaId = randomUUID();
const traceId = randomUUID();
await prisma.media.create({
data: {
id: mediaId,
sha256Hash: randomUUID(),
projectId,
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30), // 30 days in the past
bucketPath: fileName,
bucketName: env.LANGFUSE_S3_MEDIA_UPLOAD_BUCKET,
contentType: fileType,
contentLength: 0,
},
});
await prisma.traceMedia.create({
data: {
id: randomUUID(),
projectId,
traceId,
mediaId,
field: "test",
},
});
// When
await handleDataRetentionProcessingJob({
data: { payload: { projectId, retention: 7 } }, // Delete after 7 days
} as Job);
// Then
const files = await storageService.listFiles("");
expect(files).not.toContain(fileName);
const media = await prisma.media.findUnique({
where: { id: mediaId },
});
expect(media).toBeNull();
const traceMedia = await prisma.traceMedia.findFirst({
where: { mediaId },
});
expect(traceMedia).toBeNull();
});
});
@@ -384,6 +384,7 @@ describe("create experiment job calls with langfuse server side tracing", async
secretKey: encrypt("test-key"),
baseURL: null,
customModels: [],
extraHeaderKeys: [],
withDefaultModels: true,
config: null,
} as any);
@@ -0,0 +1,90 @@
import { expect, test, describe, beforeAll } from "vitest";
import { env } from "../env";
import { randomUUID } from "crypto";
import {
StorageService,
StorageServiceFactory,
} from "@langfuse/shared/src/server";
describe("StorageService", () => {
let storageService: StorageService;
const baseUrl = `${env.LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT}/${env.LANGFUSE_S3_EVENT_UPLOAD_BUCKET}`;
beforeAll(() => {
storageService = StorageServiceFactory.getInstance({
accessKeyId: env.LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID,
secretAccessKey: env.LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY,
bucketName: env.LANGFUSE_S3_EVENT_UPLOAD_BUCKET,
endpoint: env.LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT,
region: env.LANGFUSE_S3_EVENT_UPLOAD_REGION,
forcePathStyle: env.LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE === "true",
});
});
test("uploadFile should upload a file and return a signed URL", async () => {
// Setup
const fileName = `${randomUUID()}.txt`;
const fileType = "text/plain";
const data = "Hello, world!";
const expiresInSeconds = 3600;
// When
const result = await storageService.uploadFile({
fileName,
fileType,
data,
expiresInSeconds,
});
// Then
expect(result.signedUrl).toContain(`${baseUrl}/${fileName}`);
const file = await storageService.download(fileName);
expect(file).toBe(data);
});
test("uploadJson should upload a JSON file", async () => {
// Setup
const fileName = `${randomUUID()}.json`;
const data = [{ hello: "world" }];
const expiresInSeconds = 3600;
// When
await storageService.uploadJson(fileName, data);
// Then
const file = await storageService.download(fileName);
expect(JSON.parse(file)).toEqual(data);
});
test("listFiles should list files in the bucket", async () => {
// Setup
const fileName1 = `${randomUUID()}.txt`;
const fileName2 = `${randomUUID()}.txt`;
await storageService.uploadJson(fileName1, [{ hello: "world" }]);
await storageService.uploadJson(fileName2, [{ hello: "world" }]);
// When
const files = await storageService.listFiles("");
// Then
const fileNames = files.map((f) => f.file);
expect(fileNames).toContain(fileName1);
expect(fileNames).toContain(fileName2);
});
test("deleteFiles should delete a file", async () => {
// Setup
const fileName1 = `${randomUUID()}.txt`;
const fileName2 = `${randomUUID()}.txt`;
await storageService.uploadJson(fileName1, [{ hello: "world" }]);
await storageService.uploadJson(fileName2, [{ hello: "world" }]);
// When
await storageService.deleteFiles([fileName1]);
// Then
const files = await storageService.listFiles("");
const fileNames = files.map((f) => f.file);
expect(fileNames).not.toContain(fileName1);
});
});
+26 -4
View File
@@ -20,11 +20,12 @@ import helmet from "helmet";
import { cloudUsageMeteringQueueProcessor } from "./queues/cloudUsageMeteringQueue";
import { WorkerManager } from "./queues/workerManager";
import {
CoreDataS3ExportQueue,
DataRetentionQueue,
MeteringDataPostgresExportQueue,
PostHogIntegrationQueue,
QueueName,
logger,
PostHogIntegrationQueue,
CoreDataS3ExportQueue,
MeteringDataPostgresExportQueue,
} from "@langfuse/shared/src/server";
import { env } from "./env";
import { ingestionQueueProcessorBuilder } from "./queues/ingestionQueue";
@@ -38,6 +39,10 @@ import {
} from "./queues/postHogIntegrationQueue";
import { coreDataS3ExportProcessor } from "./queues/coreDataS3ExportQueue";
import { meteringDataPostgresExportProcessor } from "./ee/meteringDataPostgresExport/handleMeteringDataPostgresExportJob";
import {
dataRetentionProcessingProcessor,
dataRetentionProcessor,
} from "./queues/dataRetentionQueue";
const app = express();
@@ -160,7 +165,7 @@ if (env.QUEUE_CONSUMER_INGESTION_QUEUE_IS_ENABLED === "true") {
);
}
if (env.QUEUE_CONSUMER_INGESTION_QUEUE_IS_ENABLED === "true") {
if (env.QUEUE_CONSUMER_INGESTION_SECONDARY_QUEUE_IS_ENABLED === "true") {
WorkerManager.register(
QueueName.IngestionSecondaryQueue,
ingestionQueueProcessorBuilder(false),
@@ -220,6 +225,23 @@ if (env.QUEUE_CONSUMER_POSTHOG_INTEGRATION_QUEUE_IS_ENABLED === "true") {
);
}
if (env.QUEUE_CONSUMER_DATA_RETENTION_QUEUE_IS_ENABLED === "true") {
// Instantiate the queue to trigger scheduled jobs
DataRetentionQueue.getInstance();
WorkerManager.register(QueueName.DataRetentionQueue, dataRetentionProcessor, {
concurrency: 1,
});
WorkerManager.register(
QueueName.DataRetentionProcessingQueue,
dataRetentionProcessingProcessor,
{
concurrency: 1,
},
);
}
process.on("SIGINT", () => onShutdown("SIGINT"));
process.on("SIGTERM", () => onShutdown("SIGTERM"));
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v3.13.0";
export const VERSION = "v3.17.0";
@@ -0,0 +1,73 @@
import {
logger,
StorageService,
StorageServiceFactory,
} from "@langfuse/shared/src/server";
import { Job } from "bullmq";
import { prisma } from "@langfuse/shared/src/db";
import { env } from "../../env";
let s3MediaStorageClient: StorageService;
const getS3MediaStorageClient = (bucketName: string): StorageService => {
if (!s3MediaStorageClient) {
s3MediaStorageClient = StorageServiceFactory.getInstance({
bucketName,
accessKeyId: env.LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID,
secretAccessKey: env.LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY,
endpoint: env.LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT,
region: env.LANGFUSE_S3_MEDIA_UPLOAD_REGION,
forcePathStyle: env.LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE === "true",
});
}
return s3MediaStorageClient;
};
export const handleDataRetentionProcessingJob = async (job: Job) => {
const { projectId, retention } = job.data.payload;
// Delete media files if bucket is configured
if (env.LANGFUSE_S3_MEDIA_UPLOAD_BUCKET) {
logger.info(
`[Data Retention] Deleting media files older than ${retention} days for project ${projectId}`,
);
const mediaFilesToDelete = await prisma.media.findMany({
select: {
id: true,
projectId: true,
createdAt: true,
bucketPath: true,
bucketName: true,
},
where: {
projectId,
createdAt: {
lte: new Date(Date.now() - retention * 24 * 60 * 60 * 1000),
},
},
});
const mediaStorageClient = getS3MediaStorageClient(
env.LANGFUSE_S3_MEDIA_UPLOAD_BUCKET,
);
// Delete from Cloud Storage
await mediaStorageClient.deleteFiles(
mediaFilesToDelete.map((f) => f.bucketPath),
);
// Delete from postgres. We should automatically remove the corresponding traceMedia and observationMedia
await prisma.media.deleteMany({
where: {
id: {
in: mediaFilesToDelete.map((f) => f.id),
},
projectId,
},
});
logger.info(
`[Data Retention] Deleted ${mediaFilesToDelete.length} media files for project ${projectId}`,
);
}
// Delete ClickHouse (TTL / Delete Queries)
// Set S3 Lifecycle for deletion (Future)
};
@@ -0,0 +1,42 @@
import { Job } from "bullmq";
import { prisma } from "@langfuse/shared/src/db";
import {
DataRetentionProcessingQueue,
QueueJobs,
} from "@langfuse/shared/src/server";
import { randomUUID } from "crypto";
export const handleDataRetentionSchedule = async (job: Job) => {
const projectsWithRetention = await prisma.project.findMany({
select: {
id: true,
retentionDays: true,
},
where: {
retentionDays: {
gt: 0, // Select all projects with a non-zero/non-null retention
},
},
});
const dataRetentionProcessingQueue =
DataRetentionProcessingQueue.getInstance();
if (!dataRetentionProcessingQueue) {
throw new Error("DataRetentionProcessingQueue not initialized");
}
await dataRetentionProcessingQueue.addBulk(
projectsWithRetention.map((project) => ({
name: QueueJobs.DataRetentionProcessingJob,
data: {
id: randomUUID(),
name: QueueJobs.DataRetentionProcessingJob,
timestamp: new Date(),
payload: {
projectId: project.id,
retention: project.retentionDays,
},
},
})),
);
};
+17
View File
@@ -153,6 +153,12 @@ const EnvSchema = z.object({
QUEUE_CONSUMER_POSTHOG_INTEGRATION_QUEUE_IS_ENABLED: z
.enum(["true", "false"])
.default("true"),
QUEUE_CONSUMER_INGESTION_SECONDARY_QUEUE_IS_ENABLED: z
.enum(["true", "false"])
.default("true"),
QUEUE_CONSUMER_DATA_RETENTION_QUEUE_IS_ENABLED: z
.enum(["true", "false"])
.default("true"),
// Core data S3 upload - Langfuse Cloud
LANGFUSE_S3_CORE_DATA_EXPORT_IS_ENABLED: z
@@ -168,6 +174,17 @@ const EnvSchema = z.object({
.enum(["true", "false"])
.default("false"),
// Media upload
LANGFUSE_S3_MEDIA_UPLOAD_BUCKET: z.string().optional(),
LANGFUSE_S3_MEDIA_UPLOAD_PREFIX: z.string().default(""),
LANGFUSE_S3_MEDIA_UPLOAD_REGION: z.string().optional(),
LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT: z.string().optional(),
LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID: z.string().optional(),
LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY: z.string().optional(),
LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE: z
.enum(["true", "false"])
.default("false"),
// Metering data Postgres export - Langfuse Cloud
LANGFUSE_POSTGRES_METERING_DATA_EXPORT_IS_ENABLED: z
.enum(["true", "false"])
@@ -6,7 +6,6 @@ import {
BatchExportStatus,
exportOptions,
FilterCondition,
Prisma,
Score,
TimeFilter,
} from "@langfuse/shared";
@@ -306,6 +305,10 @@ export const getDatabaseReadStream = async ({
outputCost: metric?.calculatedOutputCost,
totalCost: metric?.calculatedTotalCost,
level: metric?.level,
errorCount: metric?.errorCount,
warningCount: metric?.warningCount,
defaultCount: metric?.defaultCount,
debugCount: metric?.debugCount,
observationCount: Number(metric?.observationCount),
scores: outputScores,
inputTokens: metric?.promptTokens,
+3
View File
@@ -1,5 +1,6 @@
import {
ChatMessage,
decryptAndParseExtraHeaders,
fetchLLMCompletion,
logger,
type TraceParams,
@@ -24,6 +25,7 @@ export async function callStructuredLLM<T extends ZodSchema>(
const { completion, processTracedEvents } = await fetchLLMCompletion({
streaming: false,
apiKey: decrypt(llmApiKey.secretKey), // decrypt the secret key
extraHeaders: decryptAndParseExtraHeaders(llmApiKey.extraHeaders),
baseURL: llmApiKey.baseURL || undefined,
messages,
modelParams: {
@@ -67,6 +69,7 @@ export async function callLLM(
const { completion, processTracedEvents } = await fetchLLMCompletion({
streaming: false,
apiKey: decrypt(llmApiKey.secretKey), // decrypt the secret key
extraHeaders: decryptAndParseExtraHeaders(llmApiKey.extraHeaders),
baseURL: llmApiKey.baseURL || undefined,
messages,
modelParams: {
+27
View File
@@ -0,0 +1,27 @@
import { Processor } from "bullmq";
import { logger, QueueJobs } from "@langfuse/shared/src/server";
import { handleDataRetentionSchedule } from "../ee/dataRetention/handleDataRetentionSchedule";
import { handleDataRetentionProcessingJob } from "../ee/dataRetention/handleDataRetentionProcessingJob";
export const dataRetentionProcessor: Processor = async (job) => {
if (job.name === QueueJobs.DataRetentionJob) {
logger.info("Executing Data Retention Job");
try {
return await handleDataRetentionSchedule(job);
} catch (error) {
logger.error("Error executing DataRetentionJob", error);
throw error;
}
}
};
export const dataRetentionProcessingProcessor: Processor = async (job) => {
if (job.name === QueueJobs.DataRetentionProcessingJob) {
try {
return await handleDataRetentionProcessingJob(job);
} catch (error) {
logger.error("Error executing DataRetentionProcessingJob", error);
throw error;
}
}
};
+18 -2
View File
@@ -12,6 +12,7 @@ import {
getClickhouseEntityType,
getCurrentSpan,
getQueue,
recordDistribution,
} from "@langfuse/shared/src/server";
import { prisma } from "@langfuse/shared/src/db";
@@ -93,9 +94,24 @@ export const ingestionQueueProcessorBuilder = (
);
// Download all events from folder into a local array
const eventFiles = await s3Client.listFiles(
`${env.LANGFUSE_S3_EVENT_UPLOAD_PREFIX}${job.data.payload.authCheck.scope.projectId}/${getClickhouseEntityType(job.data.payload.data.type)}/${job.data.payload.data.eventBodyId}/`,
const clickhouseEntityType = getClickhouseEntityType(
job.data.payload.data.type,
);
const eventFiles = await s3Client.listFiles(
`${env.LANGFUSE_S3_EVENT_UPLOAD_PREFIX}${job.data.payload.authCheck.scope.projectId}/${clickhouseEntityType}/${job.data.payload.data.eventBodyId}/`,
);
recordDistribution(
"langfuse.ingestion.count_files_distribution",
eventFiles.length,
{
kind: clickhouseEntityType,
},
);
span?.setAttribute(
"langfuse.ingestion.event.count_files",
eventFiles.length,
);
span?.setAttribute("langfuse.ingestion.event.kind", clickhouseEntityType);
const firstS3WriteTime =
eventFiles
@@ -816,8 +816,16 @@ export class IngestionService {
};
}) {
if (await this.shouldSkipClickHouseRead(params.projectId)) {
recordIncrement("langfuse.ingestion.clickhouse_read_for_update", 1, {
skipped: "true",
table: params.table,
});
return null;
}
recordIncrement("langfuse.ingestion.clickhouse_read_for_update", 1, {
skipped: "false",
table: params.table,
});
const recordParser = {
traces: traceRecordReadSchema,