snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import { Statistics } from '@rocket.chat/models';
|
||||
|
||||
import { hasPermissionAsync } from '../../authorization/hasPermission';
|
||||
import { statistics } from '../lib/statistics';
|
||||
|
||||
export async function getLastStatistics({ userId, refresh }: { userId: IUser['_id']; refresh?: boolean }) {
|
||||
if (!(await hasPermissionAsync(userId, 'view-statistics'))) {
|
||||
throw new Error('error-not-allowed');
|
||||
}
|
||||
|
||||
if (refresh) {
|
||||
return statistics.save();
|
||||
}
|
||||
return Statistics.findLast();
|
||||
}
|
||||
44
apps/meteor/server/lib/statistics/functions/getStatistics.ts
Normal file
44
apps/meteor/server/lib/statistics/functions/getStatistics.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { IStats } from '@rocket.chat/core-typings';
|
||||
import { Statistics } from '@rocket.chat/models';
|
||||
import type { FindOptions, SchemaMember } from 'mongodb';
|
||||
|
||||
import { hasPermissionAsync } from '../../authorization/hasPermission';
|
||||
|
||||
type GetStatisticsParams = {
|
||||
userId: string;
|
||||
query?: Record<string, any>;
|
||||
pagination: {
|
||||
offset: number;
|
||||
count?: number;
|
||||
sort?: FindOptions<IStats>['sort'];
|
||||
fields?: SchemaMember<IStats, number | boolean>;
|
||||
};
|
||||
};
|
||||
|
||||
type GetStatisticsReturn = { statistics: IStats[]; count: number; offset: number; total: number };
|
||||
|
||||
export async function getStatistics({
|
||||
userId,
|
||||
query = {},
|
||||
pagination: { offset, count, sort, fields },
|
||||
}: GetStatisticsParams): Promise<GetStatisticsReturn> {
|
||||
if (!(await hasPermissionAsync(userId, 'view-statistics'))) {
|
||||
throw new Error('error-not-allowed');
|
||||
}
|
||||
|
||||
const { cursor, totalCount } = Statistics.findPaginated(query, {
|
||||
sort: sort || { name: 1 },
|
||||
skip: offset,
|
||||
limit: count,
|
||||
projection: fields,
|
||||
});
|
||||
|
||||
const [statistics, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
return {
|
||||
statistics,
|
||||
count: statistics.length,
|
||||
offset,
|
||||
total,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { expect } from 'chai';
|
||||
import { describe, it, beforeEach, afterEach } from 'mocha';
|
||||
import proxyquire from 'proxyquire';
|
||||
import sinon from 'sinon';
|
||||
|
||||
const sandbox = sinon.createSandbox();
|
||||
|
||||
const mocks = {
|
||||
Statistics: {
|
||||
findLast: sandbox.stub(),
|
||||
updateOne: sandbox.stub(),
|
||||
},
|
||||
statistics: {
|
||||
save: sandbox.stub(),
|
||||
},
|
||||
serverFetch: sandbox.stub(),
|
||||
getWorkspaceAccessToken: sandbox.stub().resolves('workspace-token'),
|
||||
Meteor: {
|
||||
absoluteUrl: sandbox.stub().returns('http://localhost:3000/'),
|
||||
},
|
||||
logger: {
|
||||
error: sandbox.stub(),
|
||||
},
|
||||
Info: {
|
||||
version: '3.0.1',
|
||||
},
|
||||
};
|
||||
|
||||
const { sendUsageReport } = proxyquire.noCallThru().load('./sendUsageReport', {
|
||||
'@rocket.chat/models': { Statistics: mocks.Statistics },
|
||||
'@rocket.chat/server-fetch': { serverFetch: mocks.serverFetch },
|
||||
'../index': { statistics: mocks.statistics },
|
||||
'../../cloud': { getWorkspaceAccessToken: mocks.getWorkspaceAccessToken },
|
||||
'meteor/meteor': { Meteor: mocks.Meteor },
|
||||
'../../../../app/utils/rocketchat.info': { Info: mocks.Info },
|
||||
});
|
||||
|
||||
describe('sendUsageReport', () => {
|
||||
beforeEach(() => {
|
||||
sandbox.resetHistory();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.RC_DISABLE_STATISTICS_REPORTING;
|
||||
});
|
||||
|
||||
it('should save statistics locally and not send to collector when RC_DISABLE_STATISTICS_REPORTING is true', async () => {
|
||||
process.env.RC_DISABLE_STATISTICS_REPORTING = 'true';
|
||||
|
||||
const result = await sendUsageReport(mocks.logger);
|
||||
|
||||
expect(mocks.statistics.save.called).to.be.true;
|
||||
expect(mocks.serverFetch.called).to.be.false;
|
||||
expect(result).to.be.undefined;
|
||||
});
|
||||
|
||||
it('should save statistics locally and send to collector when RC_DISABLE_STATISTICS_REPORTING is false', async () => {
|
||||
process.env.RC_DISABLE_STATISTICS_REPORTING = 'false';
|
||||
|
||||
const result = await sendUsageReport(mocks.logger);
|
||||
|
||||
expect(mocks.statistics.save.called).to.be.true;
|
||||
expect(mocks.serverFetch.calledOnce).to.be.true;
|
||||
expect(mocks.serverFetch.calledWith('https://collector.rocket.chat/', sinon.match({ method: 'POST' }))).to.be.true;
|
||||
expect(result).to.be.undefined;
|
||||
});
|
||||
|
||||
it('should generate new statistics when version changes', async () => {
|
||||
mocks.Statistics.findLast.resolves({
|
||||
_id: 'stats-id',
|
||||
version: '2.9.0',
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
mocks.statistics.save.resolves({ _id: 'new-stats-id' });
|
||||
|
||||
const result = await sendUsageReport(mocks.logger);
|
||||
|
||||
expect(mocks.statistics.save.calledOnce).to.be.true;
|
||||
expect(result).to.be.undefined;
|
||||
});
|
||||
|
||||
it('should NOT generate new statistics if last version equals current version', async () => {
|
||||
mocks.Statistics.findLast.resolves({
|
||||
_id: 'stats-id',
|
||||
version: '3.0.1',
|
||||
createdAt: new Date(),
|
||||
statsToken: 'token',
|
||||
});
|
||||
|
||||
const result = await sendUsageReport(mocks.logger);
|
||||
|
||||
expect(mocks.statistics.save.called).to.be.false;
|
||||
expect(result).to.equal('token');
|
||||
});
|
||||
|
||||
it('should generate new statistics when no previous stats exist', async () => {
|
||||
mocks.Statistics.findLast.resolves(undefined);
|
||||
mocks.statistics.save.resolves({ _id: 'new-stats-id' });
|
||||
|
||||
await sendUsageReport(mocks.logger);
|
||||
|
||||
expect(mocks.statistics.save.calledOnce).to.be.true;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { IStats } from '@rocket.chat/core-typings';
|
||||
import { License } from '@rocket.chat/license';
|
||||
import type { Logger } from '@rocket.chat/logger';
|
||||
import { Statistics } from '@rocket.chat/models';
|
||||
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
|
||||
import { tracerSpan } from '@rocket.chat/tracing';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { Info } from '../../../../app/utils/rocketchat.info';
|
||||
import { shouldReportStatistics } from '../../../cron/usageReport';
|
||||
import { getWorkspaceAccessToken } from '../../cloud';
|
||||
import { statistics } from '../index';
|
||||
|
||||
async function sendStats(logger: Logger, cronStatistics: IStats): Promise<string | undefined> {
|
||||
try {
|
||||
const token = await getWorkspaceAccessToken();
|
||||
const headers = { ...(token && { Authorization: `Bearer ${token}` }) };
|
||||
|
||||
const response = await fetch('https://collector.rocket.chat/', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
...cronStatistics,
|
||||
host: Meteor.absoluteUrl(),
|
||||
},
|
||||
headers,
|
||||
// SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check.
|
||||
ignoreSsrfValidation: true,
|
||||
});
|
||||
|
||||
const { statsToken } = await response.json();
|
||||
|
||||
if (statsToken != null) {
|
||||
await Statistics.updateOne({ _id: cronStatistics._id }, { $set: { statsToken } });
|
||||
return statsToken;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Failed to send usage report', err });
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendUsageReport(logger: Logger): Promise<string | undefined> {
|
||||
// Even when disabled, we still generate statistics locally to avoid breaking
|
||||
// internal processes, such as restriction checks for air-gapped workspaces.
|
||||
// Evaluated at send time (not entry) so the verdict cannot go stale while the
|
||||
// statistics are being generated — e.g. an offline license applied during startup.
|
||||
const shouldSendToCollector = () => shouldReportStatistics() && !License.hasOfflineLicense();
|
||||
|
||||
return tracerSpan('generateStatistics', {}, async () => {
|
||||
const last = await Statistics.findLast();
|
||||
|
||||
const currentVersion = Info.version;
|
||||
|
||||
if (last && last.version === currentVersion) {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
// if the last data we have has less than 24h and was not sent to yet, send it
|
||||
if (last.createdAt > yesterday) {
|
||||
// but if it has the confirmation token, we can skip
|
||||
if (last.statsToken) {
|
||||
return last.statsToken;
|
||||
}
|
||||
|
||||
// if it doesn't it means the request failed, so we try sending again with the same data
|
||||
if (shouldSendToCollector()) {
|
||||
return sendStats(logger, last);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// if our latest stats has more than 24h OR the statistics.version differs from the actual version, it is time to generate a new one and send it
|
||||
const cronStatistics = await statistics.save();
|
||||
if (shouldSendToCollector()) {
|
||||
return sendStats(logger, cronStatistics);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { updateCounter } from './updateStatsCounter';
|
||||
import telemetryEvent from '../lib/telemetryEvents';
|
||||
|
||||
type slashCommandsDataType = { command: string };
|
||||
|
||||
export function slashCommandsStats(data: slashCommandsDataType): void {
|
||||
if (data.command === 'jitsi') {
|
||||
updateCounter({ settingsId: 'Jitsi_Start_SlashCommands_Count' });
|
||||
}
|
||||
}
|
||||
|
||||
telemetryEvent.register('slashCommandsStats', slashCommandsStats);
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Settings } from '@rocket.chat/models';
|
||||
|
||||
import { notifyOnSettingChanged } from '../../notifyListener';
|
||||
import telemetryEvent from '../lib/telemetryEvents';
|
||||
|
||||
type updateCounterDataType = { settingsId: string };
|
||||
|
||||
export function updateCounter(data: updateCounterDataType): void {
|
||||
void (async () => {
|
||||
const value = await Settings.incrementValueById(data.settingsId, 1, { returnDocument: 'after' });
|
||||
if (value) {
|
||||
void notifyOnSettingChanged(value);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
telemetryEvent.register('updateCounter', updateCounter);
|
||||
146
apps/meteor/server/lib/statistics/getSettingsStatistics.ts
Normal file
146
apps/meteor/server/lib/statistics/getSettingsStatistics.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import type { ISettingStatistics, ISettingStatisticsObject } from '@rocket.chat/core-typings';
|
||||
|
||||
import { settings } from '../../settings';
|
||||
|
||||
const setSettingsStatistics = async (settings: ISettingStatistics): Promise<ISettingStatisticsObject> => {
|
||||
const {
|
||||
account2fa,
|
||||
cannedResponsesEnabled,
|
||||
e2e,
|
||||
e2eDefaultDirectRoom,
|
||||
e2eDefaultPrivateRoom,
|
||||
smtpHost,
|
||||
smtpPort,
|
||||
fromEmail,
|
||||
fileUploadEnable,
|
||||
frameworkDevMode,
|
||||
frameworkEnable,
|
||||
surveyEnabled,
|
||||
updateChecker,
|
||||
liveStream,
|
||||
broadcasting,
|
||||
allowEditing,
|
||||
allowDeleting,
|
||||
allowUnrecognizedSlashCommand,
|
||||
allowBadWordsFilter,
|
||||
readReceiptEnabled,
|
||||
readReceiptStoreUsers,
|
||||
globalSearchEnabled,
|
||||
pushEnable,
|
||||
threadsEnabled,
|
||||
webRTCEnableChannel,
|
||||
webRTCEnablePrivate,
|
||||
webRTCEnableDirect,
|
||||
} = settings;
|
||||
|
||||
// If Canned Response does not exist add blank object to the statistic
|
||||
const cannedRes = cannedResponsesEnabled !== undefined ? { cannedResponses: { cannedResponsesEnabled } } : {};
|
||||
|
||||
const statisticObject = {
|
||||
accounts: {
|
||||
account2fa,
|
||||
},
|
||||
...cannedRes,
|
||||
e2ee: {
|
||||
e2e,
|
||||
e2eDefaultDirectRoom,
|
||||
e2eDefaultPrivateRoom,
|
||||
},
|
||||
email: {
|
||||
smtp: {
|
||||
smtpHost,
|
||||
smtpPort,
|
||||
fromEmail,
|
||||
},
|
||||
},
|
||||
fileUpload: {
|
||||
fileUploadEnable,
|
||||
},
|
||||
general: {
|
||||
apps: {
|
||||
frameworkDevMode,
|
||||
frameworkEnable,
|
||||
},
|
||||
nps: {
|
||||
surveyEnabled,
|
||||
},
|
||||
update: {
|
||||
updateChecker,
|
||||
},
|
||||
},
|
||||
liveStreamAndBroadcasting: {
|
||||
liveStream,
|
||||
broadcasting,
|
||||
},
|
||||
message: {
|
||||
allowEditing,
|
||||
allowDeleting,
|
||||
allowUnrecognizedSlashCommand,
|
||||
allowBadWordsFilter,
|
||||
readReceiptEnabled,
|
||||
readReceiptStoreUsers,
|
||||
},
|
||||
push: {
|
||||
pushEnable,
|
||||
},
|
||||
search: {
|
||||
defaultProvider: {
|
||||
globalSearchEnabled,
|
||||
},
|
||||
},
|
||||
threads: {
|
||||
threadsEnabled,
|
||||
},
|
||||
webRTC: {
|
||||
webRTCEnableChannel,
|
||||
webRTCEnablePrivate,
|
||||
webRTCEnableDirect,
|
||||
},
|
||||
};
|
||||
return statisticObject;
|
||||
};
|
||||
export const getSettingsStatistics = async (): Promise<ISettingStatisticsObject> => {
|
||||
try {
|
||||
const settingsBase = [
|
||||
{ key: 'Accounts_TwoFactorAuthentication_Enabled', alias: 'account2fa' },
|
||||
{ key: 'E2E_Enable', alias: 'e2e' },
|
||||
{ key: 'E2E_Enabled_Default_DirectRooms', alias: 'e2eDefaultDirectRoom' },
|
||||
{ key: 'E2E_Enabled_Default_PrivateRooms', alias: 'e2eDefaultPrivateRoom' },
|
||||
{ key: 'SMTP_Host', alias: 'smtpHost' },
|
||||
{ key: 'SMTP_Port', alias: 'smtpPort' },
|
||||
{ key: 'From_Email', alias: 'fromEmail' },
|
||||
{ key: 'FileUpload_Enabled', alias: 'fileUploadEnable' },
|
||||
{ key: 'NPS_survey_enabled', alias: 'surveyEnabled' },
|
||||
{ key: 'Update_EnableChecker', alias: 'updateChecker' },
|
||||
{ key: 'Livestream_enabled', alias: 'liveStream' },
|
||||
{ key: 'Broadcasting_enabled', alias: 'broadcasting' },
|
||||
{ key: 'Message_AllowEditing', alias: 'allowEditing' },
|
||||
{ key: 'Message_AllowDeleting', alias: 'allowDeleting' },
|
||||
{ key: 'Message_AllowUnrecognizedSlashCommand', alias: 'allowUnrecognizedSlashCommand' },
|
||||
{ key: 'Message_AllowBadWordsFilter', alias: 'allowBadWordsFilter' },
|
||||
{ key: 'Message_Read_Receipt_Enabled', alias: 'readReceiptEnabled' },
|
||||
{ key: 'Message_Read_Receipt_Store_Users', alias: 'readReceiptStoreUsers' },
|
||||
{ key: 'Search.defaultProvider.GlobalSearchEnabled', alias: 'globalSearchEnabled' },
|
||||
{ key: 'Push_enable', alias: 'pushEnable' },
|
||||
{ key: 'Threads_enabled', alias: 'threadsEnabled' },
|
||||
{ key: 'WebRTC_Enable_Channel', alias: 'webRTCEnableChannel' },
|
||||
{ key: 'WebRTC_Enable_Private', alias: 'webRTCEnablePrivate' },
|
||||
{ key: 'WebRTC_Enable_Direct', alias: 'webRTCEnableDirect' },
|
||||
{ key: 'Canned_Responses_Enable', alias: 'cannedResponsesEnabled' },
|
||||
] as const;
|
||||
|
||||
const settingsStatistics = settingsBase.reduce<ISettingStatistics>((acc, { key, alias }) => {
|
||||
if (!settings.has(key)) return acc;
|
||||
|
||||
acc[alias] = settings.get(key);
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const staticticObject = await setSettingsStatistics(settingsStatistics);
|
||||
|
||||
return staticticObject;
|
||||
} catch (error: any) {
|
||||
return error;
|
||||
}
|
||||
};
|
||||
7
apps/meteor/server/lib/statistics/index.ts
Normal file
7
apps/meteor/server/lib/statistics/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import './startup/monitor';
|
||||
import './functions/slashCommandsStats';
|
||||
|
||||
export { statistics } from './lib/statistics';
|
||||
export { getLastStatistics } from './functions/getLastStatistics';
|
||||
export { getStatistics } from './functions/getStatistics';
|
||||
export { updateCounter } from './functions/updateStatsCounter';
|
||||
342
apps/meteor/server/lib/statistics/lib/SAUMonitor.ts
Normal file
342
apps/meteor/server/lib/statistics/lib/SAUMonitor.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
import type { ISession, ISessionDevice, IUser } from '@rocket.chat/core-typings';
|
||||
import { cronJobs } from '@rocket.chat/cron';
|
||||
import { Logger } from '@rocket.chat/logger';
|
||||
import { Sessions, Users, aggregates } from '@rocket.chat/models';
|
||||
import mem from 'mem';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import UAParser from 'ua-parser-js';
|
||||
|
||||
import { UAParserMobile, UAParserDesktop } from './UAParserCustom';
|
||||
import { getMostImportantRole } from '../../../../lib/roles/getMostImportantRole';
|
||||
import { sauEvents } from '../../../services/sauMonitor/events';
|
||||
|
||||
type DateObj = { day: number; month: number; year: number };
|
||||
|
||||
const getDateObj = (dateTime = new Date()): DateObj => ({
|
||||
day: dateTime.getDate(),
|
||||
month: dateTime.getMonth() + 1,
|
||||
year: dateTime.getFullYear(),
|
||||
});
|
||||
|
||||
const logger = new Logger('SAUMonitor');
|
||||
|
||||
const getUserRoles = mem(
|
||||
async (userId: string): Promise<string[]> => {
|
||||
const user = await Users.findOneById<Pick<IUser, 'roles'>>(userId, { projection: { roles: 1 } });
|
||||
|
||||
return user?.roles || [];
|
||||
},
|
||||
{ maxAge: 5000 },
|
||||
);
|
||||
|
||||
const isProdEnv = process.env.NODE_ENV === 'production';
|
||||
|
||||
type HandleSessionArgs = {
|
||||
userId: string;
|
||||
instanceId: string;
|
||||
userAgent: string;
|
||||
loginToken?: string;
|
||||
connectionId: string;
|
||||
clientAddress: string;
|
||||
host: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Server Session Monitor for SAU(Simultaneously Active Users) based on Meteor server sessions
|
||||
*/
|
||||
export class SAUMonitorClass {
|
||||
private _started: boolean;
|
||||
|
||||
private _dailyComputeJobName: string;
|
||||
|
||||
private _dailyFinishSessionsJobName: string;
|
||||
|
||||
private scheduler = cronJobs;
|
||||
|
||||
constructor() {
|
||||
this._started = false;
|
||||
this._dailyComputeJobName = 'aggregate-sessions';
|
||||
this._dailyFinishSessionsJobName = 'finish-sessions';
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.isRunning()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this._startMonitoring();
|
||||
|
||||
this._started = true;
|
||||
logger.debug({ msg: '[start]' });
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.isRunning()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._started = false;
|
||||
|
||||
if (await this.scheduler.has(this._dailyComputeJobName)) {
|
||||
await this.scheduler.remove(this._dailyComputeJobName);
|
||||
}
|
||||
if (await this.scheduler.has(this._dailyFinishSessionsJobName)) {
|
||||
await this.scheduler.remove(this._dailyFinishSessionsJobName);
|
||||
}
|
||||
|
||||
logger.debug({ msg: '[stop]' });
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return this._started === true;
|
||||
}
|
||||
|
||||
async _startMonitoring(): Promise<void> {
|
||||
try {
|
||||
this._handleAccountEvents();
|
||||
this._handleOnConnection();
|
||||
await this._startCronjobs();
|
||||
} catch (err: any) {
|
||||
throw new Meteor.Error(err);
|
||||
}
|
||||
}
|
||||
|
||||
private _handleOnConnection(): void {
|
||||
if (this.isRunning()) {
|
||||
return;
|
||||
}
|
||||
|
||||
sauEvents.on('sau.socket.disconnected', async ({ connectionId, instanceId }) => {
|
||||
if (!this.isRunning()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Sessions.closeByInstanceIdAndSessionId(instanceId, connectionId);
|
||||
});
|
||||
}
|
||||
|
||||
private _handleAccountEvents(): void {
|
||||
if (this.isRunning()) {
|
||||
return;
|
||||
}
|
||||
|
||||
sauEvents.on('sau.accounts.login', async ({ userId, instanceId, userAgent, loginToken, connectionId, clientAddress, host }) => {
|
||||
if (!this.isRunning()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const roles = await getUserRoles(userId);
|
||||
|
||||
const mostImportantRole = getMostImportantRole(roles);
|
||||
|
||||
const loginAt = new Date();
|
||||
const params = { roles, mostImportantRole, loginAt, ...getDateObj() };
|
||||
await this._handleSession({ userId, instanceId, userAgent, loginToken, connectionId, clientAddress, host }, params);
|
||||
});
|
||||
|
||||
sauEvents.on('sau.accounts.logout', async ({ userId, sessionId }) => {
|
||||
if (!this.isRunning()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
logger.warn({ msg: "Received 'sau.accounts.logout' event without 'userId'" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
logger.warn({ msg: "Received 'sau.accounts.logout' event without 'sessionId'" });
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await Sessions.getLoggedInByUserIdAndSessionId<Pick<ISession, 'loginToken'>>(userId, sessionId, {
|
||||
projection: { loginToken: 1 },
|
||||
});
|
||||
if (!session?.loginToken) {
|
||||
if (!isProdEnv) {
|
||||
throw new Error('Session not found during logout');
|
||||
}
|
||||
logger.error({ msg: 'Session not found during logout', userId, sessionId });
|
||||
return;
|
||||
}
|
||||
|
||||
await Sessions.logoutBySessionIdAndUserId({ loginToken: session.loginToken, userId });
|
||||
});
|
||||
}
|
||||
|
||||
private async _handleSession(
|
||||
{ userId, instanceId, userAgent, loginToken, connectionId, clientAddress, host }: HandleSessionArgs,
|
||||
params: Pick<ISession, 'mostImportantRole' | 'loginAt' | 'day' | 'month' | 'year' | 'roles'>,
|
||||
): Promise<void> {
|
||||
const data: Omit<ISession, '_id' | '_updatedAt' | 'createdAt' | 'searchTerm'> = {
|
||||
userId,
|
||||
...(loginToken && { loginToken }),
|
||||
ip: clientAddress,
|
||||
host,
|
||||
sessionId: connectionId,
|
||||
instanceId,
|
||||
type: 'session',
|
||||
...(loginToken && this._getUserAgentInfo(userAgent)),
|
||||
...params,
|
||||
};
|
||||
|
||||
const searchTerm = this._getSearchTerm(data);
|
||||
|
||||
await Sessions.createOrUpdate({ ...data, searchTerm });
|
||||
}
|
||||
|
||||
private async _finishSessionsFromDate(yesterday: Date, today: Date): Promise<void> {
|
||||
if (!this.isRunning()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { day, month, year } = getDateObj(yesterday);
|
||||
const beforeDateTime = new Date(year, month - 1, day, 23, 59, 59, 999);
|
||||
|
||||
const currentDate = getDateObj(today);
|
||||
const nextDateTime = new Date(currentDate.year, currentDate.month - 1, currentDate.day);
|
||||
|
||||
const cursor = Sessions.findSessionsNotClosedByDateWithoutLastActivity({ year, month, day });
|
||||
|
||||
const batch = [];
|
||||
|
||||
for await (const session of cursor) {
|
||||
// create a new session for the current day
|
||||
batch.push({
|
||||
...session,
|
||||
...currentDate,
|
||||
createdAt: nextDateTime,
|
||||
});
|
||||
|
||||
if (batch.length === 500) {
|
||||
await Sessions.createBatch(batch);
|
||||
batch.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (batch.length > 0) {
|
||||
await Sessions.createBatch(batch);
|
||||
}
|
||||
|
||||
// close all sessions from current 'date'
|
||||
await Sessions.updateActiveSessionsByDate(
|
||||
{ year, month, day },
|
||||
{
|
||||
lastActivityAt: beforeDateTime,
|
||||
},
|
||||
);
|
||||
|
||||
// TODO missing an action to perform on dangling sessions (for example remove sessions not closed one month ago)
|
||||
}
|
||||
|
||||
private _getSearchTerm(session: Omit<ISession, '_id' | '_updatedAt' | 'createdAt' | 'searchTerm'>): string {
|
||||
return [session.device?.name, session.device?.type, session.device?.os.name, session.sessionId, session.userId]
|
||||
.filter(Boolean)
|
||||
.join('');
|
||||
}
|
||||
|
||||
private _getUserAgentInfo(uaString: string): { device: ISessionDevice } | undefined {
|
||||
// TODO define a type for "result" below
|
||||
// | UAParser.IResult
|
||||
// | { device: { type: string; model?: string }; browser: undefined; os: undefined; app: { name: string; version: string } }
|
||||
// | {
|
||||
// device: { type: string; model?: string };
|
||||
// browser: undefined;
|
||||
// os: string;
|
||||
// app: { name: string; version: string };
|
||||
// }
|
||||
|
||||
const result = ((): any => {
|
||||
if (UAParserMobile.isMobileApp(uaString)) {
|
||||
return UAParserMobile.uaObject(uaString);
|
||||
}
|
||||
|
||||
if (UAParserDesktop.isDesktopApp(uaString)) {
|
||||
return UAParserDesktop.uaObject(uaString);
|
||||
}
|
||||
|
||||
const ua = new UAParser(uaString);
|
||||
return ua.getResult();
|
||||
})();
|
||||
|
||||
const info: ISessionDevice = {
|
||||
type: 'other',
|
||||
name: '',
|
||||
longVersion: '',
|
||||
os: {
|
||||
name: '',
|
||||
version: '',
|
||||
},
|
||||
version: '',
|
||||
};
|
||||
|
||||
const removeEmptyProps = (obj: any): any => {
|
||||
Object.keys(obj).forEach((p) => (!obj[p] || obj[p] === undefined) && delete obj[p]);
|
||||
return obj;
|
||||
};
|
||||
|
||||
if (result.browser?.name) {
|
||||
info.type = 'browser';
|
||||
info.name = result.browser.name;
|
||||
info.longVersion = result.browser.version || '';
|
||||
}
|
||||
|
||||
if (typeof result.os !== 'string' && result.os?.name) {
|
||||
info.os = removeEmptyProps(result.os) || '';
|
||||
}
|
||||
|
||||
if (result.device && (result.device.type || result.device.model)) {
|
||||
info.type = result.device.type || '';
|
||||
|
||||
if (result.hasOwnProperty('app') && result.app?.name) {
|
||||
info.name = result.app.name;
|
||||
info.longVersion = result.app.version;
|
||||
if (result.app.bundle) {
|
||||
info.longVersion += ` ${result.app.bundle}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof info.longVersion === 'string') {
|
||||
info.version = info.longVersion.match(/(\d+\.){0,2}\d+/)?.[0] || '';
|
||||
}
|
||||
|
||||
return {
|
||||
device: info,
|
||||
};
|
||||
}
|
||||
|
||||
private async _startCronjobs(): Promise<void> {
|
||||
logger.info({ msg: '[aggregate] - Start Cron.' });
|
||||
const dailyComputeProcessTime = '0 2 * * *';
|
||||
const dailyFinishSessionProcessTime = '5 1 * * *';
|
||||
await this.scheduler.add(this._dailyComputeJobName, dailyComputeProcessTime, async () => this._aggregate());
|
||||
await this.scheduler.add(this._dailyFinishSessionsJobName, dailyFinishSessionProcessTime, async () => {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
await this._finishSessionsFromDate(yesterday, new Date());
|
||||
});
|
||||
}
|
||||
|
||||
private async _aggregate(): Promise<void> {
|
||||
if (!this.isRunning()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
|
||||
// get sessions from 3 days ago to make sure even if a few cron jobs were skipped, we still have the data
|
||||
const threeDaysAgo = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 3, 0, 0, 0, 0);
|
||||
|
||||
const period = { start: getDateObj(threeDaysAgo), end: getDateObj(today) };
|
||||
|
||||
logger.info({ msg: '[aggregate] - Aggregating data.', period });
|
||||
|
||||
for await (const record of aggregates.dailySessions(Sessions.col, period)) {
|
||||
await Sessions.updateDailySessionById(`${record.userId}-${record.year}-${record.month}-${record.day}`, record);
|
||||
}
|
||||
|
||||
await Sessions.updateAllSessionsByDateToComputed(period);
|
||||
}
|
||||
}
|
||||
154
apps/meteor/server/lib/statistics/lib/UAParserCustom.js
Normal file
154
apps/meteor/server/lib/statistics/lib/UAParserCustom.js
Normal file
@@ -0,0 +1,154 @@
|
||||
import UAParser from 'ua-parser-js';
|
||||
|
||||
const mergeDeep = (target, source) => {
|
||||
if (!(typeof target === 'object' && typeof source === 'object')) {
|
||||
return target;
|
||||
}
|
||||
|
||||
for (const key in source) {
|
||||
if (source[key] === null && (target[key] === undefined || target[key] === null)) {
|
||||
target[key] = null;
|
||||
} else if (source[key] instanceof Array) {
|
||||
if (!target[key]) {
|
||||
target[key] = [];
|
||||
}
|
||||
target[key] = target[key].concat(source[key]);
|
||||
} else if (typeof source[key] === 'object') {
|
||||
if (!target[key]) {
|
||||
target[key] = {};
|
||||
}
|
||||
mergeDeep(target[key], source[key]);
|
||||
} else {
|
||||
target[key] = source[key];
|
||||
}
|
||||
}
|
||||
|
||||
return target;
|
||||
};
|
||||
|
||||
export const UAParserMobile = {
|
||||
appName: 'RC Mobile',
|
||||
device: 'mobile-app',
|
||||
uaSeparator: ';',
|
||||
props: {
|
||||
os: {
|
||||
list: ['name', 'version'],
|
||||
},
|
||||
app: {
|
||||
list: ['version', 'bundle'],
|
||||
get: (prop, value) => {
|
||||
if (prop === 'bundle') {
|
||||
return value.replace(/([()])/g, '');
|
||||
}
|
||||
|
||||
if (prop === 'version') {
|
||||
return value.replace(/^v/g, '');
|
||||
}
|
||||
|
||||
return value;
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
isMobileApp(uaString) {
|
||||
if (!uaString || typeof uaString !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const splitUA = uaString.split(this.uaSeparator);
|
||||
return splitUA && splitUA[0] && splitUA[0].trim() === this.appName;
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} uaString
|
||||
* @returns { device: { type: '' }, app: { name: '', version: '' } }
|
||||
*/
|
||||
uaObject(uaString) {
|
||||
if (!this.isMobileApp(uaString)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const splitUA = uaString.split(this.uaSeparator);
|
||||
|
||||
let obj = {
|
||||
device: {
|
||||
type: this.device,
|
||||
},
|
||||
app: {
|
||||
name: splitUA[0],
|
||||
},
|
||||
};
|
||||
|
||||
splitUA.shift(); // remove first element
|
||||
if (splitUA.length === 0) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
splitUA.forEach((element, index) => {
|
||||
const splitProps = element.trim().split(' ');
|
||||
const key = Object.keys(this.props)[index];
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
const props = this.props[key];
|
||||
if (!props.list || !Array.isArray(props.list) || props.list.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subProps = {};
|
||||
splitProps.forEach((value, idx) => {
|
||||
if (props.list.length > idx) {
|
||||
const propName = props.list[idx];
|
||||
subProps[propName] = props.get ? props.get(propName, value) : value;
|
||||
}
|
||||
});
|
||||
|
||||
const prop = {};
|
||||
prop[key] = subProps;
|
||||
obj = mergeDeep(obj, prop);
|
||||
});
|
||||
|
||||
return obj;
|
||||
},
|
||||
};
|
||||
|
||||
export const UAParserDesktop = {
|
||||
device: 'desktop-app',
|
||||
|
||||
isDesktopApp(uaString) {
|
||||
if (!uaString || typeof uaString !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return uaString.includes(' Electron/');
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} uaString
|
||||
* @returns { device: { type: '' }, os: '' || {}, app: { name: '', version: '' } }
|
||||
*/
|
||||
uaObject(uaString) {
|
||||
if (!this.isDesktopApp(uaString)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const ua = new UAParser(uaString);
|
||||
const uaParsed = ua.getResult();
|
||||
|
||||
const [, name, version] = uaString.match(/(Rocket\.Chat)\/(\d+(\.\d+)+)/) || [];
|
||||
|
||||
return {
|
||||
device: {
|
||||
type: this.device,
|
||||
},
|
||||
os: uaParsed.os,
|
||||
app: {
|
||||
name,
|
||||
version,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
84
apps/meteor/server/lib/statistics/lib/getAppsStatistics.ts
Normal file
84
apps/meteor/server/lib/statistics/lib/getAppsStatistics.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Apps } from '@rocket.chat/apps';
|
||||
import { AppInstallationSource } from '@rocket.chat/apps/dist/server/storage/IAppStorageItem';
|
||||
import { AppStatus, AppStatusUtils } from '@rocket.chat/apps-engine/definition/AppStatus';
|
||||
import mem from 'mem';
|
||||
|
||||
import { Info } from '../../../../app/utils/rocketchat.info';
|
||||
import { SystemLogger } from '../../logger/system';
|
||||
|
||||
type AppsStatistics = {
|
||||
engineVersion: string;
|
||||
totalInstalled: number | false;
|
||||
totalActive: number | false;
|
||||
totalFailed: number | false;
|
||||
totalPrivateApps: number | false;
|
||||
totalPrivateAppsEnabled: number | false;
|
||||
};
|
||||
|
||||
async function _getAppsStatistics(): Promise<AppsStatistics> {
|
||||
if (!Apps.self?.isInitialized()) {
|
||||
return {
|
||||
engineVersion: Info.marketplaceApiVersion,
|
||||
totalInstalled: false,
|
||||
totalActive: false,
|
||||
totalFailed: false,
|
||||
totalPrivateApps: false,
|
||||
totalPrivateAppsEnabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const apps = await Apps.getManager().get();
|
||||
|
||||
let totalInstalled = 0;
|
||||
let totalActive = 0;
|
||||
let totalFailed = 0;
|
||||
let totalPrivateApps = 0;
|
||||
let totalPrivateAppsEnabled = 0;
|
||||
|
||||
await Promise.all(
|
||||
apps.map(async (app) => {
|
||||
totalInstalled++;
|
||||
|
||||
const status = await app.getStatus();
|
||||
const storageItem = app.getStorageItem();
|
||||
|
||||
if (storageItem.installationSource === AppInstallationSource.PRIVATE) {
|
||||
totalPrivateApps++;
|
||||
|
||||
if (AppStatusUtils.isEnabled(status)) {
|
||||
totalPrivateAppsEnabled++;
|
||||
}
|
||||
}
|
||||
|
||||
if (AppStatusUtils.isEnabled(status)) {
|
||||
totalActive++;
|
||||
} else if (status !== AppStatus.MANUALLY_DISABLED) {
|
||||
totalFailed++;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
engineVersion: Info.marketplaceApiVersion,
|
||||
totalInstalled,
|
||||
totalActive,
|
||||
totalFailed,
|
||||
totalPrivateApps,
|
||||
totalPrivateAppsEnabled,
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
SystemLogger.error({ msg: 'Exception while getting Apps statistics', err });
|
||||
return {
|
||||
engineVersion: Info.marketplaceApiVersion,
|
||||
totalInstalled: false,
|
||||
totalActive: false,
|
||||
totalFailed: false,
|
||||
totalPrivateApps: false,
|
||||
totalPrivateAppsEnabled: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// since this function is called every 5s by `setPrometheusData` we're memoizing the result since the result won't change that often
|
||||
export const getAppsStatistics = mem(_getAppsStatistics, { maxAge: 60000 });
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { IStats } from '@rocket.chat/core-typings';
|
||||
import { LivechatContacts } from '@rocket.chat/models';
|
||||
|
||||
import { settings } from '../../../settings';
|
||||
|
||||
export async function getContactVerificationStatistics(): Promise<IStats['contactVerification']> {
|
||||
const [
|
||||
totalContacts,
|
||||
totalUnknownContacts,
|
||||
[{ totalConflicts, avgChannelsPerContact } = { totalConflicts: 0, avgChannelsPerContact: 0 }],
|
||||
totalBlockedContacts,
|
||||
totalFullyBlockedContacts,
|
||||
totalVerifiedContacts,
|
||||
totalContactsWithoutChannels,
|
||||
] = await Promise.all([
|
||||
LivechatContacts.estimatedDocumentCount(),
|
||||
LivechatContacts.countUnknown(),
|
||||
LivechatContacts.getStatistics().toArray(),
|
||||
LivechatContacts.countBlocked(),
|
||||
LivechatContacts.countFullyBlocked(),
|
||||
LivechatContacts.countVerified(),
|
||||
LivechatContacts.countContactsWithoutChannels(),
|
||||
]);
|
||||
|
||||
return {
|
||||
totalContacts,
|
||||
totalUnknownContacts,
|
||||
totalMergedContacts: settings.get('Merged_Contacts_Count'),
|
||||
totalConflicts,
|
||||
totalResolvedConflicts: settings.get('Resolved_Conflicts_Count'),
|
||||
totalBlockedContacts,
|
||||
totalPartiallyBlockedContacts: totalBlockedContacts - totalFullyBlockedContacts,
|
||||
totalFullyBlockedContacts,
|
||||
totalVerifiedContacts,
|
||||
avgChannelsPerContact,
|
||||
totalContactsWithoutChannels,
|
||||
totalImportedContacts: settings.get('Contacts_Importer_Count'),
|
||||
totalUpsellViews: settings.get('Advanced_Contact_Upsell_Views_Count'),
|
||||
totalUpsellClicks: settings.get('Advanced_Contact_Upsell_Clicks_Count'),
|
||||
};
|
||||
}
|
||||
103
apps/meteor/server/lib/statistics/lib/getEEStatistics.ts
Normal file
103
apps/meteor/server/lib/statistics/lib/getEEStatistics.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { log } from 'console';
|
||||
|
||||
import { Analytics } from '@rocket.chat/core-services';
|
||||
import type { IStats } from '@rocket.chat/core-typings';
|
||||
import { License } from '@rocket.chat/license';
|
||||
import { CannedResponse, OmnichannelServiceLevelAgreements, LivechatRooms, LivechatTag, LivechatUnit, Users } from '@rocket.chat/models';
|
||||
|
||||
type ENTERPRISE_STATISTICS = IStats['enterprise'];
|
||||
|
||||
type GenericStats = Pick<ENTERPRISE_STATISTICS, 'modules' | 'tags' | 'seatRequests'>;
|
||||
|
||||
type EEOnlyStats = Omit<ENTERPRISE_STATISTICS, keyof GenericStats>;
|
||||
|
||||
export async function getStatistics(): Promise<ENTERPRISE_STATISTICS> {
|
||||
const genericStats: GenericStats = {
|
||||
modules: License.getModules(),
|
||||
tags: License.getTags().map(({ name }) => name),
|
||||
seatRequests: await Analytics.getSeatRequestCount(),
|
||||
};
|
||||
|
||||
const eeModelsStats = await getEEStatistics();
|
||||
|
||||
const statistics = {
|
||||
...genericStats,
|
||||
...eeModelsStats,
|
||||
};
|
||||
|
||||
return statistics;
|
||||
}
|
||||
|
||||
async function getEEStatistics(): Promise<EEOnlyStats | undefined> {
|
||||
if (!License.hasModule('livechat-enterprise')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const statsPms: Array<Promise<any>> = [];
|
||||
|
||||
const statistics: Partial<EEOnlyStats> = {};
|
||||
|
||||
// Number of livechat tags
|
||||
statsPms.push(
|
||||
LivechatTag.estimatedDocumentCount().then((count) => {
|
||||
statistics.livechatTags = count;
|
||||
return true;
|
||||
}),
|
||||
);
|
||||
|
||||
// Number of canned responses
|
||||
statsPms.push(
|
||||
CannedResponse.estimatedDocumentCount().then((count) => {
|
||||
statistics.cannedResponses = count;
|
||||
return true;
|
||||
}),
|
||||
);
|
||||
|
||||
// Number of Service Level Agreements
|
||||
statsPms.push(
|
||||
OmnichannelServiceLevelAgreements.estimatedDocumentCount().then((count) => {
|
||||
statistics.slas = count;
|
||||
return true;
|
||||
}),
|
||||
);
|
||||
|
||||
statsPms.push(
|
||||
LivechatRooms.countPrioritizedRooms().then((count) => {
|
||||
statistics.omnichannelRoomsWithPriorities = count;
|
||||
return true;
|
||||
}),
|
||||
);
|
||||
|
||||
statsPms.push(
|
||||
LivechatRooms.countRoomsWithSla().then((count) => {
|
||||
statistics.omnichannelRoomsWithSlas = count;
|
||||
return true;
|
||||
}),
|
||||
);
|
||||
|
||||
// Number of business units
|
||||
statsPms.push(
|
||||
LivechatUnit.countUnits().then((count) => {
|
||||
statistics.businessUnits = count;
|
||||
return true;
|
||||
}),
|
||||
);
|
||||
|
||||
statsPms.push(
|
||||
// Total livechat monitors
|
||||
Users.countByRole('livechat-monitor').then((count) => {
|
||||
statistics.livechatMonitors = count;
|
||||
return true;
|
||||
}),
|
||||
);
|
||||
// Number of PDF transcript that succeeded
|
||||
statsPms.push(
|
||||
LivechatRooms.countRoomsWithTranscriptSent().then((count) => {
|
||||
statistics.omnichannelPdfTranscriptSucceeded = count;
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(statsPms).catch(log);
|
||||
|
||||
return statistics as EEOnlyStats;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { settings } from '../../../settings';
|
||||
|
||||
export function getImporterStatistics(): Record<string, unknown> {
|
||||
return {
|
||||
totalCSVImportedUsers: settings.get('CSV_Importer_Count'),
|
||||
totalHipchatEnterpriseImportedUsers: settings.get('Hipchat_Enterprise_Importer_Count'),
|
||||
totalSlackImportedUsers: settings.get('Slack_Importer_Count'),
|
||||
totalSlackUsersImportedUsers: settings.get('Slack_Users_Importer_Count'),
|
||||
};
|
||||
}
|
||||
143
apps/meteor/server/lib/statistics/lib/getServicesStatistics.ts
Normal file
143
apps/meteor/server/lib/statistics/lib/getServicesStatistics.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { Users } from '@rocket.chat/models';
|
||||
import { MongoInternals } from 'meteor/mongo';
|
||||
|
||||
import { readSecondaryPreferred } from '../../../database/readSecondaryPreferred';
|
||||
import { settings } from '../../../settings';
|
||||
|
||||
const { db } = MongoInternals.defaultRemoteCollectionDriver().mongo;
|
||||
|
||||
async function getCustomOAuthServices(): Promise<
|
||||
Record<
|
||||
string,
|
||||
{
|
||||
enabled: boolean;
|
||||
mergeRoles: boolean;
|
||||
users: number;
|
||||
}
|
||||
>
|
||||
> {
|
||||
const readPreference = readSecondaryPreferred(db);
|
||||
|
||||
const customOauth = settings.getByRegexp(/Accounts_OAuth_Custom-[^-]+$/im);
|
||||
return Object.fromEntries(
|
||||
await Promise.all(
|
||||
Object.entries(customOauth).map(async ([key, value]) => {
|
||||
const name = key.replace('Accounts_OAuth_Custom-', '');
|
||||
return [
|
||||
name,
|
||||
{
|
||||
enabled: Boolean(value),
|
||||
mergeRoles: settings.get<boolean>(`Accounts_OAuth_Custom-${name}-merge_roles`),
|
||||
users: await Users.countActiveUsersByService(name, { readPreference }),
|
||||
mapChannels: settings.get<boolean>(`Accounts_OAuth_Custom-${name}-map_channels`),
|
||||
rolesToSync: !!settings.get<string>(`Accounts_OAuth_Custom-${name}-roles_to_sync`),
|
||||
},
|
||||
];
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getServicesStatistics(): Promise<Record<string, unknown>> {
|
||||
const readPreference = readSecondaryPreferred(db);
|
||||
|
||||
return {
|
||||
ldap: {
|
||||
users: await Users.countActiveUsersByService('ldap', { readPreference }),
|
||||
enabled: settings.get('LDAP_Enable'),
|
||||
loginFallback: settings.get('LDAP_Login_Fallback'),
|
||||
encryption: settings.get('LDAP_Encryption'),
|
||||
mergeUsers: settings.get('LDAP_Merge_Existing_Users'),
|
||||
syncRoles: settings.get('LDAP_Sync_User_Data_Roles'),
|
||||
syncRolesAutoRemove: settings.get('LDAP_Sync_User_Data_Roles_AutoRemove'),
|
||||
syncData: settings.get('LDAP_Sync_Custom_Fields'),
|
||||
syncChannels: settings.get('LDAP_Sync_User_Data_Channels'),
|
||||
syncAvatar: settings.get('LDAP_Sync_User_Avatar'),
|
||||
groupFilter: settings.get('LDAP_Group_Filter_Enable'),
|
||||
backgroundSync: {
|
||||
enabled: settings.get('LDAP_Background_Sync'),
|
||||
interval: settings.get('LDAP_Background_Sync_Interval'),
|
||||
newUsers: settings.get('LDAP_Background_Sync_Import_New_Users'),
|
||||
existingUsers: settings.get('LDAP_Background_Sync_Keep_Existant_Users_Updated'),
|
||||
},
|
||||
ee: {
|
||||
syncActiveState: settings.get('LDAP_Sync_User_Active_State'),
|
||||
syncTeams: settings.get('LDAP_Enable_LDAP_Groups_To_RC_Teams'),
|
||||
syncRoles: settings.get('LDAP_Sync_User_Data_Roles'),
|
||||
},
|
||||
},
|
||||
saml: {
|
||||
enabled: settings.get('SAML_Custom_Default'),
|
||||
users: await Users.countActiveUsersByService('saml', { readPreference }),
|
||||
signatureValidationType: settings.get('SAML_Custom_Default_signature_validation_type'),
|
||||
generateUsername: settings.get('SAML_Custom_Default_generate_username'),
|
||||
updateSubscriptionsOnLogin: settings.get('SAML_Custom_Default_channels_update'),
|
||||
syncRoles: settings.get('SAML_Custom_Default_role_attribute_sync'),
|
||||
userDataCustomFieldMap: !(
|
||||
settings.getSetting('SAML_Custom_Default_user_data_custom_fieldmap')?.packageValue ===
|
||||
settings.getSetting('SAML_Custom_Default_user_data_custom_fieldmap')?.value
|
||||
),
|
||||
},
|
||||
cas: {
|
||||
enabled: settings.get('CAS_enabled'),
|
||||
users: await Users.countActiveUsersByService('cas', { readPreference }),
|
||||
allowUserCreation: settings.get('CAS_Creation_User_Enabled'),
|
||||
alwaysSyncUserData: settings.get('CAS_Sync_User_Data_Enabled'),
|
||||
},
|
||||
oauth: {
|
||||
apple: {
|
||||
enabled: settings.get('Accounts_OAuth_Apple'),
|
||||
users: await Users.countActiveUsersByService('apple', { readPreference }),
|
||||
},
|
||||
dolphin: {
|
||||
enabled: settings.get('Accounts_OAuth_Dolphin'),
|
||||
users: await Users.countActiveUsersByService('dolphin', { readPreference }),
|
||||
},
|
||||
drupal: {
|
||||
enabled: settings.get('Accounts_OAuth_Drupal'),
|
||||
users: await Users.countActiveUsersByService('drupal', { readPreference }),
|
||||
},
|
||||
facebook: {
|
||||
enabled: settings.get('Accounts_OAuth_Facebook'),
|
||||
users: await Users.countActiveUsersByService('facebook', { readPreference }),
|
||||
},
|
||||
github: {
|
||||
enabled: settings.get('Accounts_OAuth_Github'),
|
||||
users: await Users.countActiveUsersByService('github', { readPreference }),
|
||||
},
|
||||
githubEnterprise: {
|
||||
enabled: settings.get('Accounts_OAuth_GitHub_Enterprise'),
|
||||
users: await Users.countActiveUsersByService('github_enterprise', { readPreference }),
|
||||
},
|
||||
gitlab: {
|
||||
enabled: settings.get('Accounts_OAuth_Gitlab'),
|
||||
users: await Users.countActiveUsersByService('gitlab', { readPreference }),
|
||||
},
|
||||
google: {
|
||||
enabled: settings.get('Accounts_OAuth_Google'),
|
||||
users: await Users.countActiveUsersByService('google', { readPreference }),
|
||||
},
|
||||
linkedin: {
|
||||
enabled: settings.get('Accounts_OAuth_Linkedin'),
|
||||
users: await Users.countActiveUsersByService('linkedin', { readPreference }),
|
||||
},
|
||||
meteor: {
|
||||
enabled: settings.get('Accounts_OAuth_Meteor'),
|
||||
users: await Users.countActiveUsersByService('meteor', { readPreference }),
|
||||
},
|
||||
nextcloud: {
|
||||
enabled: settings.get('Accounts_OAuth_Nextcloud'),
|
||||
users: await Users.countActiveUsersByService('nextcloud', { readPreference }),
|
||||
},
|
||||
twitter: {
|
||||
enabled: settings.get('Accounts_OAuth_Twitter'),
|
||||
users: await Users.countActiveUsersByService('twitter', { readPreference }),
|
||||
},
|
||||
wordpress: {
|
||||
enabled: settings.get('Accounts_OAuth_Wordpress'),
|
||||
users: await Users.countActiveUsersByService('wordpress', { readPreference }),
|
||||
},
|
||||
custom: await getCustomOAuthServices(),
|
||||
},
|
||||
};
|
||||
}
|
||||
578
apps/meteor/server/lib/statistics/lib/statistics.ts
Normal file
578
apps/meteor/server/lib/statistics/lib/statistics.ts
Normal file
@@ -0,0 +1,578 @@
|
||||
import { log } from 'console';
|
||||
import crypto from 'node:crypto';
|
||||
import os from 'node:os';
|
||||
|
||||
import { Analytics, Team, VideoConf, Presence } from '@rocket.chat/core-services';
|
||||
import type { IStats, ISetting } from '@rocket.chat/core-typings';
|
||||
import { UserStatus } from '@rocket.chat/core-typings';
|
||||
import { License } from '@rocket.chat/license';
|
||||
import {
|
||||
NotificationQueue,
|
||||
Rooms,
|
||||
Statistics,
|
||||
Sessions,
|
||||
Integrations,
|
||||
Invites,
|
||||
Uploads,
|
||||
LivechatDepartment,
|
||||
LivechatVisitors,
|
||||
EmailInbox,
|
||||
LivechatBusinessHours,
|
||||
Messages,
|
||||
Roles as RolesRaw,
|
||||
InstanceStatus,
|
||||
Settings,
|
||||
LivechatTrigger,
|
||||
LivechatCustomField,
|
||||
Subscriptions,
|
||||
Users,
|
||||
LivechatRooms,
|
||||
AbacAttributes,
|
||||
} from '@rocket.chat/models';
|
||||
import { MongoInternals } from 'meteor/mongo';
|
||||
import moment from 'moment';
|
||||
|
||||
import { getAppsStatistics } from './getAppsStatistics';
|
||||
import { getContactVerificationStatistics } from './getContactVerificationStatistics';
|
||||
import { getStatistics as getEnterpriseStatistics } from './getEEStatistics';
|
||||
import { getImporterStatistics } from './getImporterStatistics';
|
||||
import { getServicesStatistics } from './getServicesStatistics';
|
||||
import { Info } from '../../../../app/utils/rocketchat.info';
|
||||
import { readSecondaryPreferred } from '../../../database/readSecondaryPreferred';
|
||||
import { getMatrixFederationStatistics } from '../../../services/federation/infrastructure/rocket-chat/adapters/Statistics';
|
||||
import { settings } from '../../../settings';
|
||||
import { isRunningMs } from '../../isRunningMs';
|
||||
import { getControl } from '../../migrations';
|
||||
import { getMongoInfo } from '../../utils/functions/getMongoInfo';
|
||||
import { getSettingsStatistics } from '../getSettingsStatistics';
|
||||
|
||||
const getUserLanguages = async (totalUsers: number): Promise<{ [key: string]: number }> => {
|
||||
const result = await Users.getUserLanguages();
|
||||
|
||||
const languages: { [key: string]: number } = {
|
||||
none: totalUsers,
|
||||
};
|
||||
|
||||
result.forEach(({ _id, total }: { _id: string; total: number }) => {
|
||||
if (!_id) {
|
||||
return;
|
||||
}
|
||||
languages[_id] = total;
|
||||
languages.none -= total;
|
||||
});
|
||||
|
||||
return languages;
|
||||
};
|
||||
|
||||
const { db } = MongoInternals.defaultRemoteCollectionDriver().mongo;
|
||||
|
||||
export const statistics = {
|
||||
get: async (): Promise<IStats> => {
|
||||
const readPreference = readSecondaryPreferred(db);
|
||||
|
||||
const statistics = {} as IStats;
|
||||
const statsPms = [];
|
||||
|
||||
// Setup Wizard
|
||||
const [organizationType, industry, size, country, language, serverType, registerServer] = await Promise.all([
|
||||
settings.get<string>('Organization_Type'),
|
||||
settings.get<string>('Industry'),
|
||||
settings.get<string>('Size'),
|
||||
settings.get<string>('Country'),
|
||||
settings.get<string>('Language'),
|
||||
settings.get<string>('Server_Type'),
|
||||
settings.get<boolean>('Register_Server'),
|
||||
]);
|
||||
|
||||
statistics.wizard = {
|
||||
organizationType,
|
||||
industry,
|
||||
size,
|
||||
country,
|
||||
language,
|
||||
serverType,
|
||||
registerServer,
|
||||
};
|
||||
|
||||
// Version
|
||||
const uniqueID = await Settings.findOne<Pick<ISetting, 'createdAt'>>('uniqueID', { projection: { createdAt: 1 } });
|
||||
statistics.uniqueId = settings.get('uniqueID');
|
||||
if (uniqueID) {
|
||||
statistics.installedAt = uniqueID.createdAt.toISOString();
|
||||
}
|
||||
|
||||
statistics.deploymentFingerprintHash = settings.get('Deployment_FingerPrint_Hash');
|
||||
statistics.deploymentFingerprintVerified = settings.get('Deployment_FingerPrint_Verified');
|
||||
|
||||
if (Info) {
|
||||
statistics.version = Info.version;
|
||||
statistics.tag = Info.tag;
|
||||
statistics.branch = Info.branch;
|
||||
}
|
||||
|
||||
// User statistics
|
||||
statistics.totalUsers = await Users.estimatedDocumentCount();
|
||||
statistics.activeUsers = await Users.getActiveLocalUserCount();
|
||||
statistics.activeGuests = await Users.getActiveLocalGuestCount();
|
||||
statistics.nonActiveUsers = await Users.countDocuments({ active: false });
|
||||
statistics.appUsers = await Users.countDocuments({ type: 'app' });
|
||||
statistics.onlineUsers = await Users.countDocuments({ status: UserStatus.ONLINE });
|
||||
statistics.awayUsers = await Users.countDocuments({ status: UserStatus.AWAY });
|
||||
statistics.busyUsers = await Users.countDocuments({ status: UserStatus.BUSY });
|
||||
statistics.totalConnectedUsers = statistics.onlineUsers + statistics.awayUsers;
|
||||
statistics.offlineUsers = statistics.totalUsers - statistics.onlineUsers - statistics.awayUsers - statistics.busyUsers;
|
||||
statsPms.push(
|
||||
getUserLanguages(statistics.totalUsers).then((total) => {
|
||||
statistics.userLanguages = total;
|
||||
}),
|
||||
);
|
||||
|
||||
// Room statistics
|
||||
statistics.totalRooms = await Rooms.estimatedDocumentCount();
|
||||
statistics.totalChannels = await Rooms.countByType('c');
|
||||
statistics.totalPrivateGroups = await Rooms.countByType('p');
|
||||
statistics.totalDirect = await Rooms.countByType('d');
|
||||
statistics.totalLivechat = await Rooms.countByType('l');
|
||||
statistics.totalDiscussions = await Rooms.countDiscussions();
|
||||
statistics.totalThreads = await Messages.countThreads();
|
||||
|
||||
// livechat visitors
|
||||
statistics.totalLivechatVisitors = await LivechatVisitors.estimatedDocumentCount();
|
||||
|
||||
// livechat agents
|
||||
statistics.totalLivechatAgents = await Users.countAgents();
|
||||
statistics.totalLivechatManagers = await Users.countDocuments({ roles: 'livechat-manager' });
|
||||
|
||||
// livechat enabled
|
||||
statistics.livechatEnabled = settings.get('Livechat_enabled');
|
||||
|
||||
// Count and types of omnichannel rooms
|
||||
statsPms.push(
|
||||
Rooms.allRoomSourcesCount()
|
||||
.toArray()
|
||||
.then((roomSources) => {
|
||||
statistics.omnichannelSources = roomSources.map(({ _id: { id, alias, type }, count }) => ({
|
||||
id,
|
||||
alias,
|
||||
type,
|
||||
count,
|
||||
}));
|
||||
}),
|
||||
);
|
||||
|
||||
// Number of livechat rooms with department
|
||||
statsPms.push(
|
||||
LivechatRooms.countLivechatRoomsWithDepartment().then((count) => {
|
||||
statistics.totalLivechatRoomsWithDepartment = count;
|
||||
}),
|
||||
);
|
||||
|
||||
// Number of departments
|
||||
statsPms.push(
|
||||
LivechatDepartment.estimatedDocumentCount().then((count) => {
|
||||
statistics.departments = count;
|
||||
}),
|
||||
);
|
||||
|
||||
// Number of archived departments
|
||||
statsPms.push(
|
||||
LivechatDepartment.countArchived().then((count) => {
|
||||
statistics.archivedDepartments = count;
|
||||
}),
|
||||
);
|
||||
|
||||
// Workspace allows dpeartment removal
|
||||
statistics.isDepartmentRemovalEnabled = settings.get('Omnichannel_enable_department_removal');
|
||||
|
||||
// Number of triggers
|
||||
statsPms.push(
|
||||
LivechatTrigger.estimatedDocumentCount().then((count) => {
|
||||
statistics.totalTriggers = count;
|
||||
}),
|
||||
);
|
||||
|
||||
// Number of custom fields
|
||||
statsPms.push((statistics.totalCustomFields = await LivechatCustomField.estimatedDocumentCount()));
|
||||
|
||||
// Number of public custom fields
|
||||
statsPms.push((statistics.totalLivechatPublicCustomFields = await LivechatCustomField.countDocuments({ public: true })));
|
||||
|
||||
// Livechat Automatic forwarding feature enabled
|
||||
statistics.livechatAutomaticForwardingUnansweredChats = settings.get<number>('Livechat_auto_transfer_chat_timeout') !== 0;
|
||||
|
||||
// Type of routing algorithm used on omnichannel
|
||||
statistics.routingAlgorithm = settings.get('Livechat_Routing_Method') || '';
|
||||
|
||||
// is on-hold active
|
||||
statistics.onHoldEnabled = settings.get('Livechat_allow_manual_on_hold');
|
||||
|
||||
// Number of Email Inboxes
|
||||
statsPms.push(
|
||||
EmailInbox.estimatedDocumentCount().then((count) => {
|
||||
statistics.emailInboxes = count;
|
||||
}),
|
||||
);
|
||||
|
||||
statsPms.push(
|
||||
LivechatBusinessHours.estimatedDocumentCount().then((count) => {
|
||||
statistics.BusinessHours = {
|
||||
// Number of Business Hours
|
||||
total: count,
|
||||
// Business Hours strategy
|
||||
strategy: settings.get('Livechat_enable_business_hours') || '',
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// Type of routing algorithm used on omnichannel
|
||||
statistics.routingAlgorithm = settings.get('Livechat_Routing_Method');
|
||||
|
||||
// is on-hold active
|
||||
statistics.onHoldEnabled = settings.get('Livechat_allow_manual_on_hold');
|
||||
|
||||
// Last-Chatted Agent Preferred (enabled/disabled)
|
||||
statistics.lastChattedAgentPreferred = settings.get('Livechat_last_chatted_agent_routing');
|
||||
|
||||
// Assign new conversations to the contact manager (enabled/disabled)
|
||||
statistics.assignNewConversationsToContactManager = settings.get('Omnichannel_contact_manager_routing');
|
||||
|
||||
// How to handle Visitor Abandonment setting
|
||||
statistics.visitorAbandonment = settings.get('Livechat_abandoned_rooms_action');
|
||||
|
||||
// Amount of chats placed on hold
|
||||
statsPms.push(
|
||||
Messages.countRoomsWithMessageType('omnichannel_placed_chat_on_hold', { readPreference }).then((total) => {
|
||||
statistics.chatsOnHold = total;
|
||||
}),
|
||||
);
|
||||
|
||||
const defaultValue = { contactsCount: 0, conversationsCount: 0, sources: [] };
|
||||
const billablePeriod = moment.utc().format('YYYY-MM');
|
||||
statsPms.push(
|
||||
LivechatRooms.getMACStatisticsForPeriod(billablePeriod).then(([result]) => {
|
||||
statistics.omnichannelContactsBySource = result || defaultValue;
|
||||
}),
|
||||
);
|
||||
|
||||
const monthAgo = moment.utc().subtract(30, 'days').toDate();
|
||||
const today = moment.utc().toDate();
|
||||
statsPms.push(
|
||||
LivechatRooms.getMACStatisticsBetweenDates(monthAgo, today).then(([result]) => {
|
||||
statistics.uniqueContactsOfLastMonth = result || defaultValue;
|
||||
}),
|
||||
);
|
||||
|
||||
const weekAgo = moment.utc().subtract(7, 'days').toDate();
|
||||
statsPms.push(
|
||||
LivechatRooms.getMACStatisticsBetweenDates(weekAgo, today).then(([result]) => {
|
||||
statistics.uniqueContactsOfLastWeek = result || defaultValue;
|
||||
}),
|
||||
);
|
||||
|
||||
const yesterday = moment.utc().subtract(1, 'days').toDate();
|
||||
statsPms.push(
|
||||
LivechatRooms.getMACStatisticsBetweenDates(yesterday, today).then(([result]) => {
|
||||
statistics.uniqueContactsOfYesterday = result || defaultValue;
|
||||
}),
|
||||
);
|
||||
|
||||
// Message statistics
|
||||
const channels = await Rooms.findByType('c', { projection: { msgs: 1, prid: 1 } }).toArray();
|
||||
const totalChannelDiscussionsMessages = channels.reduce(function _countChannelDiscussionsMessages(num: number, room) {
|
||||
return num + (room.prid ? room.msgs : 0);
|
||||
}, 0);
|
||||
statistics.totalChannelMessages =
|
||||
channels.reduce(function _countChannelMessages(num: number, room) {
|
||||
return num + room.msgs;
|
||||
}, 0) - totalChannelDiscussionsMessages;
|
||||
|
||||
const privateGroups = await Rooms.findByType('p', { projection: { msgs: 1, prid: 1 } }).toArray();
|
||||
const totalPrivateGroupsDiscussionsMessages = privateGroups.reduce(function _countPrivateGroupsDiscussionsMessages(num: number, room) {
|
||||
return num + (room.prid ? room.msgs : 0);
|
||||
}, 0);
|
||||
statistics.totalPrivateGroupMessages =
|
||||
privateGroups.reduce(function _countPrivateGroupMessages(num: number, room) {
|
||||
return num + room.msgs;
|
||||
}, 0) - totalPrivateGroupsDiscussionsMessages;
|
||||
|
||||
statistics.totalDiscussionsMessages = totalPrivateGroupsDiscussionsMessages + totalChannelDiscussionsMessages;
|
||||
|
||||
statistics.totalDirectMessages = (await Rooms.findByType('d', { projection: { msgs: 1 } }).toArray()).reduce(
|
||||
function _countDirectMessages(num: number, room) {
|
||||
return num + room.msgs;
|
||||
},
|
||||
0,
|
||||
);
|
||||
statistics.totalLivechatMessages = (await Rooms.findByType('l', { projection: { msgs: 1 } }).toArray()).reduce(
|
||||
function _countLivechatMessages(num: number, room) {
|
||||
return num + room.msgs;
|
||||
},
|
||||
0,
|
||||
);
|
||||
statistics.totalMessages =
|
||||
statistics.totalChannelMessages +
|
||||
statistics.totalPrivateGroupMessages +
|
||||
statistics.totalDiscussionsMessages +
|
||||
statistics.totalDirectMessages +
|
||||
statistics.totalLivechatMessages;
|
||||
|
||||
statistics.lastLogin = (await Users.getLastLogin())?.toString() || '';
|
||||
statistics.lastMessageSentAt = await Messages.getLastTimestamp();
|
||||
statistics.lastSeenSubscription = (await Subscriptions.getLastSeen())?.toString() || '';
|
||||
|
||||
statistics.os = {
|
||||
type: os.type(),
|
||||
platform: os.platform(),
|
||||
arch: os.arch(),
|
||||
release: os.release(),
|
||||
uptime: os.uptime(),
|
||||
loadavg: os.loadavg(),
|
||||
totalmem: os.totalmem(),
|
||||
freemem: os.freemem(),
|
||||
cpus: os.cpus(),
|
||||
};
|
||||
|
||||
statistics.process = {
|
||||
nodeVersion: process.version,
|
||||
pid: process.pid,
|
||||
uptime: process.uptime(),
|
||||
};
|
||||
|
||||
statistics.deploy = {
|
||||
method: process.env.DEPLOY_METHOD || 'tar',
|
||||
platform: process.env.DEPLOY_PLATFORM || 'selfinstall',
|
||||
};
|
||||
|
||||
statistics.fips = !!crypto.getFips();
|
||||
|
||||
statistics.readReceiptsEnabled = settings.get('Message_Read_Receipt_Enabled');
|
||||
statistics.readReceiptsDetailed = settings.get('Message_Read_Receipt_Store_Users');
|
||||
|
||||
statistics.enterpriseReady = true;
|
||||
statsPms.push(
|
||||
Uploads.estimatedDocumentCount().then((count) => {
|
||||
statistics.uploadsTotal = count;
|
||||
}),
|
||||
);
|
||||
statsPms.push(
|
||||
Uploads.col
|
||||
.aggregate(
|
||||
[
|
||||
{
|
||||
$group: { _id: 'total', total: { $sum: '$size' } },
|
||||
},
|
||||
],
|
||||
{ readPreference },
|
||||
)
|
||||
.toArray()
|
||||
.then((agg) => {
|
||||
const [result] = agg;
|
||||
statistics.uploadsTotalSize = result ? (result as any).total : 0;
|
||||
}),
|
||||
);
|
||||
|
||||
statistics.fileStoreType = settings.get('FileUpload_Storage_Type');
|
||||
|
||||
statistics.migration = await getControl();
|
||||
statsPms.push(
|
||||
InstanceStatus.countDocuments({ _updatedAt: { $gt: new Date(Date.now() - process.uptime() * 1000 - 2000) } }).then((count) => {
|
||||
statistics.instanceCount = count;
|
||||
}),
|
||||
);
|
||||
|
||||
const { mongoVersion, mongoStorageEngine } = await getMongoInfo();
|
||||
statistics.msEnabled = isRunningMs();
|
||||
statistics.oplogEnabled = false;
|
||||
statistics.mongoVersion = mongoVersion;
|
||||
statistics.mongoStorageEngine = mongoStorageEngine || '';
|
||||
|
||||
statsPms.push(
|
||||
Sessions.getUniqueUsersOfYesterday().then((result) => {
|
||||
statistics.uniqueUsersOfYesterday = result;
|
||||
}),
|
||||
);
|
||||
statsPms.push(
|
||||
Sessions.getUniqueUsersOfLastWeek().then((result) => {
|
||||
statistics.uniqueUsersOfLastWeek = result;
|
||||
}),
|
||||
);
|
||||
statsPms.push(
|
||||
Sessions.getUniqueUsersOfLastMonth().then((result) => {
|
||||
statistics.uniqueUsersOfLastMonth = result;
|
||||
}),
|
||||
);
|
||||
statsPms.push(
|
||||
Sessions.getUniqueDevicesOfYesterday().then((result) => {
|
||||
statistics.uniqueDevicesOfYesterday = result;
|
||||
}),
|
||||
);
|
||||
statsPms.push(
|
||||
Sessions.getUniqueDevicesOfLastWeek().then((result) => {
|
||||
statistics.uniqueDevicesOfLastWeek = result;
|
||||
}),
|
||||
);
|
||||
statsPms.push(
|
||||
Sessions.getUniqueDevicesOfLastMonth().then((result) => {
|
||||
statistics.uniqueDevicesOfLastMonth = result;
|
||||
}),
|
||||
);
|
||||
statsPms.push(
|
||||
Sessions.getUniqueOSOfYesterday().then((result) => {
|
||||
statistics.uniqueOSOfYesterday = result;
|
||||
}),
|
||||
);
|
||||
statsPms.push(
|
||||
Sessions.getUniqueOSOfLastWeek().then((result) => {
|
||||
statistics.uniqueOSOfLastWeek = result;
|
||||
}),
|
||||
);
|
||||
statsPms.push(
|
||||
Sessions.getUniqueOSOfLastMonth().then((result) => {
|
||||
statistics.uniqueOSOfLastMonth = result;
|
||||
}),
|
||||
);
|
||||
|
||||
statistics.apps = await getAppsStatistics();
|
||||
statistics.services = await getServicesStatistics();
|
||||
statistics.importer = getImporterStatistics();
|
||||
statistics.videoConf = await VideoConf.getStatistics();
|
||||
statistics.contactVerification = await getContactVerificationStatistics();
|
||||
|
||||
// If getSettingsStatistics() returns an error, save as empty object.
|
||||
statsPms.push(
|
||||
getSettingsStatistics().then((res) => {
|
||||
const settingsStatisticsObject = res || {};
|
||||
statistics.settings = settingsStatisticsObject;
|
||||
}),
|
||||
);
|
||||
|
||||
statsPms.push(
|
||||
Integrations.getStatistics({ readPreference }).then((integrations) => {
|
||||
statistics.integrations = integrations;
|
||||
}),
|
||||
);
|
||||
|
||||
statsPms.push(
|
||||
NotificationQueue.estimatedDocumentCount().then((count) => {
|
||||
statistics.pushQueue = count;
|
||||
}),
|
||||
);
|
||||
|
||||
statsPms.push(
|
||||
getEnterpriseStatistics().then((result) => {
|
||||
statistics.enterprise = result;
|
||||
}),
|
||||
);
|
||||
|
||||
statsPms.push(
|
||||
Team.getStatistics().then((result) => {
|
||||
statistics.teams = result;
|
||||
}),
|
||||
);
|
||||
|
||||
statsPms.push(Analytics.resetSeatRequestCount());
|
||||
|
||||
// TODO: Is that the best way to do this? maybe we should use a promise.all()
|
||||
|
||||
statistics.dashboardCount = settings.get('Engagement_Dashboard_Load_Count');
|
||||
statistics.messageAuditApply = settings.get('Message_Auditing_Apply_Count');
|
||||
statistics.messageAuditLoad = settings.get('Message_Auditing_Panel_Load_Count');
|
||||
statistics.joinJitsiButton = settings.get('Jitsi_Click_To_Join_Count');
|
||||
statistics.slashCommandsJitsi = settings.get('Jitsi_Start_SlashCommands_Count');
|
||||
statistics.totalBroadcastRooms = await Rooms.countByBroadcast({ readPreference });
|
||||
statistics.totalTriggeredEmails = settings.get('Triggered_Emails_Count');
|
||||
statistics.totalRoomsWithStarred = await Messages.countRoomsWithStarredMessages({ readPreference });
|
||||
statistics.totalRoomsWithPinned = await Messages.countRoomsWithPinnedMessages({ readPreference });
|
||||
statistics.totalUserTOTP = await Users.countActiveUsersTOTPEnable({ readPreference });
|
||||
statistics.totalUserEmail2fa = await Users.countActiveUsersEmail2faEnable({ readPreference });
|
||||
statistics.totalPinned = await Messages.countPinned({ readPreference });
|
||||
statistics.totalStarred = await Messages.countStarred({ readPreference });
|
||||
statistics.totalLinkInvitation = await Invites.estimatedDocumentCount();
|
||||
statistics.totalLinkInvitationUses = await Invites.countUses();
|
||||
statistics.totalEmailInvitation = settings.get('Invitation_Email_Count');
|
||||
statistics.totalE2ERooms = await Rooms.countByE2E({ readPreference });
|
||||
statistics.logoChange = Object.keys(settings.get('Assets_logo') || {}).includes('url');
|
||||
statistics.showHomeButton = settings.get('Layout_Show_Home_Button');
|
||||
statistics.totalEncryptedMessages = await Messages.countByType('e2e', { readPreference });
|
||||
statistics.totalManuallyAddedUsers = settings.get('Manual_Entry_User_Count');
|
||||
statistics.totalSubscriptionRoles = await RolesRaw.countByScope('Subscriptions', { readPreference });
|
||||
statistics.totalUserRoles = await RolesRaw.countByScope('Users', { readPreference });
|
||||
statistics.totalCustomRoles = await RolesRaw.countCustomRoles({ readPreference });
|
||||
statistics.totalWebRTCCalls = settings.get('WebRTC_Calls_Count');
|
||||
statistics.uncaughtExceptionsCount = settings.get('Uncaught_Exceptions_Count');
|
||||
|
||||
const defaultGateway = (await Settings.findOneById('Push_gateway', { projection: { packageValue: 1 } }))?.packageValue;
|
||||
|
||||
// Push notification stats
|
||||
// one bit for each of the following:
|
||||
const pushEnabled = settings.get('Push_enable') ? 1 : 0;
|
||||
const pushGatewayEnabled = settings.get('Push_enable_gateway') ? 2 : 0;
|
||||
const pushGatewayChanged = settings.get('Push_gateway') !== defaultGateway ? 4 : 0;
|
||||
|
||||
statistics.push = pushEnabled | pushGatewayEnabled | pushGatewayChanged;
|
||||
statistics.pushSecured = settings.get<boolean>('Push_request_content_from_server');
|
||||
|
||||
const defaultHomeTitle = (await Settings.findOneById('Layout_Home_Title'))?.packageValue;
|
||||
statistics.homeTitleChanged = settings.get('Layout_Home_Title') !== defaultHomeTitle;
|
||||
|
||||
const defaultHomeBody = (await Settings.findOneById('Layout_Home_Body'))?.packageValue;
|
||||
statistics.homeBodyChanged = settings.get('Layout_Home_Body') !== defaultHomeBody;
|
||||
|
||||
const defaultCustomCSS = (await Settings.findOneById('theme-custom-css'))?.packageValue;
|
||||
statistics.customCSSChanged = settings.get('theme-custom-css') !== defaultCustomCSS;
|
||||
|
||||
const defaultOnLogoutCustomScript = (await Settings.findOneById('Custom_Script_On_Logout'))?.packageValue;
|
||||
statistics.onLogoutCustomScriptChanged = settings.get('Custom_Script_On_Logout') !== defaultOnLogoutCustomScript;
|
||||
|
||||
const defaultLoggedOutCustomScript = (await Settings.findOneById('Custom_Script_Logged_Out'))?.packageValue;
|
||||
statistics.loggedOutCustomScriptChanged = settings.get('Custom_Script_Logged_Out') !== defaultLoggedOutCustomScript;
|
||||
|
||||
const defaultLoggedInCustomScript = (await Settings.findOneById('Custom_Script_Logged_In'))?.packageValue;
|
||||
statistics.loggedInCustomScriptChanged = settings.get('Custom_Script_Logged_In') !== defaultLoggedInCustomScript;
|
||||
|
||||
try {
|
||||
statistics.dailyPeakConnections = await Presence.getPeakConnections(true);
|
||||
} catch {
|
||||
statistics.dailyPeakConnections = 0;
|
||||
}
|
||||
|
||||
const peak = await Statistics.findMonthlyPeakConnections();
|
||||
statistics.maxMonthlyPeakConnections = Math.max(statistics.dailyPeakConnections, peak?.dailyPeakConnections || 0);
|
||||
|
||||
statistics.matrixFederation = await getMatrixFederationStatistics();
|
||||
|
||||
// ABAC stats
|
||||
if (License.hasModule('abac')) {
|
||||
statistics.abacEnabled = settings.get('ABAC_Enabled');
|
||||
statsPms.push(
|
||||
AbacAttributes.estimatedDocumentCount().then((result) => {
|
||||
statistics.abacTotalAttributes = result;
|
||||
}),
|
||||
);
|
||||
statsPms.push(
|
||||
AbacAttributes.countTotalValues().then((result) => {
|
||||
statistics.abacTotalAttributeValues = result;
|
||||
}),
|
||||
);
|
||||
statsPms.push(
|
||||
Rooms.countAbacEnabled().then((result) => {
|
||||
statistics.abacRoomsEnrolled = result;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
statistics.allowUnsafeQueryAndFieldsApiParamsEnabled = process.env.ALLOW_UNSAFE_QUERY_AND_FIELDS_API_PARAMS?.toUpperCase() === 'TRUE';
|
||||
|
||||
await Promise.all(statsPms).catch(log);
|
||||
|
||||
return statistics;
|
||||
},
|
||||
async save(): Promise<IStats> {
|
||||
const rcStatistics = await statistics.get();
|
||||
rcStatistics.createdAt = new Date();
|
||||
const { insertedId } = await Statistics.insertOne(rcStatistics);
|
||||
rcStatistics._id = insertedId;
|
||||
|
||||
return rcStatistics;
|
||||
},
|
||||
};
|
||||
24
apps/meteor/server/lib/statistics/lib/telemetryEvents.ts
Normal file
24
apps/meteor/server/lib/statistics/lib/telemetryEvents.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { TelemetryMap, ITelemetryEvent, TelemetryEvents } from '@rocket.chat/core-services';
|
||||
|
||||
type TelemetryEventResponse = Promise<any> | void;
|
||||
type TelemetryEventFunction<T extends TelemetryEvents> = (data: TelemetryMap[T]) => TelemetryEventResponse;
|
||||
|
||||
class TelemetryEvent implements ITelemetryEvent {
|
||||
private events = new Map<string, (...args: any[]) => any>();
|
||||
|
||||
register<T extends TelemetryEvents>(name: T, fn: TelemetryEventFunction<T>): void {
|
||||
this.events.set(name, fn);
|
||||
}
|
||||
|
||||
call<T extends TelemetryEvents>(eventName: T, data: TelemetryMap[T]): TelemetryEventResponse {
|
||||
const fn = this.events.get(eventName) as TelemetryEventFunction<T>;
|
||||
if (!fn) {
|
||||
throw new Error('event not found');
|
||||
}
|
||||
|
||||
return fn(data);
|
||||
}
|
||||
}
|
||||
|
||||
const telemetryEvent = new TelemetryEvent();
|
||||
export default telemetryEvent;
|
||||
22
apps/meteor/server/lib/statistics/startup/monitor.ts
Normal file
22
apps/meteor/server/lib/statistics/startup/monitor.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { settings } from '../../../settings';
|
||||
import { SAUMonitorClass } from '../lib/SAUMonitor';
|
||||
|
||||
const SAUMonitor = new SAUMonitorClass();
|
||||
|
||||
Meteor.startup(() => {
|
||||
let TroubleshootDisableSessionsMonitor: boolean;
|
||||
settings.watch<boolean>('Troubleshoot_Disable_Sessions_Monitor', async (value) => {
|
||||
if (TroubleshootDisableSessionsMonitor === value) {
|
||||
return;
|
||||
}
|
||||
TroubleshootDisableSessionsMonitor = value;
|
||||
|
||||
if (value) {
|
||||
return SAUMonitor.stop();
|
||||
}
|
||||
|
||||
await SAUMonitor.start();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user