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

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -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();
}

View 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,
};
}

View File

@@ -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;
});
});

View File

@@ -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);
}
});
}

View File

@@ -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);

View File

@@ -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);

View 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;
}
};

View 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';

View 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);
}
}

View 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,
},
};
},
};

View 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 });

View File

@@ -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'),
};
}

View 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;
}

View File

@@ -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'),
};
}

View 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(),
},
};
}

View 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;
},
};

View 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;

View 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();
});
});