feat: add custom_range, bin_size to /query endpoint

This commit is contained in:
Charlie Lamb
2025-12-11 19:48:16 +00:00
parent 9e08927a33
commit 61cb0c12d5
9 changed files with 211 additions and 48 deletions

View File

@@ -180,8 +180,7 @@ function generateEvents({
const events: EventInsert[] = [];
for (let i = 0; i < count; i++) {
const eventName =
featureIds[Math.floor(Math.random() * featureIds.length)];
const eventName = featureIds[Math.floor(Math.random() * featureIds.length)];
const timestamp = generateRandomTimestamp({
daysBack: CONFIG.timeRangeDays,
});

View File

@@ -25,17 +25,23 @@ export class AnalyticsServiceV2 {
aggregateAll?: boolean;
customer?: FullCustomer;
group_by?: string;
bin_size?: "day" | "hour";
custom_range?: { start: number; end: number };
};
}) {
const { clickhouseClient, org, env, db } = ctx;
const intervalType: RangeEnum = params.interval;
const isBillingCycle = intervalType === "1bc" || intervalType === "3bc";
const useCustomDateQuery =
intervalType === "1bc" || intervalType === "3bc" || !!params.custom_range;
// Skip billing cycle calculation if aggregating all customers
// Skip billing cycle calculation if aggregating all customers or using custom_range
const getBCResults =
isBillingCycle && !params.aggregateAll && params.customer
useCustomDateQuery &&
!params.aggregateAll &&
params.customer &&
!params.custom_range
? ((await getBillingCycleStartDate(
params.customer,
db,
@@ -124,20 +130,36 @@ order by dr.period${groupBy.orderBy};
"3bc": (getBCResults?.gap ?? 0) + 1,
};
// Calculate days and end_date for custom_range
const customRangeDays = params.custom_range
? Math.ceil(
(params.custom_range.end - params.custom_range.start) /
(1000 * 60 * 60 * 24),
) + 1
: undefined;
const customRangeEndDate = params.custom_range
? new Date(params.custom_range.end).toISOString().split(".")[0]
: undefined;
const queryParams = {
org_id: org?.id,
env: env,
customer_id: params.customer_id,
days: intervalTypeToDaysMap[
days:
customRangeDays ??
intervalTypeToDaysMap[
intervalType as keyof typeof intervalTypeToDaysMap
],
bin_size: intervalType === "24h" ? "hour" : "day",
end_date: isBillingCycle ? getBCResults?.endDate : undefined,
bin_size: params.bin_size ?? (intervalType === "24h" ? "hour" : "day"),
end_date: customRangeEndDate ?? getBCResults?.endDate,
};
// Use regular query for aggregateAll or when no billing cycle data is available
// Use date_range_bc_view query for billing cycles or custom ranges
const queryToUse =
isBillingCycle && !params.aggregateAll && getBCResults?.startDate
useCustomDateQuery &&
!params.aggregateAll &&
(getBCResults?.startDate || params.custom_range)
? queryBillingCycle
: query;

View File

@@ -15,7 +15,7 @@ export const handleAnalyticsAggregation = createRoute({
handler: async (c) => {
const ctx = c.get("ctx");
const { db, org, env } = ctx;
const { customer_id, feature_id, group_by, range, bucket_size } =
const { customer_id, feature_id, group_by, range, bin_size, custom_range } =
c.req.valid("json");
if (!customer_id || !feature_id) {
@@ -57,6 +57,8 @@ export const handleAnalyticsAggregation = createRoute({
no_count: true,
customer,
group_by,
bin_size,
custom_range,
},
});
@@ -72,10 +74,56 @@ export const handleAnalyticsAggregation = createRoute({
event.period = parseInt(format(new Date(event.period), "T"));
});
const usageList = events.data.filter(
let usageList = events.data.filter(
(event: any) => event.period <= Date.now(),
);
if (group_by) {
const allGroupValues = new Set<string>();
const allFeatureNames = new Set<string>();
for (const row of usageList) {
const { period, [group_by]: groupValue, ...metrics } = row;
if (groupValue != null && groupValue !== "") {
allGroupValues.add(groupValue);
}
for (const featureName of Object.keys(metrics)) {
allFeatureNames.add(featureName);
}
}
const grouped = new Map<number, Record<string, any>>();
for (const row of usageList) {
const { period, [group_by]: groupValue, ...metrics } = row;
if (groupValue == null || groupValue === "") continue;
if (!grouped.has(period)) {
grouped.set(period, { period });
}
const periodData = grouped.get(period)!;
for (const [featureName, value] of Object.entries(metrics)) {
if (!periodData[featureName]) {
periodData[featureName] = {};
}
periodData[featureName][groupValue] = value;
}
}
for (const periodData of grouped.values()) {
for (const featureName of allFeatureNames) {
if (!periodData[featureName]) {
periodData[featureName] = {};
}
for (const groupValue of allGroupValues) {
if (periodData[featureName][groupValue] === undefined) {
periodData[featureName][groupValue] = 0;
}
}
}
}
usageList = Array.from(grouped.values());
}
return c.json({
list: usageList,
});

View File

@@ -0,0 +1,55 @@
import {
AnalyticsAggregationBodySchema,
AnalyticsAggregationErrorResponseSchema,
AnalyticsAggregationResponseSchema,
} from "../../../analytics/aggregation/analyticsAggregationSchema.js";
export const analyticsOpenApi = {
"/query": {
post: {
summary: "Query Analytics Aggregation",
tags: ["analytics"],
requestBody: {
content: {
"application/json": {
schema: AnalyticsAggregationBodySchema,
},
},
},
responses: {
"200": {
description: "Analytics aggregation results",
content: {
"application/json": {
schema: AnalyticsAggregationResponseSchema,
},
},
},
"400": {
description: "Bad Request",
content: {
"application/json": {
schema: AnalyticsAggregationErrorResponseSchema,
},
},
},
"404": {
description: "Not Found",
content: {
"application/json": {
schema: AnalyticsAggregationErrorResponseSchema,
},
},
},
"500": {
description: "Internal Server Error",
content: {
"application/json": {
schema: AnalyticsAggregationErrorResponseSchema,
},
},
},
},
},
},
};

View File

@@ -10,6 +10,7 @@ import {
ApiProductItemSchema,
} from "../../../models.js";
import { ApiEntityWithMeta, entitiesOpenApi } from "../entitiesOpenApi.js";
import { analyticsOpenApi } from "./analyticsOpenApi.js";
import { coreOpenApi } from "./coreOpenApi.js";
import { ApiCustomerWithMeta, customersOpenApi } from "./customersOpenApi.js";
import { ApiFeatureWithMeta, featuresOpenApi } from "./featuresOpenApi.js";
@@ -70,6 +71,7 @@ const OPENAPI_1_2_0 = createDocument(
...coreOpenApi,
...customersOpenApi,
...entitiesOpenApi,
...analyticsOpenApi,
},
},
{

View File

@@ -1,32 +0,0 @@
import z from "zod/v4";
export const RangeEnum = z
.enum(["24h", "7d", "30d", "90d", "last_cycle", "1bc", "3bc"])
.default("1bc");
export type RangeEnum = z.infer<typeof RangeEnum>;
export const AnalyticsAggregationBodySchema = z.object({
customer_id: z.string(),
feature_id: z.string().or(z.array(z.string())),
group_by: z.string().optional(),
range: RangeEnum,
bucket_size: z.enum(["hour", "day"]).default("day"),
});
export type AnalyticsAggregationBody = z.infer<
typeof AnalyticsAggregationBodySchema
>;
export const AnalyticsAggregationResponseSchema = z.object({
data: z.array(
z.object({
period: z.string(),
count: z.number(),
}),
),
});
export type AnalyticsAggregationResponse = z.infer<
typeof AnalyticsAggregationResponseSchema
>;

View File

@@ -0,0 +1,68 @@
import z from "zod/v4";
export const RangeEnum = z
.enum(["24h", "7d", "30d", "90d", "last_cycle", "1bc", "3bc"])
.default("1bc");
export type RangeEnum = z.infer<typeof RangeEnum>;
export const AnalyticsAggregationBodySchema = z.object({
customer_id: z.string(),
feature_id: z.string().or(z.array(z.string())),
group_by: z.string().optional(),
range: RangeEnum,
bin_size: z.enum(["day", "hour"]).optional(),
custom_range: z
.object({
start: z.number(),
end: z.number(),
})
.refine((data) => data.start < data.end, {
message: "start must be before end",
})
.optional(),
});
export type AnalyticsAggregationBody = z.infer<
typeof AnalyticsAggregationBodySchema
>;
// Response without group_by: { period: number, [featureName]: number }
const AnalyticsAggregationResponseFlatSchema = z.object({
list: z.array(
z
.object({
period: z.number(),
})
.catchall(z.number()),
),
});
// Response with group_by: { period: number, [featureName]: { [groupValue]: number } }
const AnalyticsAggregationResponseGroupedSchema = z.object({
list: z.array(
z
.object({
period: z.number(),
})
.catchall(z.record(z.string(), z.number())),
),
});
export const AnalyticsAggregationResponseSchema = z.union([
AnalyticsAggregationResponseFlatSchema,
AnalyticsAggregationResponseGroupedSchema,
]);
export type AnalyticsAggregationResponse = z.infer<
typeof AnalyticsAggregationResponseSchema
>;
export const AnalyticsAggregationErrorResponseSchema = z.object({
code: z.string(),
message: z.string(),
});
export type AnalyticsAggregationErrorResponse = z.infer<
typeof AnalyticsAggregationErrorResponseSchema
>;

View File

@@ -1,7 +1,8 @@
import { config } from "dotenv";
config({ path: "../server/.env" });
import { defineConfig } from "drizzle-kit";
config({ path: "../server/.env" });
export default defineConfig({
dialect: "postgresql",
out: "./drizzle",

View File

@@ -96,7 +96,7 @@ export * from "./models/genModels/processorSchemas.js";
// Insights Models
// Insights Models
export * from "./api/analytics/aggregation/analyticsAggregationBody.js";
export * from "./api/analytics/aggregation/analyticsAggregationSchema.js";
export * from "./api/analytics/insights/query/insightsQueryBody.js";
// Attach Function Response