Skip to content

Commit 79c5055

Browse files
authored
chore: add schema for UC Metric Views on Analytics plugin (#429)
* chore(shared): add metric.json schema (Zod source + generated JSON Schema) --------- Signed-off-by: Atila Fassina <atila@fassina.eu>
1 parent 456eab4 commit 79c5055

4 files changed

Lines changed: 271 additions & 0 deletions

File tree

docs/static/schemas/metric-source.schema.json

Lines changed: 46 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { describe, expect, test } from "vitest";
2+
import { metricSourceSchema } from "./metric-source";
3+
4+
describe("metricSourceSchema", () => {
5+
test("accepts a minimal configuration and defaults executor to app_service_principal", () => {
6+
const config = {
7+
$schema:
8+
"https://databricks.github.io/appkit/schemas/metric-source.schema.json",
9+
metricViews: {
10+
revenue: { source: "appkit_demo.public.revenue_metrics" },
11+
},
12+
};
13+
const result = metricSourceSchema.safeParse(config);
14+
expect(result.success).toBe(true);
15+
expect(result.data?.metricViews?.revenue.executor).toBe(
16+
"app_service_principal",
17+
);
18+
});
19+
20+
test("accepts explicit executor values", () => {
21+
const config = {
22+
metricViews: {
23+
revenue: {
24+
source: "demo.public.revenue",
25+
executor: "app_service_principal",
26+
},
27+
my_orders: { source: "main.sales.orders_by_user", executor: "user" },
28+
},
29+
};
30+
const result = metricSourceSchema.safeParse(config);
31+
expect(result.success).toBe(true);
32+
expect(result.data?.metricViews?.my_orders.executor).toBe("user");
33+
});
34+
35+
test("accepts an empty configuration", () => {
36+
expect(metricSourceSchema.safeParse({}).success).toBe(true);
37+
expect(metricSourceSchema.safeParse({ metricViews: {} }).success).toBe(
38+
true,
39+
);
40+
});
41+
42+
test("accepts metric keys with underscores", () => {
43+
const config = {
44+
metricViews: {
45+
customer_metrics: { source: "demo.public.customer_metrics" },
46+
},
47+
};
48+
expect(metricSourceSchema.safeParse(config).success).toBe(true);
49+
});
50+
51+
test("rejects the legacy sp/obo lane shape", () => {
52+
const config = {
53+
sp: { revenue: { source: "demo.public.revenue" } },
54+
obo: { my_orders: { source: "main.sales.orders_by_user" } },
55+
};
56+
expect(metricSourceSchema.safeParse(config).success).toBe(false);
57+
});
58+
59+
test("rejects invalid executor values", () => {
60+
for (const executor of ["sp", "obo", "service_principal", "USER"]) {
61+
const config = {
62+
metricViews: { revenue: { source: "a.b.c", executor } },
63+
};
64+
expect(metricSourceSchema.safeParse(config).success).toBe(false);
65+
}
66+
});
67+
68+
test("rejects a bare-string entry (must be an object)", () => {
69+
const config = {
70+
metricViews: { revenue: "demo.public.revenue" },
71+
};
72+
expect(metricSourceSchema.safeParse(config).success).toBe(false);
73+
});
74+
75+
test("rejects an entry without source", () => {
76+
const config = {
77+
metricViews: { revenue: {} },
78+
};
79+
expect(metricSourceSchema.safeParse(config).success).toBe(false);
80+
});
81+
82+
test("rejects unknown fields on entries", () => {
83+
const config = {
84+
metricViews: {
85+
revenue: {
86+
source: "a.b.c",
87+
ttl: 5, // future option, not in v1
88+
},
89+
},
90+
};
91+
expect(metricSourceSchema.safeParse(config).success).toBe(false);
92+
});
93+
94+
test("rejects unknown top-level keys", () => {
95+
expect(metricSourceSchema.safeParse({ foo: 1 }).success).toBe(false);
96+
expect(
97+
metricSourceSchema.safeParse({ metricViews: {}, unknown: {} }).success,
98+
).toBe(false);
99+
});
100+
101+
test("rejects metric keys that start with a digit", () => {
102+
const config = {
103+
metricViews: { "1bad": { source: "a.b.c" } },
104+
};
105+
expect(metricSourceSchema.safeParse(config).success).toBe(false);
106+
});
107+
108+
test("rejects metric keys containing a hyphen", () => {
109+
const config = {
110+
metricViews: { "bad-key": { source: "a.b.c" } },
111+
};
112+
expect(metricSourceSchema.safeParse(config).success).toBe(false);
113+
});
114+
115+
test("rejects a non-three-part FQN", () => {
116+
const cases = [
117+
"revenue", // single token
118+
"demo.revenue", // two parts
119+
"four.parts.really.bad",
120+
".starts.with.dot",
121+
"ends.with.dot.",
122+
];
123+
for (const source of cases) {
124+
const config = { metricViews: { revenue: { source } } };
125+
expect(metricSourceSchema.safeParse(config).success).toBe(false);
126+
}
127+
});
128+
});
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* AppKit metric-source schema.
3+
*
4+
* Single source of truth for `metric.json`
5+
* the config that activates the Analytics' metric-view path.
6+
*
7+
* `metric.json` declares UC Metric Views under a single `metricViews` map.
8+
* Each entry binds a metric key to a UC metric view FQN plus the executor
9+
* the query runs as:
10+
* - `executor: "app_service_principal"` (default) — queried as the app service
11+
* principal (cache scope shared across all users).
12+
* - `executor: "user"` — queried as the requesting user (on-behalf-of;
13+
* cache scope per-user).
14+
*
15+
* A single map (rather than per-executor sections) makes metric keys unique
16+
* by construction — the same key cannot be declared twice with different
17+
* executors.
18+
*/
19+
20+
import { z } from "zod";
21+
22+
export const metricKeySchema = z
23+
.string()
24+
.regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/)
25+
.describe(
26+
"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.",
27+
);
28+
29+
export const metricExecutorSchema = z
30+
.enum(["app_service_principal", "user"])
31+
.describe(
32+
"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.",
33+
);
34+
35+
/**
36+
* @note Entries are objects (rather than bare strings) at v1 so future per-entry
37+
* options (cacheTtl, defaultFilter, allowlists) can ship as additive
38+
* properties without a breaking change. `executor` is the first such option.
39+
*/
40+
export const metricEntrySchema = z
41+
.object({
42+
source: z
43+
.string()
44+
.regex(
45+
/^[a-zA-Z0-9_][a-zA-Z0-9_-]*\.[a-zA-Z0-9_][a-zA-Z0-9_-]*\.[a-zA-Z0-9_][a-zA-Z0-9_-]*$/,
46+
)
47+
.describe(
48+
"Three-part Unity Catalog FQN of the metric view: <catalog>.<schema>.<metric_view>",
49+
)
50+
.meta({
51+
examples: [
52+
"appkit_demo.public.revenue_metrics",
53+
"main.analytics.customer_metrics",
54+
],
55+
}),
56+
executor: metricExecutorSchema.default("app_service_principal"),
57+
})
58+
.strict()
59+
.describe(
60+
"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.",
61+
);
62+
63+
export const metricSourceSchema = z
64+
.object({
65+
$schema: z
66+
.string()
67+
.optional()
68+
.describe("Reference to the JSON Schema for validation"),
69+
metricViews: z
70+
.record(metricKeySchema, metricEntrySchema)
71+
.optional()
72+
.describe(
73+
"Metric view declarations, keyed by metric key. Each entry names the UC metric view to query and the executor it runs as.",
74+
),
75+
})
76+
.strict()
77+
.describe(
78+
"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.",
79+
);
80+
81+
export type MetricKey = z.infer<typeof metricKeySchema>;
82+
export type MetricExecutor = z.infer<typeof metricExecutorSchema>;
83+
export type MetricEntry = z.infer<typeof metricEntrySchema>;
84+
export type MetricSource = z.infer<typeof metricSourceSchema>;

