measurr 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +92 -1
- package/dist/analytics.d.ts +5 -0
- package/dist/handler.js +8 -46
- package/dist/{index-g255jb1s.js → index-115syy01.js} +1 -1
- package/dist/{index-g2mjqx4f.js → index-pgccd363.js} +1 -1
- package/dist/index-xf6qpmgy.js +46 -0
- package/dist/{index-zgb1e89d.js → index-z5xv3ffk.js} +10 -5
- package/dist/index.js +2 -2
- package/dist/metrics.d.ts +152 -0
- package/dist/metrics.js +167 -0
- package/dist/shapes.d.ts +146 -1
- package/dist/shapes.js +84 -1
- package/dist/testing.js +2 -2
- package/package.json +8 -2
- package/src/analytics.ts +18 -8
- package/src/metrics.ts +402 -0
- package/src/shapes.ts +276 -1
package/README.md
CHANGED
|
@@ -242,6 +242,95 @@ toLeaderboard(result, { by: 'region', measure: 'revenue' }) // ranked rows with
|
|
|
242
242
|
- `toDistribution`'s `total` is the measure over every row, so many-to-many shares can add up to
|
|
243
243
|
more than 1. `toPivot` sorts numeric keys (weekdays, hours).
|
|
244
244
|
|
|
245
|
+
### Presentation
|
|
246
|
+
|
|
247
|
+
`presentKpi`, `presentTimeseries`, `presentDistribution` and `presentLeaderboard` turn a result
|
|
248
|
+
into the wire outputs a dashboard draws (`KpiOutput`, `TimeSeriesOutput`, `DistributionOutput`,
|
|
249
|
+
`LeaderboardOutput`; `TableOutput` and `PivotOutput` for outputs you build yourself). A missing
|
|
250
|
+
value is 0, keys are strings, and the caller picks labels, units and chart types:
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
presentKpi(result, 'paidShare', { label: 'Paid', percent: true })
|
|
254
|
+
// { kind: 'kpi', value: 25, unit: 'PERCENTAGE', label: 'Paid' }
|
|
255
|
+
|
|
256
|
+
presentTimeseries(result, {
|
|
257
|
+
bucket: 'day', measure: 'orders', series: 'status',
|
|
258
|
+
keys: { PAID: 'paid' }, // wire keys, by String(key); 'null' names the empty group
|
|
259
|
+
labels: { paid: 'Settled' }, // labels by wire key
|
|
260
|
+
chartType: 'line'
|
|
261
|
+
})
|
|
262
|
+
// points are { ts: Date, value }: ts is the bucket's local start date as UTC midnight
|
|
263
|
+
|
|
264
|
+
presentDistribution(result, { by: 'tag', measure: 'orders', chartType: 'donut', order: ['t2'] })
|
|
265
|
+
// total is what the segments add up to, percent is 0-100; keys order omits go last
|
|
266
|
+
|
|
267
|
+
presentLeaderboard(result, { by: 'region', measure: 'orders', meta: (values) => ({ revenue: values.revenue }) })
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
`isKpi`, `isTimeSeries`, `isDistribution`, `isTable`, `isLeaderboard` and `isPivot` narrow a
|
|
271
|
+
`MetricOutput`. The module is pure, so a browser bundle can import the types and guards.
|
|
272
|
+
|
|
273
|
+
## Dashboard metrics
|
|
274
|
+
|
|
275
|
+
`measurr/metrics` names the numbers a dashboard shows: one query and the output it is drawn
|
|
276
|
+
in. The registry runs a batch for a caller and streams one chunk per metric.
|
|
277
|
+
|
|
278
|
+
```ts
|
|
279
|
+
import { createMetricRegistry, metricDefiner } from 'measurr/metrics'
|
|
280
|
+
import { presentKpi, presentTimeseries } from 'measurr/shapes'
|
|
281
|
+
|
|
282
|
+
// Types only, so metric modules need not import the analytics instance.
|
|
283
|
+
export const defineMetric = metricDefiner<(typeof datasets)[number], Role>()
|
|
284
|
+
|
|
285
|
+
const orderTrend = defineMetric({
|
|
286
|
+
key: 'orders.trend',
|
|
287
|
+
kind: 'timeseries',
|
|
288
|
+
description: 'Orders over time',
|
|
289
|
+
allowedRoles: ['owner', 'member'],
|
|
290
|
+
supportsTimeRange: true, // false for a snapshot: all time, no previous period
|
|
291
|
+
filterSchema: z.object({ status: z.enum(['OPEN', 'PAID']).optional() }),
|
|
292
|
+
query: ({ bucket, filters }) => ({
|
|
293
|
+
dataset: 'orders',
|
|
294
|
+
measures: ['orders'],
|
|
295
|
+
groupBy: [bucket],
|
|
296
|
+
filters: filters.status ? [{ dimension: 'status', op: 'in', values: [filters.status] }] : [],
|
|
297
|
+
limit: 100
|
|
298
|
+
}),
|
|
299
|
+
shape: (result, { bucket }) =>
|
|
300
|
+
presentTimeseries(result, { bucket, measure: 'orders', chartType: 'bar' })
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
export const metrics = createMetricRegistry({
|
|
304
|
+
analytics,
|
|
305
|
+
metrics: [orderTrend, orderCount] as const,
|
|
306
|
+
canRun: (metric, ctx) => ctx.isSuperAdmin || (metric.allowedRoles ?? []).includes(ctx.role),
|
|
307
|
+
onShapeError: (error, metric) => logger.error('metric.shape_failed', { key: metric.key, error })
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
const input = metrics.requestSchema.extend({ refresh: z.boolean().default(false) }).parse(body)
|
|
311
|
+
for await (const chunk of metrics.run(input, ctx)) send(chunk)
|
|
312
|
+
// { key, requestKey, result: { current, previous?, supportsTimeRange }, done }
|
|
313
|
+
// or { key, requestKey, error, done }
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
- `query` returns the query without `period` or `compareToPrevious`. The registry counts the
|
|
317
|
+
local days `from` and `to` (instants) fall on in `analytics.timezone(ctx)`, all time without
|
|
318
|
+
them, and always all time for a snapshot. `compareToPrevious` adds the previous period, whose
|
|
319
|
+
series points are put on the current buckets so a chart overlays them.
|
|
320
|
+
- `granularity` (hour to year, inferred from the range when omitted: day up to 31 days, week up
|
|
321
|
+
to 90, else month) picks `run.bucket`: `day`, `week` or `month`. Name time-bucket dimensions
|
|
322
|
+
after them so `groupBy: [bucket]` works.
|
|
323
|
+
- A metric that `canRun` refuses (`'Access denied'`), whose filters do not parse with its
|
|
324
|
+
`filterSchema` (`'Invalid filters: ...'`), whose query fails (the engine's message) or whose
|
|
325
|
+
`shape` throws (`'The metric could not be drawn'`) fails alone. `done` marks the last chunk.
|
|
326
|
+
- `metricDefiner<Datasets, Role, Extra>()` takes the app's own definition fields as `Extra`
|
|
327
|
+
(catalog metadata, UI hints for filters); the registry carries them to `canRun` and
|
|
328
|
+
`available` without reading them.
|
|
329
|
+
- Keys and outputs stay literal: a chunk narrows to its metric by `key`, and
|
|
330
|
+
`MetricOutputOf<typeof metrics, 'orders.trend'>`, `MetricFiltersOf`, `MetricKeysOfKind` and
|
|
331
|
+
`registry.get(key)` are typed per metric. `registry.available(ctx)` lists what `canRun`
|
|
332
|
+
allows, for a catalog.
|
|
333
|
+
|
|
245
334
|
## Serving queries
|
|
246
335
|
|
|
247
336
|
`measurr/handler` serves queries from any server framework:
|
|
@@ -517,7 +606,9 @@ Kept stable for publishing: `createAnalytics` and its options (with `authorize`,
|
|
|
517
606
|
`streamBatch`, `toHttpError`); and the types `AnalyticsQuery`, `AnalyticsResult`,
|
|
518
607
|
`ResultPeriod`, `PeriodInput`, `Tenant`, `TenantId`, `AccessKey`, `AnalyticsCache`, `FieldKey`,
|
|
519
608
|
`Ratio`, `AnalyticsError` codes and their details (`isAnalyticsError`). `ExprNode` and
|
|
520
|
-
`SelectStatement` are exported for dialect authors and may grow new node kinds.
|
|
609
|
+
`SelectStatement` are exported for dialect authors and may grow new node kinds. The
|
|
610
|
+
presenters and wire outputs in `measurr/shapes`, `measurr/metrics` and `analytics.timezone`
|
|
611
|
+
are new and may still change before they join this list.
|
|
521
612
|
|
|
522
613
|
## Install
|
|
523
614
|
|
package/dist/analytics.d.ts
CHANGED
|
@@ -174,6 +174,11 @@ export type Analytics<DS extends readonly AnyDataset[], Ctx> = {
|
|
|
174
174
|
}): Promise<z.ZodType<DatasetQuery<DS[number]>>>;
|
|
175
175
|
/** The result schema for one dataset. */
|
|
176
176
|
resultSchema(dataset: DS[number]['key']): z.ZodType<QueryResult<Row, Row>>;
|
|
177
|
+
/**
|
|
178
|
+
* The timezone `ctx`'s periods and buckets resolve in: the `timezone` hook's answer, UTC
|
|
179
|
+
* when it has none. Rejects with `invalid_timezone` for a name the runtime does not know.
|
|
180
|
+
*/
|
|
181
|
+
timezone(ctx: Ctx): Promise<string>;
|
|
177
182
|
/** The datasets `ctx` may query, with the measures and dimensions it may use. */
|
|
178
183
|
listDatasets(ctx: Ctx): Promise<DatasetCatalogEntry<DS[number]['key']>[]>;
|
|
179
184
|
};
|
package/dist/handler.js
CHANGED
|
@@ -1,49 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
const results = {};
|
|
8
|
-
const errors = {};
|
|
9
|
-
for await (const chunk of streamBatch(analytics, queries, ctx)) {
|
|
10
|
-
if ("error" in chunk)
|
|
11
|
-
errors[chunk.key] = chunk.error;
|
|
12
|
-
else
|
|
13
|
-
results[chunk.key] = chunk.result;
|
|
14
|
-
}
|
|
15
|
-
return { results, errors };
|
|
16
|
-
}
|
|
17
|
-
async function* streamBatch(analytics, queries, ctx) {
|
|
18
|
-
const pending = new Map(Object.entries(queries).map(([key, query]) => {
|
|
19
|
-
const settled = analytics.query(query, ctx).then((result) => ({ key, result }), (error) => ({ key, error: batchError(error) }));
|
|
20
|
-
return [key, settled];
|
|
21
|
-
}));
|
|
22
|
-
while (pending.size > 0) {
|
|
23
|
-
const chunk = await Promise.race(pending.values());
|
|
24
|
-
pending.delete(chunk.key);
|
|
25
|
-
yield chunk;
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
function batchError(error) {
|
|
29
|
-
return error instanceof AnalyticsError2 ? { code: error.code, message: error.message } : { code: "internal", message: "The query failed" };
|
|
30
|
-
}
|
|
31
|
-
function toHttpError(error) {
|
|
32
|
-
if (error.code === "forbidden") {
|
|
33
|
-
return { status: 403, code: "FORBIDDEN", message: error.message };
|
|
34
|
-
}
|
|
35
|
-
if (error.code === "missing_tenant") {
|
|
36
|
-
return { status: 401, code: "UNAUTHORIZED", message: error.message };
|
|
37
|
-
}
|
|
38
|
-
return {
|
|
39
|
-
status: 400,
|
|
40
|
-
code: "BAD_REQUEST",
|
|
41
|
-
message: error.message,
|
|
42
|
-
data: { code: error.code, details: error.details }
|
|
43
|
-
};
|
|
44
|
-
}
|
|
2
|
+
queryBatch2,
|
|
3
|
+
streamBatch2,
|
|
4
|
+
toHttpError2
|
|
5
|
+
} from "./index-xf6qpmgy.js";
|
|
6
|
+
import"./index-z5xv3ffk.js";
|
|
45
7
|
export {
|
|
46
|
-
queryBatch,
|
|
47
|
-
streamBatch,
|
|
48
|
-
toHttpError
|
|
8
|
+
queryBatch2 as queryBatch,
|
|
9
|
+
streamBatch2 as streamBatch,
|
|
10
|
+
toHttpError2 as toHttpError
|
|
49
11
|
};
|
|
@@ -146,4 +146,4 @@ function bucketStarts(from, to, unit) {
|
|
|
146
146
|
return starts;
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
-
export { MAX_PERIOD_DAYS, DEFAULT_PERIOD, isValidTimezone, PeriodError, resolvePeriod, previousPeriod, bucketStarts };
|
|
149
|
+
export { MAX_PERIOD_DAYS, DEFAULT_PERIOD, isValidTimezone, localDate, PeriodError, resolvePeriod, previousPeriod, bucketStarts };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AnalyticsError2
|
|
3
|
+
} from "./index-z5xv3ffk.js";
|
|
4
|
+
|
|
5
|
+
// src/handler.ts
|
|
6
|
+
async function queryBatch2(analytics, queries, ctx) {
|
|
7
|
+
const results = {};
|
|
8
|
+
const errors = {};
|
|
9
|
+
for await (const chunk of streamBatch2(analytics, queries, ctx)) {
|
|
10
|
+
if ("error" in chunk)
|
|
11
|
+
errors[chunk.key] = chunk.error;
|
|
12
|
+
else
|
|
13
|
+
results[chunk.key] = chunk.result;
|
|
14
|
+
}
|
|
15
|
+
return { results, errors };
|
|
16
|
+
}
|
|
17
|
+
async function* streamBatch2(analytics, queries, ctx) {
|
|
18
|
+
const pending = new Map(Object.entries(queries).map(([key, query]) => {
|
|
19
|
+
const settled = analytics.query(query, ctx).then((result) => ({ key, result }), (error) => ({ key, error: batchError(error) }));
|
|
20
|
+
return [key, settled];
|
|
21
|
+
}));
|
|
22
|
+
while (pending.size > 0) {
|
|
23
|
+
const chunk = await Promise.race(pending.values());
|
|
24
|
+
pending.delete(chunk.key);
|
|
25
|
+
yield chunk;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function batchError(error) {
|
|
29
|
+
return error instanceof AnalyticsError2 ? { code: error.code, message: error.message } : { code: "internal", message: "The query failed" };
|
|
30
|
+
}
|
|
31
|
+
function toHttpError2(error) {
|
|
32
|
+
if (error.code === "forbidden") {
|
|
33
|
+
return { status: 403, code: "FORBIDDEN", message: error.message };
|
|
34
|
+
}
|
|
35
|
+
if (error.code === "missing_tenant") {
|
|
36
|
+
return { status: 401, code: "UNAUTHORIZED", message: error.message };
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
status: 400,
|
|
40
|
+
code: "BAD_REQUEST",
|
|
41
|
+
message: error.message,
|
|
42
|
+
data: { code: error.code, details: error.details }
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export { queryBatch2, streamBatch2, toHttpError2 };
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
PeriodError,
|
|
5
5
|
resolvePeriod,
|
|
6
6
|
previousPeriod
|
|
7
|
-
} from "./index-
|
|
7
|
+
} from "./index-115syy01.js";
|
|
8
8
|
|
|
9
9
|
// src/query.ts
|
|
10
10
|
import { z } from "zod";
|
|
@@ -685,6 +685,13 @@ function createAnalytics2(options) {
|
|
|
685
685
|
}
|
|
686
686
|
return querySchemaFor(allowed);
|
|
687
687
|
};
|
|
688
|
+
const timezoneOf = async (ctx) => {
|
|
689
|
+
const timezone = await options.timezone?.(ctx) || "UTC";
|
|
690
|
+
if (!isValidTimezone(timezone)) {
|
|
691
|
+
throw new AnalyticsError2("invalid_timezone", `Unknown timezone ${timezone}`);
|
|
692
|
+
}
|
|
693
|
+
return timezone;
|
|
694
|
+
};
|
|
688
695
|
const prepare = async (input, ctx) => {
|
|
689
696
|
const parsed = querySchemaFor(datasets).safeParse(input);
|
|
690
697
|
if (!parsed.success) {
|
|
@@ -711,10 +718,7 @@ function createAnalytics2(options) {
|
|
|
711
718
|
if (!dataset.tenantColumn && tenant !== allTenants2) {
|
|
712
719
|
throw new AnalyticsError2("forbidden", `${dataset.key} has no tenant column; only a caller allowed to see every tenant may query it`);
|
|
713
720
|
}
|
|
714
|
-
const timezone = await
|
|
715
|
-
if (!isValidTimezone(timezone)) {
|
|
716
|
-
throw new AnalyticsError2("invalid_timezone", `Unknown timezone ${timezone}`);
|
|
717
|
-
}
|
|
721
|
+
const timezone = await timezoneOf(ctx);
|
|
718
722
|
const query = normalizeQuery(dataset, parsedQuery);
|
|
719
723
|
const at = now();
|
|
720
724
|
let period;
|
|
@@ -858,6 +862,7 @@ function createAnalytics2(options) {
|
|
|
858
862
|
explain,
|
|
859
863
|
querySchema: (schemaOptions) => schemaOptions && ("ctx" in schemaOptions) ? querySchemaForCtx(schemaOptions.ctx) : querySchemaFor(schemaOptions?.datasets?.map(datasetOf) ?? datasets),
|
|
860
864
|
resultSchema: (key) => resultSchemaFor(key),
|
|
865
|
+
timezone: timezoneOf,
|
|
861
866
|
listDatasets: async (ctx) => (await allowedDatasets(ctx)).map((dataset) => catalogEntry(dataset))
|
|
862
867
|
};
|
|
863
868
|
}
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
AnalyticsError2,
|
|
5
5
|
isAnalyticsError2,
|
|
6
6
|
createAnalytics2
|
|
7
|
-
} from "./index-
|
|
7
|
+
} from "./index-z5xv3ffk.js";
|
|
8
8
|
import {
|
|
9
9
|
CompileError2,
|
|
10
10
|
decodeNumber2,
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
measure2,
|
|
15
15
|
dimension2,
|
|
16
16
|
defineDataset2
|
|
17
|
-
} from "./index-
|
|
17
|
+
} from "./index-pgccd363.js";
|
|
18
18
|
import {
|
|
19
19
|
Expr2,
|
|
20
20
|
Table2,
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named dashboard metrics: one query over a dataset and the shape it is drawn in. The
|
|
3
|
+
* registry runs a batch of them for a caller and yields each as it finishes; the period, the
|
|
4
|
+
* previous period and the tenant scope come from the request and `ctx`, never from a metric.
|
|
5
|
+
*/
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import type { Analytics } from './analytics.js';
|
|
8
|
+
import type { AnyDataset } from './dataset.js';
|
|
9
|
+
import type { DatasetQuery, QueryResult, ResultOf } from './query.js';
|
|
10
|
+
import type { MetricOutput, OutputForKind, OutputKind, TimeGranularity } from './shapes.js';
|
|
11
|
+
/** The buckets a time series is grouped by. */
|
|
12
|
+
export type MetricBucket = 'day' | 'week' | 'month';
|
|
13
|
+
/** What a metric's `query` and `shape` are run with. */
|
|
14
|
+
export type MetricRun<Filters> = {
|
|
15
|
+
/** The metric's own filters, parsed with its `filterSchema`. */
|
|
16
|
+
filters: Filters;
|
|
17
|
+
/** The time-bucket dimension to group a series by: the granularity, as near as it gets. */
|
|
18
|
+
bucket: MetricBucket;
|
|
19
|
+
granularity: TimeGranularity;
|
|
20
|
+
};
|
|
21
|
+
/** A result as `shape` receives it: one period, without `previous`. */
|
|
22
|
+
export type MetricQueryResult<DS, Q> = ResultOf<DS, Q> extends QueryResult<infer Row, infer Totals, infer _Period> ? Omit<QueryResult<Row, Totals>, 'previous'> : never;
|
|
23
|
+
export type MetricDefinition<DS extends AnyDataset, Key extends string, Kind extends OutputKind, Filters, Q extends DatasetQuery<DS>, Output extends OutputForKind<Kind>, Role extends string = string> = {
|
|
24
|
+
key: Key;
|
|
25
|
+
kind: Kind;
|
|
26
|
+
description: string;
|
|
27
|
+
/** For `canRun`; the registry does not read it. */
|
|
28
|
+
allowedRoles?: readonly Role[] | undefined;
|
|
29
|
+
/** False for a snapshot of now: it counts all time and has no previous period. */
|
|
30
|
+
supportsTimeRange: boolean;
|
|
31
|
+
filterSchema: z.ZodType<Filters>;
|
|
32
|
+
/** The query, without `period` or `compareToPrevious`: the registry sets those. */
|
|
33
|
+
query: (run: MetricRun<Filters>) => Q;
|
|
34
|
+
/** Called once for the period and once for the previous period. */
|
|
35
|
+
shape: (result: MetricQueryResult<DS, Q>, run: MetricRun<Filters>) => Output;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* `defineMetric` for metrics over the datasets `DS` (a union), with `allowedRoles` typed as
|
|
39
|
+
* `Role` and the app's own fields `Extra` (catalog metadata, say), which the registry carries
|
|
40
|
+
* along but does not read. Types only, so metric modules need not import the analytics
|
|
41
|
+
* instance:
|
|
42
|
+
*
|
|
43
|
+
* ```ts
|
|
44
|
+
* export const defineMetric = metricDefiner<(typeof datasets)[number], Role>()
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export declare function metricDefiner<DS extends AnyDataset, Role extends string = string, Extra extends object = Record<never, never>>(): <const Key extends string, const Kind extends OutputKind, Filters, const Q extends DatasetQuery<DS>, Output extends OutputForKind<Kind>>(definition: MetricDefinition<DS, Key, Kind, Filters, Q, Output, Role> & Extra) => MetricDefinition<DS, Key, Kind, Filters, Q, Output, Role> & Extra;
|
|
48
|
+
/** Any metric, as the registry holds them. */
|
|
49
|
+
export type AnyMetric = {
|
|
50
|
+
key: string;
|
|
51
|
+
kind: OutputKind;
|
|
52
|
+
description: string;
|
|
53
|
+
allowedRoles?: readonly string[] | undefined;
|
|
54
|
+
supportsTimeRange: boolean;
|
|
55
|
+
filterSchema: z.ZodType<any>;
|
|
56
|
+
query: (run: MetricRun<any>) => DatasetQuery<AnyDataset>;
|
|
57
|
+
shape: (result: any, run: MetricRun<any>) => MetricOutput;
|
|
58
|
+
};
|
|
59
|
+
export type MetricKeyOf<R extends {
|
|
60
|
+
metrics: readonly AnyMetric[];
|
|
61
|
+
}> = R['metrics'][number]['key'];
|
|
62
|
+
export type MetricOf<R extends {
|
|
63
|
+
metrics: readonly AnyMetric[];
|
|
64
|
+
}, K extends MetricKeyOf<R>> = Extract<R['metrics'][number], {
|
|
65
|
+
key: K;
|
|
66
|
+
}>;
|
|
67
|
+
export type MetricOutputOf<R extends {
|
|
68
|
+
metrics: readonly AnyMetric[];
|
|
69
|
+
}, K extends MetricKeyOf<R>> = ReturnType<MetricOf<R, K>['shape']>;
|
|
70
|
+
export type MetricFiltersOf<R extends {
|
|
71
|
+
metrics: readonly AnyMetric[];
|
|
72
|
+
}, K extends MetricKeyOf<R>> = z.infer<MetricOf<R, K>['filterSchema']>;
|
|
73
|
+
export type MetricKeysOfKind<R extends {
|
|
74
|
+
metrics: readonly AnyMetric[];
|
|
75
|
+
}, Kind extends OutputKind> = Extract<R['metrics'][number], {
|
|
76
|
+
kind: Kind;
|
|
77
|
+
}>['key'];
|
|
78
|
+
/** One metric's numbers: the period, and the previous period on the same buckets. */
|
|
79
|
+
export type MetricResult<Output = MetricOutput> = {
|
|
80
|
+
current: Output;
|
|
81
|
+
previous?: Output;
|
|
82
|
+
supportsTimeRange: boolean;
|
|
83
|
+
};
|
|
84
|
+
/** One metric's result or error, as each finishes. */
|
|
85
|
+
export type MetricChunk<M extends AnyMetric = AnyMetric> = M extends AnyMetric ? {
|
|
86
|
+
key: M['key'];
|
|
87
|
+
/** The request's name for it; the key unless one request asks for a metric twice. */
|
|
88
|
+
requestKey: string;
|
|
89
|
+
/** Whether this is the last chunk of the request. */
|
|
90
|
+
done: boolean;
|
|
91
|
+
} & ({
|
|
92
|
+
result: MetricResult<ReturnType<M['shape']>>;
|
|
93
|
+
error?: undefined;
|
|
94
|
+
} | {
|
|
95
|
+
error: string;
|
|
96
|
+
result?: undefined;
|
|
97
|
+
}) : never;
|
|
98
|
+
export type MetricsRequest<Key extends string = string> = {
|
|
99
|
+
metrics: readonly {
|
|
100
|
+
key: Key;
|
|
101
|
+
requestKey?: string | undefined;
|
|
102
|
+
filters?: unknown;
|
|
103
|
+
}[];
|
|
104
|
+
/** Instants; the registry counts the local days they fall on in `analytics.timezone(ctx)`. */
|
|
105
|
+
from?: Date | undefined;
|
|
106
|
+
to?: Date | undefined;
|
|
107
|
+
/** Inferred from the range when omitted: day up to 31 days, week up to 90, else month. */
|
|
108
|
+
granularity?: TimeGranularity | undefined;
|
|
109
|
+
compareToPrevious?: boolean | undefined;
|
|
110
|
+
};
|
|
111
|
+
export type MetricRegistry<M extends readonly AnyMetric[], Ctx> = {
|
|
112
|
+
readonly metrics: M;
|
|
113
|
+
/** The keys, for a request schema or a catalog. */
|
|
114
|
+
readonly keys: [M[number]['key'], ...M[number]['key'][]];
|
|
115
|
+
/** The request schema; `.extend` it with the app's own fields. */
|
|
116
|
+
readonly requestSchema: z.ZodObject<{
|
|
117
|
+
metrics: z.ZodArray<z.ZodObject<{
|
|
118
|
+
key: z.ZodEnum<{
|
|
119
|
+
[K in M[number]['key']]: K;
|
|
120
|
+
}>;
|
|
121
|
+
requestKey: z.ZodOptional<z.ZodString>;
|
|
122
|
+
filters: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
123
|
+
}>>;
|
|
124
|
+
from: z.ZodOptional<z.ZodDate>;
|
|
125
|
+
to: z.ZodOptional<z.ZodDate>;
|
|
126
|
+
granularity: z.ZodOptional<z.ZodEnum<{
|
|
127
|
+
[G in TimeGranularity]: G;
|
|
128
|
+
}>>;
|
|
129
|
+
compareToPrevious: z.ZodDefault<z.ZodBoolean>;
|
|
130
|
+
}>;
|
|
131
|
+
get<K extends M[number]['key']>(key: K): Extract<M[number], {
|
|
132
|
+
key: K;
|
|
133
|
+
}>;
|
|
134
|
+
/** The metrics `canRun` allows for `ctx`. */
|
|
135
|
+
available(ctx: Ctx): Promise<M[number][]>;
|
|
136
|
+
/**
|
|
137
|
+
* Runs the requested metrics in parallel and yields each as it finishes. A metric `ctx` may
|
|
138
|
+
* not run, or whose filters do not parse, fails alone. A metric that supports a time range
|
|
139
|
+
* counts `from` to `to` (all time without them) and compares with the previous period when
|
|
140
|
+
* asked; a snapshot counts all time.
|
|
141
|
+
*/
|
|
142
|
+
run(request: MetricsRequest<M[number]['key']>, ctx: Ctx): AsyncGenerator<MetricChunk<M[number]>, void, undefined>;
|
|
143
|
+
};
|
|
144
|
+
export type MetricRegistryOptions<DS extends readonly AnyDataset[], M extends readonly AnyMetric[], Ctx> = {
|
|
145
|
+
analytics: Analytics<DS, Ctx>;
|
|
146
|
+
metrics: M;
|
|
147
|
+
/** Whether `ctx` may run `metric`. Every metric runs when omitted. */
|
|
148
|
+
canRun?: ((metric: M[number], ctx: Ctx) => boolean | Promise<boolean>) | undefined;
|
|
149
|
+
/** Hears a `shape` that throws; the metric fails with "The metric could not be drawn". */
|
|
150
|
+
onShapeError?: ((error: unknown, metric: M[number], ctx: Ctx) => unknown) | undefined;
|
|
151
|
+
};
|
|
152
|
+
export declare function createMetricRegistry<DS extends readonly AnyDataset[], const M extends readonly AnyMetric[], Ctx>(options: MetricRegistryOptions<DS, M, Ctx>): MetricRegistry<M, Ctx>;
|
package/dist/metrics.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import"./index-z5xv3ffk.js";
|
|
2
|
+
import {
|
|
3
|
+
streamBatch2
|
|
4
|
+
} from "./index-xf6qpmgy.js";
|
|
5
|
+
import {
|
|
6
|
+
localDate
|
|
7
|
+
} from "./index-115syy01.js";
|
|
8
|
+
|
|
9
|
+
// src/metrics.ts
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
function metricDefiner() {
|
|
12
|
+
return (definition) => definition;
|
|
13
|
+
}
|
|
14
|
+
var GRANULARITIES = [
|
|
15
|
+
"hour",
|
|
16
|
+
"day",
|
|
17
|
+
"week",
|
|
18
|
+
"month",
|
|
19
|
+
"quarter",
|
|
20
|
+
"year"
|
|
21
|
+
];
|
|
22
|
+
function createMetricRegistry(options) {
|
|
23
|
+
const { analytics, metrics } = options;
|
|
24
|
+
const byKey = new Map;
|
|
25
|
+
for (const metric of metrics) {
|
|
26
|
+
if (byKey.has(metric.key))
|
|
27
|
+
throw new Error(`Two metrics use the key ${metric.key}`);
|
|
28
|
+
byKey.set(metric.key, metric);
|
|
29
|
+
}
|
|
30
|
+
const keys = metrics.map((metric) => metric.key);
|
|
31
|
+
if (keys.length === 0)
|
|
32
|
+
throw new Error("A registry needs a metric");
|
|
33
|
+
const canRun = (metric, ctx) => options.canRun ? options.canRun(metric, ctx) : true;
|
|
34
|
+
const requestSchema = z.object({
|
|
35
|
+
metrics: z.array(z.object({
|
|
36
|
+
key: z.enum(keys),
|
|
37
|
+
requestKey: z.string().optional(),
|
|
38
|
+
filters: z.record(z.string(), z.unknown()).optional()
|
|
39
|
+
})).min(1, "At least one metric required"),
|
|
40
|
+
from: z.date().optional(),
|
|
41
|
+
to: z.date().optional(),
|
|
42
|
+
granularity: z.enum(GRANULARITIES).optional(),
|
|
43
|
+
compareToPrevious: z.boolean().default(false)
|
|
44
|
+
});
|
|
45
|
+
async function* run(request, ctx) {
|
|
46
|
+
const timezone = await analytics.timezone(ctx);
|
|
47
|
+
const granularity = request.granularity ?? inferGranularity(request.from, request.to);
|
|
48
|
+
const bucket = bucketFor(granularity);
|
|
49
|
+
const range = request.from && request.to ? {
|
|
50
|
+
from: localDate(request.from, timezone),
|
|
51
|
+
to: localDate(request.to, timezone)
|
|
52
|
+
} : null;
|
|
53
|
+
const failed = [];
|
|
54
|
+
const runs = new Map;
|
|
55
|
+
const queries = {};
|
|
56
|
+
for (const requested of request.metrics) {
|
|
57
|
+
const requestKey = requested.requestKey ?? requested.key;
|
|
58
|
+
const fail = (error) => failed.push({ key: requested.key, requestKey, error });
|
|
59
|
+
const metric = byKey.get(requested.key);
|
|
60
|
+
if (!metric) {
|
|
61
|
+
fail("Unknown metric");
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (!await canRun(metric, ctx)) {
|
|
65
|
+
fail("Access denied");
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const filters = metric.filterSchema.safeParse(requested.filters ?? {});
|
|
69
|
+
if (!filters.success) {
|
|
70
|
+
fail(`Invalid filters: ${filters.error.message}`);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const metricRun = { filters: filters.data, bucket, granularity };
|
|
74
|
+
const period = metric.supportsTimeRange ? range : null;
|
|
75
|
+
queries[requestKey] = {
|
|
76
|
+
...metric.query(metricRun),
|
|
77
|
+
period: period ?? { all: true },
|
|
78
|
+
compareToPrevious: period !== null && !!request.compareToPrevious
|
|
79
|
+
};
|
|
80
|
+
runs.set(requestKey, { metric, run: metricRun });
|
|
81
|
+
}
|
|
82
|
+
const total = failed.length + runs.size;
|
|
83
|
+
let yielded = 0;
|
|
84
|
+
const next = (chunk) => ({ ...chunk, done: ++yielded === total });
|
|
85
|
+
for (const chunk of failed)
|
|
86
|
+
yield next(chunk);
|
|
87
|
+
for await (const chunk of streamBatch2(analytics, queries, ctx)) {
|
|
88
|
+
const prepared = runs.get(chunk.key);
|
|
89
|
+
if (!prepared)
|
|
90
|
+
continue;
|
|
91
|
+
const { metric, run: metricRun } = prepared;
|
|
92
|
+
const base = { key: metric.key, requestKey: chunk.key };
|
|
93
|
+
if ("error" in chunk) {
|
|
94
|
+
yield next({ ...base, error: chunk.error.message });
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
let result;
|
|
98
|
+
try {
|
|
99
|
+
const { previous, ...current } = chunk.result;
|
|
100
|
+
const output = metric.shape(current, metricRun);
|
|
101
|
+
result = {
|
|
102
|
+
current: output,
|
|
103
|
+
supportsTimeRange: metric.supportsTimeRange
|
|
104
|
+
};
|
|
105
|
+
if (previous) {
|
|
106
|
+
result.previous = alignPrevious(metric.shape({ ...current, ...previous }, metricRun), output);
|
|
107
|
+
}
|
|
108
|
+
} catch (error) {
|
|
109
|
+
try {
|
|
110
|
+
await options.onShapeError?.(error, metric, ctx);
|
|
111
|
+
} catch {}
|
|
112
|
+
yield next({ ...base, error: "The metric could not be drawn" });
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
yield next({ ...base, result });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
metrics,
|
|
120
|
+
keys,
|
|
121
|
+
requestSchema,
|
|
122
|
+
get: (key) => byKey.get(key),
|
|
123
|
+
available: async (ctx) => {
|
|
124
|
+
const allowed = await Promise.all(metrics.map((metric) => canRun(metric, ctx)));
|
|
125
|
+
return metrics.filter((_, index) => allowed[index]);
|
|
126
|
+
},
|
|
127
|
+
run
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function bucketFor(granularity) {
|
|
131
|
+
if (granularity === "hour" || granularity === "day")
|
|
132
|
+
return "day";
|
|
133
|
+
if (granularity === "week")
|
|
134
|
+
return "week";
|
|
135
|
+
return "month";
|
|
136
|
+
}
|
|
137
|
+
function inferGranularity(from, to) {
|
|
138
|
+
if (!from || !to)
|
|
139
|
+
return "day";
|
|
140
|
+
const days = (to.getTime() - from.getTime()) / 86400000;
|
|
141
|
+
if (days <= 31)
|
|
142
|
+
return "day";
|
|
143
|
+
if (days <= 90)
|
|
144
|
+
return "week";
|
|
145
|
+
return "month";
|
|
146
|
+
}
|
|
147
|
+
function alignPrevious(previous, current) {
|
|
148
|
+
if (previous.kind !== "timeseries" || current.kind !== "timeseries")
|
|
149
|
+
return previous;
|
|
150
|
+
return {
|
|
151
|
+
...previous,
|
|
152
|
+
series: previous.series.map((series) => {
|
|
153
|
+
const points = current.series.find((entry) => entry.key === series.key)?.points;
|
|
154
|
+
return {
|
|
155
|
+
...series,
|
|
156
|
+
points: series.points.map((point, index) => ({
|
|
157
|
+
...point,
|
|
158
|
+
ts: points?.[index]?.ts ?? point.ts
|
|
159
|
+
}))
|
|
160
|
+
};
|
|
161
|
+
})
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
export {
|
|
165
|
+
createMetricRegistry,
|
|
166
|
+
metricDefiner
|
|
167
|
+
};
|