From 93396e7082146da1828934bf5525aba373062693 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Thu, 2 Jul 2026 18:28:23 +0200 Subject: [PATCH 1/4] feat: Add utilities for custom views (push + preview) --- js/package.json | 7 ++ js/src/custom-views/exports.test.ts | 145 +++++++++++++++++++++++ js/src/custom-views/exports.ts | 1 + js/src/custom-views/registry.ts | 175 ++++++++++++++++++++++++++++ js/tsup.config.ts | 1 + 5 files changed, 329 insertions(+) create mode 100644 js/src/custom-views/exports.test.ts create mode 100644 js/src/custom-views/exports.ts create mode 100644 js/src/custom-views/registry.ts diff --git a/js/package.json b/js/package.json index b85271cb8..7b68f64a1 100644 --- a/js/package.json +++ b/js/package.json @@ -37,6 +37,13 @@ "require": "./dist/index.js", "default": "./dist/index.mjs" }, + "./custom-views": { + "types": "./dist/custom-views.d.ts", + "import": "./dist/custom-views.mjs", + "module": "./dist/custom-views.mjs", + "require": "./dist/custom-views.js", + "default": "./dist/custom-views.mjs" + }, "./workerd": { "import": "./dist/workerd.mjs", "require": "./dist/workerd.js", diff --git a/js/src/custom-views/exports.test.ts b/js/src/custom-views/exports.test.ts new file mode 100644 index 000000000..44935887d --- /dev/null +++ b/js/src/custom-views/exports.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, expectTypeOf, test } from "vitest"; +import * as customViewsExports from "./exports"; +import { customDatasetView, customTraceView } from "./exports"; + +describe("custom views", () => { + test("only exposes the public custom view helpers", () => { + expect(Object.keys(customViewsExports).sort()).toEqual([ + "customDatasetView", + "customTraceView", + ]); + }); + + test("creates trace custom view definitions", () => { + const component = (props: { span: { span_id: string } }) => + props.span.span_id; + + const returned = customTraceView( + { + name: "Trace Summary", + slug: "trace-summary", + project: { name: "my-project" }, + }, + component, + ); + + expect(returned).toEqual({ + kind: "trace", + name: "Trace Summary", + slug: "trace-summary", + project: { name: "my-project" }, + component, + }); + }); + + test("creates dataset custom view definitions", () => { + const component = (props: { id: string }) => props.id; + + const returned = customDatasetView( + { + name: "Dataset Row", + slug: "dataset-row", + dataset: { id: "dataset-id" }, + project: "my-project", + }, + component, + ); + + expect(returned).toEqual({ + kind: "dataset", + name: "Dataset Row", + slug: "dataset-row", + dataset: { id: "dataset-id" }, + project: "my-project", + component, + }); + }); + + test("allows default-export-friendly view values", () => { + const first = () => "first"; + const second = () => "second"; + + const traceView = customTraceView( + { + name: "First", + slug: "shared", + }, + first, + ); + const datasetView = customDatasetView( + { + name: "Second", + slug: "shared", + dataset: { name: "rows" }, + }, + second, + ); + + const defaultTraceExport = traceView; + const defaultDatasetExport = datasetView; + expect(defaultTraceExport.component).toBe(first); + expect(defaultDatasetExport.component).toBe(second); + }); + + test("preserves trace view generic prop types", () => { + type Input = { prompt: string }; + type Output = { answer: string }; + type Expected = { answer: string }; + type Metadata = { topic: string }; + + const returned = customTraceView( + { + name: "Typed Trace", + slug: "typed-trace", + }, + (props) => { + expectTypeOf(props.span.data.input).toEqualTypeOf(); + expectTypeOf(props.span.data.output).toEqualTypeOf< + Output | undefined + >(); + expectTypeOf(props.span.data.expected).toEqualTypeOf< + Expected | undefined + >(); + expectTypeOf(props.span.data.metadata).toEqualTypeOf< + Metadata | undefined + >(); + expectTypeOf(props.trace.spans["span-id"].data.metadata).toEqualTypeOf< + Metadata | undefined + >(); + expectTypeOf(props.trace.update).parameter(0).toEqualTypeOf<{ + target?: "selected" | "root" | { spanId: string }; + metadata?: Partial & Record; + tags?: string[] | null; + }>(); + return null; + }, + ); + + expectTypeOf(returned.kind).toEqualTypeOf<"trace">(); + expect(returned.kind).toBe("trace"); + }); + + test("preserves dataset view generic prop types", () => { + type Input = { prompt: string }; + type Expected = { answer: string }; + type Metadata = { difficulty: number }; + + const returned = customDatasetView( + { + name: "Typed Dataset", + slug: "typed-dataset", + dataset: { name: "rows" }, + }, + (props) => { + expectTypeOf(props.input).toEqualTypeOf(); + expectTypeOf(props.expected).toEqualTypeOf(); + expectTypeOf(props.metadata).toEqualTypeOf(); + expectTypeOf(props.tags).toEqualTypeOf(); + return null; + }, + ); + + expectTypeOf(returned.kind).toEqualTypeOf<"dataset">(); + expect(returned.kind).toBe("dataset"); + }); +}); diff --git a/js/src/custom-views/exports.ts b/js/src/custom-views/exports.ts new file mode 100644 index 000000000..44f3b357e --- /dev/null +++ b/js/src/custom-views/exports.ts @@ -0,0 +1 @@ +export { customDatasetView, customTraceView } from "./registry"; diff --git a/js/src/custom-views/registry.ts b/js/src/custom-views/registry.ts new file mode 100644 index 000000000..78280de97 --- /dev/null +++ b/js/src/custom-views/registry.ts @@ -0,0 +1,175 @@ +type SpanFieldName = "input" | "output" | "expected" | "metadata"; + +type SpanFields< + TInput = unknown, + TOutput = unknown, + TExpected = unknown, + TMetadata extends Record = Record, +> = { + input?: TInput; + output?: TOutput; + expected?: TExpected; + metadata?: TMetadata; +}; + +type TraceViewSpan< + TInput = unknown, + TOutput = unknown, + TExpected = unknown, + TMetadata extends Record = Record, +> = { + id: string; + span_id: string; + root_span_id: string; + parent_span_id?: string | null; + span_parents?: string[]; + data: { + input?: TInput; + output?: TOutput; + expected?: TExpected; + metadata?: TMetadata; + scores?: Record; + metrics?: Record; + error?: string; + tags?: string[]; + span_attributes?: Record; + [key: string]: unknown; + }; + children: string[]; +}; + +type CustomViewUpdateTarget = "selected" | "root" | { spanId: string }; + +type CustomViewUpdateResult = { + transactionId: string | null; +}; + +type CustomViewSpanUpdate< + TMetadata extends Record = Record, +> = { + target?: CustomViewUpdateTarget; + metadata?: Partial & Record; + tags?: string[] | null; +}; + +type TraceViewTrace< + TInput = unknown, + TOutput = unknown, + TExpected = unknown, + TMetadata extends Record = Record, +> = { + rootSpanId: string; + selectedSpanId: string; + spanOrder: string[]; + spans: Record>; + fetchSpanFields: ( + spanIds: string | string[], + fields?: SpanFieldName[], + ) => Promise< + Record> + >; + update: ( + update: CustomViewSpanUpdate, + ) => Promise; +}; + +type TraceViewUpdate< + TMetadata extends Record = Record, +> = { + (field: string, value: unknown): void; + (patch: Partial<{ metadata: TMetadata; tags: string[] | null }>): void; +}; + +type TraceViewProps< + TInput = unknown, + TOutput = unknown, + TExpected = unknown, + TMetadata extends Record = Record, +> = { + trace: TraceViewTrace; + span: TraceViewSpan; + selectSpan?: (spanId: string) => void; + update?: TraceViewUpdate; +}; + +type DatasetViewProps< + TInput = unknown, + TExpected = unknown, + TMetadata extends Record = Record, +> = { + id: string; + input?: TInput; + expected?: TExpected; + metadata?: TMetadata; + tags?: string[]; +}; + +type Component = (props: Props) => unknown; + +type ProjectRef = string | { id: string } | { name: string }; +type DatasetRef = { id: string } | { name: string }; + +type CustomTraceViewDefinition = { + name: string; + slug: string; + project?: ProjectRef; +}; + +type CustomTraceView< + TInput = unknown, + TOutput = unknown, + TExpected = unknown, + TMetadata extends Record = Record, +> = CustomTraceViewDefinition & { + kind: "trace"; + component: Component>; +}; + +type CustomDatasetViewDefinition = { + name: string; + slug: string; + dataset: DatasetRef; + project?: ProjectRef; +}; + +type CustomDatasetView< + TInput = unknown, + TExpected = unknown, + TMetadata extends Record = Record, +> = CustomDatasetViewDefinition & { + kind: "dataset"; + component: Component>; +}; + +/** + * Defines a trace custom view for discovery by the `bt views` CLI. + * + * @experimental This API is not yet stabilized and may change across non-major versions. + */ +export function customTraceView< + TInput = unknown, + TOutput = unknown, + TExpected = unknown, + TMetadata extends Record = Record, +>( + definition: CustomTraceViewDefinition, + component: Component>, +): CustomTraceView { + return { ...definition, component, kind: "trace" }; +} + +/** + * Defines a dataset custom view for discovery by the `bt views` CLI. + * + * @experimental This API is not yet stabilized and may change across non-major versions. + */ +export function customDatasetView< + TInput = unknown, + TExpected = unknown, + TMetadata extends Record = Record, +>( + definition: CustomDatasetViewDefinition, + component: Component>, +): CustomDatasetView { + return { ...definition, component, kind: "dataset" }; +} diff --git a/js/tsup.config.ts b/js/tsup.config.ts index 9ee12a9d0..2559747de 100644 --- a/js/tsup.config.ts +++ b/js/tsup.config.ts @@ -5,6 +5,7 @@ export default defineConfig([ { entry: { index: "src/node/index.ts", + "custom-views": "src/custom-views/exports.ts", "apply-auto-instrumentation": "src/node/apply-auto-instrumentation-entry.ts", "vitest-evals-reporter": "src/wrappers/vitest-evals/reporter.ts", From b3f09dd02f588ab8c06c5903caff6bdcce842068 Mon Sep 17 00:00:00 2001 From: lforst <8118419+lforst@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:09:39 +0000 Subject: [PATCH 2/4] Update PR #2191 --- js/src/custom-views/exports.test.ts | 145 ---------------------------- js/src/custom-views/registry.ts | 18 ++-- 2 files changed, 9 insertions(+), 154 deletions(-) delete mode 100644 js/src/custom-views/exports.test.ts diff --git a/js/src/custom-views/exports.test.ts b/js/src/custom-views/exports.test.ts deleted file mode 100644 index 44935887d..000000000 --- a/js/src/custom-views/exports.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { describe, expect, expectTypeOf, test } from "vitest"; -import * as customViewsExports from "./exports"; -import { customDatasetView, customTraceView } from "./exports"; - -describe("custom views", () => { - test("only exposes the public custom view helpers", () => { - expect(Object.keys(customViewsExports).sort()).toEqual([ - "customDatasetView", - "customTraceView", - ]); - }); - - test("creates trace custom view definitions", () => { - const component = (props: { span: { span_id: string } }) => - props.span.span_id; - - const returned = customTraceView( - { - name: "Trace Summary", - slug: "trace-summary", - project: { name: "my-project" }, - }, - component, - ); - - expect(returned).toEqual({ - kind: "trace", - name: "Trace Summary", - slug: "trace-summary", - project: { name: "my-project" }, - component, - }); - }); - - test("creates dataset custom view definitions", () => { - const component = (props: { id: string }) => props.id; - - const returned = customDatasetView( - { - name: "Dataset Row", - slug: "dataset-row", - dataset: { id: "dataset-id" }, - project: "my-project", - }, - component, - ); - - expect(returned).toEqual({ - kind: "dataset", - name: "Dataset Row", - slug: "dataset-row", - dataset: { id: "dataset-id" }, - project: "my-project", - component, - }); - }); - - test("allows default-export-friendly view values", () => { - const first = () => "first"; - const second = () => "second"; - - const traceView = customTraceView( - { - name: "First", - slug: "shared", - }, - first, - ); - const datasetView = customDatasetView( - { - name: "Second", - slug: "shared", - dataset: { name: "rows" }, - }, - second, - ); - - const defaultTraceExport = traceView; - const defaultDatasetExport = datasetView; - expect(defaultTraceExport.component).toBe(first); - expect(defaultDatasetExport.component).toBe(second); - }); - - test("preserves trace view generic prop types", () => { - type Input = { prompt: string }; - type Output = { answer: string }; - type Expected = { answer: string }; - type Metadata = { topic: string }; - - const returned = customTraceView( - { - name: "Typed Trace", - slug: "typed-trace", - }, - (props) => { - expectTypeOf(props.span.data.input).toEqualTypeOf(); - expectTypeOf(props.span.data.output).toEqualTypeOf< - Output | undefined - >(); - expectTypeOf(props.span.data.expected).toEqualTypeOf< - Expected | undefined - >(); - expectTypeOf(props.span.data.metadata).toEqualTypeOf< - Metadata | undefined - >(); - expectTypeOf(props.trace.spans["span-id"].data.metadata).toEqualTypeOf< - Metadata | undefined - >(); - expectTypeOf(props.trace.update).parameter(0).toEqualTypeOf<{ - target?: "selected" | "root" | { spanId: string }; - metadata?: Partial & Record; - tags?: string[] | null; - }>(); - return null; - }, - ); - - expectTypeOf(returned.kind).toEqualTypeOf<"trace">(); - expect(returned.kind).toBe("trace"); - }); - - test("preserves dataset view generic prop types", () => { - type Input = { prompt: string }; - type Expected = { answer: string }; - type Metadata = { difficulty: number }; - - const returned = customDatasetView( - { - name: "Typed Dataset", - slug: "typed-dataset", - dataset: { name: "rows" }, - }, - (props) => { - expectTypeOf(props.input).toEqualTypeOf(); - expectTypeOf(props.expected).toEqualTypeOf(); - expectTypeOf(props.metadata).toEqualTypeOf(); - expectTypeOf(props.tags).toEqualTypeOf(); - return null; - }, - ); - - expectTypeOf(returned.kind).toEqualTypeOf<"dataset">(); - expect(returned.kind).toBe("dataset"); - }); -}); diff --git a/js/src/custom-views/registry.ts b/js/src/custom-views/registry.ts index 78280de97..23f06ff09 100644 --- a/js/src/custom-views/registry.ts +++ b/js/src/custom-views/registry.ts @@ -28,7 +28,7 @@ type TraceViewSpan< output?: TOutput; expected?: TExpected; metadata?: TMetadata; - scores?: Record; + scores?: Record; metrics?: Record; error?: string; tags?: string[]; @@ -49,6 +49,7 @@ type CustomViewSpanUpdate< > = { target?: CustomViewUpdateTarget; metadata?: Partial & Record; + scores?: Record; tags?: string[] | null; }; @@ -73,13 +74,6 @@ type TraceViewTrace< ) => Promise; }; -type TraceViewUpdate< - TMetadata extends Record = Record, -> = { - (field: string, value: unknown): void; - (patch: Partial<{ metadata: TMetadata; tags: string[] | null }>): void; -}; - type TraceViewProps< TInput = unknown, TOutput = unknown, @@ -89,7 +83,7 @@ type TraceViewProps< trace: TraceViewTrace; span: TraceViewSpan; selectSpan?: (spanId: string) => void; - update?: TraceViewUpdate; + update?: (field: string, value: unknown) => void; }; type DatasetViewProps< @@ -102,6 +96,12 @@ type DatasetViewProps< expected?: TExpected; metadata?: TMetadata; tags?: string[]; + update: (update: { + input?: TInput; + expected?: TExpected; + metadata?: Partial & Record; + tags?: string[] | null; + }) => Promise; }; type Component = (props: Props) => unknown; From f5b31211da901a965c73071d72341e35baa389ee Mon Sep 17 00:00:00 2001 From: lforst <8118419+lforst@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:56:58 +0000 Subject: [PATCH 3/4] Update PR #2191 --- .changeset/custom-view-helpers.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/custom-view-helpers.md diff --git a/.changeset/custom-view-helpers.md b/.changeset/custom-view-helpers.md new file mode 100644 index 000000000..efc14939f --- /dev/null +++ b/.changeset/custom-view-helpers.md @@ -0,0 +1,5 @@ +--- +"braintrust": minor +--- + +feat: Add utilities for custom views (push + preview) From 561f4ac3dec805a625e344611cd0ab4be235194c Mon Sep 17 00:00:00 2001 From: lforst <8118419+lforst@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:21:51 +0000 Subject: [PATCH 4/4] Update PR #2191 --- js/package.json | 1 + js/src/custom-views/registry.ts | 4 +++- pnpm-lock.yaml | 21 +++++++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/js/package.json b/js/package.json index 7b68f64a1..6a3261f60 100644 --- a/js/package.json +++ b/js/package.json @@ -216,6 +216,7 @@ "dependencies": { "@apm-js-collab/code-transformer": "^0.12.0", "@next/env": "^14.2.3", + "@types/react": "^18.3.1", "@vercel/functions": "^1.0.2", "ajv": "^8.20.0", "argparse": "^2.0.1", diff --git a/js/src/custom-views/registry.ts b/js/src/custom-views/registry.ts index 23f06ff09..15b44008a 100644 --- a/js/src/custom-views/registry.ts +++ b/js/src/custom-views/registry.ts @@ -1,3 +1,5 @@ +import type { ReactNode } from "react"; + type SpanFieldName = "input" | "output" | "expected" | "metadata"; type SpanFields< @@ -104,7 +106,7 @@ type DatasetViewProps< }) => Promise; }; -type Component = (props: Props) => unknown; +type Component = (props: Props) => ReactNode; type ProjectRef = string | { id: string } | { name: string }; type DatasetRef = { id: string } | { name: string }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6892f97c5..eea3642d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -338,6 +338,9 @@ importers: '@next/env': specifier: ^14.2.3 version: 14.2.3 + '@types/react': + specifier: ^18.3.1 + version: 18.3.31 '@vercel/functions': specifier: ^1.0.2 version: 1.0.2 @@ -1799,12 +1802,18 @@ packages: '@types/pluralize@0.0.30': resolution: {integrity: sha512-kVww6xZrW/db5BR9OqiT71J9huRdQ+z/r+LbDuT7/EK50mCmj5FoaIARnVv0rvjUS/YpDox0cDU9lpQT011VBA==} + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + '@types/qs@6.9.18': resolution: {integrity: sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==} '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/react@18.3.31': + resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==} + '@types/retry@0.12.0': resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} @@ -2305,6 +2314,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + dataloader@1.4.0: resolution: {integrity: sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw==} @@ -5499,10 +5511,17 @@ snapshots: '@types/pluralize@0.0.30': {} + '@types/prop-types@15.7.15': {} + '@types/qs@6.9.18': {} '@types/range-parser@1.2.7': {} + '@types/react@18.3.31': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + '@types/retry@0.12.0': {} '@types/send@0.17.4': @@ -6063,6 +6082,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + csstype@3.2.3: {} + dataloader@1.4.0: {} dc-browser@1.0.4: {}