Skip to content

Commit 92feca1

Browse files
authored
Merge pull request #7463 from Countly/newarchitecture
chore: sync next-feature with new-architecture
2 parents 5399905 + d672454 commit 92feca1

92 files changed

Lines changed: 3086 additions & 1481 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,38 @@
1-
## Version 25.03.X
1+
## Version 25.03.36
2+
Enterprise fixes:
3+
- [journey] Workflow fixes
4+
- [users] UI events table fixes
5+
6+
## Version 25.03.35
7+
Fixes:
8+
- [core] Fixes for search bar in standart table component
9+
10+
Enterprise fixes:
11+
- [journeys] Fixes for journey data updates on incoming data.
12+
- [surveys] Return error message if invalid widget_id passed on template loading
13+
- [users] Show content and journey events in user profile
14+
- [users] Display profile group name in table column
15+
- [users] When exporting user profiles, replace user name with device id if user name does not exist
16+
- [users] Use user profile endpoint for exporting data instead of the generic export endpoint
17+
18+
## Version 25.03.34
219
Fixes:
320
- [core] Fix period calculation
21+
- [dashboards] Update dialog button color when deleting dashboard/widget
22+
- [star-rating] Fix rating number when exporting data
23+
24+
Enterprise Fixes:
25+
- [content] Uniform journey and content block actions
26+
- [content] Fix overflow and missing translations in content blocks
27+
- [content] Fix button management when creating fullscreen content blocks
28+
- [crash_symbolication] Use countlyfs for JavaScript symbolication
29+
- [funnels] Fix funnel name tooltip content
30+
- [surveys] Allow surveys to resize and reposition when user rotates devices or adjust browser window
31+
- [nps] Allow nps to resize and reposition when user rotates devices or adjust browser window
32+
- [groups] Dealing with invalid values for group permission
33+
- [geo] Update table row cursor to indicate that it's clickable
34+
- [users] Change table column min-width to width so it can be resized even smaller
35+
- [users] Display filtered user count instead of all user count in the table summary
436

537
## Version 25.03.33
638
Fixes:

Dockerfile-core

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ RUN useradd -r -M -U -d /opt/countly -s /bin/false countly && \
5858
\
5959
# npm dependencies
6060
./bin/docker/modify.sh && \
61-
HOME=/tmp npm install --unsafe-perm && \
61+
HOME=/tmp npm ci --unsafe-perm && \
6262
./bin/docker/preinstall.sh && \
6363
\
6464
# web sdk

Dockerfile-unified

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,10 +132,14 @@ WORKDIR /opt/countly
132132
COPY . .
133133

134134
# 3.1.2 copy OpenTelemetry files from build context
135+
# otel.js and otelexpress.js both require('./metrics'), so metrics.js
136+
# must be copied alongside each entry point.
135137
RUN if [ -f observability/otel.js ]; then \
136138
echo "Copying OpenTelemetry files..."; \
137139
cp observability/otel.js api/utils/otel.js; \
140+
cp observability/metrics.js api/utils/metrics.js; \
138141
cp observability/otelexpress.js frontend/express/otel.js; \
142+
cp observability/metrics.js frontend/express/metrics.js; \
139143
else \
140144
echo "Observability files not found, skipping..."; \
141145
fi

Gruntfile.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ module.exports = function(grunt) {
5757
'frontend/express/public/javascripts/utils/Sortable.min.js',
5858
'frontend/express/public/javascripts/utils/vue/vuedraggable.umd.min.js',
5959
'frontend/express/public/javascripts/utils/lodash.mergeWith.js',
60+
'frontend/express/public/javascripts/utils/cronstrue.min.js',
6061
'frontend/express/public/javascripts/utils/element-tiptap.umd.min.js'
6162
],
6263
dest: 'frontend/express/public/javascripts/min/countly.utils.concat.js'

api/eventSink/KafkaEventSink.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,28 @@ const EventSinkInterface = require('./EventSinkInterface.ts').default;
1414
const { transformToKafkaEventFormat } = require('../utils/eventTransformer');
1515
const Log = require('../utils/log.js');
1616

