Compare commits

...
30 Commits
Author SHA1 Message Date
Max Deichmann 250a5b03d2 chore: release v1.33.6
CI/CD / push-docker-image (push) Blocked by required conditions
CI/CD / tests (18) (push) Failing after 58s
CI/CD / tests (20) (push) Failing after 1m7s
release.yml / release (push) Failing after 3s
CI/CD / test-docker-build (push) Successful in 3m6s
CI/CD / e2e-tests (push) Failing after 48s
CI/CD / lint (push) Successful in 4m1s
CI/CD / all-ci-passed (push) Waiting to run
2024-01-17 16:25:56 +01:00
Max DeichmannandGitHub 5ee060dfc6 refactor: remove external id uniqueness constraint (#903) 2024-01-17 15:24:31 +00:00
Max DeichmannandGitHub f08502cde4 fix: upsert traces on ingestion only on the id (#900) 2024-01-17 13:55:32 +00:00
Max Deichmann 90ecb88249 chore: release v1.33.5
CI/CD / push-docker-image (push) Blocked by required conditions
CI/CD / tests (18) (push) Failing after 48s
CI/CD / tests (20) (push) Failing after 44s
release.yml / release (push) Failing after 2s
CI/CD / e2e-tests (push) Failing after 44s
CI/CD / test-docker-build (push) Successful in 2m47s
CI/CD / lint (push) Successful in 4m15s
CI/CD / all-ci-passed (push) Waiting to run
2024-01-17 14:05:47 +01:00
Max DeichmannandGitHub 16e60f74da fix: capture ingestion errors in sentry (#899) 2024-01-17 12:52:56 +00:00
Max Deichmann 0ab252cc7c chore: release v1.33.3
CI/CD / push-docker-image (push) Blocked by required conditions
CI/CD / tests (18) (push) Failing after 1m10s
CI/CD / tests (20) (push) Failing after 43s
CI/CD / e2e-tests (push) Failing after 42s
release.yml / release (push) Failing after 3s
CI/CD / test-docker-build (push) Successful in 3m39s
CI/CD / lint (push) Successful in 4m7s
CI/CD / all-ci-passed (push) Waiting to run
2024-01-17 13:36:03 +01:00
Max DeichmannandGitHub ebadbaf30d fix: increase ingestion API body size (#898) 2024-01-17 12:23:06 +00:00
Max Deichmann 7ce6b6817e chore: release v1.33.2
CI/CD / push-docker-image (push) Blocked by required conditions
CI/CD / tests (18) (push) Failing after 2m32s
CI/CD / tests (20) (push) Failing after 2m2s
CI/CD / e2e-tests (push) Failing after 2m11s
release.yml / release (push) Failing after 3s
CI/CD / test-docker-build (push) Successful in 5m16s
CI/CD / lint (push) Successful in 5m31s
CI/CD / all-ci-passed (push) Waiting to run
2024-01-17 11:39:23 +01:00
Max DeichmannandGitHub 6d4040d5ed fix: free tiktoken encoder to prevent OOM issues (#897) 2024-01-17 10:32:28 +00:00
Max DeichmannandGitHub 4a7a4c0add feat(infrastructure): add decimal.js support for trpc (#895) 2024-01-16 19:29:29 +01:00
Max Deichmann 883e53d8b5 chore: release v1.33.1
CI/CD / push-docker-image (push) Blocked by required conditions
CI/CD / tests (18) (push) Failing after 2m36s
CI/CD / tests (20) (push) Failing after 2m2s
CI/CD / e2e-tests (push) Failing after 2m14s
CI/CD / lint (push) Successful in 5m48s
CI/CD / test-docker-build (push) Successful in 6m10s
CI/CD / all-ci-passed (push) Waiting to run
release.yml / release (push) Failing after 3s
2024-01-16 18:17:46 +01:00
Richard KrümmelandGitHub a9e6b9ae5c fix: remove tags json view from TracePreview (#894) 2024-01-16 17:12:10 +00:00
Max Deichmann a61ee1f29f chore: release v1.33.0
CI/CD / push-docker-image (push) Blocked by required conditions
CI/CD / tests (18) (push) Failing after 2m50s
CI/CD / tests (20) (push) Failing after 2m2s
CI/CD / test-docker-build (push) Successful in 4m42s
CI/CD / lint (push) Successful in 5m33s
CI/CD / e2e-tests (push) Failing after 2m19s
CI/CD / all-ci-passed (push) Waiting to run
release.yml / release (push) Failing after 3s
2024-01-16 17:44:50 +01:00
Richard KrümmelandGitHub eefdf60e31 feat(ui): add ui for tags on traces (#879) 2024-01-16 17:44:16 +01:00
Max DeichmannandGitHub 19a633a756 fix: add new cost visualisation to trace detail header (#893)
add new cost to traces
2024-01-16 15:49:55 +00:00
Max DeichmannandGitHub e84c1a3ab8 fix: upgrade decimal on export (#892) 2024-01-16 15:32:53 +00:00
Max Deichmann 4d41aa7e53 chore: release v1.32.3
CI/CD / push-docker-image (push) Blocked by required conditions
CI/CD / tests (18) (push) Failing after 2m31s
CI/CD / tests (20) (push) Failing after 2m21s
CI/CD / test-docker-build (push) Successful in 4m44s
CI/CD / e2e-tests (push) Failing after 2m13s
CI/CD / lint (push) Successful in 5m48s
CI/CD / all-ci-passed (push) Waiting to run
release.yml / release (push) Failing after 3s
2024-01-16 16:09:48 +01:00
Max DeichmannandGitHub a4c28cc324 fix: generations table latency (#891) 2024-01-16 15:09:28 +00:00
Max DeichmannandGitHub 187f2e7b1d fix: convert decimals on trpc to number (#890) 2024-01-16 14:50:52 +00:00
Max Deichmann b355825295 chore: release v1.32.2
CI/CD / push-docker-image (push) Blocked by required conditions
CI/CD / tests (18) (push) Failing after 2m37s
CI/CD / tests (20) (push) Failing after 2m4s
CI/CD / e2e-tests (push) Failing after 2m11s
CI/CD / test-docker-build (push) Successful in 5m14s
CI/CD / lint (push) Successful in 5m35s
CI/CD / all-ci-passed (push) Waiting to run
release.yml / release (push) Failing after 4s
2024-01-16 14:47:50 +01:00
Max DeichmannandGitHub 33dc18356a fix: increase decimal places for exports and unify across UI (#889) 2024-01-16 13:35:50 +00:00
Max Deichmann c804d90f6a chore: release v1.32.1
CI/CD / push-docker-image (push) Blocked by required conditions
CI/CD / tests (18) (push) Failing after 2m32s
CI/CD / tests (20) (push) Failing after 2m3s
CI/CD / e2e-tests (push) Failing after 2m11s
CI/CD / test-docker-build (push) Successful in 5m4s
CI/CD / lint (push) Successful in 5m30s
CI/CD / all-ci-passed (push) Waiting to run
release.yml / release (push) Failing after 3s
2024-01-15 19:36:51 +01:00
Max DeichmannandGitHub 09aa547de1 fix: use async sentry span (#883) 2024-01-15 18:35:37 +00:00
Max Deichmann 57839eae5c chore: release v1.32.0
CI/CD / push-docker-image (push) Blocked by required conditions
CI/CD / tests (18) (push) Failing after 2m35s
CI/CD / tests (20) (push) Failing after 2m3s
release.yml / release (push) Failing after 2s
CI/CD / e2e-tests (push) Failing after 2m11s
CI/CD / test-docker-build (push) Successful in 5m4s
CI/CD / lint (push) Successful in 5m33s
CI/CD / all-ci-passed (push) Waiting to run
2024-01-15 19:10:40 +01:00
Max DeichmannandGitHub a43f5f38a1 fix: fix return types on traces router (#882) 2024-01-15 18:09:58 +00:00
8220d07057 feat(ui): add navigation to datasets and dataset items (#871)
* Add DetailPageNav component to Dataset item page

* Add useDetailPageLists hook and useEffect in DatasetItemsTable

* Add useDetailPageLists hook and useEffect in DatasetsTable

* Add DetailPageNav component to Dataset page

* Add DetailPageNav component to DatasetItems

* Add useDetailPageLists hook and useEffect to DatasetRunsTable

* Fix: only show navigation when there is more than one item

* Add DetailPageNav component to DatasetRunItemsTable

* Seed db with two datasets each with two runs and fix CL logging

---------

Co-authored-by: Max Deichmann <m.deichmann@tum.de>
2024-01-15 19:01:40 +01:00
Max Deichmann 89ffe20363 chore: release v1.31.4
CI/CD / push-docker-image (push) Blocked by required conditions
CI/CD / tests (18) (push) Failing after 2m36s
CI/CD / tests (20) (push) Failing after 2m4s
release.yml / release (push) Failing after 2s
CI/CD / e2e-tests (push) Failing after 2m13s
CI/CD / test-docker-build (push) Successful in 5m8s
CI/CD / lint (push) Successful in 5m33s
CI/CD / all-ci-passed (push) Waiting to run
2024-01-15 18:45:23 +01:00
Max DeichmannandGitHub 9a3adf54b1 fix: traces table performance (#881) 2024-01-15 17:44:57 +00:00
Max Deichmann 459129118e chore: release v1.31.3
CI/CD / push-docker-image (push) Blocked by required conditions
CI/CD / tests (18) (push) Failing after 2m32s
CI/CD / tests (20) (push) Failing after 2m2s
release.yml / release (push) Failing after 2s
CI/CD / e2e-tests (push) Failing after 2m8s
CI/CD / test-docker-build (push) Successful in 5m9s
CI/CD / lint (push) Successful in 5m36s
CI/CD / all-ci-passed (push) Waiting to run
2024-01-15 11:26:03 +01:00
Max DeichmannandGitHub 0862370ad6 feat(ui): add time to first token to observation detail view (#878)
add time to first token
2024-01-15 10:25:09 +00:00
48 changed files with 1009 additions and 205 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "langfuse-core",
"version": "1.31.2",
"version": "1.33.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "langfuse-core",
"version": "1.31.2",
"version": "1.33.6",
"hasInstallScript": true,
"dependencies": {
"@anthropic-ai/tokenizer": "^0.0.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse-core",
"version": "1.31.2",
"version": "1.33.6",
"private": true,
"scripts": {
"prebuild": "cp generated/openapi-client/openapi.yml public/openapi-client.yml && cp generated/openapi-server/openapi.yml public/openapi-server.yml",
@@ -0,0 +1,2 @@
-- DropIndex
DROP INDEX "traces_project_id_external_id_key";
-1
View File
@@ -198,7 +198,6 @@ model Trace {
scores Score[]
@@unique([projectId, externalId])
@@index([projectId])
@@index([sessionId])
@@index([name])
+46 -39
View File
@@ -127,10 +127,10 @@ async function main() {
for (let i = 0; i < TRACE_VOLUME; i++) {
// print progress to console with a progress bar that refreshes every 10 iterations
if (i % 10 === 0) {
if ((i + 1) % 10 === 0 || i === TRACE_VOLUME - 1) {
process.stdout.clearLine(0);
process.stdout.cursorTo(0);
process.stdout.write(`Seeding ${i} of ${TRACE_VOLUME}`);
process.stdout.write(`Seeding ${i + 1} of ${TRACE_VOLUME}`);
}
// random date within last 90 days, with a linear bias towards more recent dates
const traceTs = new Date(
@@ -409,47 +409,54 @@ async function main() {
}
}
const dataset = await prisma.dataset.create({
data: {
name: "demo-dataset",
projectId: project2.id,
},
});
const datasetRun = await prisma.datasetRuns.create({
data: {
name: "demo-dataset-run",
datasetId: dataset.id,
},
});
for (let runNumber = 0; runNumber < 10; runNumber++) {
//pick randomly from existingSpanIds
const sourceObservationId =
generationIds[Math.floor(Math.random() * generationIds.length)];
const runObservationId =
generationIds[Math.floor(Math.random() * generationIds.length)];
const datasetItem = await prisma.datasetItem.create({
for (let datasetNumber = 0; datasetNumber < 2; datasetNumber++) {
const dataset = await prisma.dataset.create({
data: {
datasetId: dataset.id,
sourceObservationId:
Math.random() > 0.5 ? sourceObservationId : undefined,
input: [
{ role: "user", content: "How can i create a React component?" },
],
expectedOutput:
"Creating a React component can be done in two ways: as a functional component or as a class component. Let's start with a basic example of both.",
name: `demo-dataset-${datasetNumber}`,
projectId: project2.id,
},
});
await prisma.datasetRunItems.create({
data: {
datasetItemId: datasetItem.id,
observationId: runObservationId!,
datasetRunId: datasetRun.id,
},
});
for (let datasetRunNumber = 0; datasetRunNumber < 2; datasetRunNumber++) {
const datasetRun = await prisma.datasetRuns.create({
data: {
name: `demo-dataset-run-${datasetRunNumber}`,
datasetId: dataset.id,
},
});
for (let runNumber = 0; runNumber < 10; runNumber++) {
//pick randomly from existingSpanIds
const sourceObservationId =
generationIds[Math.floor(Math.random() * generationIds.length)];
const runObservationId =
generationIds[Math.floor(Math.random() * generationIds.length)];
const datasetItem = await prisma.datasetItem.create({
data: {
datasetId: dataset.id,
sourceObservationId:
Math.random() > 0.5 ? sourceObservationId : undefined,
input: [
{
role: "user",
content: "How can i create a React component?",
},
],
expectedOutput:
"Creating a React component can be done in two ways: as a functional component or as a class component. Let's start with a basic example of both.",
},
});
await prisma.datasetRunItems.create({
data: {
datasetItemId: datasetItem.id,
observationId: runObservationId!,
datasetRunId: datasetRun.id,
},
});
}
}
}
}
}
+60
View File
@@ -790,6 +790,66 @@ describe("/api/public/ingestion API Endpoint", () => {
expect(dbTrace[0]?.version).toBe("2.0.0");
});
it("should not override a trace from a different project", async () => {
const traceId = v4();
const newProjectId = v4();
await prisma.project.create({
data: {
id: newProjectId,
name: "another-project",
},
});
await prisma.trace.create({
data: {
id: traceId,
project: { connect: { id: newProjectId } },
},
});
const responseOne = await makeAPICall("POST", "/api/public/ingestion", {
batch: [
{
id: v4(),
type: "trace-create",
timestamp: new Date().toISOString(),
body: {
id: traceId,
name: "trace-name",
userId: "user-1",
metadata: { key: "value" },
release: "1.0.0",
version: "2.0.0",
},
},
],
});
expect(responseOne.status).toBe(207);
console.log(responseOne.body);
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
const errors = responseOne.body.errors;
expect(errors).toBeDefined();
console.log(errors);
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
expect(errors.length).toBe(1);
const dbTrace = await prisma.trace.findMany({
where: {
id: traceId,
},
});
expect(dbTrace.length).toEqual(1);
expect(dbTrace[0]?.name).toBeNull();
expect(dbTrace[0]?.release).toBeNull();
expect(dbTrace[0]?.metadata).toBeNull();
expect(dbTrace[0]?.version).toBeNull();
});
[
{
inputs: [{ a: "a" }, { b: "b" }],
@@ -53,7 +53,7 @@ describe("Traces TRPC Router", () => {
orderBy: null,
});
expect(traces).toBeDefined();
expect(traces).toMatchObject([trace]);
expect(traces).toMatchObject({ traces: [trace] });
});
test("traces.all RPC orders traces by userId", async () => {
@@ -88,7 +88,7 @@ describe("Traces TRPC Router", () => {
order: "ASC",
},
});
expect(tracesASC).toMatchObject([trace1, trace2]);
expect(tracesASC).toMatchObject({ traces: [trace1, trace2] });
const tracesDESC = await caller.traces.all({
page: 0,
@@ -102,6 +102,6 @@ describe("Traces TRPC Router", () => {
order: "DESC",
},
});
expect(tracesDESC).toMatchObject([trace2, trace1]);
expect(tracesDESC).toMatchObject({ traces: [trace2, trace1] });
});
});
+14 -9
View File
@@ -83,15 +83,20 @@ export function StarTraceToggle({
utils.traces.all.setData(
tracesFilter,
(oldQueryData: RouterOutput["traces"]["all"] | undefined) => {
return oldQueryData
? oldQueryData.map((trace) => {
return {
...trace,
bookmarked:
trace.id === traceId ? !trace.bookmarked : trace.bookmarked,
};
})
: [];
return {
totalCount: oldQueryData?.totalCount,
traces: oldQueryData?.traces
? oldQueryData.traces.map((trace) => {
return {
...trace,
bookmarked:
trace.id === traceId
? !trace.bookmarked
: trace.bookmarked,
};
})
: [],
};
},
);
void utils.traces.all.invalidate();
+8 -4
View File
@@ -122,10 +122,14 @@ export function DataTable<TData extends object, TValue>({
}
if (orderBy?.column === header.column.columnDef.id) {
setOrderBy({
column: header.column.columnDef.id,
order: orderBy.order === "ASC" ? "DESC" : "ASC",
});
if (orderBy.order === "DESC") {
setOrderBy({
column: header.column.columnDef.id,
order: "ASC",
});
} else {
setOrderBy(null);
}
} else {
setOrderBy({
column: header.column.columnDef.id,
@@ -106,13 +106,13 @@ export default function GenerationsTable({ projectId }: GenerationsTableProps) {
isSuccess:
generationsQueries[0].isSuccess || generationsQueries[1].isSuccess,
data: [
...(generationsQueries[0].data ?? []),
...(generationsQueries[1].data ?? []),
...(generationsQueries[0].data?.generations ?? []),
...(generationsQueries[1].data?.generations ?? []),
],
error: generationsQueries[0].error ?? generationsQueries[1].error,
};
const totalCount = generations.data.slice(1)[0]?.totalCount ?? 0;
const totalCount = generationsQueries[0].data?.totalCount ?? 0;
const filterOptions = api.generations.filterOptions.useQuery({
projectId,
+23 -8
View File
@@ -6,6 +6,7 @@ import { TraceTableMultiSelectAction } from "@/src/components/table/data-table-m
import { DataTableToolbar } from "@/src/components/table/data-table-toolbar";
import TableLink from "@/src/components/table/table-link";
import { type LangfuseColumnDef } from "@/src/components/table/types";
import { TagTracePopver } from "@/src/features/tag/components/TagTracePopver";
import { TokenUsageBadge } from "@/src/components/token-usage-badge";
import { Checkbox } from "@/src/components/ui/checkbox";
import { JSONView } from "@/src/components/ui/code";
@@ -109,12 +110,12 @@ export default function TracesTable({
};
const traces = api.traces.all.useQuery(tracesAllQueryFilter);
const totalCount = traces.data?.slice(1)[0]?.totalCount ?? 0;
const totalCount = traces.data?.totalCount ?? 0;
useEffect(() => {
if (traces.isSuccess) {
setDetailPageList(
"traces",
traces.data.map((t) => t.id),
traces.data.traces.map((t) => t.id),
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -135,9 +136,8 @@ export default function TracesTable({
},
},
);
const convertToTableRow = (
trace: RouterOutput["traces"]["all"][0],
trace: RouterOutput["traces"]["all"]["traces"][0],
): TracesTableRow => {
return {
bookmarked: trace.bookmarked,
@@ -198,7 +198,6 @@ export default function TracesTable({
cell: ({ row }) => {
const bookmarked = row.getValue("bookmarked");
const traceId = row.getValue("id");
return typeof traceId === "string" &&
typeof bookmarked === "boolean" ? (
<StarTraceToggle
@@ -364,6 +363,21 @@ export default function TracesTable({
accessorKey: "tags",
id: "tags",
header: "Tags",
cell: ({ row }) => {
const tags: string[] = row.getValue("tags");
const traceId: string = row.getValue("id");
const filterOptionTags = traceFilterOptions.data?.tags ?? [];
const allTags = filterOptionTags.map((t) => t.value);
return (
<TagTracePopver
tags={tags}
availableTags={allTags}
projectId={projectId}
traceId={traceId}
tracesFilter={tracesAllQueryFilter}
/>
);
},
enableHiding: true,
},
{
@@ -403,7 +417,8 @@ export default function TracesTable({
<TraceTableMultiSelectAction
// Exclude traces that are not in the current page
selectedTraceIds={Object.keys(selectedRows).filter(
(traceId) => traces.data?.map((t) => t.id).includes(traceId),
(traceId) =>
traces.data?.traces.map((t) => t.id).includes(traceId),
)}
projectId={projectId}
onDeleteSuccess={() => {
@@ -428,11 +443,11 @@ export default function TracesTable({
: {
isLoading: false,
isError: false,
data: traces.data.map((t) => convertToTableRow(t)),
data: traces.data.traces.map((t) => convertToTableRow(t)),
}
}
pagination={{
pageCount: Math.ceil(totalCount / paginationState.pageSize),
pageCount: Math.ceil(Number(totalCount) / paginationState.pageSize),
onChange: setPaginationState,
state: paginationState,
}}
+13 -1
View File
@@ -23,6 +23,7 @@ import { api } from "@/src/utils/api";
import { IOPreview } from "@/src/components/trace/IOPreview";
import { formatInterval } from "@/src/utils/dates";
import Link from "next/link";
import { usdFormatter } from "@/src/utils/numbers";
export const ObservationPreview = (props: {
observations: Array<ObservationReturnType>;
@@ -61,8 +62,19 @@ export const ObservationPreview = (props: {
projectId={preloadedObservation.projectId}
/>
) : undefined}
{preloadedObservation.completionStartTime ? (
<Badge variant="outline">
Time to first token:{" "}
{formatInterval(
(preloadedObservation.completionStartTime.getTime() -
preloadedObservation.startTime.getTime()) /
1000,
)}
</Badge>
) : null}
{preloadedObservation.endTime ? (
<Badge variant="outline">
Latency:{" "}
{formatInterval(
(preloadedObservation.endTime.getTime() -
preloadedObservation.startTime.getTime()) /
@@ -87,7 +99,7 @@ export const ObservationPreview = (props: {
) : null}
{preloadedObservation.price ? (
<Badge variant="outline">
{preloadedObservation.price.toString()} USD
{usdFormatter(preloadedObservation.price.toNumber())}
</Badge>
) : undefined}
-3
View File
@@ -73,9 +73,6 @@ export const TracePreview = ({
title="Metadata"
json={trace.metadata}
/>
{trace.tags.length !== 0 && (
<JSONView key={trace.id + "-tags"} title="Tags" json={trace.tags} />
)}
{scores.find((s) => s.observationId === null) ? (
<div className="mt-5 flex flex-col gap-2">
<h3>Scores</h3>
+37 -8
View File
@@ -6,7 +6,6 @@ import { TracePreview } from "./TracePreview";
import Header from "@/src/components/layouts/header";
import { Badge } from "@/src/components/ui/badge";
import { TraceAggUsageBadge } from "@/src/components/token-usage-badge";
import Decimal from "decimal.js";
import { StringParam, useQueryParam } from "use-query-params";
import { PublishTraceSwitch } from "@/src/components/publish-object-switch";
import { DetailPageNav } from "@/src/features/navigate-detail-pages/DetailPageNav";
@@ -17,10 +16,13 @@ import { DeleteTrace } from "@/src/components/delete-trace";
import { StarTraceDetailsToggle } from "@/src/components/star-toggle";
import Link from "next/link";
import { NoAccessError } from "@/src/components/no-access";
import { TagTraceDetailsPopover } from "@/src/features/tag/components/TagTraceDetailsPopover";
import useLocalStorage from "@/src/components/useLocalStorage";
import { Toggle } from "@/src/components/ui/toggle";
import { Award, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
import { ScrollArea } from "@/src/components/ui/scroll-area";
import { usdFormatter } from "@/src/utils/numbers";
import type Decimal from "decimal.js";
export function Trace(props: {
observations: Array<ObservationReturnType>;
@@ -114,18 +116,34 @@ export function TracePage({ traceId }: { traceId: string }) {
},
},
);
const totalCost = trace.data?.observations.reduce(
(acc, o) => {
if (!o.price) return acc;
return acc ? acc.plus(o.price) : new Decimal(0).plus(o.price);
const traceFilterOptions = api.traces.filterOptions.useQuery(
{
projectId: trace.data?.projectId ?? "",
},
{
trpc: {
context: {
skipBatch: true,
},
},
enabled: !!trace.data?.projectId && trace.isSuccess,
},
undefined as Decimal | undefined,
);
const filterOptionTags = traceFilterOptions.data?.tags ?? [];
const allTags = filterOptionTags.map((t) => t.value);
const totalCost: Decimal | undefined = trace.data?.observations.reduce(
(prev: Decimal | undefined, curr: ObservationReturnType) => {
if (!curr.price) return prev;
return prev ? prev.plus(curr.price) : curr.price;
},
undefined,
);
if (trace.error?.data?.code === "UNAUTHORIZED") return <NoAccessError />;
if (!trace.data) return <div>loading...</div>;
return (
<div className="flex flex-col overflow-hidden xl:container md:h-[calc(100vh-2rem)]">
<Header
@@ -185,10 +203,21 @@ export function TracePage({ traceId }: { traceId: string }) {
<TraceAggUsageBadge observations={trace.data.observations} />
{totalCost ? (
<Badge variant="outline">
Total cost: {totalCost.toString()} USD
Total cost: {usdFormatter(totalCost.toNumber())}
</Badge>
) : undefined}
</div>
<div className="mt-5 rounded-lg border bg-card font-semibold text-card-foreground shadow-sm">
<div className="flex flex-row items-center gap-3 p-2.5">
Tags
<TagTraceDetailsPopover
tags={trace.data.tags}
availableTags={allTags}
traceId={trace.data.id}
projectId={trace.data.projectId}
/>
</div>
</div>
<div className="mt-5 flex-1 overflow-hidden border-t pt-5">
<Trace
key={trace.data.id}
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v1.31.2";
export const VERSION = "v1.33.6";
@@ -61,7 +61,7 @@ export const MetricTable = ({
</RightAlignedCell>,
<RightAlignedCell key={`${i}-cost`}>
{item.totalTokenCost
? usdFormatter(item.totalTokenCost as number)
? usdFormatter(item.totalTokenCost as number, 2, 2)
: "$0"}
</RightAlignedCell>,
])
@@ -83,7 +83,7 @@ export const MetricTable = ({
collapse={{ collapsed: 5, expanded: 20 }}
>
<TotalMetric
metric={totalTokens ? usdFormatter(totalTokens) : "$0"}
metric={totalTokens ? usdFormatter(totalTokens, 2, 2) : "$0"}
description="Total cost"
>
<DocPopup
@@ -94,13 +94,19 @@ export const ModelUsageChart = ({
0,
);
// had to add this function as tremor under the hodd adds more variables
// to the function call which would break usdFormatter.
const oneValueUsdFormatter = (value: number) => {
return usdFormatter(value, 2, 2);
};
const data = [
{
tabTitle: "Total cost",
data: transformedModelCost,
totalMetric: totalCost ? usdFormatter(totalCost) : usdFormatter(0),
totalMetric: totalCost ? usdFormatter(totalCost, 2, 2) : usdFormatter(0),
metricDescription: `Token cost`,
formatter: usdFormatter,
formatter: oneValueUsdFormatter,
},
{
tabTitle: "Total tokens",
@@ -115,7 +121,7 @@ export const ModelUsageChart = ({
return (
<DashboardCard
className={className}
title={"Model Usage"}
title="Model Usage"
isLoading={tokens.isLoading}
>
<TabComponent
@@ -114,15 +114,17 @@ export const UserChart = ({
const maxNumberOfEntries = { collapsed: 5, expanded: 20 } as const;
const localUsdFormatter = (value: number) => usdFormatter(value, 2, 2);
const data = [
{
tabTitle: "Token cost",
data: isExpanded
? transformedCost.slice(0, maxNumberOfEntries.expanded)
: transformedCost.slice(0, maxNumberOfEntries.collapsed),
totalMetric: totalCost ? usdFormatter(totalCost) : usdFormatter(0),
totalMetric: totalCost ? usdFormatter(totalCost, 2, 2) : usdFormatter(0),
metricDescription: "Total cost",
formatter: usdFormatter,
formatter: localUsdFormatter,
},
{
tabTitle: "Count of Traces",
@@ -15,6 +15,8 @@ import { Button } from "@/src/components/ui/button";
import { DatasetStatus, type DatasetItem } from "@prisma/client";
import { cn } from "@/src/utils/tailwind";
import { type LangfuseColumnDef } from "@/src/components/table/types";
import { useDetailPageLists } from "@/src/features/navigate-detail-pages/context";
import { useEffect } from "react";
type RowData = {
id: string;
@@ -31,12 +33,23 @@ export function DatasetItemsTable({
projectId: string;
datasetId: string;
}) {
const { setDetailPageList } = useDetailPageLists();
const utils = api.useUtils();
const items = api.datasets.itemsByDatasetId.useQuery({
projectId,
datasetId,
});
useEffect(() => {
if (items.isSuccess) {
setDetailPageList(
"datasetItems",
items.data.map((t) => t.id),
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [items.isSuccess, items.data]);
const mutUpdate = api.datasets.updateDatasetItem.useMutation({
onSuccess: () => utils.datasets.invalidate(),
});
@@ -2,9 +2,11 @@ import { GroupedScoreBadges } from "@/src/components/grouped-score-badge";
import { DataTable } from "@/src/components/table/data-table";
import TableLink from "@/src/components/table/table-link";
import { type LangfuseColumnDef } from "@/src/components/table/types";
import { useDetailPageLists } from "@/src/features/navigate-detail-pages/context";
import { api } from "@/src/utils/api";
import { formatInterval } from "@/src/utils/dates";
import { type RouterOutput } from "@/src/utils/types";
import { useEffect } from "react";
type RowData = {
key: {
@@ -25,7 +27,16 @@ export function DatasetRunsTable(props: {
projectId: props.projectId,
datasetId: props.datasetId,
});
const { setDetailPageList } = useDetailPageLists();
useEffect(() => {
if (runs.isSuccess) {
setDetailPageList(
"datasetRuns",
runs.data.map((t) => t.id),
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [runs.isSuccess, runs.data]);
const columns: LangfuseColumnDef<RowData>[] = [
{
accessorKey: "key",
@@ -10,9 +10,11 @@ import {
DropdownMenuTrigger,
} from "@/src/components/ui/dropdown-menu";
import { NewDatasetButton } from "@/src/features/datasets/components/NewDatasetButton";
import { useDetailPageLists } from "@/src/features/navigate-detail-pages/context";
import { api } from "@/src/utils/api";
import { type RouterOutput } from "@/src/utils/types";
import { MoreVertical, Trash } from "lucide-react";
import { useEffect } from "react";
type RowData = {
key: {
@@ -26,6 +28,7 @@ type RowData = {
};
export function DatasetsTable(props: { projectId: string }) {
const { setDetailPageList } = useDetailPageLists();
const utils = api.useUtils();
const datasets = api.datasets.allDatasets.useQuery({
projectId: props.projectId,
@@ -33,6 +36,15 @@ export function DatasetsTable(props: { projectId: string }) {
const mutDelete = api.datasets.deleteDataset.useMutation({
onSuccess: () => utils.datasets.invalidate(),
});
useEffect(() => {
if (datasets.isSuccess) {
setDetailPageList(
"datasets",
datasets.data.map((t) => t.id),
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [datasets.isSuccess, datasets.data]);
const columns: LangfuseColumnDef<RowData>[] = [
{
+5
View File
@@ -108,6 +108,9 @@ function openAiChatTokenCount(params: TokenCalculationParams) {
});
});
num_tokens += 3; // every reply is primed with <| start |> assistant <| message |>
// https://github.com/dqbd/tiktoken/issues/72
// we need to ensure to deallocate memory from the encoder
encoding.free();
return num_tokens;
}
@@ -132,6 +135,8 @@ const claudeStringTokenCount = (p: { model: string; text: string }) => {
const getTokens = (name: TiktokenEncoding, text: string) => {
const encoding = get_encoding(name);
const tokens = encoding.encode(text);
// https://github.com/dqbd/tiktoken/issues/72
// we need to ensure to deallocate memory from the encoder
encoding.free();
return tokens.length;
};
@@ -50,7 +50,7 @@ export const DetailPageNav = (props: {
return () => window.removeEventListener("keydown", handleKeyDown);
}, [previousPageId, nextPageId, router, props]);
if (ids.length > 0)
if (ids.length > 1)
return (
<div>
<Tooltip>
@@ -11,6 +11,7 @@ const scopes = [
"objects:publish",
"objects:bookmark",
"objects:tag",
"traces:delete",
@@ -39,6 +40,7 @@ export const roleAccessRights: Record<MembershipRole, Scope[]> = {
"apiKeys:delete",
"objects:publish",
"objects:bookmark",
"objects:tag",
"traces:delete",
"scores:CUD",
"project:delete",
@@ -58,6 +60,7 @@ export const roleAccessRights: Record<MembershipRole, Scope[]> = {
"apiKeys:delete",
"objects:publish",
"objects:bookmark",
"objects:tag",
"traces:delete",
"scores:CUD",
"datasets:CUD",
@@ -68,6 +71,7 @@ export const roleAccessRights: Record<MembershipRole, Scope[]> = {
"members:read",
"objects:publish",
"objects:bookmark",
"objects:tag",
"scores:CUD",
"datasets:CUD",
"prompts:CUD",
+16
View File
@@ -0,0 +1,16 @@
import { Button } from "@/src/components/ui/button";
import React from "react";
export const TagButton: React.FC<{ tag: string; loading: boolean }> =
React.memo(({ tag, loading }) => (
<Button
key={tag}
variant="secondary"
size="xs"
className="text-xs font-semibold hover:bg-white"
loading={loading}
>
{tag}
</Button>
));
TagButton.displayName = "TagButton";
@@ -0,0 +1,38 @@
import { CommandItem } from "@/src/components/ui/command";
import { cn } from "@/src/utils/tailwind";
import { Check } from "lucide-react";
import { Button } from "@/src/components/ui/button";
type TagCommandItemProps = {
value: string;
selectedTags: string[];
setSelectedTags: (value: string[]) => void;
};
const TagCommandItem = ({
value,
selectedTags,
setSelectedTags,
}: TagCommandItemProps) => {
return (
<CommandItem
key={value}
onSelect={() => {
setSelectedTags([...selectedTags, value]);
}}
>
<div
className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary opacity-50 [&_svg]:invisible",
)}
>
<Check className={cn("h-4 w-4")} />
</div>
<Button variant="secondary" size="xs">
{value}
</Button>
</CommandItem>
);
};
export default TagCommandItem;
@@ -0,0 +1,37 @@
import React from "react";
import { cn } from "@/src/utils/tailwind";
import { CommandItem } from "cmdk";
type TagItemCreateProps = {
inputValue: string;
options: string[];
onSelect: () => void;
};
const TagItemCreate = ({
inputValue,
options,
onSelect,
}: TagItemCreateProps) => {
const hasNoOption = !options
.map((value) => value.toLowerCase())
.includes(inputValue.toLowerCase());
const render = inputValue !== "" && hasNoOption;
if (!render) return null;
return (
<CommandItem
key={inputValue}
value={inputValue}
className="flex cursor-pointer items-center rounded-sm px-1 py-2 text-muted-foreground hover:bg-secondary/80"
onSelect={onSelect}
>
<div className={cn("mr-2 h-4 w-4")} />
Create new tag: &quot;{inputValue}&quot;
</CommandItem>
);
};
export default TagItemCreate;
+49
View File
@@ -0,0 +1,49 @@
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";
type TagInputProps = React.ComponentPropsWithoutRef<
typeof CommandPrimitive.Input
> & {
selectedTags: string[];
setSelectedTags: (tags: string[]) => void;
};
export const TagInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
TagInputProps
>(({ className, selectedTags, setSelectedTags, ...props }, ref) => (
<div
className="flex flex-wrap items-center overflow-auto rounded-lg border px-2 pt-2"
cmdk-input-wrapper=""
>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
{selectedTags.map((tag: string) => (
<Button
key={tag}
variant="secondary"
size="xs"
onClick={() => {
const newTags = selectedTags.filter((t) => t !== tag);
setSelectedTags(newTags);
}}
>
{tag}
<X className="ml-1 h-3 w-3" />
</Button>
))}
</div>
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md border-transparent bg-transparent px-1 text-sm outline-none placeholder:text-slate-500 focus:border-0 focus:border-none focus:border-transparent focus:ring-0 disabled:cursor-not-allowed disabled:opacity-50 dark:placeholder:text-slate-400 ",
className,
)}
{...props}
/>
</div>
));
TagInput.displayName = CommandPrimitive.Input.displayName;
+25
View File
@@ -0,0 +1,25 @@
import { Button } from "@/src/components/ui/button";
import { TagButton } from "@/src/features/tag/components/TagButton";
type TagListProps = {
selectedTags: string[];
isLoading: boolean;
};
const TagList = ({ selectedTags, isLoading }: TagListProps) => {
return selectedTags.length > 0 ? (
selectedTags.map((tag) => (
<TagButton key={tag} tag={tag} loading={isLoading} />
))
) : (
<Button
variant="outline"
size="xs"
className="text-xs font-bold opacity-0 hover:bg-white hover:opacity-100"
>
Add tag
</Button>
);
};
export default TagList;
@@ -0,0 +1,88 @@
import TagCommandItem from "@/src/features/tag/components/TagCommandItem";
import TagCreateItem from "@/src/features/tag/components/TagCreateItem";
import { TagInput } from "@/src/features/tag/components/TagInput";
import TagList from "@/src/features/tag/components/TagList";
import useTagManager from "@/src/features/tag/hooks/useTagManager";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/src/components/ui/popover";
import { Command, CommandList, CommandGroup } from "cmdk";
type TagManagerProps = {
tags: string[];
allTags: string[];
hasAccess: boolean;
isLoading: boolean;
mutateTags: (value: string[]) => void;
};
const TagManager = ({
tags,
allTags,
hasAccess,
isLoading,
mutateTags,
}: TagManagerProps) => {
const {
selectedTags,
inputValue,
availableTags,
handleItemCreate,
setInputValue,
setSelectedTags,
} = useTagManager({ initialTags: tags, allTags });
const handlePopoverChange = (open: boolean) => {
if (!open && selectedTags !== tags) {
setInputValue("");
mutateTags(selectedTags);
}
};
if (!hasAccess) {
return <TagList selectedTags={selectedTags} isLoading={isLoading} />;
}
return (
<Popover onOpenChange={(open) => handlePopoverChange(open)}>
<PopoverTrigger className="select-none" asChild>
<div className="flex flex-wrap gap-x-2 gap-y-1">
<TagList selectedTags={selectedTags} isLoading={isLoading} />
</div>
</PopoverTrigger>
<PopoverContent>
<Command>
<TagInput
value={inputValue}
onValueChange={setInputValue}
selectedTags={selectedTags}
setSelectedTags={setSelectedTags}
/>
<CommandList
className={availableTags.length > 0 ? "mt-2" : undefined}
>
<CommandGroup>
{availableTags.map((value: string) => (
<TagCommandItem
key={value}
value={value}
selectedTags={selectedTags}
setSelectedTags={setSelectedTags}
/>
))}
<TagCreateItem
onSelect={handleItemCreate}
inputValue={inputValue}
options={allTags}
/>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
export default TagManager;
@@ -0,0 +1,74 @@
import React, { useState } from "react";
import { api } from "@/src/utils/api";
import { useHasAccess } from "@/src/features/rbac/utils/checkAccess";
import { type RouterOutput } from "@/src/utils/types";
import TagManager from "@/src/features/tag/components/TagMananger";
type TagTraceDetailsPopoverProps = {
tags: string[];
availableTags: string[];
projectId: string;
traceId: string;
};
export function TagTraceDetailsPopover({
tags,
availableTags,
projectId,
traceId,
}: TagTraceDetailsPopoverProps) {
const [isLoading, setIsLoading] = useState(false);
const hasAccess = useHasAccess({ projectId, scope: "objects:tag" });
const utils = api.useUtils();
const mutTags = api.traces.updateTags.useMutation({
onMutate: async () => {
await utils.traces.byId.cancel();
setIsLoading(true);
// Snapshot the previous value
const prev = utils.traces.byId.getData({ traceId });
return { prev };
},
onError: (err, _newTags, context) => {
setIsLoading(false);
// Rollback to the previous value if mutation fails
utils.traces.byId.setData({ traceId }, context?.prev);
},
onSettled: (data, error, { traceId, tags }) => {
setIsLoading(false);
utils.traces.byId.setData(
{ traceId },
(oldQueryData: RouterOutput["traces"]["byId"] | undefined) => {
return oldQueryData
? {
...oldQueryData,
tags: tags,
}
: undefined;
},
);
void utils.traces.all.invalidate();
void utils.traces.byId.invalidate();
void utils.traces.filterOptions.invalidate();
},
});
function mutateTags(newTags: string[]) {
void mutTags.mutateAsync({
projectId,
traceId,
tags: newTags,
});
}
return (
<TagManager
tags={tags}
allTags={availableTags}
hasAccess={hasAccess}
isLoading={isLoading}
mutateTags={mutateTags}
/>
);
}
@@ -0,0 +1,73 @@
import React, { useState } from "react";
import { api } from "@/src/utils/api";
import { useHasAccess } from "@/src/features/rbac/utils/checkAccess";
import { type RouterOutput, type RouterInput } from "@/src/utils/types";
import TagManager from "@/src/features/tag/components/TagMananger";
type TagTracePopverProps = {
tags: string[];
availableTags: string[];
projectId: string;
traceId: string;
tracesFilter: RouterInput["traces"]["all"];
};
export function TagTracePopver({
tags,
availableTags,
projectId,
traceId,
tracesFilter,
}: TagTracePopverProps) {
const [isLoading, setIsLoading] = useState(false);
const hasAccess = useHasAccess({ projectId, scope: "objects:tag" });
const utils = api.useUtils();
const mutTags = api.traces.updateTags.useMutation({
onMutate: async () => {
await utils.traces.all.cancel();
setIsLoading(true);
const prevTrace = utils.traces.all.getData(tracesFilter);
return { prevTrace };
},
onError: (err, _newTags, context) => {
utils.traces.all.setData(tracesFilter, context?.prevTrace);
console.log("error", err);
setIsLoading(false);
},
onSettled: (data, error, { traceId, tags }) => {
utils.traces.all.setData(
tracesFilter,
(oldQueryData: RouterOutput["traces"]["all"] | undefined) => {
return oldQueryData
? {
totalCount: oldQueryData.totalCount,
traces: oldQueryData.traces.map((trace) => {
return trace.id === traceId ? { ...trace, tags } : trace;
}),
}
: { totalCount: undefined, traces: [] };
},
);
setIsLoading(false);
},
});
function mutateTags(newTags: string[]) {
void mutTags.mutateAsync({
projectId,
traceId,
tags: newTags,
});
}
return (
<TagManager
tags={tags}
allTags={availableTags}
hasAccess={hasAccess}
isLoading={isLoading}
mutateTags={mutateTags}
/>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { useState, useMemo } from "react";
type UseTagManagerProps = {
initialTags: string[];
allTags: string[];
};
function useTagManager({ initialTags, allTags }: UseTagManagerProps) {
const [selectedTags, setSelectedTags] = useState(initialTags);
const [inputValue, setInputValue] = useState("");
const availableTags = useMemo(
() => allTags.filter((value) => !selectedTags.includes(value)),
[allTags, selectedTags],
);
const handleItemCreate = () => {
setSelectedTags([...selectedTags, inputValue]);
availableTags.push(inputValue);
setInputValue("");
};
return {
selectedTags,
inputValue,
availableTags,
handleItemCreate,
setInputValue,
setSelectedTags,
};
}
export default useTagManager;
+1
View File
@@ -29,6 +29,7 @@ import "core-js/features/array/to-sorted";
// Other CSS
import "react18-json-view/src/style.css";
import { DetailPageListsProvider } from "@/src/features/navigate-detail-pages/context";
const setProjectInPosthog = () => {
// project
const url = window.location.href;
+12
View File
@@ -25,6 +25,15 @@ import { ScoreProcessor } from "../../../server/api/services/EventProcessor";
import { isNotNullOrUndefined } from "@/src/utils/types";
import { telemetry } from "@/src/features/telemetry";
import { jsonSchema } from "@/src/utils/zod";
import * as Sentry from "@sentry/nextjs";
export const config = {
api: {
bodyParser: {
sizeLimit: "3mb",
},
},
};
export default async function handler(
req: NextApiRequest,
@@ -308,6 +317,9 @@ export const handleBatchResult = (
});
if (returnedErrors.length > 0) {
if (process.env.NEXT_PUBLIC_SENTRY_DSN) {
Sentry.captureException(returnedErrors);
}
console.log("Error processing events", returnedErrors);
}
@@ -4,6 +4,7 @@ import { api } from "@/src/utils/api";
import { useRouter } from "next/router";
import { Tabs, TabsList, TabsTrigger } from "@/src/components/ui/tabs";
import Link from "next/link";
import { DetailPageNav } from "@/src/features/navigate-detail-pages/DetailPageNav";
export default function Dataset() {
const router = useRouter();
@@ -23,6 +24,13 @@ export default function Dataset() {
{ name: "Datasets", href: `/project/${projectId}/datasets` },
{ name: dataset.data?.name ?? datasetId },
]}
actionButtons={
<DetailPageNav
currentId={datasetId}
path={(id) => `/project/${projectId}/datasets/${id}`}
listKey="datasets"
/>
}
/>
<Tabs value="runs" className="mb-3">
<TabsList>
@@ -4,6 +4,7 @@ import { useRouter } from "next/router";
import { Tabs, TabsList, TabsTrigger } from "@/src/components/ui/tabs";
import Link from "next/link";
import { DatasetItemsTable } from "@/src/features/datasets/components/DatasetItemsTable";
import { DetailPageNav } from "@/src/features/navigate-detail-pages/DetailPageNav";
export default function DatasetItems() {
const router = useRouter();
@@ -23,6 +24,13 @@ export default function DatasetItems() {
{ name: "Datasets", href: `/project/${projectId}/datasets` },
{ name: dataset.data?.name ?? datasetId },
]}
actionButtons={
<DetailPageNav
currentId={datasetId}
path={(id) => `/project/${projectId}/datasets/${id}/items/`}
listKey="datasets"
/>
}
/>
<Tabs value="items" className="mb-3">
<TabsList>
@@ -1,6 +1,7 @@
import Header from "@/src/components/layouts/header";
import { DatasetRunItemsTable } from "@/src/features/datasets/components/DatasetRunItemsTable";
import { EditDatasetItem } from "@/src/features/datasets/components/EditDatasetItem";
import { DetailPageNav } from "@/src/features/navigate-detail-pages/DetailPageNav";
import { api } from "@/src/utils/api";
import { useRouter } from "next/router";
@@ -27,6 +28,15 @@ export default function Dataset() {
},
{ name: "Item: " + itemId },
]}
actionButtons={
<DetailPageNav
currentId={itemId}
path={(id) =>
`/project/${projectId}/datasets/${datasetId}/items/${id}`
}
listKey="datasetItems"
/>
}
/>
<EditDatasetItem
projectId={projectId}
@@ -1,5 +1,6 @@
import Header from "@/src/components/layouts/header";
import { DatasetRunItemsTable } from "@/src/features/datasets/components/DatasetRunItemsTable";
import { DetailPageNav } from "@/src/features/navigate-detail-pages/DetailPageNav";
import { api } from "@/src/utils/api";
import { useRouter } from "next/router";
@@ -26,6 +27,15 @@ export default function Dataset() {
},
{ name: "Run: " + runId },
]}
actionButtons={
<DetailPageNav
currentId={runId}
path={(id) =>
`/project/${projectId}/datasets/${datasetId}/runs/${id}`
}
listKey="datasetRuns"
/>
}
/>
<DatasetRunItemsTable
projectId={projectId}
+1
View File
@@ -105,6 +105,7 @@ export default function Start() {
projectId={projectId}
globalFilterState={globalFilterState}
/>
<MetricTable
className="col-span-1 xl:col-span-2"
projectId={projectId}
+47 -21
View File
@@ -115,8 +115,7 @@ export const generationsRouter = createTRPCRouter({
o.total_tokens as "totalTokens",
o.level,
o.status_message as "statusMessage",
o.version,
(count(*) OVER())::int AS "totalCount"
o.version
FROM observations_with_latency o
JOIN traces t ON t.id = o.trace_id
WHERE
@@ -129,25 +128,52 @@ export const generationsRouter = createTRPCRouter({
`,
);
const pricings = await ctx.prisma.pricing.findMany();
const totalGenerations = await ctx.prisma.$queryRaw<
Array<{ count: bigint }>
>(
Prisma.sql`
WITH observations_with_latency AS (
SELECT
o.*,
CASE WHEN o.end_time IS NULL THEN NULL ELSE (EXTRACT(EPOCH FROM o."end_time") - EXTRACT(EPOCH FROM o."start_time"))::double precision END AS "latency"
FROM observations o
WHERE o.type = 'GENERATION'
AND o.project_id = ${input.projectId}
${datetimeFilter}
)
SELECT
count(*)
FROM observations_with_latency o
JOIN traces t ON t.id = o.trace_id
WHERE
t.project_id = ${input.projectId}
${searchCondition}
${filterCondition}
`,
);
return generations.map(({ input, output, ...rest }) => {
return {
...rest,
input,
output,
cost: rest.model
? calculateTokenCost(pricings, {
model: rest.model,
totalTokens: new Decimal(rest.totalTokens),
promptTokens: new Decimal(rest.promptTokens),
completionTokens: new Decimal(rest.completionTokens),
input: input,
output: output,
})
: undefined,
};
});
const pricings = await ctx.prisma.pricing.findMany();
const count = totalGenerations[0]?.count;
return {
totalCount: count ? Number(count) : undefined,
generations: generations.map(({ input, output, ...rest }) => {
return {
...rest,
input,
output,
cost: rest.model
? calculateTokenCost(pricings, {
model: rest.model,
totalTokens: new Decimal(rest.totalTokens),
promptTokens: new Decimal(rest.promptTokens),
completionTokens: new Decimal(rest.completionTokens),
input: input,
output: output,
})
: undefined,
};
}),
};
}),
export: protectedProjectProcedure
@@ -253,7 +279,7 @@ export const generationsRouter = createTRPCRouter({
generation.startTime.toISOString(),
generation.endTime?.toISOString() ?? "",
generation.cost
? usdFormatter(generation.cost.toNumber())
? usdFormatter(generation.cost.toNumber(), 2, 8)
: "",
JSON.stringify(generation.input),
JSON.stringify(generation.output),
-1
View File
@@ -20,7 +20,6 @@ export const observationsRouter = createTRPCRouter({
traceId: input.traceId,
},
});
const scores = generation.traceId
? await ctx.prisma.score.findMany({
where: {
+162 -84
View File
@@ -22,6 +22,8 @@ import { throwIfNoAccess } from "@/src/features/rbac/utils/checkAccess";
import { TRPCError } from "@trpc/server";
import { orderBy } from "@/src/server/api/interfaces/orderBy";
import { orderByToPrismaSql } from "@/src/features/orderBy/server/orderByToPrisma";
import { type Sql } from "@prisma/client/runtime/library";
import { instrumentAsync } from "@/src/utils/instrumentation";
const TraceFilterOptions = z.object({
projectId: z.string(), // Required for protectedProjectProcedure
@@ -70,58 +72,8 @@ export const traceRouter = createTRPCRouter({
)`
: Prisma.empty;
const query = Prisma.sql`
WITH usage AS (
SELECT
trace_id,
sum(prompt_tokens) AS "promptTokens",
sum(completion_tokens) AS "completionTokens",
sum(total_tokens) AS "totalTokens"
FROM
"observations"
WHERE
"trace_id" IS NOT NULL
AND "type" = 'GENERATION'
AND "project_id" = ${input.projectId}
${observationTimeseriesFilter}
GROUP BY
trace_id
),
trace_latency AS (
SELECT
trace_id,
EXTRACT(EPOCH FROM COALESCE(MAX("end_time"), MAX("start_time"))) - EXTRACT(EPOCH FROM MIN("start_time"))::double precision AS "latency"
FROM
"observations"
WHERE
"trace_id" IS NOT NULL
AND "project_id" = ${input.projectId}
${observationTimeseriesFilter}
GROUP BY
trace_id
),
-- used for filtering
scores_avg AS (
SELECT
trace_id,
jsonb_object_agg(name::text, avg_value::double precision) AS scores_avg
FROM (
SELECT
trace_id,
name,
avg(value) avg_value
FROM
scores
GROUP BY
1,
2
ORDER BY
1) tmp
GROUP BY
1
)
SELECT
t.*,
const tracesQuery = createTracesQuery(
Prisma.sql`t.*,
t."user_id" AS "userId",
t."metadata" AS "metadata",
t.session_id AS "sessionId",
@@ -129,34 +81,48 @@ export const traceRouter = createTRPCRouter({
COALESCE(u."promptTokens", 0)::int AS "promptTokens",
COALESCE(u."completionTokens", 0)::int AS "completionTokens",
COALESCE(u."totalTokens", 0)::int AS "totalTokens",
tl.latency AS "latency",
(count(*) OVER ())::int AS "totalCount"
FROM
"traces" AS t
LEFT JOIN usage AS u ON u.trace_id = t.id
-- used for filtering
LEFT JOIN scores_avg AS s_avg ON s_avg.trace_id = t.id
LEFT JOIN trace_latency AS tl ON tl.trace_id = t.id
WHERE
t."project_id" = ${input.projectId}
${searchCondition}
${filterCondition}
${orderByCondition}
LIMIT ${input.limit}
OFFSET ${input.page * input.limit}
`;
tl.latency AS "latency"`,
input.projectId,
observationTimeseriesFilter,
input.page,
input.limit,
searchCondition,
filterCondition,
orderByCondition,
);
const traces = await ctx.prisma.$queryRaw<
Array<
Trace & {
promptTokens: number;
completionTokens: number;
totalTokens: number;
totalCount: number;
latency: number | null;
}
>
>(query);
const traces = await instrumentAsync(
{ name: "get-all-traces" },
async () =>
await ctx.prisma.$queryRaw<
Array<
Trace & {
promptTokens: number;
completionTokens: number;
totalTokens: number;
totalCount: number;
latency: number | null;
}
>
>(tracesQuery),
);
const countQyery = createTracesQuery(
Prisma.sql`count(*)`,
input.projectId,
observationTimeseriesFilter,
0,
1,
searchCondition,
filterCondition,
Prisma.empty,
);
const totalTraces = await instrumentAsync(
{ name: "get-total-traces" },
async () =>
await ctx.prisma.$queryRaw<Array<{ count: bigint }>>(countQyery),
);
// get scores for each trace individually to increase
// performance of the query above
@@ -170,10 +136,14 @@ export const traceRouter = createTRPCRouter({
},
},
});
return traces.map((trace) => {
const filteredScores = scores.filter((s) => s.traceId === trace.id);
return { ...trace, scores: filteredScores };
});
const totalTraceCount = totalTraces[0]?.count;
return {
traces: traces.map((trace) => {
const filteredScores = scores.filter((s) => s.traceId === trace.id);
return { ...trace, scores: filteredScores };
}),
totalCount: totalTraceCount ? Number(totalTraceCount) : undefined,
};
}),
filterOptions: protectedProjectProcedure
.input(z.object({ projectId: z.string() }))
@@ -343,7 +313,6 @@ export const traceRouter = createTRPCRouter({
bookmarked: input.bookmarked,
},
});
return trace;
} catch (error) {
console.error(error);
@@ -404,4 +373,113 @@ export const traceRouter = createTRPCRouter({
}
}
}),
updateTags: protectedProjectProcedure
.input(
z.object({
projectId: z.string(),
traceId: z.string(),
tags: z.array(z.string()),
}),
)
.mutation(async ({ input, ctx }) => {
throwIfNoAccess({
session: ctx.session,
projectId: input.projectId,
scope: "objects:tag",
});
try {
const trace = await ctx.prisma.trace.update({
where: {
id: input.traceId,
projectId: input.projectId,
},
data: {
tags: {
set: input.tags,
},
},
});
return trace;
} catch (error) {
console.error(error);
}
}),
});
function createTracesQuery(
select: Sql,
projectId: string,
observationTimeseriesFilter: Sql,
page: number,
limit: number,
searchCondition: Sql,
filterCondition: Sql,
orderByCondition: Sql,
) {
return Prisma.sql`
WITH usage AS (
SELECT
trace_id,
sum(prompt_tokens) AS "promptTokens",
sum(completion_tokens) AS "completionTokens",
sum(total_tokens) AS "totalTokens"
FROM
"observations"
WHERE
"trace_id" IS NOT NULL
AND "type" = 'GENERATION'
AND "project_id" = ${projectId}
${observationTimeseriesFilter}
GROUP BY
trace_id
),
trace_latency AS (
SELECT
trace_id,
EXTRACT(EPOCH FROM COALESCE(MAX("end_time"), MAX("start_time"))) - EXTRACT(EPOCH FROM MIN("start_time"))::double precision AS "latency"
FROM
"observations"
WHERE
"trace_id" IS NOT NULL
AND "project_id" = ${projectId}
${observationTimeseriesFilter}
GROUP BY
trace_id
),
-- used for filtering
scores_avg AS (
SELECT
trace_id,
jsonb_object_agg(name::text, avg_value::double precision) AS scores_avg
FROM (
SELECT
trace_id,
name,
avg(value) avg_value
FROM
scores
GROUP BY
1,
2
ORDER BY
1) tmp
GROUP BY
1
)
SELECT
${select}
FROM
"traces" AS t
LEFT JOIN usage AS u ON u.trace_id = t.id
-- used for filtering
LEFT JOIN scores_avg AS s_avg ON s_avg.trace_id = t.id
LEFT JOIN trace_latency AS tl ON tl.trace_id = t.id
WHERE
t."project_id" = ${projectId}
${searchCondition}
${filterCondition}
${orderByCondition}
LIMIT ${limit}
OFFSET ${page * limit}
`;
}
+13 -2
View File
@@ -277,7 +277,9 @@ export class TraceProcessor implements EventProcessor {
const { body } = this.event;
if (apiScope.accessLevel !== "all")
throw new AuthenticationError("Access denied for trace creation");
throw new AuthenticationError(
`Access denied for trace creation, ${apiScope.accessLevel}`,
);
const internalId = body.id ?? v4();
@@ -294,6 +296,16 @@ export class TraceProcessor implements EventProcessor {
},
});
// access rights note:
// if trace exists, check if project id matches
// if trace does not exist, insert the trace with the projectId from scope
if (existingTrace && existingTrace.projectId !== apiScope.projectId) {
throw new AuthenticationError(
`Access denied for trace creation ${existingTrace.projectId} `,
);
}
const mergedMetadata = mergeJson(
existingTrace?.metadata
? jsonSchema.parse(existingTrace.metadata)
@@ -304,7 +316,6 @@ export class TraceProcessor implements EventProcessor {
const upsertedTrace = await prisma.trace.upsert({
where: {
id: internalId,
projectId: apiScope.projectId,
},
create: {
id: internalId,
+3
View File
@@ -70,6 +70,9 @@ export const createTRPCContext = async (opts: CreateNextContextOptions) => {
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import { ZodError } from "zod";
import { setUpSuperjson } from "@/src/utils/superjson";
setUpSuperjson();
const t = initTRPC.context<typeof createTRPCContext>().create({
transformer: superjson,
+3
View File
@@ -16,6 +16,9 @@ import { type inferRouterInputs, type inferRouterOutputs } from "@trpc/server";
import superjson from "superjson";
import { type AppRouter } from "@/src/server/api/root";
import { setUpSuperjson } from "@/src/utils/superjson";
setUpSuperjson();
const getBaseUrl = () => {
if (typeof window !== "undefined") return ""; // browser should use relative url
+11 -5
View File
@@ -14,12 +14,18 @@ export const numberFormatter = (number: number) => {
}).format(number);
};
export const usdFormatter = (number: number) =>
new Intl.NumberFormat("en-US", {
export const usdFormatter = (
number: number,
minimumFractionDigits: number = 2,
maximumFractionDigits: number = 4,
) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
// These options are needed to round to whole numbers if that's what you want.
//minimumFractionDigits: 0, // (this suffices for whole numbers, but will print 2500.10 as $2,500.1)
//maximumFractionDigits: 0, // (causes 2500.99 to be printed as $2,501)
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#minimumfractiondigits
minimumFractionDigits,
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#maximumfractiondigits
maximumFractionDigits,
}).format(number);
};
+13
View File
@@ -0,0 +1,13 @@
import Decimal from "decimal.js";
import superjson from "superjson";
export const setUpSuperjson = () => {
superjson.registerCustom<Decimal, string>(
{
isApplicable: (v): v is Decimal => Decimal.isDecimal(v),
serialize: (v) => v.toJSON(),
deserialize: (v) => new Decimal(v),
},
"decimal.js",
);
};