Skip to content

feat(api-service): refactor usage queries to use materialized views#9824

Merged
djabarovgeorge merged 30 commits intonextfrom
wip-improve-pref-usage-2
Jan 15, 2026
Merged

feat(api-service): refactor usage queries to use materialized views#9824
djabarovgeorge merged 30 commits intonextfrom
wip-improve-pref-usage-2

Conversation

@djabarovgeorge
Copy link
Contributor

@djabarovgeorge djabarovgeorge commented Jan 11, 2026

What changed? Why was the change needed?

EE-PR

Screenshots

Expand for optional sections

Related enterprise PR

Special notes for your reviewer

…for event counts

- Introduced scripts for seeding ClickHouse with realistic mock data, including organizations, workflows, and events.
- Added a new materialized view `trace_event_counts_mv` to optimize query performance for step_completed event counts.
- Updated package.json with new seed commands for ClickHouse.
- Implemented data generation logic to simulate various organizational profiles and workflows.
- Enhanced trace logging and analytics capabilities by integrating new repository methods for fetching event counts.

This commit enhances the observability and testing capabilities of the API service by providing a robust data seeding mechanism.
@netlify
Copy link

netlify bot commented Jan 11, 2026

Deploy Preview for dashboard-v2-novu-staging canceled.

Name Link
🔨 Latest commit 9b61f6c
🔍 Latest deploy log https://app.netlify.com/projects/dashboard-v2-novu-staging/deploys/6967b880ffaebe00087ff1a1

@github-actions
Copy link
Contributor

github-actions bot commented Jan 11, 2026

Hey there and thank you for opening this pull request! 👋

We require pull request titles to follow specific formatting rules and it looks like your proposed title needs to be adjusted.

Your PR title is: feat(api-service): refactor usage queries to use materialized views

Requirements:

  1. Follow the Conventional Commits specification
  2. As a team member, include Linear ticket ID at the end: fixes TICKET-ID or include it in your branch name

Expected format: feat(scope): Add fancy new feature fixes NOV-123

Details:

PR title must end with 'fixes TICKET-ID' (e.g., 'fixes NOV-123') or include ticket ID in branch name

…ountsRepository and remove unused trace-event-counts schema

- Updated BuildMessagesDeliveredChart use case to utilize MessageSentCountsRepository for fetching message delivery data.
- Removed the trace-event-counts schema and related code from the repository as it is no longer needed.
- Cleaned up imports in the analytic-logs module to reflect the changes.
@pkg-pr-new
Copy link

pkg-pr-new bot commented Jan 11, 2026

Open in StackBlitz

npm i https://pkg.pr.new/novuhq/novu@9824
npm i https://pkg.pr.new/novuhq/novu/@novu/providers@9824
npm i https://pkg.pr.new/novuhq/novu/@novu/shared@9824

commit: 9b61f6c

…d total interactions charts

- Added feature flag checks to determine the data source for interaction trend and total interactions charts.
- Introduced InteractionCountsRepository for fetching interaction data based on feature flags.
- Updated shared module to include InteractionCountsRepository and added new feature flag key for interaction counts.
…ed SQL migration

- Deleted InteractionCountsRepository and its associated SQL migration for interaction counts.
- Updated use cases to utilize WorkflowActivityCountsRepository for interaction trend and total interactions data retrieval.
- Adjusted shared module to remove InteractionCountsRepository references.
…onents

- Deleted WorkflowVolumeCounts table and materialized view from SQL migrations.
- Removed WorkflowVolumeCountsRepository and its schema from the analytic logs service.
- Updated build-workflow-by-volume-chart use case to eliminate dependency on WorkflowVolumeCountsRepository.
- Adjusted shared module to remove references to WorkflowVolumeCountsRepository.
- Removed feature flag key for workflow volume data retrieval.
@djabarovgeorge djabarovgeorge changed the title feat(api-service): add materialized views for daily aggregation in for event counts feat(api-service): refactor usage queries to use materialized views Jan 13, 2026
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i am still not sure if i will merge this one, its purpose was to seed quickly, click house directly for testing purposes.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here, i am still not sure if i will merge this one, its purpose was to seed the application with data through a trigger, this one is much slower as it require to go through all of the trigger flow.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (2)
packages/shared/src/types/feature-flags.ts (1)

77-85: Minor naming inconsistency between related flags.

There's an inconsistency in pluralization:

  • Line 79: IS_ANALYTIC_V2_ACTIVE_SUBSCRIBER_TREND_READ_ENABLED (singular)
  • Line 84: IS_ANALYTIC_V2_ACTIVE_SUBSCRIBERS_READ_ENABLED (plural)

Consider aligning these for consistency, e.g., both using SUBSCRIBERS (plural) to match the domain concept of "active subscribers."

apps/api/src/app/activity/usecases/build-delivery-trend-chart/build-delivery-trend-chart.usecase.ts (1)

64-107: Consider extracting the feature-flag checking logic into a shared helper.

The feature-flag checking pattern (building context, parallel flag fetching, combining with OR) is duplicated across all chart use cases. While this is acceptable and keeps each use case self-contained, you could optionally extract this into a reusable helper for consistency and reduced boilerplate.

Example approach:

// In a shared helper or base class
async isAnalyticV2Enabled(
  organizationId: string,
  environmentId: string,
  dedicatedFlagKey: FeatureFlagsKeysEnum
): Promise<boolean> {
  const [isGlobalEnabled, isDedicatedEnabled] = await Promise.all([
    this.featureFlagsService.getFlag({
      key: FeatureFlagsKeysEnum.IS_ANALYTIC_V2_LOGS_READ_GLOBAL_ENABLED,
      defaultValue: false,
      organization: { _id: organizationId },
      environment: { _id: environmentId },
    }),
    this.featureFlagsService.getFlag({
      key: dedicatedFlagKey,
      defaultValue: false,
      organization: { _id: organizationId },
      environment: { _id: environmentId },
    }),
  ]);
  
  return isGlobalEnabled || isDedicatedEnabled;
}
📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c24394d and 618db5c.

📒 Files selected for processing (9)
  • apps/api/src/app/activity/usecases/build-active-subscribers-chart/build-active-subscribers-chart.usecase.ts
  • apps/api/src/app/activity/usecases/build-active-subscribers-trend-chart/build-active-subscribers-trend-chart.usecase.ts
  • apps/api/src/app/activity/usecases/build-avg-messages-per-subscriber-chart/build-avg-messages-per-subscriber-chart.usecase.ts
  • apps/api/src/app/activity/usecases/build-delivery-trend-chart/build-delivery-trend-chart.usecase.ts
  • apps/api/src/app/activity/usecases/build-interaction-trend-chart/build-interaction-trend-chart.usecase.ts
  • apps/api/src/app/activity/usecases/build-messages-delivered-chart/build-messages-delivered-chart.usecase.ts
  • apps/api/src/app/activity/usecases/build-provider-by-volume-chart/build-provider-by-volume-chart.usecase.ts
  • apps/api/src/app/activity/usecases/build-total-interactions-chart/build-total-interactions-chart.usecase.ts
  • packages/shared/src/types/feature-flags.ts
🧰 Additional context used
📓 Path-based instructions (5)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{ts,tsx}: Write concise, technical TypeScript code with accurate examples
Use descriptive variable names with auxiliary verbs (isLoading, hasError)
Add blank lines before return statements
Import motion components from 'motion/react' instead of 'motion-react'

**/*.{ts,tsx}: Write concise, technical TypeScript code with accurate examples
Use functional and declarative programming patterns; avoid classes
Prefer iteration and modularization over code duplication, minimize code duplication as possible
Use descriptive variable names with auxiliary verbs (e.g., isLoading, hasError)
Structure files: exported component, subcomponents, helpers, static content, types
Don't leave comments in code, unless they explain something complex and not trivial
Don't use nested ternaries
Favor named exports for components
Use TypeScript for all code; prefer interfaces over types
In front end code, use types over interfaces
Use functional components with TypeScript types
Use the "function" keyword for pure functions
Avoid unnecessary curly braces in conditionals; use concise syntax for simple statements
Add blank lines before return statements
When importing "motion-react" package, import it from "motion/react"

Files:

  • apps/api/src/app/activity/usecases/build-provider-by-volume-chart/build-provider-by-volume-chart.usecase.ts
  • apps/api/src/app/activity/usecases/build-messages-delivered-chart/build-messages-delivered-chart.usecase.ts
  • apps/api/src/app/activity/usecases/build-active-subscribers-trend-chart/build-active-subscribers-trend-chart.usecase.ts
  • apps/api/src/app/activity/usecases/build-interaction-trend-chart/build-interaction-trend-chart.usecase.ts
  • apps/api/src/app/activity/usecases/build-avg-messages-per-subscriber-chart/build-avg-messages-per-subscriber-chart.usecase.ts
  • apps/api/src/app/activity/usecases/build-delivery-trend-chart/build-delivery-trend-chart.usecase.ts
  • packages/shared/src/types/feature-flags.ts
  • apps/api/src/app/activity/usecases/build-total-interactions-chart/build-total-interactions-chart.usecase.ts
  • apps/api/src/app/activity/usecases/build-active-subscribers-chart/build-active-subscribers-chart.usecase.ts
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (CLAUDE.md)

Use functional and declarative programming patterns; avoid classes

Files:

  • apps/api/src/app/activity/usecases/build-provider-by-volume-chart/build-provider-by-volume-chart.usecase.ts
  • apps/api/src/app/activity/usecases/build-messages-delivered-chart/build-messages-delivered-chart.usecase.ts
  • apps/api/src/app/activity/usecases/build-active-subscribers-trend-chart/build-active-subscribers-trend-chart.usecase.ts
  • apps/api/src/app/activity/usecases/build-interaction-trend-chart/build-interaction-trend-chart.usecase.ts
  • apps/api/src/app/activity/usecases/build-avg-messages-per-subscriber-chart/build-avg-messages-per-subscriber-chart.usecase.ts
  • apps/api/src/app/activity/usecases/build-delivery-trend-chart/build-delivery-trend-chart.usecase.ts
  • packages/shared/src/types/feature-flags.ts
  • apps/api/src/app/activity/usecases/build-total-interactions-chart/build-total-interactions-chart.usecase.ts
  • apps/api/src/app/activity/usecases/build-active-subscribers-chart/build-active-subscribers-chart.usecase.ts
**/*.{tsx,ts}

