diff --git a/docs/static/schemas/metric-source.schema.json b/docs/static/schemas/metric-source.schema.json new file mode 100644 index 00000000..43b8a932 --- /dev/null +++ b/docs/static/schemas/metric-source.schema.json @@ -0,0 +1,46 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://databricks.github.io/appkit/schemas/metric-source.schema.json", + "title": "AppKit Metric Source Configuration", + "type": "object", + "properties": { + "$schema": { + "description": "Reference to the JSON Schema for validation", + "type": "string" + }, + "metricViews": { + "description": "Metric view declarations, keyed by metric key. Each entry names the UC metric view to query and the executor it runs as.", + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "description": "Metric key. Must be a valid identifier (letters, digits, underscores; cannot start with a digit). Becomes the route key in POST /api/analytics/metric/:key, the hook argument in useMetricView('', ...), and the MetricRegistry augmentation key." + }, + "additionalProperties": { + "type": "object", + "properties": { + "source": { + "type": "string", + "pattern": "^[a-zA-Z0-9_][a-zA-Z0-9_-]*\\.[a-zA-Z0-9_][a-zA-Z0-9_-]*\\.[a-zA-Z0-9_][a-zA-Z0-9_-]*$", + "description": "Three-part Unity Catalog FQN of the metric view: ..", + "examples": [ + "appkit_demo.public.revenue_metrics", + "main.analytics.customer_metrics" + ] + }, + "executor": { + "default": "app_service_principal", + "type": "string", + "enum": ["app_service_principal", "user"], + "description": "Who the metric view is queried as. 'app_service_principal' (default) runs as the app service principal with a cache shared across all users; 'user' runs on-behalf-of the requesting user with a per-user cache." + } + }, + "required": ["source"], + "additionalProperties": false, + "description": "A single metric view source declaration: the UC FQN to query and the executor to query it as. Future per-entry options (cacheTtl, defaultFilter, allowlists) ship as additive properties." + } + } + }, + "additionalProperties": false, + "description": "Schema for AppKit metric.json — declares Unity Catalog Metric View sources for the analytics plugin's metric-view path. Each entry under 'metricViews' binds a metric key to a UC metric view FQN and an executor ('app_service_principal' shared cache, or 'user' per-user cache). Object form (rather than bare string) at v1 enables future per-entry option growth without breaking changes." +} diff --git a/packages/shared/src/schemas/metric-source.test.ts b/packages/shared/src/schemas/metric-source.test.ts new file mode 100644 index 00000000..4b805c25 --- /dev/null +++ b/packages/shared/src/schemas/metric-source.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, test } from "vitest"; +import { metricSourceSchema } from "./metric-source"; + +describe("metricSourceSchema", () => { + test("accepts a minimal configuration and defaults executor to app_service_principal", () => { + const config = { + $schema: + "https://databricks.github.io/appkit/schemas/metric-source.schema.json", + metricViews: { + revenue: { source: "appkit_demo.public.revenue_metrics" }, + }, + }; + const result = metricSourceSchema.safeParse(config); + expect(result.success).toBe(true); + expect(result.data?.metricViews?.revenue.executor).toBe( + "app_service_principal", + ); + }); + + test("accepts explicit executor values", () => { + const config = { + metricViews: { + revenue: { + source: "demo.public.revenue", + executor: "app_service_principal", + }, + my_orders: { source: "main.sales.orders_by_user", executor: "user" }, + }, + }; + const result = metricSourceSchema.safeParse(config); + expect(result.success).toBe(true); + expect(result.data?.metricViews?.my_orders.executor).toBe("user"); + }); + + test("accepts an empty configuration", () => { + expect(metricSourceSchema.safeParse({}).success).toBe(true); + expect(metricSourceSchema.safeParse({ metricViews: {} }).success).toBe( + true, + ); + }); + + test("accepts metric keys with underscores", () => { + const config = { + metricViews: { + customer_metrics: { source: "demo.public.customer_metrics" }, + }, + }; + expect(metricSourceSchema.safeParse(config).success).toBe(true); + }); + + test("rejects the legacy sp/obo lane shape", () => { + const config = { + sp: { revenue: { source: "demo.public.revenue" } }, + obo: { my_orders: { source: "main.sales.orders_by_user" } }, + }; + expect(metricSourceSchema.safeParse(config).success).toBe(false); + }); + + test("rejects invalid executor values", () => { + for (const executor of ["sp", "obo", "service_principal", "USER"]) { + const config = { + metricViews: { revenue: { source: "a.b.c", executor } }, + }; + expect(metricSourceSchema.safeParse(config).success).toBe(false); + } + }); + + test("rejects a bare-string entry (must be an object)", () => { + const config = { + metricViews: { revenue: "demo.public.revenue" }, + }; + expect(metricSourceSchema.safeParse(config).success).toBe(false); + }); + + test("rejects an entry without source", () => { + const config = { + metricViews: { revenue: {} }, + }; + expect(metricSourceSchema.safeParse(config).success).toBe(false); + }); + + test("rejects unknown fields on entries", () => { + const config = { + metricViews: { + revenue: { + source: "a.b.c", + ttl: 5, // future option, not in v1 + }, + }, + }; + expect(metricSourceSchema.safeParse(config).success).toBe(false); + }); + + test("rejects unknown top-level keys", () => { + expect(metricSourceSchema.safeParse({ foo: 1 }).success).toBe(false); + expect( + metricSourceSchema.safeParse({ metricViews: {}, unknown: {} }).success, + ).toBe(false); + }); + + test("rejects metric keys that start with a digit", () => { + const config = { + metricViews: { "1bad": { source: "a.b.c" } }, + }; + expect(metricSourceSchema.safeParse(config).success).toBe(false); + }); + + test("rejects metric keys containing a hyphen", () => { + const config = { + metricViews: { "bad-key": { source: "a.b.c" } }, + }; + expect(metricSourceSchema.safeParse(config).success).toBe(false); + }); + + test("rejects a non-three-part FQN", () => { + const cases = [ + "revenue", // single token + "demo.revenue", // two parts + "four.parts.really.bad", + ".starts.with.dot", + "ends.with.dot.", + ]; + for (const source of cases) { + const config = { metricViews: { revenue: { source } } }; + expect(metricSourceSchema.safeParse(config).success).toBe(false); + } + }); +}); diff --git a/packages/shared/src/schemas/metric-source.ts b/packages/shared/src/schemas/metric-source.ts new file mode 100644 index 00000000..0e1170d7 --- /dev/null +++ b/packages/shared/src/schemas/metric-source.ts @@ -0,0 +1,84 @@ +/** + * AppKit metric-source schema. + * + * Single source of truth for `metric.json` + * the config that activates the Analytics' metric-view path. + * + * `metric.json` declares UC Metric Views under a single `metricViews` map. + * Each entry binds a metric key to a UC metric view FQN plus the executor + * the query runs as: + * - `executor: "app_service_principal"` (default) — queried as the app service + * principal (cache scope shared across all users). + * - `executor: "user"` — queried as the requesting user (on-behalf-of; + * cache scope per-user). + * + * A single map (rather than per-executor sections) makes metric keys unique + * by construction — the same key cannot be declared twice with different + * executors. + */ + +import { z } from "zod"; + +export const metricKeySchema = z + .string() + .regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/) + .describe( + "Metric key. Must be a valid identifier (letters, digits, underscores; cannot start with a digit). Becomes the route key in POST /api/analytics/metric/:key, the hook argument in useMetricView('', ...), and the MetricRegistry augmentation key.", + ); + +export const metricExecutorSchema = z + .enum(["app_service_principal", "user"]) + .describe( + "Who the metric view is queried as. 'app_service_principal' (default) runs as the app service principal with a cache shared across all users; 'user' runs on-behalf-of the requesting user with a per-user cache.", + ); + +/** + * @note Entries are objects (rather than bare strings) at v1 so future per-entry + * options (cacheTtl, defaultFilter, allowlists) can ship as additive + * properties without a breaking change. `executor` is the first such option. + */ +export const metricEntrySchema = z + .object({ + source: z + .string() + .regex( + /^[a-zA-Z0-9_][a-zA-Z0-9_-]*\.[a-zA-Z0-9_][a-zA-Z0-9_-]*\.[a-zA-Z0-9_][a-zA-Z0-9_-]*$/, + ) + .describe( + "Three-part Unity Catalog FQN of the metric view: ..", + ) + .meta({ + examples: [ + "appkit_demo.public.revenue_metrics", + "main.analytics.customer_metrics", + ], + }), + executor: metricExecutorSchema.default("app_service_principal"), + }) + .strict() + .describe( + "A single metric view source declaration: the UC FQN to query and the executor to query it as. Future per-entry options (cacheTtl, defaultFilter, allowlists) ship as additive properties.", + ); + +export const metricSourceSchema = z + .object({ + $schema: z + .string() + .optional() + .describe("Reference to the JSON Schema for validation"), + metricViews: z + .record(metricKeySchema, metricEntrySchema) + .optional() + .describe( + "Metric view declarations, keyed by metric key. Each entry names the UC metric view to query and the executor it runs as.", + ), + }) + .strict() + .describe( + "Schema for AppKit metric.json — declares Unity Catalog Metric View sources for the analytics plugin's metric-view path. Each entry under 'metricViews' binds a metric key to a UC metric view FQN and an executor ('app_service_principal' shared cache, or 'user' per-user cache). Object form (rather than bare string) at v1 enables future per-entry option growth without breaking changes.", + ); + +export type MetricKey = z.infer; +export type MetricExecutor = z.infer; +export type MetricEntry = z.infer; +export type MetricSource = z.infer; diff --git a/tools/generate-json-schema.ts b/tools/generate-json-schema.ts index 367103e2..25cd7519 100644 --- a/tools/generate-json-schema.ts +++ b/tools/generate-json-schema.ts @@ -14,6 +14,7 @@ import { pluginManifestSchema, templatePluginsManifestSchema, } from "../packages/shared/src/schemas/manifest"; +import { metricSourceSchema } from "../packages/shared/src/schemas/metric-source"; import { formatWithBiome } from "./format-with-biome"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -28,11 +29,17 @@ const TEMPLATE_OUT_PATH = path.join( DOCS_SCHEMAS_DIR, "template-plugins.schema.json", ); +const METRIC_SOURCE_OUT_PATH = path.join( + DOCS_SCHEMAS_DIR, + "metric-source.schema.json", +); const PLUGIN_SCHEMA_ID = "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json"; const TEMPLATE_SCHEMA_ID = "https://databricks.github.io/appkit/schemas/template-plugins.schema.json"; +const METRIC_SOURCE_SCHEMA_ID = + "https://databricks.github.io/appkit/schemas/metric-source.schema.json"; function emit( schema: z.ZodType, @@ -91,9 +98,15 @@ async function main(): Promise { TEMPLATE_SCHEMA_ID, "AppKit Template Plugins Manifest", ); + const metricSourceJson = emit( + metricSourceSchema, + METRIC_SOURCE_SCHEMA_ID, + "AppKit Metric Source Configuration", + ); writeJson(PLUGIN_OUT_PATH, pluginJson); writeJson(TEMPLATE_OUT_PATH, templateJson); + writeJson(METRIC_SOURCE_OUT_PATH, metricSourceJson); } main().catch((err) => {