Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
879ef9c8f9 | ||
|
|
a343d5b187 | ||
|
|
ac2b9eaa56 | ||
|
|
43e9cba99a | ||
|
|
601f431f69 | ||
|
|
4fd1f86f9d | ||
|
|
8245888e3c | ||
|
|
daf4e2fe02 | ||
|
|
129693fb69 | ||
|
|
204948db44 | ||
|
|
d42ba5fc99 | ||
|
|
97a539ced3 | ||
|
|
9d8dace197 | ||
|
|
bc2dc4d89c |
@@ -22,6 +22,13 @@ service:
|
||||
docs: limit of items per page
|
||||
response: PaginatedAnnotationQueues
|
||||
|
||||
createQueue:
|
||||
docs: Create an annotation queue
|
||||
method: POST
|
||||
path: /annotation-queues
|
||||
request: CreateAnnotationQueueRequest
|
||||
response: AnnotationQueue
|
||||
|
||||
getQueue:
|
||||
docs: Get an annotation queue by ID
|
||||
method: GET
|
||||
@@ -168,6 +175,12 @@ types:
|
||||
data: list<AnnotationQueueItem>
|
||||
meta: pagination.MetaResponse
|
||||
|
||||
CreateAnnotationQueueRequest:
|
||||
properties:
|
||||
name: string
|
||||
description: optional<string>
|
||||
scoreConfigIds: list<string>
|
||||
|
||||
CreateAnnotationQueueItemRequest:
|
||||
properties:
|
||||
objectId: string
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "langfuse",
|
||||
"version": "3.97.2",
|
||||
"version": "3.97.4",
|
||||
"author": "engineering@langfuse.com",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
|
||||
@@ -158,6 +158,12 @@ const EnvSchema = z.object({
|
||||
LANGFUSE_EXPERIMENT_INSERT_INTO_AGGREGATING_MERGE_TREES: z
|
||||
.enum(["true", "false"])
|
||||
.default("false"),
|
||||
LANGFUSE_EXPERIMENT_WHITELISTED_AMT_TABLES: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((s) =>
|
||||
s ? s.split(",").map((s) => s.toLowerCase().trim()) : [],
|
||||
),
|
||||
LANGFUSE_INGESTION_PROCESSING_SAMPLED_PROJECTS: z
|
||||
.string()
|
||||
.optional()
|
||||
|
||||
@@ -88,7 +88,7 @@ async function removeIngestionEventsFromS3AndDeleteClickhouseRefs(p: {
|
||||
);
|
||||
await softDeleteInClickhouse(blobStorageRefs);
|
||||
logger.info(
|
||||
`Deleted batch ${batch} of size ${blobStorageRefs.length} for ${projectId} of deleting s3 refs`,
|
||||
`Deleted last batch ${batch} of size ${blobStorageRefs.length} for ${projectId} of deleting s3 refs`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { env } from "../../env";
|
||||
import { logger } from "../../server/logger";
|
||||
import {
|
||||
DatasetRunItemsExecutionStrategy,
|
||||
DatasetRunItemsOperationType,
|
||||
@@ -48,23 +47,8 @@ export async function executeWithDatasetRunItemsStrategy<TInput, TOutput>({
|
||||
if (operationType === DatasetRunItemsOperationType.WRITE) {
|
||||
// For write operations, implement dual-write strategy
|
||||
if (strategy.shouldWriteToClickHouse) {
|
||||
// Dual-write phase: write to both databases
|
||||
const postgresResult = await postgresExecution(input);
|
||||
|
||||
try {
|
||||
await clickhouseExecution(input);
|
||||
logger.debug("Successfully wrote to both PostgreSQL and ClickHouse", {
|
||||
operation: `dataset_run_items_${operationType}`,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("ClickHouse write failed during dual-write phase", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
operation: `dataset_run_items_${operationType}`,
|
||||
});
|
||||
// Continue with PostgreSQL result since it succeeded
|
||||
}
|
||||
|
||||
return postgresResult;
|
||||
// Write only to ClickHouse
|
||||
return await clickhouseExecution(input);
|
||||
} else {
|
||||
// Write only to PostgreSQL
|
||||
return await postgresExecution(input);
|
||||
@@ -74,20 +58,10 @@ export async function executeWithDatasetRunItemsStrategy<TInput, TOutput>({
|
||||
const shouldExecuteClickhouse = strategy.shouldReadFromClickHouse;
|
||||
|
||||
if (shouldExecuteClickhouse) {
|
||||
try {
|
||||
return await clickhouseExecution(input);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"ClickHouse execution failed, falling back to PostgreSQL",
|
||||
{
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
operation: `dataset_run_items_${operationType}`,
|
||||
},
|
||||
);
|
||||
// Fallback to PostgreSQL for reliability
|
||||
return await postgresExecution(input);
|
||||
}
|
||||
// Read from ClickHouse
|
||||
return await clickhouseExecution(input);
|
||||
} else {
|
||||
// Read from PostgreSQL
|
||||
return await postgresExecution(input);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export const testModelCall = async ({
|
||||
apiKey: z.infer<typeof LLMApiKeySchema>;
|
||||
prompt?: string;
|
||||
modelConfig?: ModelConfig | null;
|
||||
}): Promise<void> => {
|
||||
}) => {
|
||||
(
|
||||
await fetchLLMCompletion({
|
||||
streaming: false,
|
||||
|
||||
@@ -47,12 +47,16 @@ enum TracesAMTs {
|
||||
* for <= 29 days, we use traces_30d_amt,
|
||||
* for all other cases we use traces_all_amt.
|
||||
*
|
||||
* If LANGFUSE_EXPERIMENT_WHITELISTED_AMT_TABLES is set, we only return timeframes
|
||||
* that are whitelisted or fallback to the traces_all_amt.
|
||||
*
|
||||
* @param fromTimestamp
|
||||
*/
|
||||
export const getTimeframesTracesAMT = (
|
||||
fromTimestamp: Date | undefined,
|
||||
): TracesAMTs => {
|
||||
if (!fromTimestamp) {
|
||||
// The TracesAllAMT must always be returned if there is no timestamp.
|
||||
return TracesAMTs.TracesAllAMT;
|
||||
}
|
||||
|
||||
@@ -60,12 +64,21 @@ export const getTimeframesTracesAMT = (
|
||||
const diffInDays = Math.floor(
|
||||
(now.getTime() - fromTimestamp.getTime()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
|
||||
let selectedTable: TracesAMTs;
|
||||
if (diffInDays <= 6) {
|
||||
return TracesAMTs.Traces7dAMT;
|
||||
selectedTable = TracesAMTs.Traces7dAMT;
|
||||
} else if (diffInDays <= 29) {
|
||||
return TracesAMTs.Traces30dAMT;
|
||||
selectedTable = TracesAMTs.Traces30dAMT;
|
||||
} else {
|
||||
selectedTable = TracesAMTs.TracesAllAMT;
|
||||
}
|
||||
return TracesAMTs.TracesAllAMT;
|
||||
|
||||
// Check if the selected table is whitelisted, fallback to TracesAllAMT if not
|
||||
return env.LANGFUSE_EXPERIMENT_WHITELISTED_AMT_TABLES.length === 0 ||
|
||||
env.LANGFUSE_EXPERIMENT_WHITELISTED_AMT_TABLES.includes(selectedTable)
|
||||
? selectedTable
|
||||
: TracesAMTs.TracesAllAMT;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,6 +15,7 @@ export function createBasicAuthHeader(
|
||||
|
||||
export type CreateOrgProjectAndApiKeyOptions = {
|
||||
projectId?: string;
|
||||
plan?: "Team" | "Hobby" | "Core" | "Pro" | "Enterprise";
|
||||
};
|
||||
export const createOrgProjectAndApiKey = async (
|
||||
props?: CreateOrgProjectAndApiKeyOptions,
|
||||
@@ -25,7 +26,7 @@ export const createOrgProjectAndApiKey = async (
|
||||
id: v4(),
|
||||
name: v4(),
|
||||
cloudConfig: CloudConfigSchema.parse({
|
||||
plan: "Team",
|
||||
plan: props?.plan ?? "Team",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "3.97.2",
|
||||
"version": "3.97.4",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
@@ -79,6 +79,52 @@ paths:
|
||||
schema: {}
|
||||
security:
|
||||
- BasicAuth: []
|
||||
post:
|
||||
description: Create an annotation queue
|
||||
operationId: annotationQueues_createQueue
|
||||
tags:
|
||||
- AnnotationQueues
|
||||
parameters: []
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AnnotationQueue'
|
||||
'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:
|
||||
$ref: '#/components/schemas/CreateAnnotationQueueRequest'
|
||||
/api/public/annotation-queues/{queueId}:
|
||||
get:
|
||||
description: Get an annotation queue by ID
|
||||
@@ -3254,7 +3300,7 @@ paths:
|
||||
parameters:
|
||||
- name: filter
|
||||
in: query
|
||||
description: Filter expression (e.g. userName eq 'value')
|
||||
description: Filter expression (e.g. userName eq "value")
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
@@ -4459,6 +4505,22 @@ components:
|
||||
required:
|
||||
- data
|
||||
- meta
|
||||
CreateAnnotationQueueRequest:
|
||||
title: CreateAnnotationQueueRequest
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
nullable: true
|
||||
scoreConfigIds:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
- scoreConfigIds
|
||||
CreateAnnotationQueueItemRequest:
|
||||
title: CreateAnnotationQueueItemRequest
|
||||
type: object
|
||||
|
||||
@@ -78,6 +78,39 @@
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"_type": "endpoint",
|
||||
"name": "Create Queue",
|
||||
"request": {
|
||||
"description": "Create an annotation queue",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/annotation-queues",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"public",
|
||||
"annotation-queues"
|
||||
],
|
||||
"query": [],
|
||||
"variable": []
|
||||
},
|
||||
"header": [],
|
||||
"method": "POST",
|
||||
"auth": null,
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"name\": \"example\",\n \"description\": \"example\",\n \"scoreConfigIds\": [\n \"example\"\n ]\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"_type": "endpoint",
|
||||
"name": "Get Queue",
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
CreateAnnotationQueueItemResponse,
|
||||
UpdateAnnotationQueueItemResponse,
|
||||
DeleteAnnotationQueueItemResponse,
|
||||
CreateAnnotationQueueResponse,
|
||||
} from "@/src/features/public-api/types/annotation-queues";
|
||||
import {
|
||||
AnnotationQueueObjectType,
|
||||
@@ -173,6 +174,120 @@ describe("Annotation Queues API Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /annotation-queues", () => {
|
||||
it("should create a new annotation queue", async () => {
|
||||
const scoreConfig = await prisma.scoreConfig.create({
|
||||
data: {
|
||||
name: "Test Score Config",
|
||||
description: "Test Score Config Description",
|
||||
projectId,
|
||||
dataType: "NUMERIC",
|
||||
},
|
||||
});
|
||||
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
CreateAnnotationQueueResponse,
|
||||
"POST",
|
||||
"/api/public/annotation-queues",
|
||||
{
|
||||
name: "Test Queue",
|
||||
description: "Test Queue Description",
|
||||
scoreConfigIds: [scoreConfig.id],
|
||||
},
|
||||
auth,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.id).toBeDefined();
|
||||
expect(response.body.name).toBe("Test Queue");
|
||||
expect(response.body.description).toBe("Test Queue Description");
|
||||
expect(response.body.scoreConfigIds).toEqual([scoreConfig.id]);
|
||||
});
|
||||
|
||||
it("should return 400 if the queue name already exists", async () => {
|
||||
const response = await makeAPICall(
|
||||
"POST",
|
||||
"/api/public/annotation-queues",
|
||||
{
|
||||
name: "Test Queue",
|
||||
description: "Test Queue Description",
|
||||
scoreConfigIds: [],
|
||||
},
|
||||
auth,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("should return 400 if no score config IDs are provided", async () => {
|
||||
const response = await makeAPICall(
|
||||
"POST",
|
||||
"/api/public/annotation-queues",
|
||||
{
|
||||
name: "No configs queue",
|
||||
description: "Test Queue Description",
|
||||
scoreConfigIds: [],
|
||||
},
|
||||
auth,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("should return 400 if the score config IDs are invalid", async () => {
|
||||
const response = await makeAPICall(
|
||||
"POST",
|
||||
"/api/public/annotation-queues",
|
||||
{
|
||||
name: "Invalid configs queue",
|
||||
description: "Test Queue Description",
|
||||
scoreConfigIds: ["invalid-score-config-id"],
|
||||
},
|
||||
auth,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("should return 405 if the user is on the Hobby plan and has reached the maximum number of annotation queues", async () => {
|
||||
const { auth: hobbyPlanAuth, projectId: hobbyProjectId } =
|
||||
await createOrgProjectAndApiKey({
|
||||
plan: "Hobby",
|
||||
});
|
||||
|
||||
const config = await prisma.scoreConfig.create({
|
||||
data: {
|
||||
name: "Test Score Config",
|
||||
description: "Test Score Config Description",
|
||||
projectId: hobbyProjectId,
|
||||
dataType: "NUMERIC",
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.annotationQueue.create({
|
||||
data: {
|
||||
name: "First queue",
|
||||
description: "First queue description",
|
||||
scoreConfigIds: [config.id],
|
||||
projectId: hobbyProjectId,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await makeAPICall(
|
||||
"POST",
|
||||
"/api/public/annotation-queues",
|
||||
{
|
||||
name: "Hobby plan queue",
|
||||
description: "Test Queue Description",
|
||||
scoreConfigIds: [config.id],
|
||||
},
|
||||
hobbyPlanAuth,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(405);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /annotation-queues/:queueId", () => {
|
||||
it("should get a specific annotation queue", async () => {
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const VERSION = "v3.97.2";
|
||||
export const VERSION = "v3.97.4";
|
||||
|
||||
@@ -33,9 +33,6 @@ export const EvalTemplateDetail = () => {
|
||||
const templateId = router.query.id as string;
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<EvalTemplate | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// get the current template by id
|
||||
const template = api.evals.templateById.useQuery({
|
||||
@@ -58,15 +55,7 @@ export const EvalTemplateDetail = () => {
|
||||
},
|
||||
);
|
||||
|
||||
// Set the selected template when data is loaded
|
||||
React.useEffect(() => {
|
||||
if (template.data && !selectedTemplate) {
|
||||
setSelectedTemplate(template.data);
|
||||
}
|
||||
}, [template.data, selectedTemplate]);
|
||||
|
||||
const handleTemplateSelect = (newTemplate: EvalTemplate) => {
|
||||
setSelectedTemplate(newTemplate);
|
||||
// Update URL without full page reload
|
||||
router.push(
|
||||
`/project/${projectId}/evals/templates/${newTemplate.id}`,
|
||||
@@ -75,13 +64,10 @@ export const EvalTemplateDetail = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// Get the appropriate template to display
|
||||
const displayTemplate = selectedTemplate || template.data;
|
||||
|
||||
return (
|
||||
<Page
|
||||
headerProps={{
|
||||
title: `${displayTemplate?.name || ""}`,
|
||||
title: `${template.data?.name ?? ""}`,
|
||||
itemType: "EVALUATOR",
|
||||
breadcrumb: [
|
||||
{
|
||||
@@ -95,7 +81,7 @@ export const EvalTemplateDetail = () => {
|
||||
projectId={projectId}
|
||||
isEditing={isEditing}
|
||||
setIsEditing={setIsEditing}
|
||||
isCustom={!!displayTemplate?.projectId}
|
||||
isCustom={!!template.data?.projectId}
|
||||
/>
|
||||
|
||||
{/* TODO: moved to LFE-4573 */}
|
||||
@@ -114,14 +100,14 @@ export const EvalTemplateDetail = () => {
|
||||
),
|
||||
}}
|
||||
>
|
||||
{allTemplates.isLoading || !allTemplates.data || !displayTemplate ? (
|
||||
{allTemplates.isLoading || !allTemplates.data || !template.data ? (
|
||||
<div className="p-3">Loading...</div>
|
||||
) : isEditing ? (
|
||||
<div className="overflow-y-auto p-3 pt-1">
|
||||
<EvalTemplateForm
|
||||
useDialog={false}
|
||||
projectId={projectId}
|
||||
existingEvalTemplate={displayTemplate}
|
||||
existingEvalTemplate={template.data}
|
||||
isEditing={isEditing}
|
||||
setIsEditing={setIsEditing}
|
||||
/>
|
||||
@@ -132,7 +118,7 @@ export const EvalTemplateDetail = () => {
|
||||
<EvalTemplateForm
|
||||
useDialog={false}
|
||||
projectId={projectId}
|
||||
existingEvalTemplate={displayTemplate}
|
||||
existingEvalTemplate={template.data}
|
||||
isEditing={isEditing}
|
||||
setIsEditing={setIsEditing}
|
||||
/>
|
||||
@@ -150,7 +136,7 @@ export const EvalTemplateDetail = () => {
|
||||
<div
|
||||
key={template.id}
|
||||
className={`flex cursor-pointer flex-col rounded-md px-2 py-1.5 hover:bg-accent ${
|
||||
template.id === displayTemplate.id ? "bg-accent" : ""
|
||||
template.id === templateId ? "bg-accent" : ""
|
||||
}`}
|
||||
onClick={() => handleTemplateSelect(template)}
|
||||
>
|
||||
|
||||
@@ -58,6 +58,12 @@ export function EvaluatorSelector({
|
||||
},
|
||||
);
|
||||
|
||||
// Ensure per-name arrays are sorted by createdAt ascending so last is latest
|
||||
const sortByCreatedAt = (arr: EvalTemplate[]) =>
|
||||
arr.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
||||
Object.values(groupedTemplates.custom).forEach(sortByCreatedAt);
|
||||
Object.values(groupedTemplates.langfuse).forEach(sortByCreatedAt);
|
||||
|
||||
// Filter templates based on search
|
||||
const filteredTemplates = {
|
||||
langfuse: Object.entries(groupedTemplates.langfuse)
|
||||
|
||||
@@ -289,6 +289,7 @@ export const llmApiKeyRouter = createTRPCRouter({
|
||||
customModels: true,
|
||||
withDefaultModels: true,
|
||||
extraHeaderKeys: true,
|
||||
config: true,
|
||||
},
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
|
||||
@@ -14,6 +14,19 @@ const isUniqueConstraintError = (error: any): boolean => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create or fetch a dataset run with optimistic concurrency handling.
|
||||
*
|
||||
* Behavior:
|
||||
* - First tries to find an existing run by (projectId, datasetId, name).
|
||||
* - If not found, attempts to create it.
|
||||
* - If creation fails due to a unique constraint (likely created concurrently),
|
||||
* fetches and returns the existing run.
|
||||
* - If all steps fail, throws an error.
|
||||
*
|
||||
* Rationale: The public API can receive many POST requests almost simultaneously,
|
||||
* which is not concurrency-safe without this guard.
|
||||
*/
|
||||
export const createOrFetchDatasetRun = async ({
|
||||
projectId,
|
||||
datasetId,
|
||||
@@ -28,7 +41,21 @@ export const createOrFetchDatasetRun = async ({
|
||||
metadata?: Json | null;
|
||||
}) => {
|
||||
try {
|
||||
// Attempt optimistic creation
|
||||
// Attempt to fetch existing run
|
||||
const existingRun = await prisma.datasetRuns.findUnique({
|
||||
where: {
|
||||
datasetId_projectId_name: {
|
||||
datasetId,
|
||||
projectId,
|
||||
name: name,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (existingRun) {
|
||||
return existingRun;
|
||||
}
|
||||
|
||||
// Attempt creation
|
||||
const datasetRun = await prisma.datasetRuns.create({
|
||||
data: {
|
||||
id: v4(),
|
||||
|
||||
@@ -84,10 +84,9 @@ export const generateTracesForPublicApi = async ({
|
||||
SELECT
|
||||
trace_id,
|
||||
project_id,
|
||||
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,
|
||||
groupArray(id) as observation_ids
|
||||
FROM observations FINAL
|
||||
${includeMetrics ? "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, " : ""}
|
||||
groupUniqArray(id) as observation_ids
|
||||
FROM observations ${includeMetrics ? "FINAL" : ""}
|
||||
WHERE project_id = {projectId: String}
|
||||
${timeFilter ? `AND start_time >= {cteTimeFilter: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}` : ""}
|
||||
${environmentFilter.length() > 0 ? `AND ${appliedEnvironmentFilter.query}` : ""}
|
||||
|
||||
@@ -57,6 +57,17 @@ export const GetAnnotationQueuesResponse = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
// POST /annotation-queues
|
||||
export const CreateAnnotationQueueBody = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
description: z.string().nullable(),
|
||||
scoreConfigIds: z.array(z.string()).min(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const CreateAnnotationQueueResponse = AnnotationQueueSchema;
|
||||
|
||||
// GET /annotation-queues/:queueId
|
||||
export const GetAnnotationQueueByIdQuery = z
|
||||
.object({
|
||||
|
||||
@@ -1,50 +1,79 @@
|
||||
import React from "react";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { X } from "lucide-react";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
|
||||
type TagInputProps = React.ComponentPropsWithoutRef<
|
||||
typeof CommandPrimitive.Input
|
||||
> & {
|
||||
selectedTags: string[];
|
||||
setSelectedTags?: (tags: string[]) => void;
|
||||
allowTagRemoval?: boolean;
|
||||
};
|
||||
|
||||
export const TagInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
TagInputProps
|
||||
>(({ className, selectedTags, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-wrap items-center overflow-auto rounded-lg border px-2"
|
||||
cmdk-input-wrapper=""
|
||||
>
|
||||
{selectedTags.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 pt-2">
|
||||
{selectedTags.map((tag: string) => (
|
||||
<Button
|
||||
key={tag}
|
||||
variant="tertiary"
|
||||
size="icon-sm"
|
||||
disabled
|
||||
className="cursor-default"
|
||||
>
|
||||
{tag}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"placeholder:muted-foreground flex h-8 w-full rounded-md border-transparent bg-transparent px-1 text-sm outline-none focus:border-0 focus:border-none focus:border-transparent focus:ring-0 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
selectedTags,
|
||||
setSelectedTags,
|
||||
allowTagRemoval = false,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const capture = usePostHogClientCapture();
|
||||
|
||||
const removeTag = (tagToRemove: string) => {
|
||||
if (setSelectedTags && allowTagRemoval) {
|
||||
setSelectedTags(selectedTags.filter((t) => t !== tagToRemove));
|
||||
capture("tag:remove_tag", {
|
||||
name: tagToRemove,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-wrap items-center overflow-auto rounded-lg border px-2"
|
||||
cmdk-input-wrapper=""
|
||||
>
|
||||
{selectedTags.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 pt-2">
|
||||
{selectedTags.map((tag: string) => (
|
||||
<Button
|
||||
key={tag}
|
||||
variant="tertiary"
|
||||
size="icon-sm"
|
||||
disabled={!allowTagRemoval}
|
||||
className={
|
||||
allowTagRemoval ? "cursor-pointer" : "cursor-default"
|
||||
}
|
||||
onClick={allowTagRemoval ? () => removeTag(tag) : undefined}
|
||||
>
|
||||
{tag}
|
||||
{allowTagRemoval && <X className="ml-1 h-3 w-3" />}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
autoFocus
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"placeholder:muted-foreground flex h-8 w-full rounded-md border-transparent bg-transparent px-1 text-sm outline-none focus:border-0 focus:border-none focus:border-transparent focus:ring-0 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
autoFocus
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
TagInput.displayName = CommandPrimitive.Input.displayName;
|
||||
|
||||
+3
@@ -22,6 +22,7 @@ type TagManagerProps = {
|
||||
mutateTags: (value: string[]) => void;
|
||||
className?: string;
|
||||
isTableCell?: boolean;
|
||||
allowTagRemoval?: boolean;
|
||||
};
|
||||
|
||||
const TagManager = ({
|
||||
@@ -33,6 +34,7 @@ const TagManager = ({
|
||||
mutateTags,
|
||||
className,
|
||||
isTableCell = false,
|
||||
allowTagRemoval = true,
|
||||
}: TagManagerProps) => {
|
||||
const {
|
||||
selectedTags,
|
||||
@@ -114,6 +116,7 @@ const TagManager = ({
|
||||
onValueChange={setInputValue}
|
||||
selectedTags={selectedTags}
|
||||
setSelectedTags={setSelectedTags}
|
||||
allowTagRemoval={allowTagRemoval}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandGroup
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from "react";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import { type RouterOutput } from "@/src/utils/types";
|
||||
import TagManager from "@/src/features/tag/components/TagMananger";
|
||||
import TagManager from "@/src/features/tag/components/TagManager";
|
||||
import { trpcErrorToast } from "@/src/utils/trpcErrorToast";
|
||||
|
||||
type TagPromptDetailsPopoverProps = {
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from "react";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import { type RouterOutput, type RouterInput } from "@/src/utils/types";
|
||||
import TagManager from "@/src/features/tag/components/TagMananger";
|
||||
import TagManager from "@/src/features/tag/components/TagManager";
|
||||
import { trpcErrorToast } from "@/src/utils/trpcErrorToast";
|
||||
|
||||
type TagPromptPopverProps = {
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from "react";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import { type RouterOutput } from "@/src/utils/types";
|
||||
import TagManager from "@/src/features/tag/components/TagMananger";
|
||||
import TagManager from "@/src/features/tag/components/TagManager";
|
||||
import { trpcErrorToast } from "@/src/utils/trpcErrorToast";
|
||||
|
||||
type TagTraceDetailsPopoverProps = {
|
||||
@@ -85,6 +85,7 @@ export function TagTraceDetailsPopover({
|
||||
isLoading={isLoading}
|
||||
mutateTags={mutateTags}
|
||||
className={className}
|
||||
allowTagRemoval={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from "react";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import { type RouterOutput, type RouterInput } from "@/src/utils/types";
|
||||
import TagManager from "@/src/features/tag/components/TagMananger";
|
||||
import TagManager from "@/src/features/tag/components/TagManager";
|
||||
import { trpcErrorToast } from "@/src/utils/trpcErrorToast";
|
||||
|
||||
type TagTracePopoverProps = {
|
||||
@@ -75,6 +75,7 @@ export function TagTracePopover({
|
||||
mutateTags={mutateTags}
|
||||
className={className}
|
||||
isTableCell
|
||||
allowTagRemoval={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,13 +56,6 @@ export default async function handler(
|
||||
return;
|
||||
}
|
||||
|
||||
const body = ManageBullBody.safeParse(req.body);
|
||||
|
||||
if (!body.success) {
|
||||
res.status(400).json({ error: body.error });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET") {
|
||||
const queues: string[] = Object.values(QueueName);
|
||||
queues.push(...IngestionQueue.getShardNames());
|
||||
@@ -94,6 +87,13 @@ export default async function handler(
|
||||
return res.status(200).json(queueCounts);
|
||||
}
|
||||
|
||||
const body = ManageBullBody.safeParse(req.body);
|
||||
|
||||
if (!body.success) {
|
||||
res.status(400).json({ error: body.error });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && body.data.action === "remove") {
|
||||
logger.info(
|
||||
`Removing jobs for queues ${body.data.queueNames.join(", ")}`,
|
||||
|
||||
@@ -2,12 +2,14 @@ import { prisma } from "@langfuse/shared/src/db";
|
||||
import { withMiddlewares } from "@/src/features/public-api/server/withMiddlewares";
|
||||
import { createAuthedProjectAPIRoute } from "@/src/features/public-api/server/createAuthedProjectAPIRoute";
|
||||
import {
|
||||
CreateAnnotationQueueBody,
|
||||
CreateAnnotationQueueResponse,
|
||||
GetAnnotationQueuesQuery,
|
||||
GetAnnotationQueuesResponse,
|
||||
} from "@/src/features/public-api/types/annotation-queues";
|
||||
import { InvalidRequestError, MethodNotAllowedError } from "@langfuse/shared";
|
||||
|
||||
export default withMiddlewares({
|
||||
// NOTE: Post API requires entitlement check
|
||||
GET: createAuthedProjectAPIRoute({
|
||||
name: "Get annotation queues",
|
||||
querySchema: GetAnnotationQueuesQuery,
|
||||
@@ -54,4 +56,72 @@ export default withMiddlewares({
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
POST: createAuthedProjectAPIRoute({
|
||||
name: "Create annotation queue",
|
||||
bodySchema: CreateAnnotationQueueBody,
|
||||
responseSchema: CreateAnnotationQueueResponse,
|
||||
fn: async ({ body, auth }) => {
|
||||
// entitlement check
|
||||
if (auth.scope.plan === "cloud:hobby") {
|
||||
if (
|
||||
(await prisma.annotationQueue.count({
|
||||
where: {
|
||||
projectId: auth.scope.projectId,
|
||||
},
|
||||
})) >= 1
|
||||
) {
|
||||
throw new MethodNotAllowedError(
|
||||
"Maximum number of annotation queues reached on Hobby plan.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const existingQueue = await prisma.annotationQueue.findFirst({
|
||||
where: {
|
||||
projectId: auth.scope.projectId,
|
||||
name: body.name,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingQueue) {
|
||||
throw new InvalidRequestError("A queue with this name already exists.");
|
||||
}
|
||||
|
||||
// verify the score configs exist
|
||||
const scoreConfigs = await prisma.scoreConfig.findMany({
|
||||
where: {
|
||||
id: { in: body.scoreConfigIds },
|
||||
projectId: auth.scope.projectId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
const scoreConfigIdSet = new Set(scoreConfigs.map((config) => config.id));
|
||||
if (body.scoreConfigIds.some((id) => !scoreConfigIdSet.has(id))) {
|
||||
throw new InvalidRequestError(
|
||||
"At least one of the score config IDs cannot be found for the given project.",
|
||||
);
|
||||
}
|
||||
|
||||
const queue = await prisma.annotationQueue.create({
|
||||
data: {
|
||||
projectId: auth.scope.projectId,
|
||||
name: body.name,
|
||||
description: body.description,
|
||||
scoreConfigIds: body.scoreConfigIds,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: queue.id,
|
||||
name: queue.name,
|
||||
description: queue.description,
|
||||
scoreConfigIds: queue.scoreConfigIds,
|
||||
createdAt: queue.createdAt,
|
||||
updatedAt: queue.updatedAt,
|
||||
};
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "worker",
|
||||
"version": "3.97.2",
|
||||
"version": "3.97.4",
|
||||
"description": "",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
|
||||
@@ -588,26 +588,7 @@ describe("create experiment job calls with langfuse server side tracing", async
|
||||
await createExperimentJobPostgres({ event: mockEvent });
|
||||
|
||||
// Verify callLLM was called with correct trace parameters
|
||||
expect(callLLM).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.any(Array),
|
||||
expect.any(Object),
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
environment: PROMPT_EXPERIMENT_ENVIRONMENT,
|
||||
traceName: expect.stringMatching(/^dataset-run-item-/),
|
||||
traceId: expect.any(String),
|
||||
projectId: mockEvent.projectId,
|
||||
authCheck: expect.objectContaining({
|
||||
validKey: true,
|
||||
scope: expect.objectContaining({
|
||||
projectId: mockEvent.projectId,
|
||||
accessLevel: "project",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(callLLM).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const VERSION = "v3.97.2";
|
||||
export const VERSION = "v3.97.4";
|
||||
|
||||
@@ -56,22 +56,21 @@ export const handleDataRetentionProcessingJob = async (job: Job) => {
|
||||
);
|
||||
}
|
||||
|
||||
await removeIngestionEventsFromS3AndDeleteClickhouseRefsForProject(
|
||||
projectId,
|
||||
cutoffDate,
|
||||
);
|
||||
|
||||
// Delete ClickHouse (TTL / Delete Queries)
|
||||
logger.info(
|
||||
`[Data Retention] Deleting ClickHouse data older than ${retention} days for project ${projectId}`,
|
||||
`[Data Retention] Deleting ClickHouse and S3 data older than ${retention} days for project ${projectId}`,
|
||||
);
|
||||
await Promise.all([
|
||||
removeIngestionEventsFromS3AndDeleteClickhouseRefsForProject(
|
||||
projectId,
|
||||
cutoffDate,
|
||||
),
|
||||
deleteTracesOlderThanDays(projectId, cutoffDate),
|
||||
deleteObservationsOlderThanDays(projectId, cutoffDate),
|
||||
deleteScoresOlderThanDays(projectId, cutoffDate),
|
||||
]);
|
||||
logger.info(
|
||||
`[Data Retention] Deleted ClickHouse data older than ${retention} days for project ${projectId}`,
|
||||
`[Data Retention] Deleted ClickHouse and S3 data older than ${retention} days for project ${projectId}`,
|
||||
);
|
||||
|
||||
// Set S3 Lifecycle for deletion (Future)
|
||||
|
||||
@@ -262,6 +262,9 @@ const EnvSchema = z.object({
|
||||
.positive()
|
||||
.default(2),
|
||||
LANGFUSE_DELETE_BATCH_SIZE: z.coerce.number().positive().default(2000),
|
||||
LANGFUSE_EXPERIMENT_DATASET_RUN_ITEMS_TRACE_SOURCE_CH: z
|
||||
.enum(["true", "false"])
|
||||
.default("true"),
|
||||
});
|
||||
|
||||
export const env: z.infer<typeof EnvSchema> =
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { kyselyPrisma, prisma } from "@langfuse/shared/src/db";
|
||||
import z from "zod/v4";
|
||||
import { createHash } from "crypto";
|
||||
import { env } from "../../env";
|
||||
|
||||
export enum TraceExecutionSource {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
@@ -52,7 +53,9 @@ export enum TraceExecutionSource {
|
||||
*
|
||||
*/
|
||||
export const shouldCreateTrace = (source: TraceExecutionSource) => {
|
||||
// TODO: use env variable instead
|
||||
if (env.LANGFUSE_EXPERIMENT_DATASET_RUN_ITEMS_TRACE_SOURCE_CH === "true") {
|
||||
return source === TraceExecutionSource.CLICKHOUSE;
|
||||
}
|
||||
return source === TraceExecutionSource.POSTGRES;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user