📄 CodeRabbit inference engine (CLAUDE.md)

Favor named exports for components

Files:

  • apps/api/src/app/activity/usecases/build-provider-by-volume-chart/build-provider-by-volume-chart.usecase.ts
  • apps/api/src/app/activity/usecases/build-messages-delivered-chart/build-messages-delivered-chart.usecase.ts
  • apps/api/src/app/activity/usecases/build-active-subscribers-trend-chart/build-active-subscribers-trend-chart.usecase.ts
  • apps/api/src/app/activity/usecases/build-interaction-trend-chart/build-interaction-trend-chart.usecase.ts
  • apps/api/src/app/activity/usecases/build-avg-messages-per-subscriber-chart/build-avg-messages-per-subscriber-chart.usecase.ts
  • apps/api/src/app/activity/usecases/build-delivery-trend-chart/build-delivery-trend-chart.usecase.ts
  • packages/shared/src/types/feature-flags.ts
  • apps/api/src/app/activity/usecases/build-total-interactions-chart/build-total-interactions-chart.usecase.ts
  • apps/api/src/app/activity/usecases/build-active-subscribers-chart/build-active-subscribers-chart.usecase.ts
apps/api/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

Prefer interfaces over types in backend code

Files:

  • apps/api/src/app/activity/usecases/build-provider-by-volume-chart/build-provider-by-volume-chart.usecase.ts
  • apps/api/src/app/activity/usecases/build-messages-delivered-chart/build-messages-delivered-chart.usecase.ts
  • apps/api/src/app/activity/usecases/build-active-subscribers-trend-chart/build-active-subscribers-trend-chart.usecase.ts
  • apps/api/src/app/activity/usecases/build-interaction-trend-chart/build-interaction-trend-chart.usecase.ts
  • apps/api/src/app/activity/usecases/build-avg-messages-per-subscriber-chart/build-avg-messages-per-subscriber-chart.usecase.ts
  • apps/api/src/app/activity/usecases/build-delivery-trend-chart/build-delivery-trend-chart.usecase.ts
  • apps/api/src/app/activity/usecases/build-total-interactions-chart/build-total-interactions-chart.usecase.ts
  • apps/api/src/app/activity/usecases/build-active-subscribers-chart/build-active-subscribers-chart.usecase.ts
**

📄 CodeRabbit inference engine (.cursor/rules/novu.mdc)

Use lowercase with dashes for directories and files (e.g., components/auth-wizard)

Files:

  • apps/api/src/app/activity/usecases/build-provider-by-volume-chart/build-provider-by-volume-chart.usecase.ts
  • apps/api/src/app/activity/usecases/build-messages-delivered-chart/build-messages-delivered-chart.usecase.ts
  • apps/api/src/app/activity/usecases/build-active-subscribers-trend-chart/build-active-subscribers-trend-chart.usecase.ts
  • apps/api/src/app/activity/usecases/build-interaction-trend-chart/build-interaction-trend-chart.usecase.ts
  • apps/api/src/app/activity/usecases/build-avg-messages-per-subscriber-chart/build-avg-messages-per-subscriber-chart.usecase.ts
  • apps/api/src/app/activity/usecases/build-delivery-trend-chart/build-delivery-trend-chart.usecase.ts
  • packages/shared/src/types/feature-flags.ts
  • apps/api/src/app/activity/usecases/build-total-interactions-chart/build-total-interactions-chart.usecase.ts
  • apps/api/src/app/activity/usecases/build-active-subscribers-chart/build-active-subscribers-chart.usecase.ts
🧬 Code graph analysis (3)
apps/api/src/app/activity/usecases/build-interaction-trend-chart/build-interaction-trend-chart.usecase.ts (6)
apps/api/src/app/activity/usecases/build-active-subscribers-trend-chart/build-active-subscribers-trend-chart.usecase.ts (1)
  • Injectable (13-89)
apps/api/src/app/activity/usecases/build-delivery-trend-chart/build-delivery-trend-chart.usecase.ts (1)
  • Injectable (13-108)
apps/api/src/app/activity/usecases/build-messages-delivered-chart/build-messages-delivered-chart.usecase.ts (1)
  • Injectable (13-77)
apps/api/src/app/activity/usecases/build-provider-by-volume-chart/build-provider-by-volume-chart.usecase.ts (1)
  • Injectable (13-69)
apps/api/src/app/activity/usecases/build-total-interactions-chart/build-total-interactions-chart.usecase.ts (1)
  • Injectable (13-77)
libs/application-generic/src/services/analytic-logs/trace-log/trace-log.repository.ts (1)
  • Injectable (15-228)
apps/api/src/app/activity/usecases/build-total-interactions-chart/build-total-interactions-chart.usecase.ts (3)
apps/api/src/app/activity/usecases/build-active-subscribers-chart/build-active-subscribers-chart.usecase.ts (1)
  • Injectable (13-77)
apps/api/src/app/activity/usecases/build-messages-delivered-chart/build-messages-delivered-chart.usecase.ts (1)
  • Injectable (13-77)
apps/api/src/app/activity/usecases/build-provider-by-volume-chart/build-provider-by-volume-chart.usecase.ts (1)
  • Injectable (13-69)
apps/api/src/app/activity/usecases/build-active-subscribers-chart/build-active-subscribers-chart.usecase.ts (1)
libs/application-generic/src/services/analytic-logs/index.ts (1)
  • WorkflowRunRepository (10-10)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (15)
  • GitHub Check: Unit test backend services (@novu/worker)
  • GitHub Check: E2E test API / Test E2E
  • GitHub Check: Unit test backend services (@novu/api-service)
  • GitHub Check: Unit test backend services (@novu/webhook)
  • GitHub Check: Unit test @novu public NPM packages (except providers)
  • GitHub Check: Unit test @novu internal packages
  • GitHub Check: Unit test backend services (@novu/inbound-mail)
  • GitHub Check: Unit test backend services (@novu/ws)
  • GitHub Check: Validate OpenAPI
  • GitHub Check: Analyze (javascript)
  • GitHub Check: Analyze (typescript)
  • GitHub Check: Redirect rules - dashboard-v2-novu-staging
  • GitHub Check: Header rules - dashboard-v2-novu-staging
  • GitHub Check: Pages changed - dashboard-v2-novu-staging
  • GitHub Check: publish_preview_packages
🔇 Additional comments (11)
apps/api/src/app/activity/usecases/build-active-subscribers-trend-chart/build-active-subscribers-trend-chart.usecase.ts (1)

1-62: LGTM! Clean implementation of feature-flag driven data source selection.

The pattern follows the established convention across other chart use cases: parallel flag fetching, combined boolean check, and conditional repository call. The existing chart data transformation logic is preserved.

apps/api/src/app/activity/usecases/build-active-subscribers-chart/build-active-subscribers-chart.usecase.ts (1)

1-76: LGTM! Implementation follows the established feature-flag pattern.

The conditional data source selection between TraceRollupRepository and WorkflowRunRepository is correctly implemented with parallel flag fetching.

apps/api/src/app/activity/usecases/build-messages-delivered-chart/build-messages-delivered-chart.usecase.ts (1)

1-76: LGTM! Consistent implementation of feature-flag driven data source selection.

The pattern aligns with other chart use cases, correctly using TraceRollupRepository.getMessageSendCount when enabled and falling back to StepRunRepository.getMessagesDeliveredData.

apps/api/src/app/activity/usecases/build-provider-by-volume-chart/build-provider-by-volume-chart.usecase.ts (1)

