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