From 9234f7815114418c27a8beb40c48587f2c1d5981 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Thu, 2 Jul 2026 13:57:51 +0200 Subject: [PATCH 1/4] feat: Add custom view commands (dataset views, trace views) --- scripts/views-runner.ts | 518 ++++++++++ scripts/views-sdk.ts | 249 +++++ src/datasets/api.rs | 42 + src/main.rs | 7 + src/views.rs | 2007 +++++++++++++++++++++++++++++++++++++++ tests/cli.rs | 107 +++ 6 files changed, 2930 insertions(+) create mode 100644 scripts/views-runner.ts create mode 100644 scripts/views-sdk.ts create mode 100644 src/views.rs diff --git a/scripts/views-runner.ts b/scripts/views-runner.ts new file mode 100644 index 0000000..712f4bc --- /dev/null +++ b/scripts/views-runner.ts @@ -0,0 +1,518 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { pathToFileURL } from "node:url"; + +type EsbuildModule = { + build: (options: Record) => Promise; +}; + +type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue }; + +type RegisteredCustomView = { + kind?: string; + name?: string; + slug?: string; + description?: string; + project?: string | { id?: string; name?: string }; + dataset?: { id?: string; name?: string }; + tags?: unknown; + metadata?: unknown; +}; + +type ManifestEntry = { + kind: "view"; + view_type: "trace" | "dataset"; + name: string; + slug: string; + code: string; + description?: string; + project_id?: string; + project_name?: string; + dataset_id?: string; + dataset_name?: string; + tags?: string[]; + metadata?: JsonValue; +}; + +type Manifest = { + runtime_context: { + runtime: "browser"; + version: "latest"; + }; + files: Array<{ + source_file: string; + entries: ManifestEntry[]; + }>; +}; + +const __filename = path.resolve(process.argv[1] || "views-runner.ts"); +const __dirname = path.dirname(__filename); + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeEsbuildModule(value: unknown): EsbuildModule | null { + if (isObject(value) && typeof value.build === "function") { + return value as EsbuildModule; + } + if ( + isObject(value) && + isObject(value.default) && + typeof value.default.build === "function" + ) { + return value.default as EsbuildModule; + } + return null; +} + +async function loadEsbuild(sourceFiles: string[]): Promise { + const candidates = [ + ...sourceFiles.map((file) => path.resolve(file)), + path.join(process.cwd(), "package.json"), + path.join(__dirname, "package.json"), + ]; + + for (const candidate of candidates) { + try { + const requireFrom = createRequire(pathToFileURL(candidate).href); + const resolved = requireFrom.resolve("esbuild"); + const loaded = requireFrom(resolved); + const normalized = normalizeEsbuildModule(loaded); + if (normalized) { + return normalized; + } + } catch { + // Try the next search root. + } + } + + try { + const esbuildSpecifier = "esbuild"; + const loaded = await import(esbuildSpecifier); + const normalized = normalizeEsbuildModule(loaded); + if (normalized) { + return normalized; + } + } catch { + // handled below + } + + throw new Error( + "failed to load esbuild for custom view bundling; install esbuild in your project", + ); +} + +function loadTsconfigPath(): string | undefined { + const tsNode = process.env.TS_NODE_PROJECT?.trim(); + if (tsNode) { + return tsNode; + } + const tsx = process.env.TSX_TSCONFIG_PATH?.trim(); + if (tsx) { + return tsx; + } + return undefined; +} + +function sdkPath(): string { + return process.env.BT_VIEWS_SDK_PATH || path.join(__dirname, "views-sdk.ts"); +} + +function reactModuleSource(): string { + return ` +const ReactValue = globalThis.React || React; +export default ReactValue; +export const Children = ReactValue.Children; +export const Component = ReactValue.Component; +export const Fragment = ReactValue.Fragment; +export const Profiler = ReactValue.Profiler; +export const PureComponent = ReactValue.PureComponent; +export const StrictMode = ReactValue.StrictMode; +export const Suspense = ReactValue.Suspense; +export const cloneElement = ReactValue.cloneElement; +export const createContext = ReactValue.createContext; +export const createElement = ReactValue.createElement; +export const createRef = ReactValue.createRef; +export const forwardRef = ReactValue.forwardRef; +export const isValidElement = ReactValue.isValidElement; +export const lazy = ReactValue.lazy; +export const memo = ReactValue.memo; +export const startTransition = ReactValue.startTransition; +export const useCallback = ReactValue.useCallback; +export const useContext = ReactValue.useContext; +export const useDebugValue = ReactValue.useDebugValue; +export const useDeferredValue = ReactValue.useDeferredValue; +export const useEffect = ReactValue.useEffect; +export const useId = ReactValue.useId; +export const useImperativeHandle = ReactValue.useImperativeHandle; +export const useInsertionEffect = ReactValue.useInsertionEffect; +export const useLayoutEffect = ReactValue.useLayoutEffect; +export const useMemo = ReactValue.useMemo; +export const useReducer = ReactValue.useReducer; +export const useRef = ReactValue.useRef; +export const useState = ReactValue.useState; +export const useSyncExternalStore = ReactValue.useSyncExternalStore; +export const useTransition = ReactValue.useTransition; +`; +} + +function jsxRuntimeSource(): string { + return ` +const ReactValue = globalThis.React || React; +export const Fragment = ReactValue.Fragment; +export const jsx = ReactValue.createElement; +export const jsxs = ReactValue.createElement; +export const jsxDEV = ReactValue.createElement; +`; +} + +function installDiscoveryReactStub(): void { + const globalWithReact = globalThis as { + React?: Record; + }; + if (globalWithReact.React) { + return; + } + const createElement = (...args: unknown[]) => ({ + type: args[0], + props: args[1] ?? {}, + children: args.slice(2), + }); + globalWithReact.React = { + Children: {}, + Component: class {}, + Fragment: Symbol.for("react.fragment"), + Profiler: Symbol.for("react.profiler"), + PureComponent: class {}, + StrictMode: Symbol.for("react.strict_mode"), + Suspense: Symbol.for("react.suspense"), + cloneElement: createElement, + createContext: () => ({}), + createElement, + createRef: () => ({ current: null }), + forwardRef: (component: unknown) => component, + isValidElement: (value: unknown) => + typeof value === "object" && value !== null, + lazy: (loader: unknown) => loader, + memo: (component: unknown) => component, + startTransition: (callback: () => void) => callback(), + useCallback: (callback: unknown) => callback, + useContext: () => undefined, + useDebugValue: () => undefined, + useDeferredValue: (value: unknown) => value, + useEffect: () => undefined, + useId: () => "bt-view-preview-id", + useImperativeHandle: () => undefined, + useInsertionEffect: () => undefined, + useLayoutEffect: () => undefined, + useMemo: (factory: () => unknown) => factory(), + useReducer: (_reducer: unknown, initialValue: unknown) => [ + initialValue, + () => undefined, + ], + useRef: (current: unknown) => ({ current }), + useState: (initialValue: unknown) => [initialValue, () => undefined], + useSyncExternalStore: () => undefined, + useTransition: () => [false, () => undefined], + }; +} + +function braintrustViewPlugin() { + const helperPath = sdkPath(); + return { + name: "braintrust-custom-view-local-sdk", + setup(build: { + onResolve: ( + options: { filter: RegExp }, + callback: (args: { path: string }) => unknown, + ) => void; + onLoad: ( + options: { filter: RegExp; namespace?: string }, + callback: (args: { path: string }) => unknown, + ) => void; + }) { + build.onResolve({ filter: /^braintrust\/custom-views$/ }, () => ({ + path: helperPath, + })); + build.onResolve({ filter: /^@braintrust\/local\/custom-views$/ }, () => ({ + path: helperPath, + })); + build.onResolve({ filter: /^react$/ }, () => ({ + path: "react", + namespace: "bt-view-react", + })); + build.onResolve({ filter: /^react\/jsx-runtime$/ }, () => ({ + path: "react/jsx-runtime", + namespace: "bt-view-react", + })); + build.onResolve({ filter: /^react\/jsx-dev-runtime$/ }, () => ({ + path: "react/jsx-dev-runtime", + namespace: "bt-view-react", + })); + build.onLoad( + { filter: /^react$/, namespace: "bt-view-react" }, + () => ({ + contents: reactModuleSource(), + loader: "js", + }), + ); + build.onLoad( + { filter: /^react\/jsx-(dev-)?runtime$/, namespace: "bt-view-react" }, + () => ({ + contents: jsxRuntimeSource(), + loader: "js", + }), + ); + }, + }; +} + +function validateJson(value: unknown, label: string): JsonValue | undefined { + if (value === undefined) { + return undefined; + } + try { + return JSON.parse(JSON.stringify(value)) as JsonValue; + } catch { + throw new Error(`${label} must be JSON-serializable`); + } +} + +function normalizeTags(value: unknown, slug: string): string[] | undefined { + if (value === undefined) { + return undefined; + } + if (!Array.isArray(value) || !value.every((tag) => typeof tag === "string")) { + throw new Error(`custom view '${slug}' tags must be an array of strings`); + } + return value; +} + +function projectFields( + project: RegisteredCustomView["project"], +): Pick { + if (project === undefined) { + return {}; + } + if (typeof project === "string") { + return { project_name: project }; + } + if (typeof project.id === "string" && project.id.trim()) { + return { project_id: project.id }; + } + if (typeof project.name === "string" && project.name.trim()) { + return { project_name: project.name }; + } + throw new Error("custom view project must be a string, { id }, or { name }"); +} + +function datasetFields( + view: RegisteredCustomView, +): Pick { + if (view.kind !== "dataset") { + return {}; + } + const dataset = view.dataset; + if (!dataset) { + throw new Error(`dataset custom view '${view.slug}' requires dataset`); + } + if (typeof dataset.id === "string" && dataset.id.trim()) { + return { dataset_id: dataset.id }; + } + if (typeof dataset.name === "string" && dataset.name.trim()) { + return { dataset_name: dataset.name }; + } + throw new Error( + `dataset custom view '${view.slug}' dataset must be { id } or { name }`, + ); +} + +function validateRegisteredView(view: RegisteredCustomView): void { + if (view.kind !== "trace" && view.kind !== "dataset") { + throw new Error("custom view kind must be 'trace' or 'dataset'"); + } + if (typeof view.name !== "string" || !view.name.trim()) { + throw new Error("custom view name is required"); + } + if (typeof view.slug !== "string" || !view.slug.trim()) { + throw new Error(`custom view '${view.name}' slug is required`); + } +} + +async function bundleForDiscovery( + esbuild: EsbuildModule, + sourceFile: string, + outputFile: string, + tsconfig: string | undefined, +): Promise { + await esbuild.build({ + entryPoints: [sourceFile], + outfile: outputFile, + bundle: true, + platform: "node", + target: "node18", + format: "cjs", + tsconfig, + write: true, + plugins: [braintrustViewPlugin()], + }); +} + +async function collectViews( + esbuild: EsbuildModule, + sourceFile: string, + tempDir: string, + tsconfig: string | undefined, +): Promise { + (globalThis as { __braintrust_custom_views?: RegisteredCustomView[] }).__braintrust_custom_views = []; + installDiscoveryReactStub(); + const outputFile = path.join( + tempDir, + `${path.basename(sourceFile).replace(/[^\w.-]/g, "_")}.collector.cjs`, + ); + await bundleForDiscovery(esbuild, sourceFile, outputFile, tsconfig); + await import(`${pathToFileURL(outputFile).href}?bt_view_nonce=${Date.now()}`); + const views = + (globalThis as { __braintrust_custom_views?: RegisteredCustomView[] }) + .__braintrust_custom_views ?? []; + const seen = new Set(); + for (const view of views) { + validateRegisteredView(view); + const key = `${view.kind}:${view.slug}`; + if (seen.has(key)) { + throw new Error(`duplicate custom view slug '${view.slug}' in ${sourceFile}`); + } + seen.add(key); + } + return [...views]; +} + +function virtualEntrySource(sourceFile: string, slug: string): string { + return ` +import ${JSON.stringify(sourceFile)}; +import { __getCustomViews } from "braintrust/custom-views"; +const view = [...__getCustomViews()].reverse().find((candidate) => candidate.slug === ${JSON.stringify(slug)}); +if (!view) { + throw new Error("Custom view not registered: ${slug.replaceAll('"', '\\"')}"); +} +export default view.component; +`; +} + +async function bundleBrowserCode( + esbuild: EsbuildModule, + sourceFile: string, + view: RegisteredCustomView, + tempDir: string, + tsconfig: string | undefined, +): Promise { + const entryFile = path.join( + tempDir, + `${view.slug?.replace(/[^\w.-]/g, "_") || "view"}.entry.ts`, + ); + const outputFile = path.join( + tempDir, + `${view.slug?.replace(/[^\w.-]/g, "_") || "view"}.browser.js`, + ); + fs.writeFileSync(entryFile, virtualEntrySource(sourceFile, view.slug!), "utf8"); + + await esbuild.build({ + entryPoints: [entryFile], + outfile: outputFile, + bundle: true, + platform: "browser", + target: "es2019", + format: "iife", + globalName: "__BraintrustCustomView", + tsconfig, + write: true, + treeShaking: true, + jsxFactory: "React.createElement", + jsxFragment: "React.Fragment", + footer: { + js: "module.exports = __BraintrustCustomView;", + }, + plugins: [braintrustViewPlugin()], + }); + + return fs.readFileSync(outputFile, "utf8"); +} + +async function buildManifest(files: string[]): Promise { + const sourceFiles = files.map((file) => path.resolve(file)); + const esbuild = await loadEsbuild(sourceFiles); + const tsconfig = loadTsconfigPath(); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "bt-views-")); + + try { + const manifest: Manifest = { + runtime_context: { + runtime: "browser", + version: "latest", + }, + files: [], + }; + + for (const sourceFile of sourceFiles) { + if (!fs.existsSync(sourceFile)) { + throw new Error(`custom view file not found: ${sourceFile}`); + } + const views = await collectViews(esbuild, sourceFile, tempDir, tsconfig); + const entries: ManifestEntry[] = []; + for (const view of views) { + const code = await bundleBrowserCode( + esbuild, + sourceFile, + view, + tempDir, + tsconfig, + ); + entries.push({ + kind: "view", + view_type: view.kind as "trace" | "dataset", + name: view.name!, + slug: view.slug!, + description: view.description, + code, + ...projectFields(view.project), + ...datasetFields(view), + tags: normalizeTags(view.tags, view.slug!), + metadata: validateJson(view.metadata, `custom view '${view.slug}' metadata`), + }); + } + manifest.files.push({ source_file: sourceFile, entries }); + } + + return manifest; + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +async function main(): Promise { + const files = process.argv.slice(2); + if (files.length === 0) { + throw new Error("views-runner requires at least one view file"); + } + + const manifest = await buildManifest(files); + process.stdout.write(`${JSON.stringify(manifest)}\n`); +} + +main().catch((error: unknown) => { + const message = + error instanceof Error + ? error.message + : `failed to build custom view manifest: ${String(error)}`; + process.stderr.write(`${message}\n`); + process.exitCode = 1; +}); diff --git a/scripts/views-sdk.ts b/scripts/views-sdk.ts new file mode 100644 index 0000000..173203c --- /dev/null +++ b/scripts/views-sdk.ts @@ -0,0 +1,249 @@ +export type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue }; + +export type JsonObject = { [key: string]: JsonValue }; + +export type InlineAttachment = { + type: "inline_attachment"; + src: string; + content_type: string; + filename?: string; + data?: unknown; +}; + +export type SpanFieldName = "input" | "output" | "expected" | "metadata"; + +export type SpanFields = { + input?: unknown; + output?: unknown; + expected?: unknown; + metadata?: Record; +}; + +export 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[]; +}; + +export type TraceViewTrace = { + rootSpanId: string; + selectedSpanId: string; + spanOrder: string[]; + spans: Record; + fetchSpanFields: ( + spanIds: string | string[], + fields?: SpanFieldName[], + ) => Promise>; +}; + +export type TraceViewUpdate< + TMetadata extends Record = Record, +> = { + (field: string, value: unknown): void; + (patch: Partial<{ metadata: TMetadata; tags: string[] }>): void; +}; + +export type TraceViewProps< + TInput = unknown, + TOutput = unknown, + TExpected = unknown, + TMetadata extends Record = Record, +> = { + trace: TraceViewTrace; + span: TraceViewSpan; + update: TraceViewUpdate; + selectSpan: (spanId: string) => void; +}; + +export type DatasetViewProps< + TInput = unknown, + TExpected = unknown, + TMetadata extends Record = Record, +> = { + id: string; + input: TInput; + expected: TExpected; + metadata: TMetadata; + tags: string[]; +}; + +export type ReactComponent = (props: Props) => unknown; + +type ProjectRef = string | { id: string } | { name: string }; +type DatasetRef = { id: string } | { name: string }; + +export type CustomTraceViewDefinition< + TInput = unknown, + TOutput = unknown, + TExpected = unknown, + TMetadata extends Record = Record, +> = { + name: string; + slug: string; + component: ReactComponent< + TraceViewProps + >; + description?: string; + project?: ProjectRef; + tags?: string[]; + metadata?: JsonObject; +}; + +export type CustomDatasetViewDefinition< + TInput = unknown, + TExpected = unknown, + TMetadata extends Record = Record, +> = { + name: string; + slug: string; + dataset: DatasetRef; + component: ReactComponent>; + description?: string; + project?: ProjectRef; + tags?: string[]; + metadata?: JsonObject; +}; + +export type RegisteredCustomView = { + kind: "trace" | "dataset"; + name: string; + slug: string; + component: ReactComponent; + description?: string; + project?: ProjectRef; + dataset?: DatasetRef; + tags?: string[]; + metadata?: JsonObject; +}; + +declare global { + // eslint-disable-next-line no-var + var __braintrust_custom_views: RegisteredCustomView[] | undefined; +} + +function registry(): RegisteredCustomView[] { + const current = globalThis.__braintrust_custom_views; + if (Array.isArray(current)) { + return current; + } + globalThis.__braintrust_custom_views = []; + return globalThis.__braintrust_custom_views; +} + +function registerView(view: RegisteredCustomView): void { + const views = registry(); + const existing = views.findIndex( + (candidate) => + candidate.kind === view.kind && candidate.slug === view.slug, + ); + if (existing >= 0) { + views.splice(existing, 1); + } + views.push(view); +} + +export function __getCustomViews(): RegisteredCustomView[] { + return [...registry()]; +} + +export function __clearCustomViews(): void { + registry().splice(0); +} + +export function customTraceView< + TInput = unknown, + TOutput = unknown, + TExpected = unknown, + TMetadata extends Record = Record, +>( + definition: CustomTraceViewDefinition< + TInput, + TOutput, + TExpected, + TMetadata + >, +): ReactComponent> { + registerView({ + kind: "trace", + name: definition.name, + slug: definition.slug, + component: definition.component, + description: definition.description, + project: definition.project, + tags: definition.tags, + metadata: definition.metadata, + }); + return definition.component; +} + +export function customDatasetView< + TInput = unknown, + TExpected = unknown, + TMetadata extends Record = Record, +>( + definition: CustomDatasetViewDefinition, +): ReactComponent> { + registerView({ + kind: "dataset", + name: definition.name, + slug: definition.slug, + component: definition.component, + description: definition.description, + project: definition.project, + dataset: definition.dataset, + tags: definition.tags, + metadata: definition.metadata, + }); + return definition.component; +} + +export function isInlineAttachment(value: unknown): value is InlineAttachment { + return ( + typeof value === "object" && + value !== null && + (value as { type?: unknown }).type === "inline_attachment" && + typeof (value as { src?: unknown }).src === "string" && + typeof (value as { content_type?: unknown }).content_type === "string" + ); +} + +export function parseMaybeJson(value: unknown): unknown { + if (typeof value !== "string") { + return value; + } + try { + return JSON.parse(value); + } catch { + return value; + } +} + +export function formatJson(value: unknown): string { + return JSON.stringify(value, null, 2); +} diff --git a/src/datasets/api.rs b/src/datasets/api.rs index f07532d..119dea4 100644 --- a/src/datasets/api.rs +++ b/src/datasets/api.rs @@ -230,6 +230,16 @@ pub async fn list_dataset_rows_limited( Ok((rows, truncated)) } +pub async fn get_dataset_row_by_id( + client: &ApiClient, + dataset_id: &str, + row_id: &str, +) -> Result> { + let query = build_dataset_row_by_id_query(dataset_id, row_id, DatasetRowsPreviewLength::Full); + let response = client.btql_structured::(&query).await?; + Ok(response.data.into_iter().next()) +} + pub async fn create_dataset( client: &ApiClient, project_id: &str, @@ -401,6 +411,38 @@ fn build_dataset_rows_query( query } +fn build_dataset_row_by_id_query( + dataset_id: &str, + row_id: &str, + preview_length: DatasetRowsPreviewLength, +) -> Value { + json!({ + "select": dataset_rows_select_fields(), + "from": { + "op": "function", + "name": {"op": "ident", "name": ["dataset"]}, + "args": [{"op": "literal", "value": dataset_id}] + }, + "filter": { + "op": "and", + "children": [ + { + "op": "ge", + "left": {"op": "ident", "name": ["created"]}, + "right": {"op": "literal", "value": DATASET_ROWS_SINCE} + }, + { + "op": "eq", + "left": {"op": "ident", "name": ["id"]}, + "right": {"op": "literal", "value": row_id} + } + ] + }, + "preview_length": preview_length.btql_value(), + "limit": 1 + }) +} + fn dataset_rows_select_fields() -> Vec { DATASET_RECORD_FIELDS .iter() diff --git a/src/main.rs b/src/main.rs index 676e79e..52f2b6b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,6 +34,7 @@ mod traces; mod ui; mod util_cmd; mod utils; +mod views; use crate::args::{has_explicit_profile_arg, ArgValueSource, BaseArgs, CLIArgs}; @@ -60,6 +61,7 @@ Core auth Authenticate bt with Braintrust switch Switch org and project context view View logs, traces, and spans + views Push and preview custom views Projects & resources projects Manage projects @@ -133,6 +135,8 @@ enum Commands { Auth(CLIArgs), /// View logs, traces, and spans View(CLIArgs), + /// Push and preview custom views + Views(CLIArgs), #[cfg(unix)] /// Run eval files Eval(CLIArgs), @@ -176,6 +180,7 @@ impl Commands { Commands::Sql(cmd) => &cmd.base, Commands::Auth(cmd) => &cmd.base, Commands::View(cmd) => &cmd.base, + Commands::Views(cmd) => &cmd.base, #[cfg(unix)] Commands::Eval(cmd) => &cmd.base, Commands::Projects(cmd) => &cmd.base, @@ -202,6 +207,7 @@ impl Commands { Commands::Sql(cmd) => &mut cmd.base, Commands::Auth(cmd) => &mut cmd.base, Commands::View(cmd) => &mut cmd.base, + Commands::Views(cmd) => &mut cmd.base, #[cfg(unix)] Commands::Eval(cmd) => &mut cmd.base, Commands::Projects(cmd) => &mut cmd.base, @@ -305,6 +311,7 @@ fn try_main() -> Result<()> { match cli.command { Commands::Auth(cmd) => auth::run(cmd.base, cmd.args).await?, Commands::View(cmd) => traces::run(cmd.base, cmd.args).await?, + Commands::Views(cmd) => views::run(cmd.base, cmd.args).await?, Commands::Init(cmd) => init::run(cmd.base, cmd.args).await?, Commands::Sql(cmd) => sql::run(cmd.base, cmd.args).await?, Commands::Setup(cmd) => setup::run_setup_top(cmd.base, cmd.args).await?, diff --git a/src/views.rs b/src/views.rs new file mode 100644 index 0000000..9390a2a --- /dev/null +++ b/src/views.rs @@ -0,0 +1,2007 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt::Write as _; +use std::net::TcpListener; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use actix_web::{web, App, HttpResponse, HttpServer}; +use anyhow::{anyhow, bail, Context, Result}; +use clap::{builder::BoolishValueParser, Args, Subcommand}; +use reqwest::Url; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; +use urlencoding::encode; + +use crate::args::BaseArgs; +use crate::auth; +use crate::datasets::api as datasets_api; +use crate::functions::{self, api as functions_api, IfExistsMode}; +use crate::http::ApiClient; +use crate::js_runner; +use crate::project_context::resolve_required_project; +use crate::projects::api::{get_project_by_name, list_projects, Project}; +use crate::ui::{self, with_spinner}; +use crate::utils::{app_project_url, app_project_url_with_encoded_path}; + +const VIEWS_JS_RUNNER_FILE: &str = "views-runner.ts"; +const VIEWS_JS_SDK_FILE: &str = "views-sdk.ts"; +const VIEWS_JS_RUNNER_SOURCE: &str = include_str!("../scripts/views-runner.ts"); +const VIEWS_JS_SDK_SOURCE: &str = include_str!("../scripts/views-sdk.ts"); +const DEFAULT_TRACE_PREVIEW_LIMIT: usize = 1000; + +#[derive(Debug, Clone, Args)] +#[command(after_help = "\ +Examples: + bt views push ./views + bt views push ./conversation.view.tsx --if-exists replace + bt views trace bootstrap + bt views dataset bootstrap --dataset test-dataset + bt views trace preview ./conversation.view.tsx --url + bt views trace preview ./conversation.view.tsx --trace-id + bt views dataset preview ./dataset.view.tsx --dataset test-dataset --row-index 0 +")] +pub struct ViewsArgs { + #[command(subcommand)] + command: ViewsCommands, +} + +#[derive(Debug, Clone, Subcommand)] +enum ViewsCommands { + /// Push local custom view definitions + Push(ViewsPushArgs), + /// Work with trace custom views + #[command(visible_alias = "traces")] + Trace(TraceViewsArgs), + /// Work with dataset custom views + #[command(visible_alias = "datasets")] + Dataset(DatasetViewsArgs), + /// Preview one custom view locally + #[command(hide = true)] + Preview(ViewsPreviewArgs), +} + +#[derive(Debug, Clone, Args)] +struct ViewsPushArgs { + /// File or directory path(s) to scan for custom view definitions. + #[arg(value_name = "PATH")] + paths: Vec, + + /// File or directory path(s) to scan for custom view definitions. + #[arg( + long = "file", + env = "BT_VIEWS_PUSH_FILES", + value_name = "PATH", + value_delimiter = ',' + )] + file_flag: Vec, + + /// Behavior when a custom view with the same slug already exists. + #[arg( + long = "if-exists", + env = "BT_VIEWS_PUSH_IF_EXISTS", + value_enum, + default_value = "error" + )] + if_exists: IfExistsMode, + + /// Override JS runner binary (e.g. tsx, vite-node, deno). + #[arg(long, env = "BT_VIEWS_PUSH_RUNNER", value_name = "RUNNER")] + runner: Option, + + /// Optional tsconfig path for JS runner and browser bundling. + #[arg(long, env = "BT_VIEWS_PUSH_TSCONFIG", value_name = "PATH")] + tsconfig: Option, + + /// Skip confirmation prompt. + #[arg( + long, + short = 'y', + env = "BT_VIEWS_PUSH_YES", + value_parser = BoolishValueParser::new(), + default_value_t = false + )] + yes: bool, +} + +impl ViewsPushArgs { + fn resolved_paths(&self) -> Vec { + let mut paths = self.paths.clone(); + paths.extend(self.file_flag.iter().cloned()); + if paths.is_empty() { + vec![PathBuf::from(".")] + } else { + paths + } + } +} + +#[derive(Debug, Clone, Args)] +struct TraceViewsArgs { + #[command(subcommand)] + command: TraceViewsCommands, +} + +#[derive(Debug, Clone, Subcommand)] +enum TraceViewsCommands { + /// Create a starter trace custom view file + Bootstrap(TraceViewBootstrapArgs), + /// Preview one trace custom view locally + Preview(TraceViewPreviewArgs), +} + +#[derive(Debug, Clone, Args)] +struct DatasetViewsArgs { + #[command(subcommand)] + command: DatasetViewsCommands, +} + +#[derive(Debug, Clone, Subcommand)] +enum DatasetViewsCommands { + /// Create a starter dataset custom view file + Bootstrap(DatasetViewBootstrapArgs), + /// Preview one dataset custom view locally + Preview(DatasetViewPreviewArgs), +} + +#[derive(Debug, Clone, Args)] +struct BootstrapCommonArgs { + /// Output file path. Defaults to views/.view.tsx. + #[arg(value_name = "PATH", conflicts_with = "file_flag")] + path: Option, + + /// Output file path. Defaults to views/.view.tsx. + #[arg(long = "file", env = "BT_VIEWS_BOOTSTRAP_FILE", value_name = "PATH")] + file_flag: Option, + + /// Overwrite an existing file. + #[arg( + long, + short = 'f', + env = "BT_VIEWS_BOOTSTRAP_FORCE", + value_parser = BoolishValueParser::new(), + default_value_t = false + )] + force: bool, +} + +#[derive(Debug, Clone, Args)] +struct TraceViewBootstrapArgs { + #[command(flatten)] + common: BootstrapCommonArgs, +} + +#[derive(Debug, Clone, Args)] +struct DatasetViewBootstrapArgs { + #[command(flatten)] + common: BootstrapCommonArgs, + + /// Dataset name to reference in the starter view. + #[arg( + long, + env = "BT_VIEWS_BOOTSTRAP_DATASET", + value_name = "NAME", + conflicts_with = "dataset_id" + )] + dataset: Option, + + /// Dataset id to reference in the starter view. + #[arg( + long = "dataset-id", + env = "BT_VIEWS_BOOTSTRAP_DATASET_ID", + value_name = "ID", + conflicts_with = "dataset" + )] + dataset_id: Option, +} + +#[derive(Debug, Serialize)] +struct BootstrapResult { + path: String, + view_type: ViewType, +} + +#[derive(Debug, Clone, Args)] +struct PreviewCommonArgs { + /// Custom view file to preview. + #[arg(value_name = "PATH")] + path: PathBuf, + + /// View slug or name when the file registers multiple views. + #[arg(long, env = "BT_VIEWS_PREVIEW_VIEW")] + view: Option, + + /// Local port to bind. Defaults to an ephemeral port. + #[arg(long, env = "BT_VIEWS_PREVIEW_PORT", default_value_t = 0)] + port: u16, + + /// Do not open a browser. + #[arg( + long, + env = "BT_VIEWS_PREVIEW_NO_OPEN", + value_parser = BoolishValueParser::new(), + default_value_t = false + )] + no_open: bool, + + /// Override JS runner binary (e.g. tsx, vite-node, deno). + #[arg(long, env = "BT_VIEWS_PREVIEW_RUNNER", value_name = "RUNNER")] + runner: Option, + + /// Optional tsconfig path for JS runner and browser bundling. + #[arg(long, env = "BT_VIEWS_PREVIEW_TSCONFIG", value_name = "PATH")] + tsconfig: Option, +} + +#[derive(Debug, Clone, Args)] +struct TraceViewPreviewArgs { + #[command(flatten)] + common: PreviewCommonArgs, + + #[command(flatten)] + target: TracePreviewTargetArgs, + + /// Print BTQL queries used for preview data. + #[arg( + long, + env = "BT_VIEWS_PREVIEW_PRINT_QUERIES", + value_parser = BoolishValueParser::new(), + default_value_t = false + )] + print_queries: bool, +} + +#[derive(Debug, Clone, Args)] +struct TracePreviewTargetArgs { + /// Braintrust app URL to resolve trace preview data from. + #[arg(long, env = "BT_VIEWS_PREVIEW_URL")] + url: Option, + + /// Object reference, format: project_logs:. + #[arg(long, env = "BT_VIEWS_PREVIEW_OBJECT_REF")] + object_ref: Option, + + /// Project ID to query for trace preview data. + #[arg(long, env = "BT_VIEWS_PREVIEW_PROJECT_ID")] + project_id: Option, + + /// Root span id for trace preview data. + #[arg( + long = "trace-id", + alias = "root-span-id", + env = "BT_VIEWS_PREVIEW_TRACE_ID" + )] + trace_id: Option, + + /// Selected span id or row id for trace preview data. + #[arg(long, env = "BT_VIEWS_PREVIEW_SPAN_ID")] + span_id: Option, +} + +#[derive(Debug, Clone, Args)] +struct DatasetViewPreviewArgs { + #[command(flatten)] + common: PreviewCommonArgs, + + #[command(flatten)] + target: DatasetPreviewTargetArgs, +} + +#[derive(Debug, Clone, Args)] +struct DatasetPreviewTargetArgs { + /// Dataset name or id for dataset preview data. + #[arg(long, env = "BT_VIEWS_PREVIEW_DATASET")] + dataset: Option, + + /// Dataset row id for dataset preview data. + #[arg(long, env = "BT_VIEWS_PREVIEW_ROW_ID")] + row_id: Option, + + /// Dataset row index for dataset preview data. + #[arg(long, env = "BT_VIEWS_PREVIEW_ROW_INDEX")] + row_index: Option, +} + +#[derive(Debug, Clone, Args)] +struct ViewsPreviewArgs { + #[command(flatten)] + common: PreviewCommonArgs, + + #[command(flatten)] + trace: TracePreviewTargetArgs, + + #[command(flatten)] + dataset: DatasetPreviewTargetArgs, + + /// Print BTQL queries used for preview data. + #[arg( + long, + env = "BT_VIEWS_PREVIEW_PRINT_QUERIES", + value_parser = BoolishValueParser::new(), + default_value_t = false + )] + print_queries: bool, +} + +#[derive(Debug, Deserialize)] +struct ViewsManifest { + runtime_context: ViewsRuntimeContext, + #[serde(default)] + files: Vec, +} + +#[derive(Debug, Deserialize)] +struct ViewsRuntimeContext { + runtime: String, + version: String, +} + +#[derive(Debug, Deserialize)] +struct ViewsManifestFile { + source_file: String, + #[serde(default)] + entries: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +struct ViewManifestEntry { + view_type: ViewType, + name: String, + slug: String, + code: String, + #[serde(default)] + description: Option, + #[serde(default)] + project_id: Option, + #[serde(default)] + project_name: Option, + #[serde(default)] + dataset_id: Option, + #[serde(default)] + dataset_name: Option, + #[serde(default)] + metadata: Option, + #[serde(default)] + tags: Option>, +} + +#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "snake_case")] +enum ViewType { + Trace, + Dataset, +} + +impl ViewType { + fn label(self) -> &'static str { + match self { + Self::Trace => "trace", + Self::Dataset => "dataset", + } + } +} + +#[derive(Debug, Clone)] +struct PreparedView { + source_file: String, + entry: ViewManifestEntry, + project: Project, + dataset: Option, +} + +#[derive(Debug, Serialize)] +struct PushedView { + source_file: String, + name: String, + slug: String, + view_type: ViewType, + project_id: String, + project_name: String, + dataset_id: Option, + function_id: Option, + url: Option, +} + +#[derive(Debug, Deserialize)] +struct BtqlResponse { + data: Vec>, +} + +#[derive(Clone)] +struct PreviewServerState { + client: ApiClient, + project_id: Option, + data: Value, +} + +struct PreviewContext { + client: ApiClient, + project: Project, + entry: ViewManifestEntry, +} + +struct TracePreviewData { + project_id: String, + data: Value, +} + +#[derive(Debug, Deserialize)] +struct SpanFieldsRequest { + #[serde(rename = "spanIds")] + span_ids: Vec, + fields: Option>, +} + +pub async fn run(base: BaseArgs, args: ViewsArgs) -> Result<()> { + match args.command { + ViewsCommands::Push(push_args) => push(base, push_args).await, + ViewsCommands::Trace(trace_args) => match trace_args.command { + TraceViewsCommands::Bootstrap(bootstrap_args) => bootstrap_trace(base, bootstrap_args), + TraceViewsCommands::Preview(preview_args) => preview_trace(base, preview_args).await, + }, + ViewsCommands::Dataset(dataset_args) => match dataset_args.command { + DatasetViewsCommands::Bootstrap(bootstrap_args) => { + bootstrap_dataset(base, bootstrap_args) + } + DatasetViewsCommands::Preview(preview_args) => { + preview_dataset(base, preview_args).await + } + }, + ViewsCommands::Preview(preview_args) => preview(base, preview_args).await, + } +} + +fn bootstrap_trace(base: BaseArgs, args: TraceViewBootstrapArgs) -> Result<()> { + let path = write_bootstrap_file(args.common, "trace.view.tsx", TRACE_VIEW_BOOTSTRAP_TEMPLATE)?; + print_bootstrap_result(base.json, ViewType::Trace, &path) +} + +fn bootstrap_dataset(base: BaseArgs, args: DatasetViewBootstrapArgs) -> Result<()> { + let dataset_ref = match ( + args.dataset_id.as_deref().map(str::trim), + args.dataset.as_deref().map(str::trim), + ) { + (Some(dataset_id), _) if !dataset_id.is_empty() => { + format!("{{ id: {dataset_id:?} }}") + } + (_, Some(dataset)) if !dataset.is_empty() => format!("{{ name: {dataset:?} }}"), + _ => "{ name: \"test-dataset\" }".to_string(), + }; + let content = dataset_view_bootstrap_template(&dataset_ref); + let path = write_bootstrap_file(args.common, "dataset.view.tsx", &content)?; + print_bootstrap_result(base.json, ViewType::Dataset, &path) +} + +fn write_bootstrap_file( + args: BootstrapCommonArgs, + default_file_name: &str, + content: &str, +) -> Result { + let selected_path = args + .path + .or(args.file_flag) + .unwrap_or_else(|| PathBuf::from("views")); + let path = if selected_path.is_dir() || selected_path.extension().is_none() { + selected_path.join(default_file_name) + } else { + selected_path + }; + let is_tsx_view_file = path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(".view.tsx")); + if !is_tsx_view_file { + bail!("custom view bootstrap path must end with .view.tsx"); + } + if path.exists() && !args.force { + bail!( + "custom view file already exists: {}. Use --force to overwrite.", + path.display() + ); + } + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create directory {}", parent.display()))?; + } + std::fs::write(&path, content) + .with_context(|| format!("failed to write custom view file {}", path.display()))?; + Ok(path) +} + +fn print_bootstrap_result(json_output: bool, view_type: ViewType, path: &Path) -> Result<()> { + let result = BootstrapResult { + path: path.display().to_string(), + view_type, + }; + if json_output { + println!("{}", serde_json::to_string_pretty(&result)?); + } else { + println!( + "Created {} custom view starter at {}", + view_type.label(), + path.display() + ); + println!( + "Preview it with: bt views {} preview {}", + view_type.label(), + path.display() + ); + } + Ok(()) +} + +const TRACE_VIEW_BOOTSTRAP_TEMPLATE: &str = r##"import React from "react"; +import { + customTraceView, + formatJson, + type TraceViewProps, +} from "braintrust/custom-views"; + +function pretty(value: unknown) { + return value === undefined ? "" : formatJson(value); +} + +function StarterTraceView({ trace, span, selectSpan }: TraceViewProps) { + const spanIds = trace.spanOrder.slice(0, 100); + + return ( +
+
+ Trace starter view + +
+ +
+
Selected span
+
row id: {span.id}
+
span id: {span.span_id}
+
children: {span.children.length}
+
+ +
+
+          {pretty(span.data.input)}
+        
+
+          {pretty(span.data.output)}
+        
+
+
+ ); +} + +customTraceView({ + name: "Starter Trace View", + slug: "starter-trace-view", + description: "Starter trace custom view generated by bt.", + component: StarterTraceView, +}); +"##; + +const DATASET_VIEW_BOOTSTRAP_TEMPLATE: &str = r##"import React from "react"; +import { + customDatasetView, + formatJson, + type DatasetViewProps, +} from "braintrust/custom-views"; + +function pretty(value: unknown) { + return value === undefined ? "" : formatJson(value); +} + +function StarterDatasetView({ id, input, expected, metadata, tags }: DatasetViewProps) { + return ( +
+
+ Dataset starter view +
row id: {id}
+ {tags.length > 0 ? ( +
tags: {tags.join(", ")}
+ ) : null} +
+ +
+
+          {pretty(input)}
+        
+
+          {pretty(expected)}
+        
+
+          {pretty(metadata)}
+        
+
+
+ ); +} + +customDatasetView({ + name: "Starter Dataset View", + slug: "starter-dataset-view", + description: "Starter dataset custom view generated by bt.", + dataset: __DATASET_REF__, + component: StarterDatasetView, +}); +"##; + +fn dataset_view_bootstrap_template(dataset_ref: &str) -> String { + DATASET_VIEW_BOOTSTRAP_TEMPLATE.replace("__DATASET_REF__", dataset_ref) +} + +async fn push(base: BaseArgs, args: ViewsPushArgs) -> Result<()> { + let auth_ctx = functions::resolve_auth_context(&base).await?; + let default_project = resolve_required_project(&base, &auth_ctx.client, true).await?; + let files = collect_view_files(&args.resolved_paths())?; + if files.is_empty() { + bail!("no custom view files found; expected files matching *.view.tsx, *.view.ts, *.view.jsx, or *.view.js"); + } + + let manifest = run_views_runner(args.runner.as_deref(), args.tsconfig.as_deref(), &files)?; + validate_manifest_runtime(&manifest)?; + let prepared = prepare_views(&auth_ctx.client, &default_project, &manifest).await?; + if prepared.is_empty() { + bail!("no custom views were registered by the selected files"); + } + + if !args.yes && ui::can_prompt() { + let prompt = format!("Push {} custom view(s)?", prepared.len()); + let confirmed = dialoguer::Confirm::new() + .with_prompt(prompt) + .default(true) + .interact()?; + if !confirmed { + bail!("custom view push cancelled"); + } + } + + let events = prepared + .iter() + .map(|view| build_insert_event(view, args.if_exists)) + .collect::>(); + + let ignored = with_spinner( + "Pushing custom views...", + functions_api::insert_functions(&auth_ctx.client, &events), + ) + .await? + .ignored_entries + .unwrap_or(0); + + let pushed = resolve_pushed_views(&auth_ctx.client, &auth_ctx.app_url, &prepared).await?; + if base.json { + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "pushed": pushed, + "ignored": ignored, + }))? + ); + return Ok(()); + } + + eprintln!( + "{} Pushed {} custom view(s)", + dialoguer::console::style("✓").green(), + prepared.len().saturating_sub(ignored) + ); + for view in pushed { + match view.url { + Some(url) => println!("{} ({}) {}", view.name, view.slug, url), + None => println!("{} ({})", view.name, view.slug), + } + } + + Ok(()) +} + +async fn preview(base: BaseArgs, args: ViewsPreviewArgs) -> Result<()> { + let context = resolve_preview_context(&base, &args.common, None).await?; + match context.entry.view_type { + ViewType::Trace => { + let trace_data = build_trace_preview_data( + &context.client, + &context.project, + &args.trace, + args.print_queries, + ) + .await?; + serve_preview( + base, + &args.common, + context.client, + Some(trace_data.project_id), + context.entry, + trace_data.data, + ) + .await + } + ViewType::Dataset => { + let preview_data = build_dataset_preview_data( + &context.client, + &context.project, + &context.entry, + &args.dataset, + ) + .await?; + serve_preview( + base, + &args.common, + context.client, + None, + context.entry, + preview_data, + ) + .await + } + } +} + +async fn preview_trace(base: BaseArgs, args: TraceViewPreviewArgs) -> Result<()> { + let context = resolve_preview_context(&base, &args.common, Some(ViewType::Trace)).await?; + let trace_data = build_trace_preview_data( + &context.client, + &context.project, + &args.target, + args.print_queries, + ) + .await?; + serve_preview( + base, + &args.common, + context.client, + Some(trace_data.project_id), + context.entry, + trace_data.data, + ) + .await +} + +async fn preview_dataset(base: BaseArgs, args: DatasetViewPreviewArgs) -> Result<()> { + let context = resolve_preview_context(&base, &args.common, Some(ViewType::Dataset)).await?; + let preview_data = build_dataset_preview_data( + &context.client, + &context.project, + &context.entry, + &args.target, + ) + .await?; + serve_preview( + base, + &args.common, + context.client, + None, + context.entry, + preview_data, + ) + .await +} + +async fn resolve_preview_context( + base: &BaseArgs, + args: &PreviewCommonArgs, + view_type: Option, +) -> Result { + let files = vec![args.path.clone()]; + let manifest = run_views_runner(args.runner.as_deref(), args.tsconfig.as_deref(), &files)?; + validate_manifest_runtime(&manifest)?; + let entry = select_preview_entry(&manifest, args.view.as_deref(), view_type)?.clone(); + + let auth_ctx = auth::login_read_only(&base).await?; + let client = ApiClient::new(&auth_ctx)?; + let default_project = resolve_required_project(&base, &client, true).await?; + let project = resolve_project_for_entry(&client, &default_project, &entry).await?; + Ok(PreviewContext { + client, + project, + entry, + }) +} + +async fn serve_preview( + base: BaseArgs, + args: &PreviewCommonArgs, + client: ApiClient, + trace_project_id: Option, + entry: ViewManifestEntry, + preview_data: Value, +) -> Result<()> { + let listener = TcpListener::bind(("127.0.0.1", args.port)) + .with_context(|| format!("failed to bind preview server on port {}", args.port))?; + let addr = listener + .local_addr() + .context("failed to read preview address")?; + let url = format!("http://{addr}"); + let state = web::Data::new(PreviewServerState { + client, + project_id: trace_project_id, + data: preview_data, + }); + let code = entry.code.clone(); + let name = entry.name.clone(); + let server = HttpServer::new(move || { + App::new() + .app_data(state.clone()) + .app_data(web::Data::new(PreviewPage { + title: name.clone(), + code: code.clone(), + })) + .route("/", web::get().to(preview_index)) + .route("/span-fields", web::post().to(preview_span_fields)) + }) + .workers(1) + .listen(listener)? + .run(); + let handle = server.handle(); + tokio::spawn(server); + + if base.json { + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "url": url, + "view": { + "name": entry.name, + "slug": entry.slug, + "type": entry.view_type, + }, + }))? + ); + } else { + println!("Previewing {} at {}", entry.name, url); + } + + if !args.no_open { + open::that(&url)?; + } + + tokio::signal::ctrl_c() + .await + .context("failed to wait for Ctrl+C")?; + handle.stop(true).await; + Ok(()) +} + +#[derive(Clone)] +struct PreviewPage { + title: String, + code: String, +} + +async fn preview_index( + state: web::Data, + page: web::Data, +) -> HttpResponse { + let html = render_preview_html(&page.title, &page.code, &state.data); + HttpResponse::Ok() + .content_type("text/html; charset=utf-8") + .body(html) +} + +async fn preview_span_fields( + state: web::Data, + body: web::Json, +) -> HttpResponse { + let Some(project_id) = state.project_id.as_deref() else { + return HttpResponse::BadRequest().json(json!({ + "error": "fetchSpanFields is only available for trace previews" + })); + }; + + let mut response = Map::new(); + let spans = state + .data + .get("trace") + .and_then(|trace| trace.get("spans")) + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + + for span_id in &body.span_ids { + let row_id = resolve_preview_row_id(&spans, span_id).unwrap_or_else(|| span_id.clone()); + match fetch_full_span_row(&state.client, project_id, &row_id, false).await { + Ok(Some(row)) => { + response.insert( + span_id.clone(), + fields_from_row(row, body.fields.as_deref()), + ); + } + Ok(None) => { + response.insert(span_id.clone(), json!({})); + } + Err(err) => { + return HttpResponse::InternalServerError().json(json!({ + "error": format!("{err:#}") + })); + } + } + } + + HttpResponse::Ok().json(Value::Object(response)) +} + +fn render_preview_html(title: &str, code: &str, data: &Value) -> String { + let data_json = script_json(data); + let code_json = script_json(&Value::String(code.to_string())); + let title_json = script_json(&Value::String(title.to_string())); + format!( + r##" + + + + + {} + + + + + + +
+ + +"##, + html_escape(title), + ) +} + +fn script_json(value: &Value) -> String { + serde_json::to_string(value) + .unwrap_or_else(|_| "null".to_string()) + .replace(" String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +fn collect_view_files(paths: &[PathBuf]) -> Result> { + let mut files = Vec::new(); + for path in paths { + let path = path.as_path(); + if !path.exists() { + bail!("custom view path not found: {}", path.display()); + } + if path.is_file() { + if is_view_file(path) { + files.push(path.to_path_buf()); + } + continue; + } + collect_view_files_in_dir(path, &mut files)?; + } + files.sort(); + files.dedup(); + Ok(files) +} + +fn collect_view_files_in_dir(dir: &Path, files: &mut Vec) -> Result<()> { + for entry in std::fs::read_dir(dir) + .with_context(|| format!("failed to read directory {}", dir.display()))? + { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + if should_skip_dir(&path) { + continue; + } + collect_view_files_in_dir(&path, files)?; + } else if path.is_file() && is_view_file(&path) { + files.push(path); + } + } + Ok(()) +} + +fn should_skip_dir(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| { + matches!( + name, + ".git" | ".bt" | "node_modules" | "target" | "dist" | "build" | ".venv" | "venv" + ) + }) +} + +fn is_view_file(path: &Path) -> bool { + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + return false; + }; + [".view.tsx", ".view.ts", ".view.jsx", ".view.js"] + .iter() + .any(|suffix| name.ends_with(suffix)) +} + +fn run_views_runner( + runner: Option<&str>, + tsconfig: Option<&Path>, + files: &[PathBuf], +) -> Result { + let sdk_script = js_runner::materialize_runner_script_in_cwd( + "views-runners", + VIEWS_JS_SDK_FILE, + VIEWS_JS_SDK_SOURCE, + ) + .context("failed to materialize custom views SDK helper")?; + let runner_script = js_runner::materialize_runner_script_in_cwd( + "views-runners", + VIEWS_JS_RUNNER_FILE, + VIEWS_JS_RUNNER_SOURCE, + ) + .context("failed to materialize custom views runner")?; + let mut command = js_runner::build_js_runner_command(runner, &runner_script, files); + command.env("BT_VIEWS_SDK_PATH", &sdk_script); + if let Some(tsconfig) = tsconfig { + command.env("TS_NODE_PROJECT", tsconfig); + command.env("TSX_TSCONFIG_PATH", tsconfig); + } + + let output = command.output().with_context(|| { + format!( + "failed to spawn custom views runner: {}", + command_display(&command) + ) + })?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let details = stderr.trim(); + let details = if details.is_empty() { + stdout.trim() + } else { + details + }; + bail!( + "custom views runner exited with status {}: {}", + output.status, + details + ); + } + + let stdout = + String::from_utf8(output.stdout).context("custom views runner output was not UTF-8")?; + serde_json::from_str(&stdout).with_context(|| { + format!( + "failed to parse custom views runner output as JSON: {}", + stdout.trim() + ) + }) +} + +fn command_display(command: &Command) -> String { + let mut rendered = command.get_program().to_string_lossy().to_string(); + for arg in command.get_args() { + rendered.push(' '); + rendered.push_str(&arg.to_string_lossy()); + } + rendered +} + +fn validate_manifest_runtime(manifest: &ViewsManifest) -> Result<()> { + if manifest.runtime_context.runtime != "browser" { + bail!( + "custom views runner returned unsupported runtime '{}'", + manifest.runtime_context.runtime + ); + } + if manifest.runtime_context.version.trim().is_empty() { + bail!("custom views runner returned an empty runtime version"); + } + Ok(()) +} + +async fn prepare_views( + client: &ApiClient, + default_project: &Project, + manifest: &ViewsManifest, +) -> Result> { + let mut prepared = Vec::new(); + let mut seen = BTreeSet::new(); + for file in &manifest.files { + for entry in &file.entries { + let key = ( + file.source_file.clone(), + entry.view_type, + entry.slug.clone(), + ); + if !seen.insert(key) { + bail!( + "duplicate custom view slug '{}' in {}", + entry.slug, + file.source_file + ); + } + let project = resolve_project_for_entry(client, default_project, entry).await?; + let dataset = if entry.view_type == ViewType::Dataset { + Some(resolve_dataset_for_entry(client, &project, entry).await?) + } else { + None + }; + prepared.push(PreparedView { + source_file: file.source_file.clone(), + entry: entry.clone(), + project, + dataset, + }); + } + } + Ok(prepared) +} + +async fn resolve_project_for_entry( + client: &ApiClient, + default_project: &Project, + entry: &ViewManifestEntry, +) -> Result { + if let Some(project_id) = entry + .project_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if project_id == default_project.id { + return Ok(default_project.clone()); + } + let projects = list_projects(client).await?; + return projects + .into_iter() + .find(|project| project.id == project_id) + .ok_or_else(|| anyhow!("project id '{project_id}' not found")); + } + + if let Some(project_name) = entry + .project_name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if project_name == default_project.name { + return Ok(default_project.clone()); + } + return get_project_by_name(client, project_name) + .await? + .ok_or_else(|| anyhow!("project '{project_name}' not found")); + } + + Ok(default_project.clone()) +} + +async fn resolve_dataset_for_entry( + client: &ApiClient, + project: &Project, + entry: &ViewManifestEntry, +) -> Result { + if let Some(dataset_id) = entry + .dataset_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + let datasets = datasets_api::list_datasets(client, &project.id).await?; + return datasets + .into_iter() + .find(|dataset| dataset.id == dataset_id) + .ok_or_else(|| { + anyhow!( + "dataset id '{dataset_id}' not found in project '{}'", + project.name + ) + }); + } + + let dataset_name = entry + .dataset_name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + anyhow!( + "dataset custom view '{}' requires dataset id or name", + entry.slug + ) + })?; + + datasets_api::get_dataset_by_name(client, &project.id, dataset_name) + .await? + .ok_or_else(|| { + anyhow!( + "dataset '{dataset_name}' not found in project '{}'", + project.name + ) + }) +} + +fn build_insert_event(view: &PreparedView, if_exists: IfExistsMode) -> Value { + let mut object = Map::new(); + object.insert( + "project_id".to_string(), + Value::String(view.project.id.clone()), + ); + object.insert("name".to_string(), Value::String(view.entry.name.clone())); + object.insert("slug".to_string(), Value::String(view.entry.slug.clone())); + object.insert( + "description".to_string(), + Value::String(view.entry.description.clone().unwrap_or_default()), + ); + object.insert( + "function_type".to_string(), + Value::String("custom_view".to_string()), + ); + object.insert( + "if_exists".to_string(), + Value::String(if_exists.as_str().to_string()), + ); + object.insert( + "function_data".to_string(), + json!({ + "type": "code", + "data": { + "type": "inline", + "runtime_context": { + "runtime": "browser", + "version": "latest", + }, + "code": view.entry.code, + } + }), + ); + + if let Some(metadata) = &view.entry.metadata { + object.insert("metadata".to_string(), metadata.clone()); + } + if let Some(tags) = &view.entry.tags { + object.insert( + "tags".to_string(), + Value::Array(tags.iter().cloned().map(Value::String).collect()), + ); + } + if let Some(dataset) = &view.dataset { + object.insert( + "origin".to_string(), + json!({ + "object_type": "dataset", + "object_id": dataset.id, + }), + ); + } + + Value::Object(object) +} + +async fn resolve_pushed_views( + client: &ApiClient, + app_url: &str, + views: &[PreparedView], +) -> Result> { + let mut pushed = Vec::new(); + for view in views { + let function = + functions_api::get_function_by_slug(client, &view.project.id, &view.entry.slug, None) + .await + .ok() + .flatten(); + let function_id = function.as_ref().map(|function| function.id.clone()); + let url = function_id + .as_deref() + .map(|id| custom_view_url(app_url, client.org_name(), view, id)); + pushed.push(PushedView { + source_file: view.source_file.clone(), + name: view.entry.name.clone(), + slug: view.entry.slug.clone(), + view_type: view.entry.view_type, + project_id: view.project.id.clone(), + project_name: view.project.name.clone(), + dataset_id: view.dataset.as_ref().map(|dataset| dataset.id.clone()), + function_id, + url, + }); + } + Ok(pushed) +} + +fn custom_view_url( + app_url: &str, + org_name: &str, + view: &PreparedView, + function_id: &str, +) -> String { + match (&view.entry.view_type, view.dataset.as_ref()) { + (ViewType::Dataset, Some(dataset)) => { + let mut url = app_project_url( + app_url, + org_name, + &view.project.name, + &["datasets", &dataset.name], + ); + write!(url, "?dvt=custom&dv={}", encode(function_id)).ok(); + url + } + _ => app_project_url_with_encoded_path( + app_url, + org_name, + &view.project.name, + &format!("logs?tvt=custom&tv={}", encode(function_id)), + ), + } +} + +fn select_preview_entry<'a>( + manifest: &'a ViewsManifest, + selector: Option<&str>, + view_type: Option, +) -> Result<&'a ViewManifestEntry> { + let entries = manifest + .files + .iter() + .flat_map(|file| file.entries.iter()) + .filter(|entry| view_type.is_none_or(|view_type| entry.view_type == view_type)) + .collect::>(); + if entries.is_empty() { + match view_type { + Some(view_type) => bail!( + "selected file did not register any {} custom views", + view_type.label() + ), + None => bail!("selected file did not register any custom views"), + } + } + if let Some(selector) = selector { + let matches = entries + .into_iter() + .filter(|entry| entry.slug == selector || entry.name == selector) + .collect::>(); + return match matches.as_slice() { + [entry] => Ok(*entry), + [] => match view_type { + Some(view_type) => bail!( + "{} custom view '{selector}' not found in selected file", + view_type.label() + ), + None => bail!("custom view '{selector}' not found in selected file"), + }, + _ => match view_type { + Some(view_type) => bail!( + "{} custom view selector '{selector}' matched multiple views", + view_type.label() + ), + None => bail!("custom view selector '{selector}' matched multiple views"), + }, + }; + } + if entries.len() == 1 { + return Ok(entries[0]); + } + match view_type { + Some(view_type) => bail!( + "selected file registers multiple {} custom views; pass --view ", + view_type.label() + ), + None => bail!("selected file registers multiple custom views; pass --view "), + } +} + +async fn build_dataset_preview_data( + client: &ApiClient, + project: &Project, + entry: &ViewManifestEntry, + args: &DatasetPreviewTargetArgs, +) -> Result { + let dataset = match args.dataset.as_deref() { + Some(selector) => resolve_dataset_by_selector(client, project, selector).await?, + None => resolve_dataset_for_entry(client, project, entry).await?, + }; + + let row = if let Some(row_id) = args.row_id.as_deref() { + datasets_api::get_dataset_row_by_id(client, &dataset.id, row_id) + .await? + .ok_or_else(|| anyhow!("dataset row id '{row_id}' not found in '{}'", dataset.name))? + } else { + let index = args.row_index.unwrap_or(0); + let limit = index + 1; + let (rows, _) = datasets_api::list_dataset_rows_limited( + client, + &dataset.id, + Some(limit), + datasets_api::DatasetRowsPreviewLength::Full, + ) + .await?; + rows.into_iter().nth(index).ok_or_else(|| { + anyhow!( + "dataset '{}' does not have row index {}", + dataset.name, + index + ) + })? + }; + + Ok(json!({ + "props": { + "id": row.get("id").cloned().unwrap_or(Value::Null), + "input": row.get("input").cloned().unwrap_or(Value::Null), + "expected": row.get("expected").cloned().unwrap_or(Value::Null), + "metadata": row.get("metadata").cloned().unwrap_or_else(|| json!({})), + "tags": row.get("tags").cloned().unwrap_or_else(|| json!([])), + }, + "dataset": dataset, + })) +} + +async fn resolve_dataset_by_selector( + client: &ApiClient, + project: &Project, + selector: &str, +) -> Result { + let datasets = datasets_api::list_datasets(client, &project.id).await?; + datasets + .into_iter() + .find(|dataset| dataset.id == selector || dataset.name == selector) + .ok_or_else(|| { + anyhow!( + "dataset '{selector}' not found in project '{}'", + project.name + ) + }) +} + +async fn build_trace_preview_data( + client: &ApiClient, + project: &Project, + args: &TracePreviewTargetArgs, + print_queries: bool, +) -> Result { + let target = resolve_trace_preview_target(client, project, args).await?; + let rows = fetch_trace_rows( + client, + &target.project_id, + &target.root_span_id, + DEFAULT_TRACE_PREVIEW_LIMIT, + print_queries, + ) + .await?; + if rows.is_empty() { + bail!("trace '{}' returned no spans", target.root_span_id); + } + let (trace, selected_span) = + build_trace_payload(rows, &target.root_span_id, target.span_id.as_deref())?; + Ok(TracePreviewData { + project_id: target.project_id, + data: json!({ + "trace": trace, + "span": selected_span, + }), + }) +} + +#[derive(Debug)] +struct TracePreviewTarget { + project_id: String, + root_span_id: String, + span_id: Option, +} + +async fn resolve_trace_preview_target( + client: &ApiClient, + default_project: &Project, + args: &TracePreviewTargetArgs, +) -> Result { + if let Some(url) = args.url.as_deref() { + let parsed = parse_trace_url(url)?; + let project_id = match parsed.project.as_deref() { + Some(project) if project == default_project.id || project == default_project.name => { + default_project.id.clone() + } + Some(project) if is_uuid_like(project) => project.to_string(), + Some(project) => { + get_project_by_name(client, project) + .await? + .ok_or_else(|| anyhow!("project '{project}' from trace URL not found"))? + .id + } + None => default_project.id.clone(), + }; + let root_span_id = parsed + .row_ref + .or(parsed.span_id.clone()) + .ok_or_else(|| anyhow!("trace URL must include query parameter r or s"))?; + return Ok(TracePreviewTarget { + project_id, + root_span_id, + span_id: args.span_id.clone().or(parsed.span_id), + }); + } + + let project_id = if let Some(object_ref) = args.object_ref.as_deref() { + let (object_type, object_name) = object_ref.split_once(':').ok_or_else(|| { + anyhow!("invalid --object-ref '{object_ref}', expected project_logs:") + })?; + if object_type != "project_logs" { + bail!("bt views preview currently supports project_logs object refs"); + } + object_name.to_string() + } else { + args.project_id + .clone() + .unwrap_or_else(|| default_project.id.clone()) + }; + let root_span_id = args + .trace_id + .clone() + .ok_or_else(|| anyhow!("trace preview requires --trace-id or --url"))?; + Ok(TracePreviewTarget { + project_id, + root_span_id, + span_id: args.span_id.clone(), + }) +} + +#[derive(Debug)] +struct ParsedPreviewTraceUrl { + project: Option, + row_ref: Option, + span_id: Option, +} + +fn parse_trace_url(input: &str) -> Result { + let parsed_url = Url::parse(input) + .or_else(|_| Url::parse(&format!("https://{}", input.trim_start_matches('/')))) + .context("invalid trace URL")?; + let mut parsed = ParsedPreviewTraceUrl { + project: None, + row_ref: None, + span_id: None, + }; + if let Some(segments) = parsed_url.path_segments() { + let parts = segments.filter(|part| !part.is_empty()).collect::>(); + if parts.len() >= 4 && parts[0] == "app" && parts[2] == "p" { + parsed.project = Some(parts[3].to_string()); + } + } + for (key, value) in parsed_url.query_pairs() { + match key.as_ref() { + "r" if !value.is_empty() => parsed.row_ref = Some(value.to_string()), + "s" if !value.is_empty() => parsed.span_id = Some(value.to_string()), + _ => {} + } + } + Ok(parsed) +} + +fn is_uuid_like(value: &str) -> bool { + let bytes = value.as_bytes(); + if bytes.len() != 36 { + return false; + } + for (idx, b) in bytes.iter().enumerate() { + let is_hyphen = matches!(idx, 8 | 13 | 18 | 23); + if is_hyphen { + if *b != b'-' { + return false; + } + } else if !(*b as char).is_ascii_hexdigit() { + return false; + } + } + true +} + +async fn fetch_trace_rows( + client: &ApiClient, + project_id: &str, + root_span_id: &str, + limit: usize, + print_queries: bool, +) -> Result>> { + let query = format!( + "select: * | from: project_logs({}) spans | filter: root_span_id = {} | preview_length: 125 | sort: _pagination_key ASC | limit: {}", + sql_quote(project_id), + sql_quote(root_span_id), + limit, + ); + maybe_print_query(print_queries, "custom-view-preview-trace", &query); + execute_query(client, &query) + .await + .with_context(|| format!("BTQL query failed: {query}")) + .map(|response| response.data) +} + +async fn fetch_full_span_row( + client: &ApiClient, + project_id: &str, + row_id: &str, + print_queries: bool, +) -> Result>> { + let query = format!( + "select: * | from: project_logs({}) spans | filter: id = {} | preview_length: -1 | limit: 1", + sql_quote(project_id), + sql_quote(row_id), + ); + maybe_print_query(print_queries, "custom-view-preview-span-fields", &query); + execute_query(client, &query) + .await + .with_context(|| format!("BTQL query failed: {query}")) + .map(|response| response.data.into_iter().next()) +} + +async fn execute_query(client: &ApiClient, query: &str) -> Result { + let body = json!({ + "query": query, + "fmt": "json", + "query_source": "bt_views_preview_7e8680d0f2484e2fbef8a61f2de0b9df", + }); + let org_name = client.org_name(); + let headers = if org_name.is_empty() { + Vec::new() + } else { + vec![("x-bt-org-name", org_name)] + }; + client.post_with_headers("/btql", &body, &headers).await +} + +fn maybe_print_query(enabled: bool, label: &str, query: &str) { + if enabled { + eprintln!("bt views [{label}] BTQL:\n{query}\n"); + } +} + +fn sql_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "''")) +} + +fn build_trace_payload( + rows: Vec>, + root_span_id: &str, + selected_selector: Option<&str>, +) -> Result<(Value, Value)> { + let mut spans = Map::new(); + let mut span_order = Vec::new(); + let mut parent_children: BTreeMap> = BTreeMap::new(); + let mut row_id_to_span_id = BTreeMap::new(); + + for row in &rows { + let Some(span_id) = row.get("span_id").and_then(Value::as_str) else { + continue; + }; + span_order.push(Value::String(span_id.to_string())); + if let Some(row_id) = row.get("id").and_then(Value::as_str) { + row_id_to_span_id.insert(row_id.to_string(), span_id.to_string()); + } + if let Some(parent) = parent_span_id(row) { + parent_children + .entry(parent) + .or_default() + .push(span_id.to_string()); + } + } + + for row in rows { + let Some(span_id) = row.get("span_id").and_then(Value::as_str) else { + continue; + }; + let span_id = span_id.to_string(); + let children = parent_children + .get(&span_id) + .cloned() + .unwrap_or_default() + .into_iter() + .map(Value::String) + .collect::>(); + spans.insert(span_id.clone(), row_to_custom_span(row, children)); + } + + let selected_span_id = selected_selector + .and_then(|selector| { + spans + .contains_key(selector) + .then(|| selector.to_string()) + .or_else(|| row_id_to_span_id.get(selector).cloned()) + }) + .or_else(|| { + spans + .contains_key(root_span_id) + .then(|| root_span_id.to_string()) + }) + .or_else(|| { + span_order + .first() + .and_then(Value::as_str) + .map(ToOwned::to_owned) + }) + .ok_or_else(|| anyhow!("trace has no selectable spans"))?; + let selected_span = spans + .get(&selected_span_id) + .cloned() + .ok_or_else(|| anyhow!("selected span '{selected_span_id}' not found"))?; + + Ok(( + json!({ + "rootSpanId": root_span_id, + "selectedSpanId": selected_span_id, + "spanOrder": span_order, + "spans": spans, + }), + selected_span, + )) +} + +fn row_to_custom_span(row: Map, children: Vec) -> Value { + let data_fields = [ + "input", + "output", + "expected", + "metadata", + "scores", + "metrics", + "error", + "tags", + "span_attributes", + ]; + let mut data = Map::new(); + for field in data_fields { + if let Some(value) = row.get(field) { + data.insert(field.to_string(), value.clone()); + } + } + + let mut span = Map::new(); + for field in ["id", "span_id", "root_span_id", "parent_span_id"] { + if let Some(value) = row.get(field) { + span.insert(field.to_string(), value.clone()); + } + } + if let Some(value) = row.get("span_parents") { + span.insert("span_parents".to_string(), value.clone()); + } + span.insert("data".to_string(), Value::Object(data)); + span.insert("children".to_string(), Value::Array(children)); + Value::Object(span) +} + +fn parent_span_id(row: &Map) -> Option { + row.get("parent_span_id") + .and_then(Value::as_str) + .map(ToOwned::to_owned) + .or_else(|| { + row.get("span_parents") + .and_then(Value::as_array) + .and_then(|parents| parents.last()) + .and_then(Value::as_str) + .map(ToOwned::to_owned) + }) +} + +fn resolve_preview_row_id(spans: &Map, requested: &str) -> Option { + spans + .get(requested) + .and_then(|span| span.get("id")) + .and_then(Value::as_str) + .map(ToOwned::to_owned) + .or_else(|| { + spans.values().find_map(|span| { + let row_id = span.get("id").and_then(Value::as_str)?; + (row_id == requested).then(|| row_id.to_string()) + }) + }) +} + +fn fields_from_row(row: Map, fields: Option<&[String]>) -> Value { + let requested = fields + .map(|fields| fields.iter().map(String::as_str).collect::>()) + .unwrap_or_else(|| vec!["input", "output", "expected", "metadata"]); + let mut object = Map::new(); + for field in requested { + if matches!(field, "input" | "output" | "expected" | "metadata") { + if let Some(value) = row.get(field) { + object.insert(field.to_string(), value.clone()); + } + } + } + Value::Object(object) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_view_files() { + assert!(is_view_file(Path::new("conversation.view.tsx"))); + assert!(is_view_file(Path::new("dataset.view.js"))); + assert!(!is_view_file(Path::new("regular.tsx"))); + } + + #[test] + fn builds_inline_custom_view_insert_event() { + let view = PreparedView { + source_file: "test.view.tsx".to_string(), + entry: ViewManifestEntry { + view_type: ViewType::Trace, + name: "Test View".to_string(), + slug: "test-view".to_string(), + code: "module.exports = function View() {}".to_string(), + description: Some("desc".to_string()), + project_id: None, + project_name: None, + dataset_id: None, + dataset_name: None, + metadata: Some(json!({ "kind": "test" })), + tags: Some(vec!["review".to_string()]), + }, + project: Project { + id: "proj_test".to_string(), + name: "test-project".to_string(), + org_id: "org_test".to_string(), + description: None, + }, + dataset: None, + }; + + let event = build_insert_event(&view, IfExistsMode::Replace); + assert_eq!(event["function_type"], "custom_view"); + assert_eq!(event["if_exists"], "replace"); + assert_eq!(event["function_data"]["type"], "code"); + assert_eq!(event["function_data"]["data"]["type"], "inline"); + assert_eq!( + event["function_data"]["data"]["runtime_context"]["runtime"], + "browser" + ); + assert!(event.get("origin").is_none()); + } + + #[test] + fn dataset_insert_event_sets_origin() { + let view = PreparedView { + source_file: "dataset.view.tsx".to_string(), + entry: ViewManifestEntry { + view_type: ViewType::Dataset, + name: "Dataset View".to_string(), + slug: "dataset-view".to_string(), + code: "module.exports = function View() {}".to_string(), + description: None, + project_id: None, + project_name: None, + dataset_id: Some("dataset_test".to_string()), + dataset_name: None, + metadata: None, + tags: None, + }, + project: Project { + id: "proj_test".to_string(), + name: "test-project".to_string(), + org_id: "org_test".to_string(), + description: None, + }, + dataset: Some(datasets_api::Dataset { + id: "dataset_test".to_string(), + name: "test-dataset".to_string(), + project_id: Some("proj_test".to_string()), + description: None, + created: None, + created_at: None, + metadata: None, + }), + }; + + let event = build_insert_event(&view, IfExistsMode::Error); + assert_eq!(event["origin"]["object_type"], "dataset"); + assert_eq!(event["origin"]["object_id"], "dataset_test"); + } +} diff --git a/tests/cli.rs b/tests/cli.rs index 952a95d..6ead7d7 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -97,6 +97,113 @@ fn topics_report_help_accepts_global_org_short_conflict_free() { .stdout(predicate::str::contains("--output")); } +#[test] +fn views_help_accepts_push_trace_and_dataset_subcommands() { + bt_command() + .args(["views", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("push")) + .stdout(predicate::str::contains("trace")) + .stdout(predicate::str::contains("dataset")); +} + +#[test] +fn views_trace_help_lists_bootstrap_and_preview() { + bt_command() + .args(["views", "trace", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("bootstrap")) + .stdout(predicate::str::contains("preview")); +} + +#[test] +fn views_dataset_help_lists_bootstrap_and_preview() { + bt_command() + .args(["views", "dataset", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("bootstrap")) + .stdout(predicate::str::contains("preview")); +} + +#[test] +fn views_push_help_lists_custom_view_flags() { + bt_command() + .args(["views", "push", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--if-exists")) + .stdout(predicate::str::contains("--tsconfig")); +} + +#[test] +fn views_trace_preview_help_lists_trace_selectors() { + bt_command() + .args(["views", "trace", "preview", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--trace-id")) + .stdout(predicate::str::contains("--url")) + .stdout(predicate::str::contains("--dataset").not()) + .stdout(predicate::str::contains("--row-index").not()); +} + +#[test] +fn views_dataset_preview_help_lists_dataset_selectors() { + bt_command() + .args(["views", "dataset", "preview", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--dataset")) + .stdout(predicate::str::contains("--trace-id").not()) + .stdout(predicate::str::contains("--url").not()) + .stdout(predicate::str::contains("--row-index")); +} + +#[test] +fn views_trace_bootstrap_creates_starter_file() { + let dir = tempfile::tempdir().expect("tempdir"); + + bt_command() + .current_dir(dir.path()) + .args(["views", "trace", "bootstrap"]) + .assert() + .success() + .stdout(predicate::str::contains("views/trace.view.tsx")); + + let contents = + fs::read_to_string(dir.path().join("views/trace.view.tsx")).expect("read starter view"); + assert!(contents.contains("customTraceView")); + assert!(contents.contains("Starter Trace View")); +} + +#[test] +fn views_dataset_bootstrap_creates_starter_file_with_dataset_name() { + let dir = tempfile::tempdir().expect("tempdir"); + + bt_command() + .current_dir(dir.path()) + .args([ + "views", + "dataset", + "bootstrap", + "--dataset", + "test-dataset", + "--file", + "custom.dataset.view.tsx", + ]) + .assert() + .success() + .stdout(predicate::str::contains("custom.dataset.view.tsx")); + + let contents = + fs::read_to_string(dir.path().join("custom.dataset.view.tsx")).expect("read starter view"); + assert!(contents.contains("customDatasetView")); + assert!(contents.contains(r#"dataset: { name: "test-dataset" }"#)); +} + #[test] fn status_quiet_and_verbose_conflict() { bt_command() From b71453185589fd50e71fdb82732b7390f7c8b01c Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Thu, 2 Jul 2026 19:52:16 +0200 Subject: [PATCH 2/4] . --- scripts/views-runner.ts | 238 +++++---- scripts/views-sdk.ts | 257 +-------- src/views.rs | 1099 +++++++++++++++++++++++++++++++-------- tests/cli.rs | 205 +++++++- 4 files changed, 1232 insertions(+), 567 deletions(-) diff --git a/scripts/views-runner.ts b/scripts/views-runner.ts index 712f4bc..c9cf291 100644 --- a/scripts/views-runner.ts +++ b/scripts/views-runner.ts @@ -5,26 +5,22 @@ import { createRequire } from "node:module"; import { pathToFileURL } from "node:url"; type EsbuildModule = { - build: (options: Record) => Promise; + build: (options: Record) => Promise; }; -type JsonValue = - | string - | number - | boolean - | null - | JsonValue[] - | { [key: string]: JsonValue }; - -type RegisteredCustomView = { - kind?: string; - name?: string; - slug?: string; - description?: string; +type EsbuildBuildResult = { + metafile?: { + inputs?: Record; + }; +}; + +type CustomViewDefinition = { + kind: "trace" | "dataset"; + name: string; + slug: string; + component: unknown; project?: string | { id?: string; name?: string }; dataset?: { id?: string; name?: string }; - tags?: unknown; - metadata?: unknown; }; type ManifestEntry = { @@ -33,13 +29,10 @@ type ManifestEntry = { name: string; slug: string; code: string; - description?: string; project_id?: string; project_name?: string; dataset_id?: string; dataset_name?: string; - tags?: string[]; - metadata?: JsonValue; }; type Manifest = { @@ -49,6 +42,7 @@ type Manifest = { }; files: Array<{ source_file: string; + dependencies: string[]; entries: ManifestEntry[]; }>; }; @@ -276,29 +270,8 @@ function braintrustViewPlugin() { }; } -function validateJson(value: unknown, label: string): JsonValue | undefined { - if (value === undefined) { - return undefined; - } - try { - return JSON.parse(JSON.stringify(value)) as JsonValue; - } catch { - throw new Error(`${label} must be JSON-serializable`); - } -} - -function normalizeTags(value: unknown, slug: string): string[] | undefined { - if (value === undefined) { - return undefined; - } - if (!Array.isArray(value) || !value.every((tag) => typeof tag === "string")) { - throw new Error(`custom view '${slug}' tags must be an array of strings`); - } - return value; -} - function projectFields( - project: RegisteredCustomView["project"], + project: CustomViewDefinition["project"], ): Pick { if (project === undefined) { return {}; @@ -316,7 +289,7 @@ function projectFields( } function datasetFields( - view: RegisteredCustomView, + view: CustomViewDefinition, ): Pick { if (view.kind !== "dataset") { return {}; @@ -336,16 +309,48 @@ function datasetFields( ); } -function validateRegisteredView(view: RegisteredCustomView): void { - if (view.kind !== "trace" && view.kind !== "dataset") { - throw new Error("custom view kind must be 'trace' or 'dataset'"); +function validateCustomView( + value: unknown, + sourceFile: string, +): asserts value is CustomViewDefinition { + if (!isObject(value)) { + throw new Error( + `${sourceFile} must default-export customTraceView(...) or customDatasetView(...)`, + ); + } + if (value.kind !== "trace" && value.kind !== "dataset") { + throw new Error( + `${sourceFile} default export must be a trace or dataset custom view`, + ); + } + if (typeof value.name !== "string" || !value.name.trim()) { + throw new Error(`${sourceFile} custom view name is required`); } - if (typeof view.name !== "string" || !view.name.trim()) { - throw new Error("custom view name is required"); + if (typeof value.slug !== "string" || !value.slug.trim()) { + throw new Error(`${sourceFile} custom view '${value.name}' slug is required`); + } + if (typeof value.component !== "function") { + throw new Error( + `${sourceFile} custom view '${value.slug}' component must be a function`, + ); + } +} + +function defaultExportFromModule(loaded: unknown): unknown { + if ( + !isObject(loaded) || + !Object.prototype.hasOwnProperty.call(loaded, "default") + ) { + return undefined; } - if (typeof view.slug !== "string" || !view.slug.trim()) { - throw new Error(`custom view '${view.name}' slug is required`); + const value = loaded.default; + if ( + isObject(value) && + Object.prototype.hasOwnProperty.call(value, "default") + ) { + return value.default; } + return value; } async function bundleForDiscovery( @@ -353,8 +358,8 @@ async function bundleForDiscovery( sourceFile: string, outputFile: string, tsconfig: string | undefined, -): Promise { - await esbuild.build({ +): Promise { + return await esbuild.build({ entryPoints: [sourceFile], outfile: outputFile, bundle: true, @@ -363,46 +368,41 @@ async function bundleForDiscovery( format: "cjs", tsconfig, write: true, + metafile: true, plugins: [braintrustViewPlugin()], }); } -async function collectViews( +async function collectView( esbuild: EsbuildModule, sourceFile: string, tempDir: string, tsconfig: string | undefined, -): Promise { - (globalThis as { __braintrust_custom_views?: RegisteredCustomView[] }).__braintrust_custom_views = []; +): Promise<{ view: CustomViewDefinition; dependencies: string[] }> { installDiscoveryReactStub(); const outputFile = path.join( tempDir, `${path.basename(sourceFile).replace(/[^\w.-]/g, "_")}.collector.cjs`, ); - await bundleForDiscovery(esbuild, sourceFile, outputFile, tsconfig); - await import(`${pathToFileURL(outputFile).href}?bt_view_nonce=${Date.now()}`); - const views = - (globalThis as { __braintrust_custom_views?: RegisteredCustomView[] }) - .__braintrust_custom_views ?? []; - const seen = new Set(); - for (const view of views) { - validateRegisteredView(view); - const key = `${view.kind}:${view.slug}`; - if (seen.has(key)) { - throw new Error(`duplicate custom view slug '${view.slug}' in ${sourceFile}`); - } - seen.add(key); - } - return [...views]; + const buildResult = await bundleForDiscovery( + esbuild, + sourceFile, + outputFile, + tsconfig, + ); + const loaded = await import( + `${pathToFileURL(outputFile).href}?bt_view_nonce=${Date.now()}` + ); + const view = defaultExportFromModule(loaded); + validateCustomView(view, sourceFile); + return { view, dependencies: dependencyFiles(buildResult, tempDir) }; } -function virtualEntrySource(sourceFile: string, slug: string): string { +function virtualEntrySource(sourceFile: string): string { return ` -import ${JSON.stringify(sourceFile)}; -import { __getCustomViews } from "braintrust/custom-views"; -const view = [...__getCustomViews()].reverse().find((candidate) => candidate.slug === ${JSON.stringify(slug)}); -if (!view) { - throw new Error("Custom view not registered: ${slug.replaceAll('"', '\\"')}"); +import view from ${JSON.stringify(sourceFile)}; +if (!view || (view.kind !== "trace" && view.kind !== "dataset") || !view.component) { + throw new Error("Custom view file must default-export customTraceView(...) or customDatasetView(...)"); } export default view.component; `; @@ -411,10 +411,10 @@ export default view.component; async function bundleBrowserCode( esbuild: EsbuildModule, sourceFile: string, - view: RegisteredCustomView, + view: CustomViewDefinition, tempDir: string, tsconfig: string | undefined, -): Promise { +): Promise<{ code: string; dependencies: string[] }> { const entryFile = path.join( tempDir, `${view.slug?.replace(/[^\w.-]/g, "_") || "view"}.entry.ts`, @@ -423,9 +423,9 @@ async function bundleBrowserCode( tempDir, `${view.slug?.replace(/[^\w.-]/g, "_") || "view"}.browser.js`, ); - fs.writeFileSync(entryFile, virtualEntrySource(sourceFile, view.slug!), "utf8"); + fs.writeFileSync(entryFile, virtualEntrySource(sourceFile), "utf8"); - await esbuild.build({ + const buildResult = await esbuild.build({ entryPoints: [entryFile], outfile: outputFile, bundle: true, @@ -435,6 +435,7 @@ async function bundleBrowserCode( globalName: "__BraintrustCustomView", tsconfig, write: true, + metafile: true, treeShaking: true, jsxFactory: "React.createElement", jsxFragment: "React.Fragment", @@ -444,7 +445,39 @@ async function bundleBrowserCode( plugins: [braintrustViewPlugin()], }); - return fs.readFileSync(outputFile, "utf8"); + return { + code: fs.readFileSync(outputFile, "utf8"), + dependencies: dependencyFiles(buildResult, tempDir), + }; +} + +function dependencyFiles( + buildResult: EsbuildBuildResult, + tempDir: string, +): string[] { + const inputs = Object.keys(buildResult.metafile?.inputs ?? {}); + const dependencies = new Set(); + const tempRoot = fs.realpathSync.native(tempDir); + for (const input of inputs) { + if (input.includes(":") && !path.isAbsolute(input)) { + continue; + } + let file = path.isAbsolute(input) + ? path.normalize(input) + : path.resolve(process.cwd(), input); + if (fs.existsSync(file)) { + file = fs.realpathSync.native(file); + } + const relativeToTemp = path.relative(tempRoot, file); + if ( + relativeToTemp === "" || + (!relativeToTemp.startsWith("..") && !path.isAbsolute(relativeToTemp)) + ) { + continue; + } + dependencies.add(file); + } + return [...dependencies].sort(); } async function buildManifest(files: string[]): Promise { @@ -466,30 +499,29 @@ async function buildManifest(files: string[]): Promise { if (!fs.existsSync(sourceFile)) { throw new Error(`custom view file not found: ${sourceFile}`); } - const views = await collectViews(esbuild, sourceFile, tempDir, tsconfig); - const entries: ManifestEntry[] = []; - for (const view of views) { - const code = await bundleBrowserCode( - esbuild, - sourceFile, - view, - tempDir, - tsconfig, - ); - entries.push({ + const collected = await collectView(esbuild, sourceFile, tempDir, tsconfig); + const bundled = await bundleBrowserCode( + esbuild, + sourceFile, + collected.view, + tempDir, + tsconfig, + ); + const dependencies = [ + ...new Set([...collected.dependencies, ...bundled.dependencies]), + ].sort(); + const entries: ManifestEntry[] = [ + { kind: "view", - view_type: view.kind as "trace" | "dataset", - name: view.name!, - slug: view.slug!, - description: view.description, - code, - ...projectFields(view.project), - ...datasetFields(view), - tags: normalizeTags(view.tags, view.slug!), - metadata: validateJson(view.metadata, `custom view '${view.slug}' metadata`), - }); - } - manifest.files.push({ source_file: sourceFile, entries }); + view_type: collected.view.kind, + name: collected.view.name, + slug: collected.view.slug, + code: bundled.code, + ...projectFields(collected.view.project), + ...datasetFields(collected.view), + }, + ]; + manifest.files.push({ source_file: sourceFile, dependencies, entries }); } return manifest; diff --git a/scripts/views-sdk.ts b/scripts/views-sdk.ts index 173203c..5097c4d 100644 --- a/scripts/views-sdk.ts +++ b/scripts/views-sdk.ts @@ -1,249 +1,16 @@ -export type JsonValue = - | string - | number - | boolean - | null - | JsonValue[] - | { [key: string]: JsonValue }; +type CustomViewDefinition = Record; +type Component = (props: unknown) => unknown; -export type JsonObject = { [key: string]: JsonValue }; - -export type InlineAttachment = { - type: "inline_attachment"; - src: string; - content_type: string; - filename?: string; - data?: unknown; -}; - -export type SpanFieldName = "input" | "output" | "expected" | "metadata"; - -export type SpanFields = { - input?: unknown; - output?: unknown; - expected?: unknown; - metadata?: Record; -}; - -export 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[]; -}; - -export type TraceViewTrace = { - rootSpanId: string; - selectedSpanId: string; - spanOrder: string[]; - spans: Record; - fetchSpanFields: ( - spanIds: string | string[], - fields?: SpanFieldName[], - ) => Promise>; -}; - -export type TraceViewUpdate< - TMetadata extends Record = Record, -> = { - (field: string, value: unknown): void; - (patch: Partial<{ metadata: TMetadata; tags: string[] }>): void; -}; - -export type TraceViewProps< - TInput = unknown, - TOutput = unknown, - TExpected = unknown, - TMetadata extends Record = Record, -> = { - trace: TraceViewTrace; - span: TraceViewSpan; - update: TraceViewUpdate; - selectSpan: (spanId: string) => void; -}; - -export type DatasetViewProps< - TInput = unknown, - TExpected = unknown, - TMetadata extends Record = Record, -> = { - id: string; - input: TInput; - expected: TExpected; - metadata: TMetadata; - tags: string[]; -}; - -export type ReactComponent = (props: Props) => unknown; - -type ProjectRef = string | { id: string } | { name: string }; -type DatasetRef = { id: string } | { name: string }; - -export type CustomTraceViewDefinition< - TInput = unknown, - TOutput = unknown, - TExpected = unknown, - TMetadata extends Record = Record, -> = { - name: string; - slug: string; - component: ReactComponent< - TraceViewProps - >; - description?: string; - project?: ProjectRef; - tags?: string[]; - metadata?: JsonObject; -}; - -export type CustomDatasetViewDefinition< - TInput = unknown, - TExpected = unknown, - TMetadata extends Record = Record, -> = { - name: string; - slug: string; - dataset: DatasetRef; - component: ReactComponent>; - description?: string; - project?: ProjectRef; - tags?: string[]; - metadata?: JsonObject; -}; - -export type RegisteredCustomView = { - kind: "trace" | "dataset"; - name: string; - slug: string; - component: ReactComponent; - description?: string; - project?: ProjectRef; - dataset?: DatasetRef; - tags?: string[]; - metadata?: JsonObject; -}; - -declare global { - // eslint-disable-next-line no-var - var __braintrust_custom_views: RegisteredCustomView[] | undefined; -} - -function registry(): RegisteredCustomView[] { - const current = globalThis.__braintrust_custom_views; - if (Array.isArray(current)) { - return current; - } - globalThis.__braintrust_custom_views = []; - return globalThis.__braintrust_custom_views; -} - -function registerView(view: RegisteredCustomView): void { - const views = registry(); - const existing = views.findIndex( - (candidate) => - candidate.kind === view.kind && candidate.slug === view.slug, - ); - if (existing >= 0) { - views.splice(existing, 1); - } - views.push(view); -} - -export function __getCustomViews(): RegisteredCustomView[] { - return [...registry()]; -} - -export function __clearCustomViews(): void { - registry().splice(0); -} - -export function customTraceView< - TInput = unknown, - TOutput = unknown, - TExpected = unknown, - TMetadata extends Record = Record, ->( - definition: CustomTraceViewDefinition< - TInput, - TOutput, - TExpected, - TMetadata - >, -): ReactComponent> { - registerView({ - kind: "trace", - name: definition.name, - slug: definition.slug, - component: definition.component, - description: definition.description, - project: definition.project, - tags: definition.tags, - metadata: definition.metadata, - }); - return definition.component; -} - -export function customDatasetView< - TInput = unknown, - TExpected = unknown, - TMetadata extends Record = Record, ->( - definition: CustomDatasetViewDefinition, -): ReactComponent> { - registerView({ - kind: "dataset", - name: definition.name, - slug: definition.slug, - component: definition.component, - description: definition.description, - project: definition.project, - dataset: definition.dataset, - tags: definition.tags, - metadata: definition.metadata, - }); - return definition.component; -} - -export function isInlineAttachment(value: unknown): value is InlineAttachment { - return ( - typeof value === "object" && - value !== null && - (value as { type?: unknown }).type === "inline_attachment" && - typeof (value as { src?: unknown }).src === "string" && - typeof (value as { content_type?: unknown }).content_type === "string" - ); -} - -export function parseMaybeJson(value: unknown): unknown { - if (typeof value !== "string") { - return value; - } - try { - return JSON.parse(value); - } catch { - return value; - } +export function customTraceView( + definition: CustomViewDefinition, + component: Component, +) { + return { ...definition, component, kind: "trace" as const }; } -export function formatJson(value: unknown): string { - return JSON.stringify(value, null, 2); +export function customDatasetView( + definition: CustomViewDefinition, + component: Component, +) { + return { ...definition, component, kind: "dataset" as const }; } diff --git a/src/views.rs b/src/views.rs index 9390a2a..f67a261 100644 --- a/src/views.rs +++ b/src/views.rs @@ -3,6 +3,8 @@ use std::fmt::Write as _; use std::net::TcpListener; use std::path::{Path, PathBuf}; use std::process::Command; +use std::sync::Mutex; +use std::time::UNIX_EPOCH; use actix_web::{web, App, HttpResponse, HttpServer}; use anyhow::{anyhow, bail, Context, Result}; @@ -18,7 +20,7 @@ use crate::datasets::api as datasets_api; use crate::functions::{self, api as functions_api, IfExistsMode}; use crate::http::ApiClient; use crate::js_runner; -use crate::project_context::resolve_required_project; +use crate::project_context::{resolve_project_optional, resolve_required_project}; use crate::projects::api::{get_project_by_name, list_projects, Project}; use crate::ui::{self, with_spinner}; use crate::utils::{app_project_url, app_project_url_with_encoded_path}; @@ -28,17 +30,22 @@ const VIEWS_JS_SDK_FILE: &str = "views-sdk.ts"; const VIEWS_JS_RUNNER_SOURCE: &str = include_str!("../scripts/views-runner.ts"); const VIEWS_JS_SDK_SOURCE: &str = include_str!("../scripts/views-sdk.ts"); const DEFAULT_TRACE_PREVIEW_LIMIT: usize = 1000; +const DEFAULT_CUSTOM_VIEWS_DIR: &str = "braintrust-custom-views"; +const CUSTOM_VIEW_TSCONFIG_FILE: &str = "tsconfig.json"; +const CUSTOM_VIEW_TYPES_FILE: &str = "custom-view-env.d.ts"; +const VIEW_FILE_PATTERN_HELP: &str = + "*.view.tsx, *.view.ts, *.view.jsx, *.view.js, *-view.tsx, *-view.ts, *-view.jsx, or *-view.js"; #[derive(Debug, Clone, Args)] #[command(after_help = "\ Examples: bt views push ./views bt views push ./conversation.view.tsx --if-exists replace - bt views trace bootstrap - bt views dataset bootstrap --dataset test-dataset - bt views trace preview ./conversation.view.tsx --url - bt views trace preview ./conversation.view.tsx --trace-id - bt views dataset preview ./dataset.view.tsx --dataset test-dataset --row-index 0 + bt views trace bootstrap 'Trace Review' + bt views dataset bootstrap 'Dataset Review' --dataset test-dataset + bt views trace preview ./conversation.trace-view.tsx --url + bt views trace preview ./conversation.trace-view.tsx --trace-id + bt views dataset preview ./dataset.dataset-view.tsx --dataset test-dataset --row-index 0 ")] pub struct ViewsArgs { #[command(subcommand)] @@ -145,11 +152,11 @@ enum DatasetViewsCommands { #[derive(Debug, Clone, Args)] struct BootstrapCommonArgs { - /// Output file path. Defaults to views/.view.tsx. - #[arg(value_name = "PATH", conflicts_with = "file_flag")] - path: Option, + /// Custom view name. + #[arg(value_name = "NAME")] + name: String, - /// Output file path. Defaults to views/.view.tsx. + /// Output file or directory path. Defaults to braintrust-custom-views/.-view.tsx. #[arg(long = "file", env = "BT_VIEWS_BOOTSTRAP_FILE", value_name = "PATH")] file_flag: Option, @@ -197,6 +204,10 @@ struct DatasetViewBootstrapArgs { #[derive(Debug, Serialize)] struct BootstrapResult { path: String, + tsconfig_path: String, + tsconfig_created: bool, + types_path: String, + types_created: bool, view_type: ViewType, } @@ -206,7 +217,7 @@ struct PreviewCommonArgs { #[arg(value_name = "PATH")] path: PathBuf, - /// View slug or name when the file registers multiple views. + /// View slug or name to preview. #[arg(long, env = "BT_VIEWS_PREVIEW_VIEW")] view: Option, @@ -339,6 +350,8 @@ struct ViewsRuntimeContext { struct ViewsManifestFile { source_file: String, #[serde(default)] + dependencies: Vec, + #[serde(default)] entries: Vec, } @@ -349,8 +362,6 @@ struct ViewManifestEntry { slug: String, code: String, #[serde(default)] - description: Option, - #[serde(default)] project_id: Option, #[serde(default)] project_name: Option, @@ -358,10 +369,6 @@ struct ViewManifestEntry { dataset_id: Option, #[serde(default)] dataset_name: Option, - #[serde(default)] - metadata: Option, - #[serde(default)] - tags: Option>, } #[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] @@ -406,17 +413,20 @@ struct BtqlResponse { data: Vec>, } -#[derive(Clone)] struct PreviewServerState { client: ApiClient, project_id: Option, data: Value, + source: PreviewSource, + title: String, + dependency_paths: Mutex>, } struct PreviewContext { client: ApiClient, - project: Project, + project: Option, entry: ViewManifestEntry, + dependency_paths: Vec, } struct TracePreviewData { @@ -424,6 +434,15 @@ struct TracePreviewData { data: Value, } +#[derive(Clone)] +struct PreviewSource { + path: PathBuf, + view: Option, + view_type: Option, + runner: Option, + tsconfig: Option, +} + #[derive(Debug, Deserialize)] struct SpanFieldsRequest { #[serde(rename = "spanIds")] @@ -451,11 +470,15 @@ pub async fn run(base: BaseArgs, args: ViewsArgs) -> Result<()> { } fn bootstrap_trace(base: BaseArgs, args: TraceViewBootstrapArgs) -> Result<()> { - let path = write_bootstrap_file(args.common, "trace.view.tsx", TRACE_VIEW_BOOTSTRAP_TEMPLATE)?; - print_bootstrap_result(base.json, ViewType::Trace, &path) + let slug = bootstrap_slug(&args.common.name)?; + let default_file_name = format!("{slug}.trace-view.tsx"); + let content = trace_view_bootstrap_template(&args.common.name, &slug); + let result = write_bootstrap_scaffold(args.common, &default_file_name, &content)?; + print_bootstrap_result(base.json, ViewType::Trace, &result) } fn bootstrap_dataset(base: BaseArgs, args: DatasetViewBootstrapArgs) -> Result<()> { + let slug = bootstrap_slug(&args.common.name)?; let dataset_ref = match ( args.dataset_id.as_deref().map(str::trim), args.dataset.as_deref().map(str::trim), @@ -466,53 +489,183 @@ fn bootstrap_dataset(base: BaseArgs, args: DatasetViewBootstrapArgs) -> Result<( (_, Some(dataset)) if !dataset.is_empty() => format!("{{ name: {dataset:?} }}"), _ => "{ name: \"test-dataset\" }".to_string(), }; - let content = dataset_view_bootstrap_template(&dataset_ref); - let path = write_bootstrap_file(args.common, "dataset.view.tsx", &content)?; - print_bootstrap_result(base.json, ViewType::Dataset, &path) + let default_file_name = format!("{slug}.dataset-view.tsx"); + let content = dataset_view_bootstrap_template(&args.common.name, &slug, &dataset_ref); + let result = write_bootstrap_scaffold(args.common, &default_file_name, &content)?; + print_bootstrap_result(base.json, ViewType::Dataset, &result) } -fn write_bootstrap_file( +fn bootstrap_slug(name: &str) -> Result { + let mut slug = String::new(); + let mut pending_separator = false; + + for ch in name.trim().chars() { + if ch.is_ascii_alphanumeric() { + if pending_separator && !slug.is_empty() { + slug.push('-'); + } + slug.push(ch.to_ascii_lowercase()); + pending_separator = false; + } else if !slug.is_empty() { + pending_separator = true; + } + } + + if slug.is_empty() { + bail!("custom view name must contain at least one ASCII letter or number"); + } + Ok(slug) +} + +struct BootstrapWriteResult { + view_path: PathBuf, + tsconfig_path: PathBuf, + tsconfig_status: BootstrapSupportFileStatus, + types_path: PathBuf, + types_status: BootstrapSupportFileStatus, +} + +impl BootstrapWriteResult { + fn tsconfig_created(&self) -> bool { + matches!(self.tsconfig_status, BootstrapSupportFileStatus::Created) + } + + fn types_created(&self) -> bool { + matches!(self.types_status, BootstrapSupportFileStatus::Created) + } +} + +#[derive(Debug, Clone, Copy)] +enum BootstrapSupportFileStatus { + Created, + Reused, + Updated, +} + +impl BootstrapSupportFileStatus { + fn label(self) -> &'static str { + match self { + Self::Created => "Created", + Self::Reused => "Reused", + Self::Updated => "Updated", + } + } +} + +fn write_bootstrap_scaffold( args: BootstrapCommonArgs, default_file_name: &str, content: &str, -) -> Result { +) -> Result { let selected_path = args - .path - .or(args.file_flag) - .unwrap_or_else(|| PathBuf::from("views")); - let path = if selected_path.is_dir() || selected_path.extension().is_none() { - selected_path.join(default_file_name) + .file_flag + .unwrap_or_else(|| PathBuf::from(DEFAULT_CUSTOM_VIEWS_DIR)); + let (view_path, config_dir) = if selected_path.is_dir() || selected_path.extension().is_none() { + (selected_path.join(default_file_name), Some(selected_path)) } else { - selected_path + let config_dir = selected_path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .map(Path::to_path_buf); + (selected_path, config_dir) }; - let is_tsx_view_file = path + let tsconfig_path = config_dir + .as_ref() + .map(|dir| dir.join(CUSTOM_VIEW_TSCONFIG_FILE)) + .unwrap_or_else(|| PathBuf::from(CUSTOM_VIEW_TSCONFIG_FILE)); + let types_path = config_dir + .as_ref() + .map(|dir| dir.join(CUSTOM_VIEW_TYPES_FILE)) + .unwrap_or_else(|| PathBuf::from(CUSTOM_VIEW_TYPES_FILE)); + + let is_tsx_view_file = view_path .file_name() .and_then(|name| name.to_str()) - .is_some_and(|name| name.ends_with(".view.tsx")); + .is_some_and(|name| name.ends_with(".view.tsx") || name.ends_with("-view.tsx")); if !is_tsx_view_file { - bail!("custom view bootstrap path must end with .view.tsx"); + bail!("custom view bootstrap path must end with .view.tsx or -view.tsx"); } - if path.exists() && !args.force { + if view_path.exists() && !args.force { bail!( "custom view file already exists: {}. Use --force to overwrite.", - path.display() + view_path.display() ); } - if let Some(parent) = path + if let Some(parent) = view_path .parent() .filter(|parent| !parent.as_os_str().is_empty()) { std::fs::create_dir_all(parent) .with_context(|| format!("failed to create directory {}", parent.display()))?; } - std::fs::write(&path, content) - .with_context(|| format!("failed to write custom view file {}", path.display()))?; - Ok(path) + let tsconfig_exists = tsconfig_path.exists(); + if tsconfig_exists && !tsconfig_path.is_file() { + bail!( + "custom view tsconfig path already exists and is not a file: {}", + tsconfig_path.display() + ); + } + let types_exists = types_path.exists(); + if types_exists && !types_path.is_file() { + bail!( + "custom view types path already exists and is not a file: {}", + types_path.display() + ); + } + std::fs::write(&view_path, content) + .with_context(|| format!("failed to write custom view file {}", view_path.display()))?; + + let tsconfig_status = if !tsconfig_exists || args.force { + std::fs::write(&tsconfig_path, CUSTOM_VIEW_TSCONFIG_TEMPLATE).with_context(|| { + format!( + "failed to write custom view tsconfig {}", + tsconfig_path.display() + ) + })?; + if tsconfig_exists { + BootstrapSupportFileStatus::Updated + } else { + BootstrapSupportFileStatus::Created + } + } else { + BootstrapSupportFileStatus::Reused + }; + + let types_status = if !types_exists || args.force { + std::fs::write(&types_path, CUSTOM_VIEW_TYPES_TEMPLATE).with_context(|| { + format!("failed to write custom view types {}", types_path.display()) + })?; + if types_exists { + BootstrapSupportFileStatus::Updated + } else { + BootstrapSupportFileStatus::Created + } + } else { + BootstrapSupportFileStatus::Reused + }; + + Ok(BootstrapWriteResult { + view_path, + tsconfig_path, + tsconfig_status, + types_path, + types_status, + }) } -fn print_bootstrap_result(json_output: bool, view_type: ViewType, path: &Path) -> Result<()> { +fn print_bootstrap_result( + json_output: bool, + view_type: ViewType, + result: &BootstrapWriteResult, +) -> Result<()> { + let tsconfig_action = result.tsconfig_status.label(); + let types_action = result.types_status.label(); let result = BootstrapResult { - path: path.display().to_string(), + path: result.view_path.display().to_string(), + tsconfig_path: result.tsconfig_path.display().to_string(), + tsconfig_created: result.tsconfig_created(), + types_path: result.types_path.display().to_string(), + types_created: result.types_created(), view_type, }; if json_output { @@ -521,119 +674,253 @@ fn print_bootstrap_result(json_output: bool, view_type: ViewType, path: &Path) - println!( "Created {} custom view starter at {}", view_type.label(), - path.display() + result.path + ); + println!( + "{tsconfig_action} TypeScript config at {}", + result.tsconfig_path + ); + println!( + "{types_action} custom view type declarations at {}", + result.types_path ); println!( "Preview it with: bt views {} preview {}", view_type.label(), - path.display() + result.path ); } Ok(()) } -const TRACE_VIEW_BOOTSTRAP_TEMPLATE: &str = r##"import React from "react"; -import { - customTraceView, - formatJson, - type TraceViewProps, -} from "braintrust/custom-views"; +const CUSTOM_VIEW_TSCONFIG_TEMPLATE: &str = r#"{ + "compilerOptions": { + "target": "ES2020", + "lib": ["DOM", "DOM.Iterable", "ES2020"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "allowJs": true, + "skipLibCheck": true, + "types": [] + }, + "include": [ + "**/*.view.ts", + "**/*.view.tsx", + "**/*.view.js", + "**/*.view.jsx", + "**/*-view.ts", + "**/*-view.tsx", + "**/*-view.js", + "**/*-view.jsx", + "**/*.d.ts" + ] +} +"#; + +const CUSTOM_VIEW_TYPES_TEMPLATE: &str = r#"type CustomViewElementProps = { + [propName: string]: unknown; +}; + +type CustomViewSelectChangeEvent = { + target: { value: string }; + currentTarget: { value: string }; + preventDefault: () => void; + stopPropagation: () => void; +}; + +interface CustomViewSelectProps extends CustomViewElementProps { + value?: string | number | readonly string[]; + defaultValue?: string | number | readonly string[]; + onChange?: (event: CustomViewSelectChangeEvent) => void; +} + +interface CustomViewOptionProps extends CustomViewElementProps { + value?: string | number; +} + +interface CustomViewIntrinsicElements { + select: CustomViewSelectProps; + option: CustomViewOptionProps; + [elementName: string]: CustomViewElementProps; +} + +declare namespace JSX { + interface IntrinsicElements extends CustomViewIntrinsicElements {} +} + +declare module "react" { + type ReactNode = any; + type ComponentType = (props: Props) => ReactNode; + type Dispatch = (value: Value) => void; + type SetStateAction = Value | ((previous: Value) => Value); + + const React: { + createElement: (...args: any[]) => ReactNode; + Fragment: any; + }; + + export default React; + export const Children: any; + export const Component: any; + export const Fragment: any; + export const Profiler: any; + export const PureComponent: any; + export const StrictMode: any; + export const Suspense: any; + export const cloneElement: (...args: any[]) => ReactNode; + export const createContext: (...args: any[]) => any; + export const createElement: (...args: any[]) => ReactNode; + export const createRef: (...args: any[]) => any; + export const forwardRef: (component: any) => any; + export const isValidElement: (value: any) => boolean; + export const lazy: (loader: any) => any; + export const memo: (component: ComponentType) => ComponentType; + export const startTransition: (callback: () => void) => void; + export const useCallback: any>(callback: Value, deps?: any[]) => Value; + export const useContext: (context: any) => any; + export const useDebugValue: (...args: any[]) => void; + export const useDeferredValue: (value: Value) => Value; + export const useEffect: (effect: () => void | (() => void), deps?: any[]) => void; + export const useId: () => string; + export const useImperativeHandle: (...args: any[]) => void; + export const useInsertionEffect: (effect: () => void | (() => void), deps?: any[]) => void; + export const useLayoutEffect: (effect: () => void | (() => void), deps?: any[]) => void; + export const useMemo: (factory: () => Value, deps?: any[]) => Value; + export const useReducer: (...args: any[]) => any; + export const useRef: (initialValue: Value) => { current: Value }; + export const useState: (initialValue: Value | (() => Value)) => [Value, Dispatch>]; + export const useSyncExternalStore: (...args: any[]) => any; + export const useTransition: () => [boolean, (callback: () => void) => void]; +} + +declare module "react/jsx-runtime" { + export namespace JSX { + interface IntrinsicElements extends CustomViewIntrinsicElements {} + } + + export const Fragment: any; + export const jsx: (...args: any[]) => any; + export const jsxs: (...args: any[]) => any; +} + +declare module "react/jsx-dev-runtime" { + export namespace JSX { + interface IntrinsicElements extends CustomViewIntrinsicElements {} + } + + export const Fragment: any; + export const jsxDEV: (...args: any[]) => any; +} +"#; + +const TRACE_VIEW_BOOTSTRAP_TEMPLATE: &str = r##"import { customTraceView } from "braintrust/custom-views"; function pretty(value: unknown) { - return value === undefined ? "" : formatJson(value); -} - -function StarterTraceView({ trace, span, selectSpan }: TraceViewProps) { - const spanIds = trace.spanOrder.slice(0, 100); - - return ( -
-
- Trace starter view - + return value === undefined ? "" : JSON.stringify(value, null, 2); +} + +export default customTraceView( + { + name: __VIEW_NAME__, + slug: __VIEW_SLUG__, + }, + ({ trace, span, selectSpan }) => { + const spanIds = trace.spanOrder.slice(0, 100); + + return ( +
+
+ Trace starter view + +
+ +
+
Selected span
+
row id: {span.id}
+
span id: {span.span_id}
+
children: {span.children.length}
+
+ +
+
+            {pretty(span.data.input)}
+          
+
+            {pretty(span.data.output)}
+          
+
- -
-
Selected span
-
row id: {span.id}
-
span id: {span.span_id}
-
children: {span.children.length}
-
- -
-
-          {pretty(span.data.input)}
-        
-
-          {pretty(span.data.output)}
-        
-
-
- ); -} - -customTraceView({ - name: "Starter Trace View", - slug: "starter-trace-view", - description: "Starter trace custom view generated by bt.", - component: StarterTraceView, -}); + ); + }, +); "##; -const DATASET_VIEW_BOOTSTRAP_TEMPLATE: &str = r##"import React from "react"; -import { - customDatasetView, - formatJson, - type DatasetViewProps, -} from "braintrust/custom-views"; +const DATASET_VIEW_BOOTSTRAP_TEMPLATE: &str = r##"import { customDatasetView } from "braintrust/custom-views"; function pretty(value: unknown) { - return value === undefined ? "" : formatJson(value); -} - -function StarterDatasetView({ id, input, expected, metadata, tags }: DatasetViewProps) { - return ( -
-
- Dataset starter view -
row id: {id}
- {tags.length > 0 ? ( -
tags: {tags.join(", ")}
- ) : null} + return value === undefined ? "" : JSON.stringify(value, null, 2); +} + +export default customDatasetView( + { + name: __VIEW_NAME__, + slug: __VIEW_SLUG__, + dataset: __DATASET_REF__, + }, + ({ id, input, expected, metadata, tags = [] }) => { + return ( +
+
+ Dataset starter view +
row id: {id}
+ {tags.length > 0 ? ( +
tags: {tags.join(", ")}
+ ) : null} +
+ +
+
+            {pretty(input)}
+          
+
+            {pretty(expected)}
+          
+
+            {pretty(metadata)}
+          
+
- -
-
-          {pretty(input)}
-        
-
-          {pretty(expected)}
-        
-
-          {pretty(metadata)}
-        
-
-
- ); -} - -customDatasetView({ - name: "Starter Dataset View", - slug: "starter-dataset-view", - description: "Starter dataset custom view generated by bt.", - dataset: __DATASET_REF__, - component: StarterDatasetView, -}); + ); + }, +); "##; -fn dataset_view_bootstrap_template(dataset_ref: &str) -> String { - DATASET_VIEW_BOOTSTRAP_TEMPLATE.replace("__DATASET_REF__", dataset_ref) +fn js_string_literal(value: &str) -> String { + serde_json::to_string(value).expect("strings serialize to JSON") +} + +fn trace_view_bootstrap_template(name: &str, slug: &str) -> String { + TRACE_VIEW_BOOTSTRAP_TEMPLATE + .replace("__VIEW_NAME__", &js_string_literal(name)) + .replace("__VIEW_SLUG__", &js_string_literal(slug)) +} + +fn dataset_view_bootstrap_template(name: &str, slug: &str, dataset_ref: &str) -> String { + DATASET_VIEW_BOOTSTRAP_TEMPLATE + .replace("__VIEW_NAME__", &js_string_literal(name)) + .replace("__VIEW_SLUG__", &js_string_literal(slug)) + .replace("__DATASET_REF__", dataset_ref) } async fn push(base: BaseArgs, args: ViewsPushArgs) -> Result<()> { @@ -641,7 +928,7 @@ async fn push(base: BaseArgs, args: ViewsPushArgs) -> Result<()> { let default_project = resolve_required_project(&base, &auth_ctx.client, true).await?; let files = collect_view_files(&args.resolved_paths())?; if files.is_empty() { - bail!("no custom view files found; expected files matching *.view.tsx, *.view.ts, *.view.jsx, or *.view.js"); + bail!("no custom view files found; expected files matching {VIEW_FILE_PATTERN_HELP}"); } let manifest = run_views_runner(args.runner.as_deref(), args.tsconfig.as_deref(), &files)?; @@ -703,12 +990,13 @@ async fn push(base: BaseArgs, args: ViewsPushArgs) -> Result<()> { } async fn preview(base: BaseArgs, args: ViewsPreviewArgs) -> Result<()> { - let context = resolve_preview_context(&base, &args.common, None).await?; + let context = + resolve_preview_context(&base, &args.common, None, args.trace.url.as_deref()).await?; match context.entry.view_type { ViewType::Trace => { let trace_data = build_trace_preview_data( &context.client, - &context.project, + context.project.as_ref(), &args.trace, args.print_queries, ) @@ -719,24 +1007,26 @@ async fn preview(base: BaseArgs, args: ViewsPreviewArgs) -> Result<()> { context.client, Some(trace_data.project_id), context.entry, + context.dependency_paths, trace_data.data, ) .await } ViewType::Dataset => { - let preview_data = build_dataset_preview_data( - &context.client, - &context.project, - &context.entry, - &args.dataset, - ) - .await?; + let project = context + .project + .as_ref() + .ok_or_else(|| anyhow!("dataset preview requires a project"))?; + let preview_data = + build_dataset_preview_data(&context.client, project, &context.entry, &args.dataset) + .await?; serve_preview( base, &args.common, context.client, None, context.entry, + context.dependency_paths, preview_data, ) .await @@ -745,10 +1035,16 @@ async fn preview(base: BaseArgs, args: ViewsPreviewArgs) -> Result<()> { } async fn preview_trace(base: BaseArgs, args: TraceViewPreviewArgs) -> Result<()> { - let context = resolve_preview_context(&base, &args.common, Some(ViewType::Trace)).await?; + let context = resolve_preview_context( + &base, + &args.common, + Some(ViewType::Trace), + args.target.url.as_deref(), + ) + .await?; let trace_data = build_trace_preview_data( &context.client, - &context.project, + context.project.as_ref(), &args.target, args.print_queries, ) @@ -759,26 +1055,28 @@ async fn preview_trace(base: BaseArgs, args: TraceViewPreviewArgs) -> Result<()> context.client, Some(trace_data.project_id), context.entry, + context.dependency_paths, trace_data.data, ) .await } async fn preview_dataset(base: BaseArgs, args: DatasetViewPreviewArgs) -> Result<()> { - let context = resolve_preview_context(&base, &args.common, Some(ViewType::Dataset)).await?; - let preview_data = build_dataset_preview_data( - &context.client, - &context.project, - &context.entry, - &args.target, - ) - .await?; + let context = + resolve_preview_context(&base, &args.common, Some(ViewType::Dataset), None).await?; + let project = context + .project + .as_ref() + .ok_or_else(|| anyhow!("dataset preview requires a project"))?; + let preview_data = + build_dataset_preview_data(&context.client, project, &context.entry, &args.target).await?; serve_preview( base, &args.common, context.client, None, context.entry, + context.dependency_paths, preview_data, ) .await @@ -788,20 +1086,46 @@ async fn resolve_preview_context( base: &BaseArgs, args: &PreviewCommonArgs, view_type: Option, + trace_url: Option<&str>, ) -> Result { + if !args.path.exists() { + bail!("custom view file not found: {}", args.path.display()); + } + if !args.path.is_file() { + bail!( + "custom view preview requires a single view file; got {}", + args.path.display() + ); + } + if !is_view_file(&args.path) { + bail!("custom view preview path must match {VIEW_FILE_PATTERN_HELP}"); + } let files = vec![args.path.clone()]; let manifest = run_views_runner(args.runner.as_deref(), args.tsconfig.as_deref(), &files)?; validate_manifest_runtime(&manifest)?; let entry = select_preview_entry(&manifest, args.view.as_deref(), view_type)?.clone(); + let dependency_paths = preview_dependency_paths(&manifest); let auth_ctx = auth::login_read_only(&base).await?; let client = ApiClient::new(&auth_ctx)?; - let default_project = resolve_required_project(&base, &client, true).await?; - let project = resolve_project_for_entry(&client, &default_project, &entry).await?; + let project = if trace_url_supplies_project(entry.view_type, trace_url) { + None + } else if entry.view_type == ViewType::Trace && trace_url.is_some() { + match resolve_project_optional(&base, &client, true).await? { + Some(default_project) => { + Some(resolve_project_for_entry(&client, &default_project, &entry).await?) + } + None => None, + } + } else { + let default_project = resolve_required_project(&base, &client, true).await?; + Some(resolve_project_for_entry(&client, &default_project, &entry).await?) + }; Ok(PreviewContext { client, project, entry, + dependency_paths, }) } @@ -811,6 +1135,7 @@ async fn serve_preview( client: ApiClient, trace_project_id: Option, entry: ViewManifestEntry, + dependency_paths: Vec, preview_data: Value, ) -> Result<()> { let listener = TcpListener::bind(("127.0.0.1", args.port)) @@ -823,17 +1148,21 @@ async fn serve_preview( client, project_id: trace_project_id, data: preview_data, + source: PreviewSource { + path: args.path.clone(), + view: args.view.clone(), + view_type: Some(entry.view_type), + runner: args.runner.clone(), + tsconfig: args.tsconfig.clone(), + }, + title: entry.name.clone(), + dependency_paths: Mutex::new(dependency_paths), }); - let code = entry.code.clone(); - let name = entry.name.clone(); let server = HttpServer::new(move || { App::new() .app_data(state.clone()) - .app_data(web::Data::new(PreviewPage { - title: name.clone(), - code: code.clone(), - })) .route("/", web::get().to(preview_index)) + .route("/preview-version", web::get().to(preview_version)) .route("/span-fields", web::post().to(preview_span_fields)) }) .workers(1) @@ -869,22 +1198,44 @@ async fn serve_preview( Ok(()) } -#[derive(Clone)] struct PreviewPage { title: String, code: String, + dependency_paths: Vec, } -async fn preview_index( - state: web::Data, - page: web::Data, -) -> HttpResponse { - let html = render_preview_html(&page.title, &page.code, &state.data); +async fn preview_index(state: web::Data) -> HttpResponse { + let dependency_paths = state + .dependency_paths + .lock() + .map(|paths| paths.clone()) + .unwrap_or_default(); + let version = preview_source_version(&state.source, &dependency_paths); + let html = match load_preview_page(&state.source) { + Ok(page) => { + if let Ok(mut dependency_paths) = state.dependency_paths.lock() { + *dependency_paths = page.dependency_paths.clone(); + } + render_preview_html(&page.title, &page.code, &state.data, &version) + } + Err(err) => render_preview_error_html(&state.title, &format!("{err:#}"), &version), + }; HttpResponse::Ok() .content_type("text/html; charset=utf-8") .body(html) } +async fn preview_version(state: web::Data) -> HttpResponse { + let dependency_paths = state + .dependency_paths + .lock() + .map(|paths| paths.clone()) + .unwrap_or_default(); + HttpResponse::Ok().json(json!({ + "version": preview_source_version(&state.source, &dependency_paths), + })) +} + async fn preview_span_fields( state: web::Data, body: web::Json, @@ -927,10 +1278,66 @@ async fn preview_span_fields( HttpResponse::Ok().json(Value::Object(response)) } -fn render_preview_html(title: &str, code: &str, data: &Value) -> String { +fn load_preview_page(source: &PreviewSource) -> Result { + let files = vec![source.path.clone()]; + let manifest = run_views_runner(source.runner.as_deref(), source.tsconfig.as_deref(), &files)?; + validate_manifest_runtime(&manifest)?; + let entry = select_preview_entry(&manifest, source.view.as_deref(), source.view_type)?; + let dependency_paths = preview_dependency_paths(&manifest); + Ok(PreviewPage { + title: entry.name.clone(), + code: entry.code.clone(), + dependency_paths, + }) +} + +fn preview_dependency_paths(manifest: &ViewsManifest) -> Vec { + let mut paths = BTreeSet::new(); + for file in &manifest.files { + paths.insert(file.source_file.clone()); + for dependency in &file.dependencies { + paths.insert(dependency.clone()); + } + } + paths.into_iter().map(PathBuf::from).collect() +} + +fn preview_source_version(source: &PreviewSource, dependency_paths: &[PathBuf]) -> String { + let mut parts = Vec::new(); + push_preview_file_version(&mut parts, &source.path); + for dependency in dependency_paths { + if dependency != &source.path { + push_preview_file_version(&mut parts, dependency); + } + } + if let Some(tsconfig) = &source.tsconfig { + push_preview_file_version(&mut parts, tsconfig); + } + parts.join("|") +} + +fn push_preview_file_version(parts: &mut Vec, path: &Path) { + match std::fs::metadata(path) { + Ok(metadata) => { + let modified = metadata + .modified() + .ok() + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + parts.push(format!("{}:{modified}:{}", path.display(), metadata.len())); + } + Err(err) => { + parts.push(format!("{}:error:{err}", path.display())); + } + } +} + +fn render_preview_html(title: &str, code: &str, data: &Value, version: &str) -> String { let data_json = script_json(data); let code_json = script_json(&Value::String(code.to_string())); let title_json = script_json(&Value::String(title.to_string())); + let version_json = script_json(&Value::String(version.to_string())); format!( r##" @@ -950,6 +1357,7 @@ fn render_preview_html(title: &str, code: &str, data: &Value) -> String {
+ +"##, + html_escape(title), + ) +} + +fn render_preview_error_html(title: &str, error: &str, version: &str) -> String { + let title_json = script_json(&Value::String(title.to_string())); + let error_json = script_json(&Value::String(error.to_string())); + let version_json = script_json(&Value::String(version.to_string())); + format!( + r##" + + + + + {} + + + +

+  
 
 "##,
@@ -1126,9 +1584,18 @@ fn is_view_file(path: &Path) -> bool {
     let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
         return false;
     };
-    [".view.tsx", ".view.ts", ".view.jsx", ".view.js"]
-        .iter()
-        .any(|suffix| name.ends_with(suffix))
+    [
+        ".view.tsx",
+        ".view.ts",
+        ".view.jsx",
+        ".view.js",
+        "-view.tsx",
+        "-view.ts",
+        "-view.jsx",
+        "-view.js",
+    ]
+    .iter()
+    .any(|suffix| name.ends_with(suffix))
 }
 
 fn run_views_runner(
@@ -1338,10 +1805,6 @@ fn build_insert_event(view: &PreparedView, if_exists: IfExistsMode) -> Value {
     );
     object.insert("name".to_string(), Value::String(view.entry.name.clone()));
     object.insert("slug".to_string(), Value::String(view.entry.slug.clone()));
-    object.insert(
-        "description".to_string(),
-        Value::String(view.entry.description.clone().unwrap_or_default()),
-    );
     object.insert(
         "function_type".to_string(),
         Value::String("custom_view".to_string()),
@@ -1365,15 +1828,6 @@ fn build_insert_event(view: &PreparedView, if_exists: IfExistsMode) -> Value {
         }),
     );
 