1-68: LGTM! Clean feature-flag driven data source selection.

The implementation correctly follows the established pattern. The data mapping from provider_id to providerId is preserved regardless of which repository is used.

apps/api/src/app/activity/usecases/build-avg-messages-per-subscriber-chart/build-avg-messages-per-subscriber-chart.usecase.ts (1)

1-76: LGTM! Follows the consistent feature-flag pattern.

The implementation correctly selects between TraceRollupRepository and StepRunRepository based on feature flags, maintaining the same return structure.

apps/api/src/app/activity/usecases/build-delivery-trend-chart/build-delivery-trend-chart.usecase.ts (1)

1-62: LGTM! Consistent feature-flag driven data source selection.

The implementation correctly uses DeliveryTrendCountsRepository (appropriate for this specific use case) instead of TraceRollupRepository, following the same flag-checking pattern. The existing chart data transformation logic is preserved.

apps/api/src/app/activity/usecases/build-total-interactions-chart/build-total-interactions-chart.usecase.ts (2)

2-9: LGTM!

The imports and constructor injections correctly add TraceRollupRepository and FeatureFlagsService dependencies, following the established pattern used across other analytics usecases in this PR.

Also applies to: 15-22


32-70: Feature-flag routing is consistent with other usecases.

The implementation correctly:

  • Constructs the feature flag context with org/env IDs
  • Fetches both global and dedicated flags in parallel
  • Uses OR logic to enable the new query path
  • Passes identical parameters to both the new and legacy repository methods

This matches the established pattern in BuildProviderByVolumeChart, BuildMessagesDeliveredChart, and other analytics usecases.

apps/api/src/app/activity/usecases/build-interaction-trend-chart/build-interaction-trend-chart.usecase.ts (3)

2-9: LGTM!

The imports and dependency injection follow the same pattern as BuildTotalInteractionsChart and other usecases, correctly adding TraceRollupRepository and FeatureFlagsService.

Also applies to: 15-22


28-62: Feature-flag based routing implemented correctly.

The feature flag logic follows the established pattern, using IS_ANALYTIC_V2_INTERACTION_TREND_READ_ENABLED as the dedicated flag for this usecase. Both repository paths receive identical parameters.


64-102: Data transformation logic is sound.

The chart data aggregation correctly:

  • Pre-populates all dates in the range to ensure continuous time series
  • Safely handles the event_type lookup with optional chaining
  • Parses string counts from the repository response

No changes needed here.

…lated scripts

- Introduced provider_id column in the traces table to store the provider ID for messages.
- Updated SQL migration to ensure provider_id is added before creating dependent materialized views.
- Modified seed script to reflect changes in trace rollup and delivery trend counts.
- Adjusted generator interface and trace record creation to include provider_id.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

🤖 Fix all issues with AI agents
In @apps/api/migrations/clickhouse-migrations/3_analytics_tables.sql:
- Around line 1-27: The initial CREATE TABLE/CREATE MATERIALIZED VIEW for
trace_rollup and trace_rollup_mv are redundant because later statements drop and
recreate them with extended schema; remove lines creating trace_rollup and
trace_rollup_mv (the CREATE TABLE IF NOT EXISTS trace_rollup and CREATE
MATERIALIZED VIEW IF NOT EXISTS trace_rollup_mv blocks) from this migration, or
instead split into two migrations: keep an initial migration that creates
trace_rollup and delivery_trend_counts, and move the schema-evolution changes
that add event_type and provider_id into a subsequent migration that
alters/recreates trace_rollup and trace_rollup_mv.
- Around line 1-2: The top-of-file comment above the trace rollup materialized
view is stale: it claims the table "Handles both message counts and subscriber
activity from traces table (message_sent events)" but the final materialized
view aggregates multiple event types (message_sent, message_seen, message_read,
message_snoozed, message_archived). Update the comment to accurately describe
the materialized view's purpose and the full set of event types it captures (or
remove the misleading parenthetical), referencing the trace rollup materialized
view and the listed event types so readers know what is being aggregated.
- Around line 64-70: The DROP TABLEs for trace_rollup and interaction_counts
will permanently delete historical aggregates; confirm with the team whether
data loss is acceptable, and if not implement a backfill before dropping:
create/ensure the new schema (trace_rollup with event_type and merged
structure), run an INSERT ... SELECT from the source traces (or from
interaction_counts if it contains unique rows) to populate the new trace_rollup
(and/or merged interaction_counts data) preserving timestamps and deriving
event_type as needed, verify counts match pre-drop totals, then drop the old
tables and create the new materialized views (trace_rollup_mv,
interaction_counts_mv) only after successful verification.

In @apps/api/scripts/clickhouse-seeder/generators.ts:
- Around line 268-276: The loop creating subscribers uses a single subscriberId
variable initialized from singleEnvConfig.subscriberId so every subscriber gets
the same ID when a custom id is provided; change the logic in the for loop
(where subscriberId is set and env.subscribers.push is called) to use the
provided singleEnvConfig.subscriberId only for the first subscriber (s === 0)
and call generateId() for all other iterations so each subscriber gets a unique
id.
- Around line 253-266: The loop assigns the same provided
singleEnvConfig.workflowId to every workflow, causing duplicate IDs; change the
workflowId assignment inside the for-loop so it uses singleEnvConfig.workflowId
only for the first iteration (w === 0) and calls generateId() for all subsequent
workflows (use a conditional when setting workflowId), keeping the rest of the
push logic (selectWorkflowTemplate(), env.workflows.push(...), template,
channels) unchanged.

In
@apps/worker/src/app/workflow/usecases/subscriber-job-bound/subscriber-job-bound.usecase.ts:
- Line 414: The traceData object sets provider_id to an empty string which
contradicts the schema (CHNullable(CHString())) and other fields that use null;
change the assignment to use null instead of '' (update provider_id to null),
following the existing pattern used elsewhere (e.g.,
create-execution-details.usecase.ts uses command.providerId || null) so
traceData.provider_id is consistently null when absent.

In
@libs/application-generic/src/services/analytic-logs/trace-rollup/trace-rollup.repository.ts:
- Around line 106-183: getActiveSubscribersCount is ignoring the previousEndDate
parameter and instead uses adjustedPreviousEndDate computed from startDate;
update the method to use the provided previousEndDate (formatted to date-only)
for previousParams (replace the getDateOnlyPreviousEndDate(startDate) usage),
ensure adjustedPreviousEndDate (or directly previousParams.previousEndDate) is
set from previousEndDate.toISOString().split('T')[0], and keep workflowIds
injection and query params logic unchanged so the previous period query uses the
passed previousEndDate.
- Around line 234-320: The method getAvgMessagesPerSubscriberData defines a
previousEndDate parameter but then ignores it by using adjustedPreviousEndDate;
update previousParams to use previousEndDate (converted to date-only string)
instead of adjustedPreviousEndDate, or if adjustedPreviousEndDate is required,
remove the unused previousEndDate parameter and its references; ensure
previousParams uses the matching key previousEndDate and workflowIds logic
remains the same so the ClickHouse query receives the correct date value.
- Around line 322-397: The previousEndDate parameter passed into
getTotalInteractionsCount is never used; replace the computed
adjustedPreviousEndDate (currently from getDateOnlyPreviousEndDate(startDate))
with a string derived from the previousEndDate parameter and assign that to
previousParams.previousEndDate (e.g.,
previousEndDate.toISOString().split('T')[0]), removing the unused
adjustedPreviousEndDate computation; update references in
getTotalInteractionsCount so previousParams uses the provided previousEndDate
value.
🧹 Nitpick comments (6)
apps/api/scripts/clickhouse-seeder/generators.ts (4)

137-139: Consider adding empty array guard.

If items is empty, this returns undefined (typed as T), which could cause subtle issues. Since this is a seeding script, empty arrays are unlikely, but a defensive check would be safer.

♻️ Optional defensive fix
 function randomChoice<T>(items: T[]): T {
+  if (items.length === 0) {
+    throw new Error('Cannot select from empty array');
+  }
   return items[Math.floor(Math.random() * items.length)];
 }

161-166: Hardcoded divisor couples to distribution sum.

Line 166 divides by 10, which assumes ENTERPRISE_HEAVY_DISTRIBUTION values sum to 10. If the distribution changes, this calculation breaks silently.

♻️ Derive divisor from distribution
   const distribution = ENTERPRISE_HEAVY_DISTRIBUTION;