tools/generate-json-schema.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
pluginManifestSchema,
1515
templatePluginsManifestSchema,
1616
} from "../packages/shared/src/schemas/manifest";
17+
import { metricSourceSchema } from "../packages/shared/src/schemas/metric-source";
1718
import { formatWithBiome } from "./format-with-biome";
1819

1920
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -28,11 +29,17 @@ const TEMPLATE_OUT_PATH = path.join(
2829
DOCS_SCHEMAS_DIR,
2930
"template-plugins.schema.json",
3031
);
32+
const METRIC_SOURCE_OUT_PATH = path.join(
33+
DOCS_SCHEMAS_DIR,
34+
"metric-source.schema.json",
35+
);
3136

3237
const PLUGIN_SCHEMA_ID =
3338
"https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json";
3439
const TEMPLATE_SCHEMA_ID =
3540
"https://databricks.github.io/appkit/schemas/template-plugins.schema.json";
41+
const METRIC_SOURCE_SCHEMA_ID =
42+
"https://databricks.github.io/appkit/schemas/metric-source.schema.json";
3643

3744
function emit(
3845
schema: z.ZodType,
@@ -91,9 +98,15 @@ async function main(): Promise<void> {
9198
TEMPLATE_SCHEMA_ID,
9299
"AppKit Template Plugins Manifest",
93100
);
101+
const metricSourceJson = emit(
102+
metricSourceSchema,
103+
METRIC_SOURCE_SCHEMA_ID,
104+
"AppKit Metric Source Configuration",
105+
);
94106

95107
writeJson(PLUGIN_OUT_PATH, pluginJson);
96108
writeJson(TEMPLATE_OUT_PATH, templateJson);
109+
writeJson(METRIC_SOURCE_OUT_PATH, metricSourceJson);
97110
}
98111

99112
main().catch((err) => {

0 commit comments

Comments
 (0)