internal/repository/clickhouse/processed_event.go
Rama main · réplica de lectura del repositorio original.
package clickhouse
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/flexprice/flexprice/internal/clock"
"github.com/samber/lo"
"github.com/shopspring/decimal"
"github.com/flexprice/flexprice/internal/clickhouse"
"github.com/flexprice/flexprice/internal/domain/events"
ierr "github.com/flexprice/flexprice/internal/errors"
"github.com/flexprice/flexprice/internal/logger"
"github.com/flexprice/flexprice/internal/types"
)
type ProcessedEventRepository struct {
store *clickhouse.ClickHouseStore
logger *logger.Logger
}
func NewProcessedEventRepository(store *clickhouse.ClickHouseStore, logger *logger.Logger) events.ProcessedEventRepository {
return &ProcessedEventRepository{
store: store,
logger: logger,
}
}
// InsertProcessedEvent inserts a single processed event
func (r *ProcessedEventRepository) InsertProcessedEvent(ctx context.Context, event *events.ProcessedEvent) error {
query := `
INSERT INTO events_processed (
id, tenant_id, external_customer_id, customer_id, event_name, source,
timestamp, ingested_at, properties, environment_id,
subscription_id, sub_line_item_id, price_id, meter_id, feature_id, period_id,
unique_hash, qty_total, qty_billable, qty_free_applied, tier_snapshot,
unit_cost, cost, currency, sign
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
`
propertiesJSON, err := json.Marshal(event.Properties)
if err != nil {
return ierr.WithError(err).
WithHint("Failed to marshal event properties").
WithReportableDetails(map[string]interface{}{
"event_id": event.ID,
}).
Mark(ierr.ErrValidation)
}
// Default sign to 1 if not set
sign := event.Sign
if sign == 0 {
sign = 1
}
args := []interface{}{
event.ID,
event.TenantID,
event.ExternalCustomerID,
event.CustomerID,
event.EventName,
event.Source,
event.Timestamp,
event.IngestedAt,
string(propertiesJSON),
event.EnvironmentID,
event.SubscriptionID,
event.SubLineItemID,
event.PriceID,
event.MeterID,
event.FeatureID,
event.PeriodID,
event.UniqueHash,
event.QtyTotal,
event.QtyBillable,
event.QtyFreeApplied,
event.TierSnapshot,
event.UnitCost,
event.Cost,
event.Currency,
sign,
}
err = r.store.GetConn().Exec(ctx, query, args...)
if err != nil {
return ierr.WithError(err).
WithHint("Failed to insert processed event").
WithReportableDetails(map[string]interface{}{
"event_id": event.ID,
}).
Mark(ierr.ErrDatabase)
}
return nil
}
// BulkInsertProcessedEvents inserts multiple processed events
func (r *ProcessedEventRepository) BulkInsertProcessedEvents(ctx context.Context, events []*events.ProcessedEvent) error {
if len(events) == 0 {
return nil
}
// Split events in batches of 100
eventsBatches := lo.Chunk(events, 100)
for _, eventsBatch := range eventsBatches {
// Prepare batch statement
batch, err := r.store.GetConn().PrepareBatch(ctx, `
INSERT INTO events_processed (
id, tenant_id, external_customer_id, customer_id, event_name, source,
timestamp, ingested_at, properties, environment_id,
subscription_id, sub_line_item_id, price_id, meter_id, feature_id, period_id,
unique_hash, qty_total, qty_billable, qty_free_applied, tier_snapshot,
unit_cost, cost, currency, sign
)
`)
if err != nil {
return ierr.WithError(err).
WithHint("Failed to prepare batch for processed events").
Mark(ierr.ErrDatabase)
}
for _, event := range eventsBatch {
propertiesJSON, err := json.Marshal(event.Properties)
if err != nil {
return ierr.WithError(err).
WithHint("Failed to marshal event properties").
WithReportableDetails(map[string]interface{}{
"event_id": event.ID,
}).
Mark(ierr.ErrValidation)
}
// Default sign to 1 if not set
sign := event.Sign
if sign == 0 {
sign = 1
}
err = batch.Append(
event.ID,
event.TenantID,
event.ExternalCustomerID,
event.CustomerID,
event.EventName,
event.Source,
event.Timestamp,
event.IngestedAt,
string(propertiesJSON),
event.EnvironmentID,
event.SubscriptionID,
event.SubLineItemID,
event.PriceID,
event.MeterID,
event.FeatureID,
event.PeriodID,
event.UniqueHash,
event.QtyTotal,
event.QtyBillable,
event.QtyFreeApplied,
event.TierSnapshot,
event.UnitCost,
event.Cost,
event.Currency,
sign,
)
if err != nil {
return ierr.WithError(err).
WithHint("Failed to append processed event to batch").
WithReportableDetails(map[string]interface{}{
"event_id": event.ID,
}).
Mark(ierr.ErrDatabase)
}
}
// Send batch
if err := batch.Send(); err != nil {
return ierr.WithError(err).
WithHint("Failed to execute batch insert for processed events").
WithReportableDetails(map[string]interface{}{
"event_count": len(events),
}).
Mark(ierr.ErrDatabase)
}
}
return nil
}
// GetProcessedEvents retrieves processed events based on the provided parameters
func (r *ProcessedEventRepository) GetProcessedEvents(ctx context.Context, params *events.GetProcessedEventsParams) ([]*events.ProcessedEvent, uint64, error) {
query := `
SELECT
id, tenant_id, external_customer_id, customer_id, event_name, source,
timestamp, ingested_at, properties, processed_at, environment_id,
subscription_id, sub_line_item_id, price_id, meter_id, feature_id, period_id,
unique_hash, qty_total, qty_billable, qty_free_applied, tier_snapshot,
unit_cost, cost, currency, version, sign, final_lag_ms
FROM events_processed
WHERE tenant_id = ?
AND environment_id = ?
AND timestamp >= ?
AND timestamp <= ?
`
countQuery := `
SELECT COUNT(*)
FROM events_processed
WHERE tenant_id = ?
AND environment_id = ?
AND timestamp >= ?
AND timestamp <= ?
`
args := []interface{}{types.GetTenantID(ctx), types.GetEnvironmentID(ctx), params.StartTime, params.EndTime}
countArgs := []interface{}{types.GetTenantID(ctx), types.GetEnvironmentID(ctx), params.StartTime, params.EndTime}
// Add filters
if params.CustomerID != "" {
query += " AND customer_id = ?"
countQuery += " AND customer_id = ?"
args = append(args, params.CustomerID)
countArgs = append(countArgs, params.CustomerID)
}
if params.SubscriptionID != "" {
query += " AND subscription_id = ?"
countQuery += " AND subscription_id = ?"
args = append(args, params.SubscriptionID)
countArgs = append(countArgs, params.SubscriptionID)
}
if params.MeterID != "" {
query += " AND meter_id = ?"
countQuery += " AND meter_id = ?"
args = append(args, params.MeterID)
countArgs = append(countArgs, params.MeterID)
}
if params.FeatureID != "" {
query += " AND feature_id = ?"
countQuery += " AND feature_id = ?"
args = append(args, params.FeatureID)
countArgs = append(countArgs, params.FeatureID)
}
if params.PriceID != "" {
query += " AND price_id = ?"
countQuery += " AND price_id = ?"
args = append(args, params.PriceID)
countArgs = append(countArgs, params.PriceID)
}
// Add FINAL modifier to handle ReplacingMergeTree
query += " FINAL"
countQuery += " FINAL"
// Apply pagination
if params.Limit > 0 {
query += " LIMIT ?"
args = append(args, params.Limit)
if params.Offset > 0 {
query += " OFFSET ?"
args = append(args, params.Offset)
}
}
// Execute count query if requested
var totalCount uint64 = 0
if params.CountTotal {
err := r.store.GetConn().QueryRow(ctx, countQuery, countArgs...).Scan(&totalCount)
if err != nil {
return nil, 0, ierr.WithError(err).
WithHint("Failed to count processed events").
Mark(ierr.ErrDatabase)
}
}
// Execute main query
rows, err := r.store.GetConn().Query(ctx, query, args...)
if err != nil {
return nil, 0, ierr.WithError(err).
WithHint("Failed to query processed events").
Mark(ierr.ErrDatabase)
}
defer rows.Close()
var eventsList []*events.ProcessedEvent
for rows.Next() {
var event events.ProcessedEvent
var propertiesJSON string
err := rows.Scan(
&event.ID,
&event.TenantID,
&event.ExternalCustomerID,
&event.CustomerID,
&event.EventName,
&event.Source,
&event.Timestamp,
&event.IngestedAt,
&propertiesJSON,
&event.ProcessedAt,
&event.EnvironmentID,
&event.SubscriptionID,
&event.SubLineItemID,
&event.PriceID,
&event.MeterID,
&event.FeatureID,
&event.PeriodID,
&event.UniqueHash,
&event.QtyTotal,
&event.QtyBillable,
&event.QtyFreeApplied,
&event.TierSnapshot,
&event.UnitCost,
&event.Cost,
&event.Currency,
&event.Version,
&event.Sign,
&event.FinalLagMs,
)
if err != nil {
return nil, 0, ierr.WithError(err).
WithHint("Failed to scan processed event").
Mark(ierr.ErrDatabase)
}
// Parse properties
if propertiesJSON != "" {
if err := json.Unmarshal([]byte(propertiesJSON), &event.Properties); err != nil {
return nil, 0, ierr.WithError(err).
WithHint("Failed to unmarshal properties").
Mark(ierr.ErrValidation)
}
} else {
event.Properties = make(map[string]interface{})
}
eventsList = append(eventsList, &event)
}
return eventsList, totalCount, nil
}
// IsDuplicate checks if an event with the given unique hash already exists
func (r *ProcessedEventRepository) IsDuplicate(ctx context.Context, subscriptionID, meterID string, periodID uint64, uniqueHash string) (bool, error) {
query := `
SELECT 1
FROM events_processed
WHERE subscription_id = ?
AND meter_id = ?
AND period_id = ?
AND unique_hash = ?
LIMIT 1
`
var exists int
err := r.store.GetConn().QueryRow(ctx, query, subscriptionID, meterID, periodID, uniqueHash).Scan(&exists)
if err != nil {
// If no rows, it means no duplicate
if err.Error() == "sql: no rows in result set" {
return false, nil
}
return false, ierr.WithError(err).
WithHint("Failed to check for duplicate event").
Mark(ierr.ErrDatabase)
}
return exists == 1, nil
}
// GetPeriodCost gets the total cost for a subscription in a billing period
func (r *ProcessedEventRepository) GetPeriodCost(ctx context.Context, tenantID, environmentID, customerID, subscriptionID string, periodID uint64) (decimal.Decimal, error) {
query := `
SELECT sumMerge(cost_state) AS cost
FROM agg_usage_period_totals
WHERE tenant_id = ?
AND environment_id = ?
AND customer_id = ?
AND subscription_id = ?
AND period_id = ?
`
var cost decimal.Decimal
err := r.store.GetConn().QueryRow(ctx, query, tenantID, environmentID, customerID, subscriptionID, periodID).Scan(&cost)
if err != nil {
// If no rows found, return zero
if err.Error() == "sql: no rows in result set" {
return decimal.Zero, nil
}
return decimal.Zero, ierr.WithError(err).
WithHint("Failed to get period cost").
Mark(ierr.ErrDatabase)
}
return cost, nil
}
// GetPeriodFeatureTotals gets usage totals by feature for a subscription in a period
func (r *ProcessedEventRepository) GetPeriodFeatureTotals(ctx context.Context, tenantID, environmentID, customerID, subscriptionID string, periodID uint64) ([]*events.PeriodFeatureTotal, error) {
query := `
SELECT
feature_id,
sumMerge(qty_state) AS qty,
sumMerge(free_state) AS free,
sumMerge(cost_state) AS cost
FROM agg_usage_period_totals
WHERE tenant_id = ?
AND environment_id = ?
AND customer_id = ?
AND subscription_id = ?
AND period_id = ?
GROUP BY feature_id
`
rows, err := r.store.GetConn().Query(ctx, query, tenantID, environmentID, customerID, subscriptionID, periodID)
if err != nil {
return nil, ierr.WithError(err).
WithHint("Failed to query period feature totals").
Mark(ierr.ErrDatabase)
}
defer rows.Close()
var results []*events.PeriodFeatureTotal
for rows.Next() {
var total events.PeriodFeatureTotal
err := rows.Scan(&total.FeatureID, &total.Quantity, &total.FreeUnits, &total.Cost)
if err != nil {
return nil, ierr.WithError(err).
WithHint("Failed to scan period feature total").
Mark(ierr.ErrDatabase)
}
results = append(results, &total)
}
return results, nil
}
// GetUsageAnalytics gets recent usage analytics for a customer
func (r *ProcessedEventRepository) GetUsageAnalytics(ctx context.Context, tenantID, environmentID, customerID string, lookbackHours int) ([]*events.UsageAnalytic, error) {
query := `
SELECT
source,
feature_id,
sum(cost) AS cost,
sum(qty_billable) AS usage
FROM events_processed
WHERE tenant_id = ?
AND environment_id = ?
AND customer_id = ?
AND timestamp >= now64(3) - INTERVAL ? HOUR
GROUP BY source, feature_id
ORDER BY cost DESC
LIMIT 100
`
rows, err := r.store.GetConn().Query(ctx, query, tenantID, environmentID, customerID, lookbackHours)
if err != nil {
return nil, ierr.WithError(err).
WithHint("Failed to query usage analytics").
Mark(ierr.ErrDatabase)
}
defer rows.Close()
var results []*events.UsageAnalytic
for rows.Next() {
var analytics events.UsageAnalytic
err := rows.Scan(&analytics.Source, &analytics.FeatureID, &analytics.Cost, &analytics.Usage)
if err != nil {
return nil, ierr.WithError(err).
WithHint("Failed to scan usage analytics").
Mark(ierr.ErrDatabase)
}
results = append(results, &analytics)
}
return results, nil
}
// GetDetailedUsageAnalytics provides comprehensive usage analytics with filtering, grouping, and time-series data
func (r *ProcessedEventRepository) GetDetailedUsageAnalytics(ctx context.Context, params *events.UsageAnalyticsParams) ([]*events.DetailedUsageAnalytic, error) {
span := StartRepositorySpan(ctx, "processed_event", "get_detailed_usage_analytics", map[string]interface{}{
"external_customer_id": params.ExternalCustomerID,
"feature_ids_count": len(params.FeatureIDs),
"sources_count": len(params.Sources),
"property_filters_count": len(params.PropertyFilters),
})
defer FinishSpan(span)
// Set default start/end times if not provided
if params.EndTime.IsZero() {
params.EndTime = clock.Now()
}
if params.StartTime.IsZero() {
// Default to last 6 hours if not specified
params.StartTime = params.EndTime.Add(-6 * time.Hour)
}
// Set default group by if not provided
if len(params.GroupBy) == 0 {
params.GroupBy = []string{"feature_id"}
}
// Validate group by values - now supports properties.* fields
for _, groupBy := range params.GroupBy {
if groupBy != "feature_id" && groupBy != "source" && !strings.HasPrefix(groupBy, "properties.") {
return nil, ierr.NewError("invalid group_by value").
WithHint("Valid group_by values are 'feature_id', 'source', or 'properties.<field_name>'").
WithReportableDetails(map[string]interface{}{
"group_by": params.GroupBy,
}).
Mark(ierr.ErrValidation)
}
// The property name is interpolated into the query both as a SQL string literal
// and as a column-alias identifier, so it can never be bound via `?` — reject it
// here rather than dropping it, which would desync the SELECT column list from
// the scan targets below (both are sized off params.GroupBy).
if strings.HasPrefix(groupBy, "properties.") {
propertyName := strings.TrimPrefix(groupBy, "properties.")
if propertyName == "" {
return nil, ierr.NewError("invalid group_by value").
WithHint("group_by 'properties.<field_name>' requires a non-empty field name").
WithReportableDetails(map[string]interface{}{
"group_by": groupBy,
}).
Mark(ierr.ErrValidation)
}
if err := validateGroupByProperty(propertyName); err != nil {
return nil, err
}
}
}
// Initialize query parameters with the standard parameters that will be added later
// This ensures they're always in the right order
queryParams := []interface{}{
params.TenantID,
params.EnvironmentID,
params.CustomerID,
params.StartTime,
params.EndTime,
}
// Add group by columns based on params.GroupBy
groupByColumns := []string{}
groupByColumnAliases := []string{} // for SELECT clause
groupByFieldMapping := make(map[string]string) // maps original field to column alias
// Check if source is in group_by
sourceInGroupBy := false
for _, groupBy := range params.GroupBy {
if groupBy == "source" {
sourceInGroupBy = true
break
}
}
for _, groupBy := range params.GroupBy {
switch {
case groupBy == "feature_id":
groupByColumns = append(groupByColumns, "feature_id")
groupByColumnAliases = append(groupByColumnAliases, "feature_id")
groupByFieldMapping["feature_id"] = "feature_id"
case groupBy == "source":
groupByColumns = append(groupByColumns, "source")
groupByColumnAliases = append(groupByColumnAliases, "source")
groupByFieldMapping["source"] = "source"
case strings.HasPrefix(groupBy, "properties."):
// Extract property name from "properties.field_name". Already validated
// against the allow-list above, so it is safe to interpolate here — and
// every entry emits exactly one column, keeping this list in lockstep with
// the scan targets built from params.GroupBy.
propertyName := strings.TrimPrefix(groupBy, "properties.")
// Dot-free, collision-free alias for "properties.<name>" (e.g. prop_org_5fid).
alias := groupByPropertyAlias(propertyName)
sqlExpression := fmt.Sprintf("JSONExtractString(properties, '%s') AS %s", propertyName, alias)
groupByColumns = append(groupByColumns, fmt.Sprintf("JSONExtractString(properties, '%s')", propertyName))
groupByColumnAliases = append(groupByColumnAliases, sqlExpression)
groupByFieldMapping[groupBy] = alias
}
}
// Base query for aggregates - always include event count
selectColumns := []string{}
if len(groupByColumnAliases) > 0 {
selectColumns = append(selectColumns, strings.Join(groupByColumnAliases, ", ")) // group by columns with aliases
}
selectColumns = append(selectColumns,
"SUM(qty_billable * sign) AS total_usage",
"SUM(cost * sign) AS total_cost",
"COUNT(DISTINCT id) AS event_count", // Count distinct event IDs, not rows
)
// Add sources array when source is not in group_by
if !sourceInGroupBy {
selectColumns = append(selectColumns, "groupUniqArray(source) AS sources")
}
aggregateQuery := fmt.Sprintf(`
SELECT
%s
FROM events_processed
WHERE tenant_id = ?
AND environment_id = ?
AND customer_id = ?
AND timestamp >= ?
AND timestamp <= ?
AND sign != 0
`, strings.Join(selectColumns, ",\n\t\t\t"))
// Add filters for feature_ids
filterParams := []interface{}{}
if len(params.FeatureIDs) > 0 {
placeholders := make([]string, len(params.FeatureIDs))
for i := range params.FeatureIDs {
placeholders[i] = "?"
filterParams = append(filterParams, params.FeatureIDs[i])
}
aggregateQuery += " AND feature_id IN (" + strings.Join(placeholders, ", ") + ")"
}
// Add filters for sources
if len(params.Sources) > 0 {
placeholders := make([]string, len(params.Sources))
for i := range params.Sources {
placeholders[i] = "?"
filterParams = append(filterParams, params.Sources[i])
}
aggregateQuery += " AND source IN (" + strings.Join(placeholders, ", ") + ")"
}
// add properties filters
if len(params.PropertyFilters) > 0 {
for property, values := range params.PropertyFilters {
if len(values) > 0 {
if len(values) == 1 {
aggregateQuery += " AND JSONExtractString(properties, ?) = ?"
filterParams = append(filterParams, property, values[0])
} else {
placeholders := make([]string, len(values))
for i := range values {
placeholders[i] = "?"
}
aggregateQuery += " AND JSONExtractString(properties, ?) IN (" + strings.Join(placeholders, ",") + ")"
filterParams = append(filterParams, property)
// Now append all values after the property
for _, v := range values {
filterParams = append(filterParams, v)
}
}
}
}
}
// Add all filter parameters after the standard parameters
queryParams = append(queryParams, filterParams...)
// Add group by clause
if len(groupByColumns) > 0 {
aggregateQuery += " GROUP BY " + strings.Join(groupByColumns, ", ")
}
r.logger.Debug(ctx, "executing detailed usage analytics query",
"query", aggregateQuery,
"params", queryParams,
"group_by", params.GroupBy,
"property_filters", params.PropertyFilters,
)
// Execute the query
rows, err := r.store.GetConn().Query(ctx, aggregateQuery, queryParams...)
if err != nil {
SetSpanError(span, err)
return nil, ierr.WithError(err).
WithHint("Failed to execute usage analytics query").
WithReportableDetails(map[string]interface{}{
"customer_id": params.CustomerID,
}).
Mark(ierr.ErrDatabase)
}
defer rows.Close()
// Process the results
results := []*events.DetailedUsageAnalytic{}
// Read the rows
for rows.Next() {
analytics := &events.DetailedUsageAnalytic{
Points: []events.UsageAnalyticPoint{},
}
// Initialize properties map
analytics.Properties = make(map[string]string)
// Scan the row based on group by columns
expectedColumns := len(params.GroupBy) + 3 // +3 for total_usage, total_cost, event_count
if !sourceInGroupBy {
expectedColumns++ // +1 for sources array
}
scanArgs := make([]interface{}, expectedColumns)
// Prepare scan targets for each group by field
scanTargets := make([]string, len(params.GroupBy)) // to store scanned string values
for i := range params.GroupBy {
scanArgs[i] = &scanTargets[i]
}
// Scan the aggregate values
scanArgs[len(params.GroupBy)] = &analytics.TotalUsage
scanArgs[len(params.GroupBy)+1] = &analytics.TotalCost
scanArgs[len(params.GroupBy)+2] = &analytics.EventCount
// Add sources array scan target when source is not in group_by
if !sourceInGroupBy {
analytics.Sources = []string{}
scanArgs[len(params.GroupBy)+3] = &analytics.Sources
}
if err := rows.Scan(scanArgs...); err != nil {
SetSpanError(span, err)
return nil, ierr.WithError(err).
WithHint("Failed to scan analytics row").
WithReportableDetails(map[string]interface{}{
"customer_id": params.CustomerID,
}).
Mark(ierr.ErrDatabase)
}
// Populate analytics fields and grouped values based on scanned results
for i, groupBy := range params.GroupBy {
value := scanTargets[i]
switch groupBy {
case "feature_id":
analytics.FeatureID = value
case "source":
analytics.Source = value
default:
// For properties fields, store in Properties map with simplified key
if strings.HasPrefix(groupBy, "properties.") {
propertyName := strings.TrimPrefix(groupBy, "properties.")
analytics.Properties[propertyName] = value
}
}
}
// If we need time-series data and a window size is specified, fetch the points
if params.WindowSize != "" {
points, err := r.getAnalyticsPoints(ctx, params, analytics)
if err != nil {
SetSpanError(span, err)
return nil, err
}
analytics.Points = points
}
results = append(results, analytics)
}
if err := rows.Err(); err != nil {
SetSpanError(span, err)
return nil, ierr.WithError(err).
WithHint("Error iterating analytics rows").
WithReportableDetails(map[string]interface{}{
"customer_id": params.CustomerID,
}).
Mark(ierr.ErrDatabase)
}
SetSpanSuccess(span)
return results, nil
}
// getAnalyticsPoints fetches time-series data points for a specific analytics item
func (r *ProcessedEventRepository) getAnalyticsPoints(
ctx context.Context,
params *events.UsageAnalyticsParams,
analytics *events.DetailedUsageAnalytic,
) ([]events.UsageAnalyticPoint, error) {
// Build the time window expression based on window size
var timeWindowExpr string
switch params.WindowSize {
case types.WindowSizeMinute:
timeWindowExpr = "toStartOfMinute(timestamp)"
case types.WindowSizeHour:
timeWindowExpr = "toStartOfHour(timestamp)"
case types.WindowSizeDay:
timeWindowExpr = "toStartOfDay(timestamp)"
case types.WindowSizeWeek:
timeWindowExpr = "toStartOfWeek(timestamp)"
case types.WindowSize15Min:
timeWindowExpr = "toStartOfInterval(timestamp, INTERVAL 15 MINUTE)"
case types.WindowSize30Min:
timeWindowExpr = "toStartOfInterval(timestamp, INTERVAL 30 MINUTE)"
case types.WindowSize12Hour:
timeWindowExpr = "toStartOfInterval(timestamp, INTERVAL 12 HOUR)"
case types.WindowSize3Hour:
timeWindowExpr = "toStartOfInterval(timestamp, INTERVAL 3 HOUR)"
case types.WindowSize6Hour:
timeWindowExpr = "toStartOfInterval(timestamp, INTERVAL 6 HOUR)"
case types.WindowSizeMonth:
// Use custom monthly billing period if billing anchor is provided
if params.BillingAnchor != nil {
// Extract only the day component from billing anchor for simplicity
anchorDay := params.BillingAnchor.Day()
// Generate the custom monthly window expression using day-level granularity
timeWindowExpr = fmt.Sprintf(`
addDays(
toStartOfMonth(addDays(timestamp, -%d)),
%d
)`, anchorDay-1, anchorDay-1)
} else {
timeWindowExpr = "toStartOfMonth(timestamp)"
}
default:
// Default to hourly for unknown window sizes
timeWindowExpr = "toStartOfHour(timestamp)"
}
// Build the select columns for time-series query - always include event count
selectColumns := []string{
fmt.Sprintf("%s AS window_time", timeWindowExpr),
"SUM(qty_billable * sign) AS usage",
"SUM(cost * sign) AS cost",
"COUNT(DISTINCT id) AS event_count", // Count distinct event IDs, not rows
}
// Build the query
query := fmt.Sprintf(`
SELECT
%s
FROM events_processed
WHERE tenant_id = ?
AND environment_id = ?
AND customer_id = ?
AND timestamp >= ?
AND timestamp <= ?
AND sign != 0
`, strings.Join(selectColumns, ",\n\t\t\t"))
// Add filters for the specific analytics item
queryParams := []interface{}{
params.TenantID,
params.EnvironmentID,
params.CustomerID,
params.StartTime,
params.EndTime,
}
// Add feature_id filter if present in analytics
if analytics.FeatureID != "" {
query += " AND feature_id = ?"
queryParams = append(queryParams, analytics.FeatureID)
}
// Add source filter if present in analytics
if analytics.Source != "" {
query += " AND source = ?"
queryParams = append(queryParams, analytics.Source)
}
// Add filters for grouped properties values
if analytics.Properties != nil {
for propertyName, value := range analytics.Properties {
if value != "" {
query += " AND JSONExtractString(properties, ?) = ?"
queryParams = append(queryParams, propertyName, value)
}
}
}
// Add property filters
filterParamsForTimeSeries := []interface{}{}
if len(params.PropertyFilters) > 0 {
for property, values := range params.PropertyFilters {
if len(values) > 0 {
if len(values) == 1 {
query += " AND JSONExtractString(properties, ?) = ?"
filterParamsForTimeSeries = append(filterParamsForTimeSeries, property, values[0])
} else {
placeholders := make([]string, len(values))
for i := range values {
placeholders[i] = "?"
}
query += " AND JSONExtractString(properties, ?) IN (" + strings.Join(placeholders, ",") + ")"
filterParamsForTimeSeries = append(filterParamsForTimeSeries, property)
// Now append all values after the property
for _, v := range values {
filterParamsForTimeSeries = append(filterParamsForTimeSeries, v)
}
}
}
}
}
queryParams = append(queryParams, filterParamsForTimeSeries...)
// Group by the time window and order by time
query += fmt.Sprintf(" GROUP BY %s ORDER BY window_time", timeWindowExpr)
r.logger.Debug(ctx, "executing time-series query",
"query", query,
"params", queryParams,
"feature_id", analytics.FeatureID,
"source", analytics.Source,
"property_filters", params.PropertyFilters,
)
// Execute the query
rows, err := r.store.GetConn().Query(ctx, query, queryParams...)
if err != nil {
return nil, ierr.WithError(err).
WithHint("Failed to execute time-series query").
WithReportableDetails(map[string]interface{}{
"customer_id": params.CustomerID,
"feature_id": analytics.FeatureID,
"source": analytics.Source,
}).
Mark(ierr.ErrDatabase)
}
defer rows.Close()
// Process the results
var points []events.UsageAnalyticPoint
for rows.Next() {
var point events.UsageAnalyticPoint
if err := rows.Scan(&point.Timestamp, &point.Usage, &point.Cost, &point.EventCount); err != nil {
return nil, ierr.WithError(err).
WithHint("Failed to scan time-series point").
Mark(ierr.ErrDatabase)
}
points = append(points, point)
}
if err := rows.Err(); err != nil {
return nil, ierr.WithError(err).
WithHint("Error iterating time-series points").
Mark(ierr.ErrDatabase)
}
return points, nil
}