+  const distributionTotal = Object.values(distribution).reduce((sum, count) => sum + count, 0);

   let orgCount = 0;

   for (const [profileType, count] of Object.entries(distribution)) {
-    const scaledCount = Math.ceil(count * (config.organizations / 10));
+    const scaledCount = Math.ceil(count * (config.organizations / distributionTotal));

520-523: Unused variable: orgMap is created but never used.

The orgMap is populated but never referenced in this function.

♻️ Remove unused code
 export function generateStepRuns(workflowRuns: WorkflowRunRecord[], organizations: Organization[]): StepRunRecord[] {
   const allStepRuns: StepRunRecord[] = [];

-  const orgMap = new Map<string, Organization>();
-  for (const org of organizations) {
-    orgMap.set(org.id, org);
-  }
-
   const workflowMap = new Map<string, Workflow>();

639-649: Dead code: 'canceled' status case is unreachable.

STEP_RUN_STATUS_DISTRIBUTION defines statuses as completed, failed, skipped, and delayed. The stepStatus === 'canceled' branch (Line 644) will never execute.

♻️ Remove unreachable case or add 'canceled' to distribution
   if (index === total - 1) {
     if (stepStatus === 'completed') {
       return 'step_completed';
     } else if (stepStatus === 'failed') {
       return 'step_canceled';
-    } else if (stepStatus === 'canceled') {
-      return 'step_canceled';
     }

     return 'step_completed';
   }
libs/application-generic/src/services/analytic-logs/trace-rollup/trace-rollup.repository.ts (2)

8-13: Consider renaming for clarity.

The function name getDateOnlyPreviousEndDate and parameter name startDate create confusion. It calculates the day before startDate, which is conceptually the end of the previous period. Consider renaming to getDayBeforeAsDateString(date: Date) or similar to better express intent.


29-397: Consider extracting shared patterns to reduce duplication.

The methods for current/previous period queries (lines 29-397) share significant boilerplate:

  • Workflow filter construction
  • Date formatting (toISOString().split('T')[0])
  • Params building for current and previous periods
  • Promise.all execution pattern
  • Result parsing

Extracting helpers (e.g., formatDateParam, buildWorkflowFilter, executePeriodComparison) would improve maintainability and reduce repetition.

♻️ Example helper extractions
private formatDateParam(date: Date): string {
  return date.toISOString().split('T')[0];
}

private buildWorkflowFilter(workflowIds?: string[]): string {
  return workflowIds?.length ? 'AND workflow_id IN {workflowIds:Array(String)}' : '';
}

private buildDateParams(
  environmentId: string,
  organizationId: string,
  startDate: Date,
  endDate: Date,
  workflowIds?: string[]
): Record<string, unknown> {
  const params: Record<string, unknown> = {
    environmentId,
    organizationId,
    startDate: this.formatDateParam(startDate),
    endDate: this.formatDateParam(endDate),
  };
  if (workflowIds?.length) {
    params.workflowIds = workflowIds;
  }

  return params;
}
📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 618db5c and b83443b.

📒 Files selected for processing (5)
  • apps/api/migrations/clickhouse-migrations/3_analytics_tables.sql
  • apps/api/scripts/clickhouse-seeder/generators.ts
  • apps/api/scripts/seed-clickhouse.ts
  • apps/worker/src/app/workflow/usecases/subscriber-job-bound/subscriber-job-bound.usecase.ts
  • libs/application-generic/src/services/analytic-logs/trace-rollup/trace-rollup.repository.ts
🧰 Additional context used
📓 Path-based instructions (5)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{ts,tsx}: Write concise, technical TypeScript code with accurate examples
Use descriptive variable names with auxiliary verbs (isLoading, hasError)
Add blank lines before return statements
Import motion components from 'motion/react' instead of 'motion-react'

**/*.{ts,tsx}: Write concise, technical TypeScript code with accurate examples
Use functional and declarative programming patterns; avoid classes
Prefer iteration and modularization over code duplication, minimize code duplication as possible
Use descriptive variable names with auxiliary verbs (e.g., isLoading, hasError)
Structure files: exported component, subcomponents, helpers, static content, types
Don't leave comments in code, unless they explain something complex and not trivial
Don't use nested ternaries
Favor named exports for components
Use TypeScript for all code; prefer interfaces over types
In front end code, use types over interfaces
Use functional components with TypeScript types
Use the "function" keyword for pure functions
Avoid unnecessary curly braces in conditionals; use concise syntax for simple statements
Add blank lines before return statements
When importing "motion-react" package, import it from "motion/react"

Files:

  • apps/worker/src/app/workflow/usecases/subscriber-job-bound/subscriber-job-bound.usecase.ts
  • apps/api/scripts/seed-clickhouse.ts
  • libs/application-generic/src/services/analytic-logs/trace-rollup/trace-rollup.repository.ts
  • apps/api/scripts/clickhouse-seeder/generators.ts
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (CLAUDE.md)

Use functional and declarative programming patterns; avoid classes

Files:

  • apps/worker/src/app/workflow/usecases/subscriber-job-bound/subscriber-job-bound.usecase.ts
  • apps/api/scripts/seed-clickhouse.ts
  • libs/application-generic/src/services/analytic-logs/trace-rollup/trace-rollup.repository.ts
  • apps/api/scripts/clickhouse-seeder/generators.ts
**/*.{tsx,ts}

📄 CodeRabbit inference engine (CLAUDE.md)

Favor named exports for components

Files:

  • apps/worker/src/app/workflow/usecases/subscriber-job-bound/subscriber-job-bound.usecase.ts
  • apps/api/scripts/seed-clickhouse.ts
  • libs/application-generic/src/services/analytic-logs/trace-rollup/trace-rollup.repository.ts
  • apps/api/scripts/clickhouse-seeder/generators.ts
**

📄 CodeRabbit inference engine (.cursor/rules/novu.mdc)

Use lowercase with dashes for directories and files (e.g., components/auth-wizard)

Files:

  • apps/worker/src/app/workflow/usecases/subscriber-job-bound/subscriber-job-bound.usecase.ts
  • apps/api/scripts/seed-clickhouse.ts
  • apps/api/migrations/clickhouse-migrations/3_analytics_tables.sql
  • libs/application-generic/src/services/analytic-logs/trace-rollup/trace-rollup.repository.ts
  • apps/api/scripts/clickhouse-seeder/generators.ts
apps/api/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

Prefer interfaces over types in backend code

Files:

  • apps/api/scripts/seed-clickhouse.ts
  • apps/api/scripts/clickhouse-seeder/generators.ts
🧬 Code graph analysis (2)
apps/api/scripts/seed-clickhouse.ts (3)
apps/api/scripts/clickhouse-seeder/generators.ts (5)
  • GenerationProgress (330-335)
  • generateOrganizations (155-227)
  • estimateTotalWorkflowRuns (345-355)
  • generateDataInBatches (357-439)
  • Organization (17-22)
apps/api/scripts/clickhouse-seeder/config.ts (1)
  • parseCliArgs (131-224)
apps/api/scripts/clickhouse-seeder/inserter.ts (2)
  • ClickHouseInserter (33-152)
  • estimateDataSize (164-173)
apps/api/scripts/clickhouse-seeder/generators.ts (2)
apps/api/scripts/clickhouse-seeder/config.ts (11)
  • OrganizationProfile (21-31)
  • WorkflowTemplate (75-80)
  • SeederConfig (12-19)
  • ENTERPRISE_HEAVY_DISTRIBUTION (69-73)
  • ORGANIZATION_PROFILES (33-67)
  • SingleEnvironmentConfig (1-10)
  • WORKFLOW_TEMPLATES (82-87)
  • WORKFLOW_RUN_STATUS_DISTRIBUTION (89-93)
  • DELIVERY_LIFECYCLE_STATUS_DISTRIBUTION (102-109)
  • STEP_RUN_STATUS_DISTRIBUTION (95-100)
  • TRACE_EVENT_TYPES (111-115)
apps/api/scripts/clickhouse-seeder/time-distribution.ts (2)
  • generateRandomTimestampsForDay (89-125)
  • addRandomJitter (155-158)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (15)
  • GitHub Check: Redirect rules - dashboard-v2-novu-staging
  • GitHub Check: Header rules - dashboard-v2-novu-staging
  • GitHub Check: Pages changed - dashboard-v2-novu-staging
  • GitHub Check: E2E test API / Test E2E
  • GitHub Check: Unit test backend services (@novu/ws)
  • GitHub Check: Unit test backend services (@novu/worker)
  • GitHub Check: Unit test backend services (@novu/inbound-mail)
  • GitHub Check: Unit test backend services (@novu/api-service)
  • GitHub Check: Unit test @novu internal packages
  • GitHub Check: Unit test backend services (@novu/webhook)
  • GitHub Check: Unit test @novu public NPM packages (except providers)
  • GitHub Check: Validate OpenAPI
  • GitHub Check: Analyze (javascript)
  • GitHub Check: Analyze (typescript)
  • GitHub Check: publish_preview_packages
🔇 Additional comments (18)
apps/api/scripts/clickhouse-seeder/generators.ts (7)

1-15: LGTM!

Imports are well-organized, using named imports from local modules. The use of randomBytes from Node's crypto module for ID generation is appropriate.


17-127: LGTM!

The interfaces are well-structured and follow the coding guideline to prefer interfaces over types in backend code. The record interfaces properly model the ClickHouse table schemas with appropriate nullable fields.


302-328: LGTM!

The function correctly generates workflow runs across organizations, environments, and days. The date handling properly creates new Date instances per iteration to avoid mutation issues.


357-438: LGTM!

Excellent use of a generator function for memory-efficient batch streaming. The progress tracking and final batch handling are correctly implemented.


471-515: LGTM!

Record creation is thorough with appropriate use of weighted random distributions for status fields and proper date handling.


552-602: LGTM!

The step run record creation correctly maps channels to providers and populates the provider_id field, which aligns with the PR objective of adding provider tracking.


656-703: LGTM!

The trace record creation correctly propagates provider_id from step runs to traces, supporting the PR's goal of adding provider tracking. The formatEventTitle helper is a clean utility.

apps/api/scripts/seed-clickhouse.ts (5)

1-6: LGTM!

Environment configuration is loaded early before other imports that might depend on it. Good practice using path.join for cross-platform compatibility.


62-73: Consider validating optional credentials consistently.

CLICK_HOUSE_USER and CLICK_HOUSE_PASSWORD are used (Lines 70-71) but not validated. If they're required for your ClickHouse setup, add them to the validation. If optional, this is fine as-is.


75-176: LGTM!

The main execution flow is well-structured with proper error handling, resource cleanup in the finally block, and efficient parallel batch inserts. Progress reporting provides good visibility during long-running seeds.


178-193: LGTM!

The breakdown helper correctly aggregates organization counts by profile type. TypeScript's type system ensures only valid keys are accessed.


195-206: LGTM!

Standard Node.js script pattern with proper module detection and clean process exit codes. The named export follows coding guidelines.

libs/application-generic/src/services/analytic-logs/trace-rollup/trace-rollup.repository.ts (4)

15-27: LGTM!

The class setup follows NestJS conventions. The constructor correctly initializes the base LogRepository and sets the logger context.


185-232: LGTM!

The trend data method is correctly implemented. It queries a single period and returns the daily breakdown as expected.


399-447: LGTM!

The interaction trend data method is correctly implemented with proper grouping by date and event type.


449-496: LGTM!

The provider volume query is correctly implemented. The LIMIT 5 is hardcoded—consider making it configurable if future requirements need flexibility, but it's acceptable as-is.

apps/api/migrations/clickhouse-migrations/3_analytics_tables.sql (2)

29-58: LGTM!

The delivery_trend_counts table and materialized view are well-structured. Good use of LowCardinality(String) for step_type and SummingMergeTree for efficient count aggregation. The filter on status = 'completed' and specific messaging step types is appropriate.


78-107: LGTM on the extended schema design.

The final trace_rollup table and materialized view are well-designed:

  • LowCardinality(String) for event_type is optimal for the limited set of event types
  • SummingMergeTree with comprehensive ORDER BY ensures proper aggregation
  • ifNull() handling for nullable source columns prevents NULL propagation
  • The event type filter captures the full range of message lifecycle events

Comment on lines +253 to +266
for (let w = 0; w < singleEnvConfig.workflows; w++) {
const template = selectWorkflowTemplate();
const workflowId = singleEnvConfig.workflowId || generateId();

env.workflows.push({
id: workflowId,
name: `${template.name} ${w + 1}`,
triggerIdentifier: `${template.type}_${w + 1}`.toLowerCase().replace(/\s+/g, '_'),
environmentId: envId,
organizationId: orgId,
channels: template.channels,
template,
});
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Bug: Same workflowId assigned to all workflows when provided.

When singleEnvConfig.workflowId is provided, every workflow in the loop receives the same ID. This creates duplicate workflow IDs which would cause data integrity issues.

🐛 Proposed fix: Only use provided ID for first workflow
   for (let w = 0; w < singleEnvConfig.workflows; w++) {
     const template = selectWorkflowTemplate();
-    const workflowId = singleEnvConfig.workflowId || generateId();
+    const workflowId = (w === 0 && singleEnvConfig.workflowId) ? singleEnvConfig.workflowId : generateId();

     env.workflows.push({
       id: workflowId,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for (let w = 0; w < singleEnvConfig.workflows; w++) {
const template = selectWorkflowTemplate();
const workflowId = singleEnvConfig.workflowId || generateId();
env.workflows.push({
id: workflowId,
name: `${template.name} ${w + 1}`,
triggerIdentifier: `${template.type}_${w + 1}`.toLowerCase().replace(/\s+/g, '_'),
environmentId: envId,
organizationId: orgId,
channels: template.channels,
template,
});
}
for (let w = 0; w < singleEnvConfig.workflows; w++) {
const template = selectWorkflowTemplate();
const workflowId = (w === 0 && singleEnvConfig.workflowId) ? singleEnvConfig.workflowId : generateId();
env.workflows.push({
id: workflowId,
name: `${template.name} ${w + 1}`,
triggerIdentifier: `${template.type}_${w + 1}`.toLowerCase().replace(/\s+/g, '_'),
environmentId: envId,
organizationId: orgId,
channels: template.channels,
template,
});
}
🤖 Prompt for AI Agents
In @apps/api/scripts/clickhouse-seeder/generators.ts around lines 253 - 266, The
loop assigns the same provided singleEnvConfig.workflowId to every workflow,
causing duplicate IDs; change the workflowId assignment inside the for-loop so
it uses singleEnvConfig.workflowId only for the first iteration (w === 0) and
calls generateId() for all subsequent workflows (use a conditional when setting
workflowId), keeping the rest of the push logic (selectWorkflowTemplate(),
env.workflows.push(...), template, channels) unchanged.

Comment on lines +268 to +276
for (let s = 0; s < singleEnvConfig.subscribers; s++) {
const subscriberId = singleEnvConfig.subscriberId || generateId();
env.subscribers.push({
id: subscriberId,
externalId: `user_${s + 1}`,
environmentId: envId,
organizationId: orgId,
});
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Bug: Same subscriberId assigned to all subscribers when provided.

Similar to the workflow issue, when singleEnvConfig.subscriberId is provided, every subscriber gets the same ID.

🐛 Proposed fix: Only use provided ID for first subscriber
   for (let s = 0; s < singleEnvConfig.subscribers; s++) {
-    const subscriberId = singleEnvConfig.subscriberId || generateId();
+    const subscriberId = (s === 0 && singleEnvConfig.subscriberId) ? singleEnvConfig.subscriberId : generateId();
     env.subscribers.push({
       id: subscriberId,
🤖 Prompt for AI Agents
In @apps/api/scripts/clickhouse-seeder/generators.ts around lines 268 - 276, The
loop creating subscribers uses a single subscriberId variable initialized from
singleEnvConfig.subscriberId so every subscriber gets the same ID when a custom
id is provided; change the logic in the for loop (where subscriberId is set and
env.subscribers.push is called) to use the provided singleEnvConfig.subscriberId
only for the first subscriber (s === 0) and call generateId() for all other
iterations so each subscriber gets a unique id.

entity_id: command.requestId,
workflow_run_identifier: command.identifier,
workflow_id: command.templateId,
provider_id: '',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, read the file around line 414 to see the full context
sed -n '400,420p' apps/worker/src/app/workflow/usecases/subscriber-job-bound/subscriber-job-bound.usecase.ts | cat -n

Repository: novuhq/novu

Length of output: 941


🏁 Script executed:

# Search for other trace creation sites and provider_id patterns
rg -n "provider_id:" --type=ts -C 3 apps/worker/src/app/workflow/usecases/subscriber-job-bound/

Repository: novuhq/novu

Length of output: 950


🏁 Script executed:

# Search for traceData definitions and trace creation patterns more broadly
rg -n "traceData\s*=" --type=ts -C 2 | head -100

Repository: novuhq/novu

Length of output: 769


🏁 Script executed:

# Read the create-execution-details usecase to see how it structures traceData
sed -n '175,210p' libs/application-generic/src/usecases/create-execution-details/create-execution-details.usecase.ts | cat -n

Repository: novuhq/novu

Length of output: 1643


🏁 Script executed:

# Search for all provider_id assignments to see the pattern
rg -n "provider_id\s*:" --type=ts -A 1 -B 1 | head -120

Repository: novuhq/novu

Length of output: 8721


🏁 Script executed:

# Search for TraceData type definition or interface
rg -n "interface TraceData|type TraceData|createRequest" --type=ts | head -30

Repository: novuhq/novu

Length of output: 3342


Use null instead of empty string for consistency.

The schema explicitly defines provider_id as CHNullable(CHString()), which expects null for missing values. Other optional fields in this same traceData object use null (subscriber_id, external_subscriber_id, message, raw_data), and other trace creation sites (e.g., create-execution-details.usecase.ts) use command.providerId || null. Using '' here creates data inconsistency that complicates queries and violates the schema design.

🤖 Prompt for AI Agents
In
@apps/worker/src/app/workflow/usecases/subscriber-job-bound/subscriber-job-bound.usecase.ts
at line 414, The traceData object sets provider_id to an empty string which
contradicts the schema (CHNullable(CHString())) and other fields that use null;
change the assignment to use null instead of '' (update provider_id to null),
following the existing pattern used elsewhere (e.g.,
create-execution-details.usecase.ts uses command.providerId || null) so
traceData.provider_id is consistently null when absent.

Comment on lines +29 to +104
async getMessageSendCount(
environmentId: string,
organizationId: string,
startDate: Date,
endDate: Date,
previousStartDate: Date,
previousEndDate: Date,
workflowIds?: string[]
): Promise<{ currentPeriod: number; previousPeriod: number }> {
const workflowFilter =
workflowIds && workflowIds.length > 0 ? `AND workflow_id IN {workflowIds:Array(String)}` : '';

const currentQuery = `
SELECT sum(count) as count
FROM ${TRACE_ROLLUP_TABLE_NAME}
WHERE
organization_id = {organizationId:String}
AND environment_id = {environmentId:String}
AND event_type = 'message_sent'
AND date >= {startDate:Date}
AND date <= {endDate:Date}
${workflowFilter}
`;

const previousQuery = `
SELECT sum(count) as count
FROM ${TRACE_ROLLUP_TABLE_NAME}
WHERE
organization_id = {organizationId:String}
AND environment_id = {environmentId:String}
AND event_type = 'message_sent'
AND date >= {previousStartDate:Date}
AND date <= {previousEndDate:Date}
${workflowFilter}
`;

const adjustedPreviousEndDate = getDateOnlyPreviousEndDate(startDate);

const currentParams: Record<string, unknown> = {
environmentId,
organizationId,
startDate: startDate.toISOString().split('T')[0],
endDate: endDate.toISOString().split('T')[0],
};

const previousParams: Record<string, unknown> = {
environmentId,
organizationId,
previousStartDate: previousStartDate.toISOString().split('T')[0],
previousEndDate: adjustedPreviousEndDate,
};

if (workflowIds && workflowIds.length > 0) {
currentParams.workflowIds = workflowIds;
previousParams.workflowIds = workflowIds;
}

const [currentResult, previousResult] = await Promise.all([
this.clickhouseService.query<{ count: string }>({
query: currentQuery,
params: currentParams,
}),
this.clickhouseService.query<{ count: string }>({
query: previousQuery,
params: previousParams,
}),
]);

const currentPeriod = parseInt(currentResult.data[0]?.count || '0', 10);
const previousPeriod = parseInt(previousResult.data[0]?.count || '0', 10);

return {
currentPeriod,
previousPeriod,
};
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

The previousEndDate parameter is accepted but never used.

The method signature accepts previousEndDate (line 36), but the actual query uses adjustedPreviousEndDate calculated from startDate (line 65). This discrepancy could cause incorrect date ranges and confuse callers who expect their previousEndDate value to be respected.

Either use the previousEndDate parameter:

🔧 Option A: Use the passed parameter
-    const adjustedPreviousEndDate = getDateOnlyPreviousEndDate(startDate);
-
     const currentParams: Record<string, unknown> = {
       ...
     };

     const previousParams: Record<string, unknown> = {
       environmentId,
       organizationId,
       previousStartDate: previousStartDate.toISOString().split('T')[0],
-      previousEndDate: adjustedPreviousEndDate,
+      previousEndDate: previousEndDate.toISOString().split('T')[0],
     };
🔧 Option B: Remove the unused parameter from the signature
   async getMessageSendCount(
     environmentId: string,
     organizationId: string,
     startDate: Date,
     endDate: Date,
     previousStartDate: Date,
-    previousEndDate: Date,
     workflowIds?: string[]
   ): Promise<{ currentPeriod: number; previousPeriod: number }> {

This would require updating all callers accordingly.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async getMessageSendCount(
environmentId: string,
organizationId: string,
startDate: Date,
endDate: Date,
previousStartDate: Date,
previousEndDate: Date,
workflowIds?: string[]
): Promise<{ currentPeriod: number; previousPeriod: number }> {
const workflowFilter =
workflowIds && workflowIds.length > 0 ? `AND workflow_id IN {workflowIds:Array(String)}` : '';
const currentQuery = `
SELECT sum(count) as count
FROM ${TRACE_ROLLUP_TABLE_NAME}
WHERE
organization_id = {organizationId:String}
AND environment_id = {environmentId:String}
AND event_type = 'message_sent'
AND date >= {startDate:Date}
AND date <= {endDate:Date}
${workflowFilter}
`;
const previousQuery = `
SELECT sum(count) as count
FROM ${TRACE_ROLLUP_TABLE_NAME}
WHERE
organization_id = {organizationId:String}
AND environment_id = {environmentId:String}
AND event_type = 'message_sent'
AND date >= {previousStartDate:Date}
AND date <= {previousEndDate:Date}
${workflowFilter}
`;
const adjustedPreviousEndDate = getDateOnlyPreviousEndDate(startDate);
const currentParams: Record<string, unknown> = {
environmentId,
organizationId,
startDate: startDate.toISOString().split('T')[0],
endDate: endDate.toISOString().split('T')[0],
};
const previousParams: Record<string, unknown> = {
environmentId,
organizationId,
previousStartDate: previousStartDate.toISOString().split('T')[0],
previousEndDate: adjustedPreviousEndDate,
};
if (workflowIds && workflowIds.length > 0) {
currentParams.workflowIds = workflowIds;
previousParams.workflowIds = workflowIds;
}
const [currentResult, previousResult] = await Promise.all([
this.clickhouseService.query<{ count: string }>({
query: currentQuery,
params: currentParams,
}),
this.clickhouseService.query<{ count: string }>({
query: previousQuery,
params: previousParams,
}),
]);
const currentPeriod = parseInt(currentResult.data[0]?.count || '0', 10);
const previousPeriod = parseInt(previousResult.data[0]?.count || '0', 10);
return {
currentPeriod,
previousPeriod,
};
}
async getMessageSendCount(
environmentId: string,
organizationId: string,
startDate: Date,
endDate: Date,
previousStartDate: Date,
previousEndDate: Date,
workflowIds?: string[]
): Promise<{ currentPeriod: number; previousPeriod: number }> {
const workflowFilter =
workflowIds && workflowIds.length > 0 ? `AND workflow_id IN {workflowIds:Array(String)}` : '';
const currentQuery = `
SELECT sum(count) as count
FROM ${TRACE_ROLLUP_TABLE_NAME}
WHERE
organization_id = {organizationId:String}
AND environment_id = {environmentId:String}
AND event_type = 'message_sent'
AND date >= {startDate:Date}
AND date <= {endDate:Date}
${workflowFilter}
`;
const previousQuery = `
SELECT sum(count) as count
FROM ${TRACE_ROLLUP_TABLE_NAME}
WHERE
organization_id = {organizationId:String}
AND environment_id = {environmentId:String}
AND event_type = 'message_sent'
AND date >= {previousStartDate:Date}
AND date <= {previousEndDate:Date}
${workflowFilter}
`;
const currentParams: Record<string, unknown> = {
environmentId,
organizationId,
startDate: startDate.toISOString().split('T')[0],
endDate: endDate.toISOString().split('T')[0],
};
const previousParams: Record<string, unknown> = {
environmentId,
organizationId,
previousStartDate: previousStartDate.toISOString().split('T')[0],
previousEndDate: previousEndDate.toISOString().split('T')[0],
};
if (workflowIds && workflowIds.length > 0) {
currentParams.workflowIds = workflowIds;
previousParams.workflowIds = workflowIds;
}
const [currentResult, previousResult] = await Promise.all([
this.clickhouseService.query<{ count: string }>({
query: currentQuery,
params: currentParams,
}),
this.clickhouseService.query<{ count: string }>({
query: previousQuery,
params: previousParams,
}),
]);
const currentPeriod = parseInt(currentResult.data[0]?.count || '0', 10);
const previousPeriod = parseInt(previousResult.data[0]?.count || '0', 10);
return {
currentPeriod,
previousPeriod,
};
}
Suggested change
async getMessageSendCount(
environmentId: string,
organizationId: string,
startDate: Date,
endDate: Date,
previousStartDate: Date,
previousEndDate: Date,
workflowIds?: string[]
): Promise<{ currentPeriod: number; previousPeriod: number }> {
const workflowFilter =
workflowIds && workflowIds.length > 0 ? `AND workflow_id IN {workflowIds:Array(String)}` : '';
const currentQuery = `
SELECT sum(count) as count
FROM ${TRACE_ROLLUP_TABLE_NAME}
WHERE
organization_id = {organizationId:String}
AND environment_id = {environmentId:String}
AND event_type = 'message_sent'
AND date >= {startDate:Date}
AND date <= {endDate:Date}
${workflowFilter}
`;
const previousQuery = `
SELECT sum(count) as count
FROM ${TRACE_ROLLUP_TABLE_NAME}
WHERE
organization_id = {organizationId:String}
AND environment_id = {environmentId:String}
AND event_type = 'message_sent'
AND date >= {previousStartDate:Date}
AND date <= {previousEndDate:Date}
${workflowFilter}
`;
const adjustedPreviousEndDate = getDateOnlyPreviousEndDate(startDate);
const currentParams: Record<string, unknown> = {
environmentId,
organizationId,
startDate: startDate.toISOString().split('T')[0],
endDate: endDate.toISOString().split('T')[0],
};
const previousParams: Record<string, unknown> = {
environmentId,
organizationId,
previousStartDate: previousStartDate.toISOString().split('T')[0],
previousEndDate: adjustedPreviousEndDate,
};
if (workflowIds && workflowIds.length > 0) {
currentParams.workflowIds = workflowIds;
previousParams.workflowIds = workflowIds;
}
const [currentResult, previousResult] = await Promise.all([
this.clickhouseService.query<{ count: string }>({
query: currentQuery,
params: currentParams,
}),
this.clickhouseService.query<{ count: string }>({
query: previousQuery,
params: previousParams,
}),
]);
const currentPeriod = parseInt(currentResult.data[0]?.count || '0', 10);
const previousPeriod = parseInt(previousResult.data[0]?.count || '0', 10);
return {
currentPeriod,
previousPeriod,
};
}
async getMessageSendCount(
environmentId: string,
organizationId: string,
startDate: Date,
endDate: Date,
previousStartDate: Date,
workflowIds?: string[]
): Promise<{ currentPeriod: number; previousPeriod: number }> {
const workflowFilter =
workflowIds && workflowIds.length > 0 ? `AND workflow_id IN {workflowIds:Array(String)}` : '';
const currentQuery = `
SELECT sum(count) as count
FROM ${TRACE_ROLLUP_TABLE_NAME}
WHERE
organization_id = {organizationId:String}
AND environment_id = {environmentId:String}
AND event_type = 'message_sent'
AND date >= {startDate:Date}
AND date <= {endDate:Date}
${workflowFilter}
`;
const previousQuery = `
SELECT sum(count) as count
FROM ${TRACE_ROLLUP_TABLE_NAME}
WHERE
organization_id = {organizationId:String}
AND environment_id = {environmentId:String}
AND event_type = 'message_sent'
AND date >= {previousStartDate:Date}
AND date <= {previousEndDate:Date}
${workflowFilter}
`;
const adjustedPreviousEndDate = getDateOnlyPreviousEndDate(startDate);
const currentParams: Record<string, unknown> = {
environmentId,
organizationId,
startDate: startDate.toISOString().split('T')[0],
endDate: endDate.toISOString().split('T')[0],
};
const previousParams: Record<string, unknown> = {
environmentId,
organizationId,
previousStartDate: previousStartDate.toISOString().split('T')[0],
previousEndDate: adjustedPreviousEndDate,
};
if (workflowIds && workflowIds.length > 0) {
currentParams.workflowIds = workflowIds;
previousParams.workflowIds = workflowIds;
}
const [currentResult, previousResult] = await Promise.all([
this.clickhouseService.query<{ count: string }>({
query: currentQuery,
params: currentParams,
}),
this.clickhouseService.query<{ count: string }>({
query: previousQuery,
params: previousParams,
}),
]);
const currentPeriod = parseInt(currentResult.data[0]?.count || '0', 10);
const previousPeriod = parseInt(previousResult.data[0]?.count || '0', 10);
return {
currentPeriod,
previousPeriod,
};
}

Comment on lines +106 to +183
async getActiveSubscribersCount(
environmentId: string,
organizationId: string,
startDate: Date,
endDate: Date,
previousStartDate: Date,
previousEndDate: Date,
workflowIds?: string[]
): Promise<{ currentPeriod: number; previousPeriod: number }> {
const workflowFilter =
workflowIds && workflowIds.length > 0 ? `AND workflow_id IN {workflowIds:Array(String)}` : '';

const currentQuery = `
SELECT count(DISTINCT external_subscriber_id) as count
FROM ${TRACE_ROLLUP_TABLE_NAME}
WHERE
organization_id = {organizationId:String}
AND environment_id = {environmentId:String}
AND event_type = 'message_sent'
AND external_subscriber_id != ''
AND date >= {startDate:Date}
AND date <= {endDate:Date}
${workflowFilter}
`;

const previousQuery = `
SELECT count(DISTINCT external_subscriber_id) as count
FROM ${TRACE_ROLLUP_TABLE_NAME}
WHERE
organization_id = {organizationId:String}
AND environment_id = {environmentId:String}
AND event_type = 'message_sent'
AND external_subscriber_id != ''
AND date >= {previousStartDate:Date}
AND date <= {previousEndDate:Date}
${workflowFilter}
`;

const adjustedPreviousEndDate = getDateOnlyPreviousEndDate(startDate);

const currentParams: Record<string, unknown> = {
environmentId,
organizationId,
startDate: startDate.toISOString().split('T')[0],
endDate: endDate.toISOString().split('T')[0],
};

const previousParams: Record<string, unknown> = {
environmentId,
organizationId,
previousStartDate: previousStartDate.toISOString().split('T')[0],
previousEndDate: adjustedPreviousEndDate,
};

if (workflowIds && workflowIds.length > 0) {
currentParams.workflowIds = workflowIds;
previousParams.workflowIds = workflowIds;
}

const [currentResult, previousResult] = await Promise.all([
this.clickhouseService.query<{ count: string }>({
query: currentQuery,
params: currentParams,
}),
this.clickhouseService.query<{ count: string }>({
query: previousQuery,
params: previousParams,
}),
]);

const currentPeriod = parseInt(currentResult.data[0]?.count || '0', 10);
const previousPeriod = parseInt(previousResult.data[0]?.count || '0', 10);

return {
currentPeriod,
previousPeriod,
};
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Same issue: previousEndDate parameter is unused.

This method has the identical problem—previousEndDate (line 112) is passed but ignored in favor of adjustedPreviousEndDate calculated from startDate (line 144). Apply the same fix as suggested for getMessageSendCount.

🤖 Prompt for AI Agents
In
@libs/application-generic/src/services/analytic-logs/trace-rollup/trace-rollup.repository.ts
around lines 106 - 183, getActiveSubscribersCount is ignoring the
previousEndDate parameter and instead uses adjustedPreviousEndDate computed from
startDate; update the method to use the provided previousEndDate (formatted to
date-only) for previousParams (replace the getDateOnlyPreviousEndDate(startDate)
usage), ensure adjustedPreviousEndDate (or directly
previousParams.previousEndDate) is set from
previousEndDate.toISOString().split('T')[0], and keep workflowIds injection and
query params logic unchanged so the previous period query uses the passed
previousEndDate.

Comment on lines +234 to +320
async getAvgMessagesPerSubscriberData(
environmentId: string,
organizationId: string,
startDate: Date,
endDate: Date,
previousStartDate: Date,
previousEndDate: Date,
workflowIds?: string[]
): Promise<{ currentPeriod: number; previousPeriod: number }> {
const workflowFilter =
workflowIds && workflowIds.length > 0 ? `AND workflow_id IN {workflowIds:Array(String)}` : '';

const currentQuery = `
SELECT
sum(count) as total_messages,
count(DISTINCT external_subscriber_id) as unique_subscribers
FROM ${TRACE_ROLLUP_TABLE_NAME}
WHERE
organization_id = {organizationId:String}
AND environment_id = {environmentId:String}
AND event_type = 'message_sent'
AND external_subscriber_id != ''
AND date >= {startDate:Date}
AND date <= {endDate:Date}
${workflowFilter}
`;

const previousQuery = `
SELECT
sum(count) as total_messages,
count(DISTINCT external_subscriber_id) as unique_subscribers
FROM ${TRACE_ROLLUP_TABLE_NAME}
WHERE
organization_id = {organizationId:String}
AND environment_id = {environmentId:String}
AND event_type = 'message_sent'
AND external_subscriber_id != ''
AND date >= {previousStartDate:Date}
AND date <= {previousEndDate:Date}
${workflowFilter}
`;

const adjustedPreviousEndDate = getDateOnlyPreviousEndDate(startDate);

const currentParams: Record<string, unknown> = {
environmentId,
organizationId,
startDate: startDate.toISOString().split('T')[0],
endDate: endDate.toISOString().split('T')[0],
};

const previousParams: Record<string, unknown> = {
environmentId,
organizationId,
previousStartDate: previousStartDate.toISOString().split('T')[0],
previousEndDate: adjustedPreviousEndDate,
};

if (workflowIds && workflowIds.length > 0) {
currentParams.workflowIds = workflowIds;
previousParams.workflowIds = workflowIds;
}

const [currentResult, previousResult] = await Promise.all([
this.clickhouseService.query<{ total_messages: string; unique_subscribers: string }>({
query: currentQuery,
params: currentParams,
}),
this.clickhouseService.query<{ total_messages: string; unique_subscribers: string }>({
query: previousQuery,
params: previousParams,
}),
]);

const currentTotalMessages = parseInt(currentResult.data[0]?.total_messages || '0', 10);
const currentUniqueSubscribers = parseInt(currentResult.data[0]?.unique_subscribers || '0', 10);
const previousTotalMessages = parseInt(previousResult.data[0]?.total_messages || '0', 10);
const previousUniqueSubscribers = parseInt(previousResult.data[0]?.unique_subscribers || '0', 10);

const currentPeriod = currentUniqueSubscribers > 0 ? currentTotalMessages / currentUniqueSubscribers : 0;
const previousPeriod = previousUniqueSubscribers > 0 ? previousTotalMessages / previousUniqueSubscribers : 0;

return {
currentPeriod: Math.round(currentPeriod * 100) / 100,
previousPeriod: Math.round(previousPeriod * 100) / 100,
};
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Same previousEndDate parameter issue; division-by-zero handling is correct.

The previousEndDate parameter (line 240) is unused (line 276 uses calculated value). Apply the same fix pattern.

The division-by-zero check and rounding logic (lines 313-318) are correctly implemented.

🤖 Prompt for AI Agents
In
@libs/application-generic/src/services/analytic-logs/trace-rollup/trace-rollup.repository.ts
around lines 234 - 320, The method getAvgMessagesPerSubscriberData defines a
previousEndDate parameter but then ignores it by using adjustedPreviousEndDate;
update previousParams to use previousEndDate (converted to date-only string)
instead of adjustedPreviousEndDate, or if adjustedPreviousEndDate is required,
remove the unused previousEndDate parameter and its references; ensure
previousParams uses the matching key previousEndDate and workflowIds logic
remains the same so the ClickHouse query receives the correct date value.

Comment on lines +322 to +397
async getTotalInteractionsCount(
environmentId: string,
organizationId: string,
startDate: Date,
endDate: Date,
previousStartDate: Date,
previousEndDate: Date,
workflowIds?: string[]
): Promise<{ currentPeriod: number; previousPeriod: number }> {
const workflowFilter =
workflowIds && workflowIds.length > 0 ? `AND workflow_id IN {workflowIds:Array(String)}` : '';

const currentQuery = `
SELECT sum(count) as count
FROM ${TRACE_ROLLUP_TABLE_NAME}
WHERE
organization_id = {organizationId:String}
AND environment_id = {environmentId:String}
AND event_type IN ('message_seen', 'message_read', 'message_snoozed', 'message_archived')
AND date >= {startDate:Date}
AND date <= {endDate:Date}
${workflowFilter}
`;

const previousQuery = `
SELECT sum(count) as count
FROM ${TRACE_ROLLUP_TABLE_NAME}
WHERE
organization_id = {organizationId:String}
AND environment_id = {environmentId:String}
AND event_type IN ('message_seen', 'message_read', 'message_snoozed', 'message_archived')
AND date >= {previousStartDate:Date}
AND date <= {previousEndDate:Date}
${workflowFilter}
`;

const adjustedPreviousEndDate = getDateOnlyPreviousEndDate(startDate);

const currentParams: Record<string, unknown> = {
environmentId,
organizationId,
startDate: startDate.toISOString().split('T')[0],
endDate: endDate.toISOString().split('T')[0],
};

const previousParams: Record<string, unknown> = {
environmentId,
organizationId,
previousStartDate: previousStartDate.toISOString().split('T')[0],
previousEndDate: adjustedPreviousEndDate,
};

if (workflowIds && workflowIds.length > 0) {
currentParams.workflowIds = workflowIds;
previousParams.workflowIds = workflowIds;
}

const [currentResult, previousResult] = await Promise.all([
this.clickhouseService.query<{ count: string }>({
query: currentQuery,
params: currentParams,
}),
this.clickhouseService.query<{ count: string }>({
query: previousQuery,
params: previousParams,
}),
]);

const currentPeriod = parseInt(currentResult.data[0]?.count || '0', 10);
const previousPeriod = parseInt(previousResult.data[0]?.count || '0', 10);

return {
currentPeriod,
previousPeriod,
};
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Same previousEndDate parameter issue.

The previousEndDate parameter (line 328) is unused (line 358). Apply the consistent fix across all affected methods.

🤖 Prompt for AI Agents
In
@libs/application-generic/src/services/analytic-logs/trace-rollup/trace-rollup.repository.ts
around lines 322 - 397, The previousEndDate parameter passed into
getTotalInteractionsCount is never used; replace the computed
adjustedPreviousEndDate (currently from getDateOnlyPreviousEndDate(startDate))
with a string derived from the previousEndDate parameter and assign that to
previousParams.previousEndDate (e.g.,
previousEndDate.toISOString().split('T')[0]), removing the unused
adjustedPreviousEndDate computation; update references in
getTotalInteractionsCount so previousParams uses the provided previousEndDate
value.

)
ENGINE = SummingMergeTree(count)
PARTITION BY toYYYYMM(date)
ORDER BY (organization_id, environment_id, workflow_id, date, external_subscriber_id);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LEt's make sure that this is optimized for environment id + date. We need to make sure that in majority of cases so we should optimize for it

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can't remove columns because SummingMergeTree is using it in order to summarize. Each defines a dimension that we group by, removing any data that would merge data that should stay separate.
i did changed the order in order to make sure we always use the columns that are always used, like date.

)
ENGINE = SummingMergeTree(count)
PARTITION BY toYYYYMM(date)
ORDER BY (organization_id, environment_id, date, workflow_id, step_type);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here for order by optimization

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (1)
apps/api/migrations/clickhouse-migrations/3_analytics_tables.sql (1)

41-53: Add DEFAULT '' to workflow_id for consistency and robustness.

Unlike delivery_trend_counts (line 9), workflow_id here lacks a default. While the MV handles NULLs via ifNull, direct inserts (e.g., backfill queries) could encounter issues. For consistency and defensive schema design:

Suggested fix
 CREATE TABLE IF NOT EXISTS trace_rollup (
   date Date,
   organization_id String,
   environment_id String,
-  workflow_id String,
+  workflow_id String DEFAULT '',
   external_subscriber_id String DEFAULT '',
   event_type LowCardinality(String),
   provider_id String DEFAULT '',
   count UInt64
 )
📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b83443b and f67b588.

📒 Files selected for processing (2)
  • apps/api/migrations/clickhouse-migrations/3_analytics_tables.sql
  • libs/application-generic/src/services/analytic-logs/trace-log/trace-log.schema.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • libs/application-generic/src/services/analytic-logs/trace-log/trace-log.schema.ts
🧰 Additional context used
📓 Path-based instructions (1)
**

📄 CodeRabbit inference engine (.cursor/rules/novu.mdc)

Use lowercase with dashes for directories and files (e.g., components/auth-wizard)

Files:

  • apps/api/migrations/clickhouse-migrations/3_analytics_tables.sql
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (15)
  • GitHub Check: Unit test backend services (@novu/api-service)
  • GitHub Check: E2E test API / Test E2E
  • GitHub Check: Unit test backend services (@novu/webhook)
  • GitHub Check: Unit test backend services (@novu/inbound-mail)
  • GitHub Check: Unit test backend services (@novu/ws)
  • GitHub Check: Unit test backend services (@novu/worker)
  • GitHub Check: Unit test @novu internal packages
  • GitHub Check: Validate OpenAPI
  • GitHub Check: Unit test @novu public NPM packages (except providers)
  • GitHub Check: Redirect rules - dashboard-v2-novu-staging
  • GitHub Check: Header rules - dashboard-v2-novu-staging
  • GitHub Check: Pages changed - dashboard-v2-novu-staging
  • GitHub Check: Analyze (javascript)
  • GitHub Check: Analyze (typescript)
  • GitHub Check: publish_preview_packages
🔇 Additional comments (4)
apps/api/migrations/clickhouse-migrations/3_analytics_tables.sql (4)

5-15: LGTM!

Good use of SummingMergeTree for count aggregation, LowCardinality for the limited step_type values, and monthly partitioning. The ORDER BY column order is well-suited for tenant-scoped time-range queries.


17-30: Verify historical data backfill strategy.

Materialized views only populate from new inserts after creation. Existing rows in step_runs won't appear in delivery_trend_counts. If historical data is needed, a separate INSERT INTO delivery_trend_counts SELECT ... backfill statement should be run after this migration.


32-36: LGTM!

Correctly ordered before the MV that references it. Using IF NOT EXISTS ensures idempotency for re-runnable migrations.


55-69: Minor: ifNull(provider_id, '') is redundant but harmless.

Since provider_id was added as String DEFAULT '' (non-nullable in ClickHouse), it can never be NULL. The ifNull wrapper is technically unnecessary but serves as defensive coding if the schema ever changes. No action required.

Also, same note as above: historical traces data won't be backfilled automatically.

@djabarovgeorge djabarovgeorge merged commit 57de0ac into next Jan 15, 2026
34 checks passed
@djabarovgeorge djabarovgeorge deleted the wip-improve-pref-usage-2 branch January 15, 2026 12:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants