Skip to content
Merged
46 changes: 46 additions & 0 deletions docs/static/schemas/metric-source.schema.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

128 changes: 128 additions & 0 deletions packages/shared/src/schemas/metric-source.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
84 changes: 84 additions & 0 deletions packages/shared/src/schemas/metric-source.ts
Original file line number Diff line number Diff line change
@@ -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('<key>', ...), 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: <catalog>.<schema>.<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<typeof metricKeySchema>;
export type MetricExecutor = z.infer<typeof metricExecutorSchema>;
export type MetricEntry = z.infer<typeof metricEntrySchema>;
export type MetricSource = z.infer<typeof metricSourceSchema>;
Comment thread
atilafassina marked this conversation as resolved.
13 changes: 13 additions & 0 deletions tools/generate-json-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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,
Expand Down Expand Up @@ -91,9 +98,15 @@ async function main(): Promise<void> {
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) => {
Expand Down
Loading