feat: add custom_range, bin_size to /query endpoint
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
@@ -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
|
||||
>;
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user