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",