-    if let Some(metadata) = &view.entry.metadata {
-        object.insert("metadata".to_string(), metadata.clone());
-    }
-    if let Some(tags) = &view.entry.tags {
-        object.insert(
-            "tags".to_string(),
-            Value::Array(tags.iter().cloned().map(Value::String).collect()),
-        );
-    }
     if let Some(dataset) = &view.dataset {
         object.insert(
             "origin".to_string(),
@@ -1564,7 +2018,7 @@ async fn resolve_dataset_by_selector(
 
 async fn build_trace_preview_data(
     client: &ApiClient,
-    project: &Project,
+    project: Option<&Project>,
     args: &TracePreviewTargetArgs,
     print_queries: bool,
 ) -> Result {
@@ -1600,14 +2054,20 @@ struct TracePreviewTarget {
 
 async fn resolve_trace_preview_target(
     client: &ApiClient,
-    default_project: &Project,
+    default_project: Option<&Project>,
     args: &TracePreviewTargetArgs,
 ) -> Result {
     if let Some(url) = args.url.as_deref() {
         let parsed = parse_trace_url(url)?;
         let project_id = match parsed.project.as_deref() {
-            Some(project) if project == default_project.id || project == default_project.name => {
-                default_project.id.clone()
+            Some(project)
+                if default_project
+                    .map(|default_project| {
+                        project == default_project.id || project == default_project.name
+                    })
+                    .unwrap_or(false) =>
+            {
+                default_project.expect("checked above").id.clone()
             }
             Some(project) if is_uuid_like(project) => project.to_string(),
             Some(project) => {
@@ -1616,7 +2076,13 @@ async fn resolve_trace_preview_target(
                     .ok_or_else(|| anyhow!("project '{project}' from trace URL not found"))?
                     .id
             }
-            None => default_project.id.clone(),
+            None => default_project
+                .map(|project| project.id.clone())
+                .ok_or_else(|| {
+                    anyhow!(
+                        "trace URL must include a project path like /app//p//... or a project must be supplied"
+                    )
+                })?,
         };
         let root_span_id = parsed
             .row_ref
@@ -1640,7 +2106,10 @@ async fn resolve_trace_preview_target(
     } else {
         args.project_id
             .clone()
-            .unwrap_or_else(|| default_project.id.clone())
+            .or_else(|| default_project.map(|project| project.id.clone()))
+            .ok_or_else(|| {
+                anyhow!("trace preview requires --project-id or --project when --trace-id is used")
+            })?
     };
     let root_span_id = args
         .trace_id
@@ -1685,6 +2154,14 @@ fn parse_trace_url(input: &str) -> Result {
     Ok(parsed)
 }
 
+fn trace_url_supplies_project(view_type: ViewType, trace_url: Option<&str>) -> bool {
+    view_type == ViewType::Trace
+        && trace_url
+            .and_then(|url| parse_trace_url(url).ok())
+            .and_then(|parsed| parsed.project)
+            .is_some()
+}
+
 fn is_uuid_like(value: &str) -> bool {
     let bytes = value.as_bytes();
     if bytes.len() != 36 {
@@ -1921,13 +2398,224 @@ fn fields_from_row(row: Map, fields: Option<&[String]>) -> Value
 mod tests {
     use super::*;
 
+    fn test_client() -> ApiClient {
+        let login = braintrust_sdk_rust::LoginState::new();
+        login.set(
+            "sk-test".to_string(),
+            "org_test".to_string(),
+            "test-org".to_string(),
+            "http://127.0.0.1:1".to_string(),
+            "http://127.0.0.1:1".to_string(),
+        );
+        ApiClient::new(&crate::auth::LoginContext {
+            login,
+            api_url: "http://127.0.0.1:1".to_string(),
+            app_url: "http://127.0.0.1:1".to_string(),
+        })
+        .expect("test client")
+    }
+
+    fn test_project() -> Project {
+        Project {
+            id: "proj_test".to_string(),
+            name: "test-project".to_string(),
+            org_id: "org_test".to_string(),
+            description: None,
+        }
+    }
+
     #[test]
     fn detects_view_files() {
         assert!(is_view_file(Path::new("conversation.view.tsx")));
         assert!(is_view_file(Path::new("dataset.view.js")));
+        assert!(is_view_file(Path::new("conversation.trace-view.tsx")));
+        assert!(is_view_file(Path::new("dataset.dataset-view.js")));
         assert!(!is_view_file(Path::new("regular.tsx")));
     }
 
+    #[test]
+    fn preview_html_includes_hot_reload_polling() {
+        let html = render_preview_html(
+            "Preview",
+            "module.exports = function View() {}",
+            &json!({}),
+            "v1",
+        );
+
+        assert!(html.contains("previewVersion"));
+        assert!(html.contains("/preview-version"));
+        assert!(html.contains("window.location.reload()"));
+    }
+
+    #[test]
+    fn preview_error_html_includes_hot_reload_polling() {
+        let html = render_preview_error_html("Preview", "bundle failed", "v1");
+
+        assert!(html.contains("bundle failed"));
+        assert!(html.contains("/preview-version"));
+        assert!(html.contains("window.location.reload()"));
+    }
+
+    #[test]
+    fn preview_source_version_changes_when_source_changes() {
+        let dir = tempfile::tempdir().expect("tempdir");
+        let path = dir.path().join("test.trace-view.tsx");
+        std::fs::write(&path, "export default null;\n").expect("write view");
+        let source = PreviewSource {
+            path: path.clone(),
+            view: None,
+            view_type: Some(ViewType::Trace),
+            runner: None,
+            tsconfig: None,
+        };
+
+        let before = preview_source_version(&source, &[]);
+        std::fs::write(&path, "export default function View() { return null; }\n")
+            .expect("rewrite view");
+        let after = preview_source_version(&source, &[]);
+
+        assert_ne!(before, after);
+    }
+
+    #[test]
+    fn preview_source_version_changes_when_dependency_changes() {
+        let dir = tempfile::tempdir().expect("tempdir");
+        let path = dir.path().join("test.trace-view.tsx");
+        let dependency = dir.path().join("component.tsx");
+        std::fs::write(&path, "export { default } from './component';\n").expect("write view");
+        std::fs::write(
+            &dependency,
+            "export default function View() { return null; }\n",
+        )
+        .expect("write dependency");
+        let source = PreviewSource {
+            path,
+            view: None,
+            view_type: Some(ViewType::Trace),
+            runner: None,
+            tsconfig: None,
+        };
+
+        let dependency_paths = vec![dependency.clone()];
+        let before = preview_source_version(&source, &dependency_paths);
+        std::fs::write(
+            &dependency,
+            "export default function View() { return 'changed'; }\n",
+        )
+        .expect("rewrite dependency");
+        let after = preview_source_version(&source, &dependency_paths);
+
+        assert_ne!(before, after);
+    }
+
+    #[test]
+    fn parses_trace_preview_url_project_and_span_selectors() {
+        let parsed = parse_trace_url(
+            "https://www.braintrust.dev/app/test-org/p/test-project/logs?r=root-span&s=selected-span",
+        )
+        .expect("parse URL");
+
+        assert_eq!(parsed.project.as_deref(), Some("test-project"));
+        assert_eq!(parsed.row_ref.as_deref(), Some("root-span"));
+        assert_eq!(parsed.span_id.as_deref(), Some("selected-span"));
+    }
+
+    #[test]
+    fn trace_url_project_only_skips_project_resolution_for_trace_views() {
+        let url = Some("https://www.braintrust.dev/app/test-org/p/test-project/logs?r=root-span");
+
+        assert!(trace_url_supplies_project(ViewType::Trace, url));
+        assert!(!trace_url_supplies_project(ViewType::Dataset, url));
+        assert!(!trace_url_supplies_project(
+            ViewType::Trace,
+            Some("https://www.braintrust.dev/app/test-org/logs?r=root-span")
+        ));
+        assert!(!trace_url_supplies_project(ViewType::Trace, None));
+    }
+
+    #[tokio::test]
+    async fn trace_preview_url_project_id_does_not_require_default_project() {
+        let client = test_client();
+        let project_id = "00000000-0000-4000-8000-000000000001";
+        let target = resolve_trace_preview_target(
+            &client,
+            None,
+            &TracePreviewTargetArgs {
+                url: Some(format!(
+                    "https://www.braintrust.dev/app/test-org/p/{project_id}/logs?r=root-span&s=selected-span"
+                )),
+                object_ref: None,
+                project_id: None,
+                trace_id: None,
+                span_id: None,
+            },
+        )
+        .await
+        .expect("resolve target");
+
+        assert_eq!(target.project_id, project_id);
+        assert_eq!(target.root_span_id, "root-span");
+        assert_eq!(target.span_id.as_deref(), Some("selected-span"));
+    }
+
+    #[tokio::test]
+    async fn trace_preview_url_without_project_uses_default_project() {
+        let client = test_client();
+        let project = test_project();
+        let target = resolve_trace_preview_target(
+            &client,
+            Some(&project),
+            &TracePreviewTargetArgs {
+                url: Some("https://www.braintrust.dev/app/test-org/logs?r=root-span".to_string()),
+                object_ref: None,
+                project_id: None,
+                trace_id: None,
+                span_id: Some("selected-span".to_string()),
+            },
+        )
+        .await
+        .expect("resolve target");
+
+        assert_eq!(target.project_id, "proj_test");
+        assert_eq!(target.root_span_id, "root-span");
+        assert_eq!(target.span_id.as_deref(), Some("selected-span"));
+    }
+
+    #[tokio::test]
+    async fn trace_preview_url_without_project_errors_without_default_project() {
+        let client = test_client();
+        let err = resolve_trace_preview_target(
+            &client,
+            None,
+            &TracePreviewTargetArgs {
+                url: Some("https://www.braintrust.dev/app/test-org/logs?r=root-span".to_string()),
+                object_ref: None,
+                project_id: None,
+                trace_id: None,
+                span_id: None,
+            },
+        )
+        .await
+        .expect_err("missing project should fail");
+
+        assert!(err
+            .to_string()
+            .contains("trace URL must include a project path"));
+    }
+
+    #[test]
+    fn normalizes_bootstrap_name_to_slug() {
+        assert_eq!(
+            bootstrap_slug("Trace Review 2026").unwrap(),
+            "trace-review-2026"
+        );
+        assert_eq!(
+            bootstrap_slug("  Dataset__Review!! ").unwrap(),
+            "dataset-review"
+        );
+        assert!(bootstrap_slug("!!!").is_err());
+    }
+
     #[test]
     fn builds_inline_custom_view_insert_event() {
         let view = PreparedView {
@@ -1937,20 +2625,12 @@ mod tests {
                 name: "Test View".to_string(),
                 slug: "test-view".to_string(),
                 code: "module.exports = function View() {}".to_string(),
-                description: Some("desc".to_string()),
                 project_id: None,
                 project_name: None,
                 dataset_id: None,
                 dataset_name: None,
-                metadata: Some(json!({ "kind": "test" })),
-                tags: Some(vec!["review".to_string()]),
-            },
-            project: Project {
-                id: "proj_test".to_string(),
-                name: "test-project".to_string(),
-                org_id: "org_test".to_string(),
-                description: None,
             },
+            project: test_project(),
             dataset: None,
         };
 
@@ -1964,6 +2644,9 @@ mod tests {
             "browser"
         );
         assert!(event.get("origin").is_none());
+        assert!(event.get("description").is_none());
+        assert!(event.get("metadata").is_none());
+        assert!(event.get("tags").is_none());
     }
 
     #[test]
@@ -1975,20 +2658,12 @@ mod tests {
                 name: "Dataset View".to_string(),
                 slug: "dataset-view".to_string(),
                 code: "module.exports = function View() {}".to_string(),
-                description: None,
                 project_id: None,
                 project_name: None,
                 dataset_id: Some("dataset_test".to_string()),
                 dataset_name: None,
-                metadata: None,
-                tags: None,
-            },
-            project: Project {
-                id: "proj_test".to_string(),
-                name: "test-project".to_string(),
-                org_id: "org_test".to_string(),
-                description: None,
             },
+            project: test_project(),
             dataset: Some(datasets_api::Dataset {
                 id: "dataset_test".to_string(),
                 name: "test-dataset".to_string(),
diff --git a/tests/cli.rs b/tests/cli.rs
index 6ead7d7..ced942c 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -168,15 +168,61 @@ fn views_trace_bootstrap_creates_starter_file() {
 
     bt_command()
         .current_dir(dir.path())
-        .args(["views", "trace", "bootstrap"])
+        .args(["views", "trace", "bootstrap", "Trace Review"])
         .assert()
         .success()
-        .stdout(predicate::str::contains("views/trace.view.tsx"));
+        .stdout(predicate::str::contains(
+            "braintrust-custom-views/trace-review.trace-view.tsx",
+        ))
+        .stdout(predicate::str::contains(
+            "braintrust-custom-views/tsconfig.json",
+        ))
+        .stdout(predicate::str::contains(
+            "braintrust-custom-views/custom-view-env.d.ts",
+        ));
 
-    let contents =
-        fs::read_to_string(dir.path().join("views/trace.view.tsx")).expect("read starter view");
-    assert!(contents.contains("customTraceView"));
-    assert!(contents.contains("Starter Trace View"));
+    let contents = fs::read_to_string(
+        dir.path()
+            .join("braintrust-custom-views/trace-review.trace-view.tsx"),
+    )
+    .expect("read starter view");
+    assert!(contents.contains("export default customTraceView"));
+    assert!(contents.contains(r#"name: "Trace Review""#));
+    assert!(contents.contains(r#"slug: "trace-review""#));
+    assert!(contents.contains("({ trace, span, selectSpan }) => {"));
+    assert!(contents.contains("onChange={(event) => selectSpan?.(event.target.value)}"));
+    assert!(!contents.contains("function StarterTraceView"));
+    assert!(!contents.contains("StarterTraceView,"));
+    assert!(!contents.contains("trace: { spanOrder: string[] }"));
+    assert!(!contents.contains("event:"));
+    assert!(!contents.contains(r#"from "react""#));
+    assert!(!contents.contains("component: StarterTraceView"));
+
+    let tsconfig = fs::read_to_string(dir.path().join("braintrust-custom-views/tsconfig.json"))
+        .expect("read custom view tsconfig");
+    assert!(tsconfig.contains(r#""jsx": "react-jsx""#));
+    assert!(tsconfig.contains(r#""moduleResolution": "Bundler""#));
+    assert!(tsconfig.contains(r#""noEmit": true"#));
+    assert!(tsconfig.contains(r#""allowJs": true"#));
+    assert!(tsconfig.contains(r#""**/*.view.tsx""#));
+    assert!(tsconfig.contains(r#""**/*.view.jsx""#));
+    assert!(tsconfig.contains(r#""**/*-view.tsx""#));
+    assert!(tsconfig.contains(r#""**/*-view.jsx""#));
+    assert!(tsconfig.contains(r#""**/*.d.ts""#));
+
+    let types = fs::read_to_string(
+        dir.path()
+            .join("braintrust-custom-views/custom-view-env.d.ts"),
+    )
+    .expect("read custom view type declarations");
+    assert!(types.contains("declare namespace JSX"));
+    assert!(types.contains("interface CustomViewIntrinsicElements"));
+    assert!(types.contains("select: CustomViewSelectProps"));
+    assert!(types.contains("option: CustomViewOptionProps"));
+    assert!(types.contains("onChange?: (event: CustomViewSelectChangeEvent) => void"));
+    assert!(types.contains("interface IntrinsicElements extends CustomViewIntrinsicElements"));
+    assert!(types.contains(r#"declare module "react/jsx-runtime""#));
+    assert!(types.contains(r#"declare module "react""#));
 }
 
 #[test]
@@ -189,6 +235,7 @@ fn views_dataset_bootstrap_creates_starter_file_with_dataset_name() {
             "views",
             "dataset",
             "bootstrap",
+            "Dataset Review",
             "--dataset",
             "test-dataset",
             "--file",
@@ -200,8 +247,152 @@ fn views_dataset_bootstrap_creates_starter_file_with_dataset_name() {
 
     let contents =
         fs::read_to_string(dir.path().join("custom.dataset.view.tsx")).expect("read starter view");
-    assert!(contents.contains("customDatasetView"));
+    assert!(contents.contains("export default customDatasetView"));
+    assert!(contents.contains(r#"name: "Dataset Review""#));
+    assert!(contents.contains(r#"slug: "dataset-review""#));
     assert!(contents.contains(r#"dataset: { name: "test-dataset" }"#));
+    assert!(contents.contains("({ id, input, expected, metadata, tags = [] }) => {"));
+    assert!(!contents.contains("function StarterDatasetView"));
+    assert!(!contents.contains("StarterDatasetView,"));
+    assert!(!contents.contains("id: string;"));
+    assert!(!contents.contains(r#"from "react""#));
+    assert!(!contents.contains("component: StarterDatasetView"));
+
+    let tsconfig =
+        fs::read_to_string(dir.path().join("tsconfig.json")).expect("read custom view tsconfig");
+    assert!(tsconfig.contains(r#""jsx": "react-jsx""#));
+    assert!(dir.path().join("custom-view-env.d.ts").exists());
+}
+
+#[test]
+fn views_bootstrap_json_reports_tsconfig() {
+    let dir = tempfile::tempdir().expect("tempdir");
+
+    let output = bt_command()
+        .current_dir(dir.path())
+        .args(["views", "--json", "trace", "bootstrap", "Trace Review"])
+        .assert()
+        .success()
+        .get_output()
+        .stdout
+        .clone();
+    let payload: serde_json::Value =
+        serde_json::from_slice(&output).expect("parse bootstrap json output");
+
+    assert_eq!(
+        payload["path"],
+        "braintrust-custom-views/trace-review.trace-view.tsx"
+    );
+    assert_eq!(
+        payload["tsconfig_path"],
+        "braintrust-custom-views/tsconfig.json"
+    );
+    assert_eq!(payload["tsconfig_created"], true);
+    assert_eq!(
+        payload["types_path"],
+        "braintrust-custom-views/custom-view-env.d.ts"
+    );
+    assert_eq!(payload["types_created"], true);
+    assert_eq!(payload["view_type"], "trace");
+}
+
+#[test]
+fn views_bootstrap_preserves_existing_tsconfig_without_force() {
+    let dir = tempfile::tempdir().expect("tempdir");
+    fs::create_dir_all(dir.path().join("custom")).expect("create custom view dir");
+    fs::write(
+        dir.path().join("custom/tsconfig.json"),
+        "{ \"compilerOptions\": { \"jsx\": \"preserve\" } }\n",
+    )
+    .expect("write existing tsconfig");
+    fs::write(
+        dir.path().join("custom/custom-view-env.d.ts"),
+        "declare const preserved: true;\n",
+    )
+    .expect("write existing types");
+
+    bt_command()
+        .current_dir(dir.path())
+        .args([
+            "views",
+            "trace",
+            "bootstrap",
+            "Trace Review",
+            "--file",
+            "custom",
+        ])
+        .assert()
+        .success()
+        .stdout(predicate::str::contains("Reused TypeScript config"))
+        .stdout(predicate::str::contains(
+            "Reused custom view type declarations",
+        ));
+
+    let tsconfig =
+        fs::read_to_string(dir.path().join("custom/tsconfig.json")).expect("read tsconfig");
+    assert_eq!(
+        tsconfig,
+        "{ \"compilerOptions\": { \"jsx\": \"preserve\" } }\n"
+    );
+    assert!(dir
+        .path()
+        .join("custom/trace-review.trace-view.tsx")
+        .exists());
+    let types =
+        fs::read_to_string(dir.path().join("custom/custom-view-env.d.ts")).expect("read types");
+    assert_eq!(types, "declare const preserved: true;\n");
+}
+
+#[test]
+fn views_bootstrap_force_overwrites_view_and_tsconfig() {
+    let dir = tempfile::tempdir().expect("tempdir");
+    fs::create_dir_all(dir.path().join("custom")).expect("create custom view dir");
+    fs::write(
+        dir.path().join("custom/trace-review.trace-view.tsx"),
+        "old view\n",
+    )
+    .expect("write view");
+    fs::write(
+        dir.path().join("custom/tsconfig.json"),
+        "{ \"compilerOptions\": { \"jsx\": \"preserve\" } }\n",
+    )
+    .expect("write existing tsconfig");
+    fs::write(
+        dir.path().join("custom/custom-view-env.d.ts"),
+        "declare const preserved: true;\n",
+    )
+    .expect("write existing types");
+
+    bt_command()
+        .current_dir(dir.path())
+        .args([
+            "views",
+            "trace",
+            "bootstrap",
+            "Trace Review",
+            "--file",
+            "custom",
+            "--force",
+        ])
+        .assert()
+        .success()
+        .stdout(predicate::str::contains("Updated TypeScript config"))
+        .stdout(predicate::str::contains(
+            "Updated custom view type declarations",
+        ));
+
+    let contents = fs::read_to_string(dir.path().join("custom/trace-review.trace-view.tsx"))
+        .expect("read starter view");
+    assert!(contents.contains("export default customTraceView"));
+
+    let tsconfig =
+        fs::read_to_string(dir.path().join("custom/tsconfig.json")).expect("read tsconfig");
+    assert!(tsconfig.contains(r#""jsx": "react-jsx""#));
+    assert!(!tsconfig.contains(r#""jsx": "preserve""#));
+    let types =
+        fs::read_to_string(dir.path().join("custom/custom-view-env.d.ts")).expect("read types");
+    assert!(types.contains(r#"declare module "react/jsx-runtime""#));
+    assert!(!types.contains("declare const preserved"));
 }
 
 #[test]

From bfb9ea97f4e4895d46eb58420cbce6a34baf3ff9 Mon Sep 17 00:00:00 2001
From: Luca Forstner 
Date: Fri, 3 Jul 2026 15:46:59 +0200
Subject: [PATCH 3/4] no aliases

---
 src/views.rs |  2 --
 tests/cli.rs | 10 ++++++++++
 2 files changed, 10 insertions(+), 2 deletions(-)

diff --git a/src/views.rs b/src/views.rs
index f67a261..beadce5 100644
--- a/src/views.rs
+++ b/src/views.rs
@@ -57,10 +57,8 @@ enum ViewsCommands {
     /// Push local custom view definitions
     Push(ViewsPushArgs),
     /// Work with trace custom views
-    #[command(visible_alias = "traces")]
     Trace(TraceViewsArgs),
     /// Work with dataset custom views
-    #[command(visible_alias = "datasets")]
     Dataset(DatasetViewsArgs),
     /// Preview one custom view locally
     #[command(hide = true)]
diff --git a/tests/cli.rs b/tests/cli.rs
index ced942c..e96ae3e 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -108,6 +108,16 @@ fn views_help_accepts_push_trace_and_dataset_subcommands() {
         .stdout(predicate::str::contains("dataset"));
 }
 
+#[test]
+fn views_rejects_trace_and_dataset_aliases() {
+    for alias in ["traces", "datasets"] {
+        bt_command()
+            .args(["views", alias, "--help"])
+            .assert()
+            .failure();
+    }
+}
+
 #[test]
 fn views_trace_help_lists_bootstrap_and_preview() {
     bt_command()

From 18790b73c60160155334a316915928101dd3e85c Mon Sep 17 00:00:00 2001
From: Luca Forstner 
Date: Tue, 7 Jul 2026 13:23:55 +0200
Subject: [PATCH 4/4] simplify tools

---
 scripts/views-runner.ts |  22 +----
 src/views.rs            | 175 ++++------------------------------------
 tests/cli.rs            |  11 ++-
 3 files changed, 24 insertions(+), 184 deletions(-)

diff --git a/scripts/views-runner.ts b/scripts/views-runner.ts
index c9cf291..46fa42e 100644
--- a/scripts/views-runner.ts
+++ b/scripts/views-runner.ts
@@ -105,18 +105,6 @@ async function loadEsbuild(sourceFiles: string[]): Promise {
   );
 }
 
-function loadTsconfigPath(): string | undefined {
-  const tsNode = process.env.TS_NODE_PROJECT?.trim();
-  if (tsNode) {
-    return tsNode;
-  }
-  const tsx = process.env.TSX_TSCONFIG_PATH?.trim();
-  if (tsx) {
-    return tsx;
-  }
-  return undefined;
-}
-
 function sdkPath(): string {
   return process.env.BT_VIEWS_SDK_PATH || path.join(__dirname, "views-sdk.ts");
 }
@@ -357,7 +345,6 @@ async function bundleForDiscovery(
   esbuild: EsbuildModule,
   sourceFile: string,
   outputFile: string,
-  tsconfig: string | undefined,
 ): Promise {
   return await esbuild.build({
     entryPoints: [sourceFile],
@@ -366,7 +353,6 @@ async function bundleForDiscovery(
     platform: "node",
     target: "node18",
     format: "cjs",
-    tsconfig,
     write: true,
     metafile: true,
     plugins: [braintrustViewPlugin()],
@@ -377,7 +363,6 @@ async function collectView(
   esbuild: EsbuildModule,
   sourceFile: string,
   tempDir: string,
-  tsconfig: string | undefined,
 ): Promise<{ view: CustomViewDefinition; dependencies: string[] }> {
   installDiscoveryReactStub();
   const outputFile = path.join(
@@ -388,7 +373,6 @@ async function collectView(
     esbuild,
     sourceFile,
     outputFile,
-    tsconfig,
   );
   const loaded = await import(
     `${pathToFileURL(outputFile).href}?bt_view_nonce=${Date.now()}`
@@ -413,7 +397,6 @@ async function bundleBrowserCode(
   sourceFile: string,
   view: CustomViewDefinition,
   tempDir: string,
-  tsconfig: string | undefined,
 ): Promise<{ code: string; dependencies: string[] }> {
   const entryFile = path.join(
     tempDir,
@@ -433,7 +416,6 @@ async function bundleBrowserCode(
     target: "es2019",
     format: "iife",
     globalName: "__BraintrustCustomView",
-    tsconfig,
     write: true,
     metafile: true,
     treeShaking: true,
@@ -483,7 +465,6 @@ function dependencyFiles(
 async function buildManifest(files: string[]): Promise {
   const sourceFiles = files.map((file) => path.resolve(file));
   const esbuild = await loadEsbuild(sourceFiles);
-  const tsconfig = loadTsconfigPath();
   const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "bt-views-"));
 
   try {
@@ -499,13 +480,12 @@ async function buildManifest(files: string[]): Promise {
       if (!fs.existsSync(sourceFile)) {
         throw new Error(`custom view file not found: ${sourceFile}`);
       }
-      const collected = await collectView(esbuild, sourceFile, tempDir, tsconfig);
+      const collected = await collectView(esbuild, sourceFile, tempDir);
       const bundled = await bundleBrowserCode(
         esbuild,
         sourceFile,
         collected.view,
         tempDir,
-        tsconfig,
       );
       const dependencies = [
         ...new Set([...collected.dependencies, ...bundled.dependencies]),
diff --git a/src/views.rs b/src/views.rs
index beadce5..041a809 100644
--- a/src/views.rs
+++ b/src/views.rs
@@ -60,9 +60,6 @@ enum ViewsCommands {
     Trace(TraceViewsArgs),
     /// Work with dataset custom views
     Dataset(DatasetViewsArgs),
-    /// Preview one custom view locally
-    #[command(hide = true)]
-    Preview(ViewsPreviewArgs),
 }
 
 #[derive(Debug, Clone, Args)]
@@ -89,14 +86,6 @@ struct ViewsPushArgs {
     )]
     if_exists: IfExistsMode,
 
-    /// Override JS runner binary (e.g. tsx, vite-node, deno).
-    #[arg(long, env = "BT_VIEWS_PUSH_RUNNER", value_name = "RUNNER")]
-    runner: Option,
-
-    /// Optional tsconfig path for JS runner and browser bundling.
-    #[arg(long, env = "BT_VIEWS_PUSH_TSCONFIG", value_name = "PATH")]
-    tsconfig: Option,
-
     /// Skip confirmation prompt.
     #[arg(
         long,
@@ -231,14 +220,6 @@ struct PreviewCommonArgs {
         default_value_t = false
     )]
     no_open: bool,
-
-    /// Override JS runner binary (e.g. tsx, vite-node, deno).
-    #[arg(long, env = "BT_VIEWS_PREVIEW_RUNNER", value_name = "RUNNER")]
-    runner: Option,
-
-    /// Optional tsconfig path for JS runner and browser bundling.
-    #[arg(long, env = "BT_VIEWS_PREVIEW_TSCONFIG", value_name = "PATH")]
-    tsconfig: Option,
 }
 
 #[derive(Debug, Clone, Args)]
@@ -248,15 +229,6 @@ struct TraceViewPreviewArgs {
 
     #[command(flatten)]
     target: TracePreviewTargetArgs,
-
-    /// Print BTQL queries used for preview data.
-    #[arg(
-        long,
-        env = "BT_VIEWS_PREVIEW_PRINT_QUERIES",
-        value_parser = BoolishValueParser::new(),
-        default_value_t = false
-    )]
-    print_queries: bool,
 }
 
 #[derive(Debug, Clone, Args)]
@@ -265,10 +237,6 @@ struct TracePreviewTargetArgs {
     #[arg(long, env = "BT_VIEWS_PREVIEW_URL")]
     url: Option,
 
-    /// Object reference, format: project_logs:.
-    #[arg(long, env = "BT_VIEWS_PREVIEW_OBJECT_REF")]
-    object_ref: Option,
-
     /// Project ID to query for trace preview data.
     #[arg(long, env = "BT_VIEWS_PREVIEW_PROJECT_ID")]
     project_id: Option,
@@ -310,27 +278,6 @@ struct DatasetPreviewTargetArgs {
     row_index: Option,
 }
 
-#[derive(Debug, Clone, Args)]
-struct ViewsPreviewArgs {
-    #[command(flatten)]
-    common: PreviewCommonArgs,
-
-    #[command(flatten)]
-    trace: TracePreviewTargetArgs,
-
-    #[command(flatten)]
-    dataset: DatasetPreviewTargetArgs,
-
-    /// Print BTQL queries used for preview data.
-    #[arg(
-        long,
-        env = "BT_VIEWS_PREVIEW_PRINT_QUERIES",
-        value_parser = BoolishValueParser::new(),
-        default_value_t = false
-    )]
-    print_queries: bool,
-}
-
 #[derive(Debug, Deserialize)]
 struct ViewsManifest {
     runtime_context: ViewsRuntimeContext,
@@ -437,8 +384,6 @@ struct PreviewSource {
     path: PathBuf,
     view: Option,
     view_type: Option,
-    runner: Option,
-    tsconfig: Option,
 }
 
 #[derive(Debug, Deserialize)]
@@ -463,7 +408,6 @@ pub async fn run(base: BaseArgs, args: ViewsArgs) -> Result<()> {
                 preview_dataset(base, preview_args).await
             }
         },
-        ViewsCommands::Preview(preview_args) => preview(base, preview_args).await,
     }
 }
 
@@ -929,7 +873,7 @@ async fn push(base: BaseArgs, args: ViewsPushArgs) -> Result<()> {
         bail!("no custom view files found; expected files matching {VIEW_FILE_PATTERN_HELP}");
     }
 
-    let manifest = run_views_runner(args.runner.as_deref(), args.tsconfig.as_deref(), &files)?;
+    let manifest = run_views_runner(&files)?;
     validate_manifest_runtime(&manifest)?;
     let prepared = prepare_views(&auth_ctx.client, &default_project, &manifest).await?;
     if prepared.is_empty() {
@@ -987,51 +931,6 @@ async fn push(base: BaseArgs, args: ViewsPushArgs) -> Result<()> {
     Ok(())
 }
 
-async fn preview(base: BaseArgs, args: ViewsPreviewArgs) -> Result<()> {
-    let context =
-        resolve_preview_context(&base, &args.common, None, args.trace.url.as_deref()).await?;
-    match context.entry.view_type {
-        ViewType::Trace => {
-            let trace_data = build_trace_preview_data(
-                &context.client,
-                context.project.as_ref(),
-                &args.trace,
-                args.print_queries,
-            )
-            .await?;
-            serve_preview(
-                base,
-                &args.common,
-                context.client,
-                Some(trace_data.project_id),
-                context.entry,
-                context.dependency_paths,
-                trace_data.data,
-            )
-            .await
-        }
-        ViewType::Dataset => {
-            let project = context
-                .project
-                .as_ref()
-                .ok_or_else(|| anyhow!("dataset preview requires a project"))?;
-            let preview_data =
-                build_dataset_preview_data(&context.client, project, &context.entry, &args.dataset)
-                    .await?;
-            serve_preview(
-                base,
-                &args.common,
-                context.client,
-                None,
-                context.entry,
-                context.dependency_paths,
-                preview_data,
-            )
-            .await
-        }
-    }
-}
-
 async fn preview_trace(base: BaseArgs, args: TraceViewPreviewArgs) -> Result<()> {
     let context = resolve_preview_context(
         &base,
@@ -1040,13 +939,8 @@ async fn preview_trace(base: BaseArgs, args: TraceViewPreviewArgs) -> Result<()>
         args.target.url.as_deref(),
     )
     .await?;
-    let trace_data = build_trace_preview_data(
-        &context.client,
-        context.project.as_ref(),
-        &args.target,
-        args.print_queries,
-    )
-    .await?;
+    let trace_data =
+        build_trace_preview_data(&context.client, context.project.as_ref(), &args.target).await?;
     serve_preview(
         base,
         &args.common,
@@ -1099,7 +993,7 @@ async fn resolve_preview_context(
         bail!("custom view preview path must match {VIEW_FILE_PATTERN_HELP}");
     }
     let files = vec![args.path.clone()];
-    let manifest = run_views_runner(args.runner.as_deref(), args.tsconfig.as_deref(), &files)?;
+    let manifest = run_views_runner(&files)?;
     validate_manifest_runtime(&manifest)?;
     let entry = select_preview_entry(&manifest, args.view.as_deref(), view_type)?.clone();
     let dependency_paths = preview_dependency_paths(&manifest);
@@ -1150,8 +1044,6 @@ async fn serve_preview(
             path: args.path.clone(),
             view: args.view.clone(),
             view_type: Some(entry.view_type),
-            runner: args.runner.clone(),
-            tsconfig: args.tsconfig.clone(),
         },
         title: entry.name.clone(),
         dependency_paths: Mutex::new(dependency_paths),
@@ -1255,7 +1147,7 @@ async fn preview_span_fields(
 
     for span_id in &body.span_ids {
         let row_id = resolve_preview_row_id(&spans, span_id).unwrap_or_else(|| span_id.clone());
-        match fetch_full_span_row(&state.client, project_id, &row_id, false).await {
+        match fetch_full_span_row(&state.client, project_id, &row_id).await {
             Ok(Some(row)) => {
                 response.insert(
                     span_id.clone(),
@@ -1278,7 +1170,7 @@ async fn preview_span_fields(
 
 fn load_preview_page(source: &PreviewSource) -> Result {
     let files = vec![source.path.clone()];
-    let manifest = run_views_runner(source.runner.as_deref(), source.tsconfig.as_deref(), &files)?;
+    let manifest = run_views_runner(&files)?;
     validate_manifest_runtime(&manifest)?;
     let entry = select_preview_entry(&manifest, source.view.as_deref(), source.view_type)?;
     let dependency_paths = preview_dependency_paths(&manifest);
@@ -1308,9 +1200,6 @@ fn preview_source_version(source: &PreviewSource, dependency_paths: &[PathBuf])
             push_preview_file_version(&mut parts, dependency);
         }
     }
-    if let Some(tsconfig) = &source.tsconfig {
-        push_preview_file_version(&mut parts, tsconfig);
-    }
     parts.join("|")
 }
 
@@ -1596,11 +1485,7 @@ fn is_view_file(path: &Path) -> bool {
     .any(|suffix| name.ends_with(suffix))
 }
 
-fn run_views_runner(
-    runner: Option<&str>,
-    tsconfig: Option<&Path>,
-    files: &[PathBuf],
-) -> Result {
+fn run_views_runner(files: &[PathBuf]) -> Result {
     let sdk_script = js_runner::materialize_runner_script_in_cwd(
         "views-runners",
         VIEWS_JS_SDK_FILE,
@@ -1613,12 +1498,8 @@ fn run_views_runner(
         VIEWS_JS_RUNNER_SOURCE,
     )
     .context("failed to materialize custom views runner")?;
-    let mut command = js_runner::build_js_runner_command(runner, &runner_script, files);
+    let mut command = js_runner::build_js_runner_command(None, &runner_script, files);
     command.env("BT_VIEWS_SDK_PATH", &sdk_script);
-    if let Some(tsconfig) = tsconfig {
-        command.env("TS_NODE_PROJECT", tsconfig);
-        command.env("TSX_TSCONFIG_PATH", tsconfig);
-    }
 
     let output = command.output().with_context(|| {
         format!(
@@ -2018,7 +1899,6 @@ async fn build_trace_preview_data(
     client: &ApiClient,
     project: Option<&Project>,
     args: &TracePreviewTargetArgs,
-    print_queries: bool,
 ) -> Result {
     let target = resolve_trace_preview_target(client, project, args).await?;
     let rows = fetch_trace_rows(
@@ -2026,7 +1906,6 @@ async fn build_trace_preview_data(
         &target.project_id,
         &target.root_span_id,
         DEFAULT_TRACE_PREVIEW_LIMIT,
-        print_queries,
     )
     .await?;
     if rows.is_empty() {
@@ -2093,22 +1972,13 @@ async fn resolve_trace_preview_target(
         });
     }
 
-    let project_id = if let Some(object_ref) = args.object_ref.as_deref() {
-        let (object_type, object_name) = object_ref.split_once(':').ok_or_else(|| {
-            anyhow!("invalid --object-ref '{object_ref}', expected project_logs:")
+    let project_id = args
+        .project_id
+        .clone()
+        .or_else(|| default_project.map(|project| project.id.clone()))
+        .ok_or_else(|| {
+            anyhow!("trace preview requires --project-id or --project when --trace-id is used")
         })?;
-        if object_type != "project_logs" {
-            bail!("bt views preview currently supports project_logs object refs");
-        }
-        object_name.to_string()
-    } else {
-        args.project_id
-            .clone()
-            .or_else(|| default_project.map(|project| project.id.clone()))
-            .ok_or_else(|| {
-                anyhow!("trace preview requires --project-id or --project when --trace-id is used")
-            })?
-    };
     let root_span_id = args
         .trace_id
         .clone()
@@ -2183,7 +2053,6 @@ async fn fetch_trace_rows(
     project_id: &str,
     root_span_id: &str,
     limit: usize,
-    print_queries: bool,
 ) -> Result>> {
     let query = format!(
         "select: * | from: project_logs({}) spans | filter: root_span_id = {} | preview_length: 125 | sort: _pagination_key ASC | limit: {}",
@@ -2191,7 +2060,6 @@ async fn fetch_trace_rows(
         sql_quote(root_span_id),
         limit,
     );
-    maybe_print_query(print_queries, "custom-view-preview-trace", &query);
     execute_query(client, &query)
         .await
         .with_context(|| format!("BTQL query failed: {query}"))
@@ -2202,14 +2070,12 @@ async fn fetch_full_span_row(
     client: &ApiClient,
     project_id: &str,
     row_id: &str,
-    print_queries: bool,
 ) -> Result>> {
     let query = format!(
         "select: * | from: project_logs({}) spans | filter: id = {} | preview_length: -1 | limit: 1",
         sql_quote(project_id),
         sql_quote(row_id),
     );
-    maybe_print_query(print_queries, "custom-view-preview-span-fields", &query);
     execute_query(client, &query)
         .await
         .with_context(|| format!("BTQL query failed: {query}"))
@@ -2231,12 +2097,6 @@ async fn execute_query(client: &ApiClient, query: &str) -> Result
     client.post_with_headers("/btql", &body, &headers).await
 }
 
-fn maybe_print_query(enabled: bool, label: &str, query: &str) {
-    if enabled {
-        eprintln!("bt views [{label}] BTQL:\n{query}\n");
-    }
-}
-
 fn sql_quote(value: &str) -> String {
     format!("'{}'", value.replace('\'', "''"))
 }
@@ -2463,8 +2323,6 @@ mod tests {
             path: path.clone(),
             view: None,
             view_type: Some(ViewType::Trace),
-            runner: None,
-            tsconfig: None,
         };
 
         let before = preview_source_version(&source, &[]);
@@ -2490,8 +2348,6 @@ mod tests {
             path,
             view: None,
             view_type: Some(ViewType::Trace),
-            runner: None,
-            tsconfig: None,
         };
 
         let dependency_paths = vec![dependency.clone()];
@@ -2542,7 +2398,6 @@ mod tests {
                 url: Some(format!(
                     "https://www.braintrust.dev/app/test-org/p/{project_id}/logs?r=root-span&s=selected-span"
                 )),
-                object_ref: None,
                 project_id: None,
                 trace_id: None,
                 span_id: None,
@@ -2565,7 +2420,6 @@ mod tests {
             Some(&project),
             &TracePreviewTargetArgs {
                 url: Some("https://www.braintrust.dev/app/test-org/logs?r=root-span".to_string()),
-                object_ref: None,
                 project_id: None,
                 trace_id: None,
                 span_id: Some("selected-span".to_string()),
@@ -2587,7 +2441,6 @@ mod tests {
             None,
             &TracePreviewTargetArgs {
                 url: Some("https://www.braintrust.dev/app/test-org/logs?r=root-span".to_string()),
-                object_ref: None,
                 project_id: None,
                 trace_id: None,
                 span_id: None,
diff --git a/tests/cli.rs b/tests/cli.rs
index e96ae3e..6fd3167 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -144,8 +144,7 @@ fn views_push_help_lists_custom_view_flags() {
         .args(["views", "push", "--help"])
         .assert()
         .success()
-        .stdout(predicate::str::contains("--if-exists"))
-        .stdout(predicate::str::contains("--tsconfig"));
+        .stdout(predicate::str::contains("--if-exists"));
 }
 
 #[test]
@@ -172,6 +171,14 @@ fn views_dataset_preview_help_lists_dataset_selectors() {
         .stdout(predicate::str::contains("--row-index"));
 }
 
+#[test]
+fn views_preview_hidden_aggregate_command_is_removed() {
+    bt_command()
+        .args(["views", "preview", "--help"])
+        .assert()
+        .failure();
+}
+
 #[test]
 fn views_trace_bootstrap_creates_starter_file() {
     let dir = tempfile::tempdir().expect("tempdir");