17+
// OTel metrics — opt-in, zero overhead when OTEL_ENABLED is not set
18+
const _otelEnabled = /^(1|true|yes)$/i.test(process.env.OTEL_ENABLED || '');
19+
let _sinkMetrics: { duration: any; eventsTotal: any } | null = null;
20+
function getSinkMetrics() {
21+
if (_sinkMetrics) {
22+
return _sinkMetrics;
23+
}
24+
if (!_otelEnabled) {
25+
return null;
26+
}
27+
try {
28+
const { metrics } = require('@opentelemetry/api');
29+
const meter = metrics.getMeter('countly-event-sink');
30+
_sinkMetrics = {
31+
duration: meter.createHistogram('countly_event_sink_duration_seconds', { unit: 's' }),
32+
eventsTotal: meter.createCounter('countly_event_sink_events_total'),
33+
};
34+
}
35+
catch (_e) { /* no-op */ }
36+
return _sinkMetrics;
37+
}
38+
1739
/**
1840
* Logger interface for type safety
1941
*/
@@ -192,6 +214,8 @@ class KafkaEventSink extends EventSinkInterface {
192214

193215
if (result.success) {
194216
this.#log.d(`Successfully sent ${result.sent} events to Kafka in ${duration}ms`);
217+
getSinkMetrics()?.duration.record(duration / 1000, { sink: 'kafka' });
218+
getSinkMetrics()?.eventsTotal.add(result.sent ?? 0, { sink: 'kafka', result: 'success' });
195219

196220
return this._createResult(true, result.sent ?? 0, 'Events sent to Kafka successfully', {
197221
duration,
@@ -205,6 +229,8 @@ class KafkaEventSink extends EventSinkInterface {
205229
}
206230
catch (error) {
207231
const duration = Date.now() - startTime;
232+
getSinkMetrics()?.duration.record(duration / 1000, { sink: 'kafka' });
233+
getSinkMetrics()?.eventsTotal.add(transformedEvents, { sink: 'kafka', result: 'error' });
208234
this.#log.e(`Failed to write ${transformedEvents} events to Kafka in ${duration}ms:`);
209235
this.#log.e('Error writing events to Kafka:', error);
210236
throw error;

api/eventSource/KafkaEventSource.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,33 @@
11
const EventSourceInterface = require('./EventSourceInterface');
22
const Log = require('../utils/log.js');
33

4+
// OTel metrics — opt-in, zero overhead when OTEL_ENABLED is not set
5+
const _otelEnabled = /^(1|true|yes)$/i.test(process.env.OTEL_ENABLED || '');
6+
let _sourceMetrics = null;
7+
8+
/**
9+
* Initialize OTel metrics for event source
10+
* @returns {Object|null} OTel metrics object or null if OTel is not enabled
11+
* @private
12+
*/
13+
function getSourceMetrics() {
14+
if (_sourceMetrics) {
15+
return _sourceMetrics;
16+
}
17+
if (!_otelEnabled) {
18+
return null;
19+
}
20+
try {
21+
const { metrics } = require('@opentelemetry/api');
22+
const meter = metrics.getMeter('countly-event-source');
23+
_sourceMetrics = {
24+
batchWait: meter.createHistogram('countly_event_source_batch_wait_seconds', { unit: 's' }),
25+
};
26+
}
27+
catch (_e) { /* no-op */ }
28+
return _sourceMetrics;
29+
}
30+
431
/**
532
* Kafka implementation of EventSourceInterface
633
* Supports async iteration with auto-acknowledgment and proper at-least-once delivery
@@ -563,6 +590,7 @@ class KafkaEventSource extends EventSourceInterface {
563590
return checkAndReturnBatch(batch);
564591
}
565592
// Wait for next batch from Kafka
593+
const waitStart = Date.now();
566594
await new Promise((resolve) => {
567595
// Race condition check: batch might have arrived while creating promise
568596
if (this.#currentBatch) {
@@ -571,6 +599,11 @@ class KafkaEventSource extends EventSourceInterface {
571599
}
572600
this.#batchAvailable = resolve;
573601
});
602+
const waitDuration = (Date.now() - waitStart) / 1000;
603+
getSourceMetrics()?.batchWait.record(waitDuration, {
604+
group_id: this.#effectiveGroupId || this.#name,
605+
topic: this.#kafkaOptions?.topics?.[0] || 'unknown'
606+
});
574607

575608
// Return the batch that arrived
576609
if (this.#currentBatch) {

api/ingestor/requestProcessor.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import { createRequire } from 'module';
99

1010
import usage from './usage.js';
1111
import common from '../utils/common.js';
12-
import url from 'url';
1312
import logModule from '../utils/log.js';
1413
import crypto from 'crypto';
1514
import { ignorePossibleDevices, checksumSaltVerification, validateRedirect } from '../utils/requestProcessorCommon.js';
@@ -378,7 +377,7 @@ interface RequestParams {
378377
/** Href */
379378
href?: string;
380379
/** URL parts */
381-
urlParts?: ReturnType<typeof url.parse>;
380+
urlParts?: { pathname: string; path: string };
382381
/** Path segments */
383382
paths?: string[];
384383
/** API path */
@@ -1246,14 +1245,15 @@ const processRequest = (params: RequestParams): boolean | void => {
12461245
}
12471246

12481247
params.tt = Date.now().valueOf();
1249-
const urlParts = url.parse(params.req.url, true);
1250-
const queryString = urlParts.query;
1251-
const paths = urlParts.pathname.split('/');
1248+
// base URL is required by WHATWG URL API for relative paths, only pathname and query are used
1249+
const parsedUrl = new URL(params.req.url, 'http://localhost');
1250+
const queryString = Object.fromEntries(parsedUrl.searchParams);
1251+
const paths = parsedUrl.pathname.split('/');
12521252

1253-
params.href = urlParts.href;
1253+
params.href = parsedUrl.pathname + parsedUrl.search;
12541254
params.qstring = params.qstring || {};
12551255
params.res = params.res || {} as ServerResponse;
1256-
params.urlParts = urlParts;
1256+
params.urlParts = { pathname: parsedUrl.pathname, path: parsedUrl.pathname + parsedUrl.search };
12571257
params.paths = paths;
12581258

12591259
params.req.headers = params.req.headers || {};

api/jobs/mutationManagerJob.ts

Lines changed: 106 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,12 @@ interface JobConfig {
2626

2727
interface MutationTask extends Document {
2828
_id: ObjectId | string;
29-
type: 'delete' | 'update';
29+
type: 'delete' | 'update' | 'native_ch';
3030
db: string;
3131
collection: string;
3232
query: Record<string, unknown>;
3333
update?: Record<string, unknown>;
34+
native_sql?: string;
3435
running: boolean;
3536
status: string;
3637
hb?: number;
@@ -272,7 +273,7 @@ class MutationManagerJob extends Job {
272273
*/
273274
async processTask(task: MutationTask, summary: SummaryEntry[], jobConfig: JobConfig = jobConfigState || DEFAULT_JOB_CONFIG): Promise<void> {
274275
const type = task.type;
275-
if (type !== 'delete' && type !== 'update') {
276+
if (type !== 'delete' && type !== 'update' && type !== 'native_ch') {
276277
await common.db.collection('mutation_manager').updateOne(
277278
{ _id: task._id },
278279
{
@@ -288,7 +289,7 @@ class MutationManagerJob extends Job {
288289
const clickhouseEnabled = mutationManager.isClickhouseEnabled();
289290
const hasClickhouseDelete = clickhouseEnabled && !!(clickHouseRunner && clickHouseRunner.deleteGranularDataByQuery);
290291
const hasClickhouseUpdate = clickhouseEnabled && !!(clickHouseRunner && clickHouseRunner.updateGranularDataByQuery);
291-
const hasClickhouse = (type === 'update' ? hasClickhouseUpdate : hasClickhouseDelete);
292+
const hasClickhouse = type === 'native_ch' ? clickhouseEnabled : (type === 'update' ? hasClickhouseUpdate : hasClickhouseDelete);
292293

293294
if (!mongoDb && !hasClickhouse) {
294295
const reason = `mongo_db_unavailable:${task.db || 'missing'}`;
@@ -317,7 +318,11 @@ class MutationManagerJob extends Job {
317318
}
318319

319320
let mongoOk = true;
320-
if (mongoDb) {
321+
if (type === 'native_ch') {
322+
// Native CH mutations skip MongoDB entirely
323+
log.d('Native CH mutation - skipping MongoDB', { taskId: task._id });
324+
}
325+
else if (mongoDb) {
321326
if (type === 'update') {
322327
mongoOk = await this.updateMongo(task, mongoDb);
323328
}
@@ -330,7 +335,10 @@ class MutationManagerJob extends Job {
330335
}
331336

332337
let chScheduledOk = true;
333-
if (type === 'update' && hasClickhouseUpdate) {
338+
if (type === 'native_ch' && clickhouseEnabled) {
339+
chScheduledOk = await this.executeNativeClickhouse(task);
340+
}
341+
else if (type === 'update' && hasClickhouseUpdate) {
334342
chScheduledOk = await this.updateClickhouse(task);
335343
}
336344
else if (type === 'delete' && hasClickhouseDelete) {
@@ -422,8 +430,10 @@ class MutationManagerJob extends Job {
422430
for (const task of awaiting) {
423431
try {
424432
if (chHealth && typeof chHealth.getMutationStatus === 'function') {
425-
// In cluster mode, mutations target _local tables, so validation must check _local
426-
const validationTable = isClusterMode ? task.collection + '_local' : task.collection;
433+
// In cluster mode, mutations target _local tables, so validation must check _local.
434+
// native_ch tasks may already have _local in collection name — avoid doubling.
435+
const needsLocalSuffix = isClusterMode && !task.collection.endsWith('_local');
436+
const validationTable = needsLocalSuffix ? task.collection + '_local' : task.collection;
427437
const status = await chHealth.getMutationStatus({ validation_command_id: task.validation_command_id, table: validationTable, database: task.db });
428438
if (status && status.is_done) {
429439
await common.db.collection('mutation_manager').updateOne(
@@ -677,6 +687,95 @@ class MutationManagerJob extends Job {
677687
}
678688
}
679689

690+
/**
691+
* Build validated ClickHouse mutation SQL with an embedded command-id for tracking.
692+
* - Strips trailing semicolon
693+
* - Validates ALTER TABLE ... DELETE/UPDATE ... WHERE ... shape
694+
* - Injects tautological AND before any SETTINGS clause
695+
* @returns Final SQL string, or null if the shape is invalid
696+
*/
697+
private buildValidatedNativeClickhouseSql(baseSql: string, commandId: string): string | null {
698+
if (!baseSql || typeof baseSql !== 'string') {
699+
return null;
700+
}
701+
let sql = baseSql.trim();
702+
if (sql.endsWith(';')) {
703+
sql = sql.slice(0, -1).trimEnd();
704+
}
705+
const upper = sql.toUpperCase();
706+
if (!upper.startsWith('ALTER TABLE ')) {
707+
return null;
708+
}
709+
if (!/\b(DELETE|UPDATE)\b/.test(upper)) {
710+
return null;
711+
}
712+
if (!/\bWHERE\b/.test(upper)) {
713+
return null;
714+
}
715+
// Find SETTINGS clause (if any) — inject command-id BEFORE it
716+
const settingsMatch = upper.match(/\bSETTINGS\b/);
717+
const settingsIdx = settingsMatch?.index ?? -1;
718+
const injection = ` AND '${commandId}' = '${commandId}'`;
719+
if (settingsIdx !== -1) {
720+
return sql.slice(0, settingsIdx) + injection + sql.slice(settingsIdx);
721+
}
722+
return sql + injection;
723+
}
724+
725+
/**
726+
* Executes a native ClickHouse SQL mutation directly.
727+
* Used for complex mutations (e.g., deduplication) that cannot be expressed as Mongo-style queries.
728+
* Embeds validation_command_id for tracking via system.mutations.
729+
* @param task - The mutation task with native_sql field
730+
*/
731+
async executeNativeClickhouse(task: MutationTask): Promise<boolean> {
732+
if (!task.native_sql || typeof task.native_sql !== 'string') {
733+
log.e('Skipping native CH mutation (empty sql)', { taskId: task._id });
734+
await this.markFailedOrRetry(task, 'empty_native_sql');
735+
return false;
736+
}
737+
738+
if (!common.clickhouseQueryService) {
739+
log.e('ClickHouse query service not available for native mutation', { taskId: task._id });
740+
await this.markFailedOrRetry(task, 'ch_query_service_unavailable');
741+
return false;
742+
}
743+
744+
try {
745+
const retryIndex = Number(task.fail_count || 0);
746+
const commandId = `nm_${String(task._id)}_${retryIndex}`;
747+
748+
const sql = this.buildValidatedNativeClickhouseSql(task.native_sql, commandId);
749+
if (!sql) {
750+
log.e('Skipping native CH mutation (invalid SQL shape)', {
751+
taskId: task._id,
752+
native_sql: task.native_sql
753+
});
754+
await this.markFailedOrRetry(task, 'invalid_native_sql_shape');
755+
return false;
756+
}
757+
758+
// Persist command_id BEFORE executing mutation (crash safety: if we crash
759+
// between execution and this update, validation can still find the command_id)
760+
await common.db.collection('mutation_manager').updateOne(
761+
{ _id: task._id },
762+
{ $set: { validation_command_id: commandId } }
763+
);
764+
765+
await common.clickhouseQueryService.executeMutation({ query: sql });
766+
log.d('Native CH mutation scheduled', { taskId: task._id, commandId });
767+
return true;
768+
}
769+
catch (err) {
770+
log.e('Native CH mutation failed', {
771+
taskId: task._id,
772+
error: (err as Error)?.message || String(err)
773+
});
774+
await this.markFailedOrRetry(task, 'native_ch_error: ' + ((err as Error)?.message || err + ''));
775+
return false;
776+
}
777+
}
778+
680779
/**
681780
* Marks a task as failed or schedules it for a retry based on the number of previous failures.
682781
* @param task - The task object to update.

0 commit comments

Comments
 (0)