Skip to content

Commit

Permalink
chore: saveAnalyticsData with accumulator (RocketChat#32961)
Browse files Browse the repository at this point in the history
Co-authored-by: Guilherme Gazzo <[email protected]>
  • Loading branch information
ricardogarim and ggazzo authored Aug 3, 2024
1 parent a38c342 commit 41b1407
Show file tree
Hide file tree
Showing 4 changed files with 131 additions and 101 deletions.
121 changes: 63 additions & 58 deletions apps/meteor/app/livechat/server/hooks/saveAnalyticsData.ts
Original file line number Diff line number Diff line change
@@ -1,79 +1,84 @@
import { isEditedMessage, isMessageFromVisitor } from '@rocket.chat/core-typings';
import { isEditedMessage } from '@rocket.chat/core-typings';
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
import { LivechatRooms } from '@rocket.chat/models';

import { callbacks } from '../../../../lib/callbacks';
import { normalizeMessageFileUpload } from '../../../utils/server/functions/normalizeMessageFileUpload';

const getMetricValue = <T>(metric: T | undefined, defaultValue: T): T => metric ?? defaultValue;
const calculateTimeDifference = <T extends Date | number>(startTime: T, now: Date): number =>
(now.getTime() - new Date(startTime).getTime()) / 1000;
const calculateAvgResponseTime = (totalResponseTime: number, newResponseTime: number, responseCount: number) =>
(totalResponseTime + newResponseTime) / (responseCount + 1);

const getFirstResponseAnalytics = (
visitorLastQuery: Date,
agentJoinTime: Date,
totalResponseTime: number,
responseCount: number,
now: Date,
) => {
const responseTime = calculateTimeDifference(visitorLastQuery, now);
const reactionTime = calculateTimeDifference(agentJoinTime, now);
const avgResponseTime = calculateAvgResponseTime(totalResponseTime, responseTime, responseCount);

return {
firstResponseDate: now,
firstResponseTime: responseTime,
responseTime,
avgResponseTime,
firstReactionDate: now,
firstReactionTime: reactionTime,
reactionTime,
};
};

const getSubsequentResponseAnalytics = (visitorLastQuery: Date, totalResponseTime: number, responseCount: number, now: Date) => {
const responseTime = calculateTimeDifference(visitorLastQuery, now);
const avgResponseTime = calculateAvgResponseTime(totalResponseTime, responseTime, responseCount);

return {
responseTime,
avgResponseTime,
reactionTime: responseTime,
};
};

const getAnalyticsData = (room: IOmnichannelRoom, now: Date): Record<string, string | number | Date> | undefined => {
const visitorLastQuery = getMetricValue(room.metrics?.v?.lq, room.ts);
const agentLastReply = getMetricValue(room.metrics?.servedBy?.lr, room.ts);
const agentJoinTime = getMetricValue(room.servedBy?.ts, room.ts);
const totalResponseTime = getMetricValue(room.metrics?.response?.tt, 0);
const responseCount = getMetricValue(room.metrics?.response?.total, 0);

if (agentLastReply === room.ts) {
return getFirstResponseAnalytics(visitorLastQuery, agentJoinTime, totalResponseTime, responseCount, now);
}
if (visitorLastQuery > agentLastReply) {
return getSubsequentResponseAnalytics(visitorLastQuery, totalResponseTime, responseCount, now);
}
};

callbacks.add(
'afterOmnichannelSaveMessage',
async (message, { room }) => {
// skips this callback if the message was edited
if (!message || isEditedMessage(message)) {
return message;
}

// if the message has a token, it was sent by the visitor
if (isMessageFromVisitor(message)) {
// When visitor sends a mesage, most metrics wont be calculated/served.
// But, v.lq (last query) will be updated to the message time. This has to be done
// As not doing it will cause the metrics to be crazy and not have real values.
await LivechatRooms.saveAnalyticsDataByRoomId(room, message);
return message;
}

if (message.file) {
message = { ...(await normalizeMessageFileUpload(message)), ...{ _updatedAt: message._updatedAt } };
}

const now = new Date();
let analyticsData;

const visitorLastQuery = room.metrics?.v ? room.metrics.v.lq : room.ts;
const agentLastReply = room.metrics?.servedBy ? room.metrics.servedBy.lr : room.ts;
const agentJoinTime = room.servedBy?.ts ? room.servedBy.ts : room.ts;

const isResponseTt = room.metrics?.response?.tt;
const isResponseTotal = room.metrics?.response?.total;

if (agentLastReply === room.ts) {
// first response
const firstResponseDate = now;
const firstResponseTime = (now.getTime() - new Date(visitorLastQuery).getTime()) / 1000;
const responseTime = (now.getTime() - new Date(visitorLastQuery).getTime()) / 1000;
const avgResponseTime =
((isResponseTt ? room.metrics?.response?.tt : 0) || 0 + responseTime) /
((isResponseTotal ? room.metrics?.response?.total : 0) || 0 + 1);
const analyticsData = getAnalyticsData(room, new Date());
const updater = await LivechatRooms.getAnalyticsUpdateQueryByRoomId(room, message, analyticsData);

const firstReactionDate = now;
const firstReactionTime = (now.getTime() - new Date(agentJoinTime).getTime()) / 1000;
const reactionTime = (now.getTime() - new Date(agentJoinTime).getTime()) / 1000;

analyticsData = {
firstResponseDate,
firstResponseTime,
responseTime,
avgResponseTime,
firstReactionDate,
firstReactionTime,
reactionTime,
};
} else if (visitorLastQuery > agentLastReply) {
// response, not first
const responseTime = (now.getTime() - new Date(visitorLastQuery).getTime()) / 1000;
const avgResponseTime =
((isResponseTt ? room.metrics?.response?.tt : 0) || 0 + responseTime) /
((isResponseTotal ? room.metrics?.response?.total : 0) || 0 + 1);

const reactionTime = (now.getTime() - new Date(visitorLastQuery).getTime()) / 1000;

analyticsData = {
responseTime,
avgResponseTime,
reactionTime,
};
} // ignore, its continuing response
if (updater.hasChanges()) {
await updater.persist({
_id: room._id,
});
}

await LivechatRooms.saveAnalyticsDataByRoomId(room, message, analyticsData);
return message;
},
callbacks.priority.LOW,
Expand Down
100 changes: 60 additions & 40 deletions apps/meteor/server/models/raw/LivechatRooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ import type {
ReportResult,
MACStats,
} from '@rocket.chat/core-typings';
import { UserStatus } from '@rocket.chat/core-typings';
import { isMessageFromVisitor, UserStatus } from '@rocket.chat/core-typings';
import type { ILivechatRoomsModel } from '@rocket.chat/model-typings';
import type { Updater } from '@rocket.chat/models';
import { Settings } from '@rocket.chat/models';
import { escapeRegExp } from '@rocket.chat/string-helpers';
import type {
Expand Down Expand Up @@ -2020,52 +2021,71 @@ export class LivechatRoomsRaw extends BaseRaw<IOmnichannelRoom> implements ILive
);
}

saveAnalyticsDataByRoomId(room: IOmnichannelRoom, message: IMessage, analyticsData: Record<string, string | number | Date>) {
const update: DeepWritable<UpdateFilter<IOmnichannelRoom>> = {
$set: {
...(analyticsData && {
'metrics.response.avg': analyticsData.avgResponseTime,
}),
...(analyticsData?.firstResponseTime && {
'metrics.reaction.fd': analyticsData.firstReactionDate,
'metrics.reaction.ft': analyticsData.firstReactionTime,
'metrics.response.fd': analyticsData.firstResponseDate,
'metrics.response.ft': analyticsData.firstResponseTime,
}),
},
...(analyticsData && {
$inc: {
'metrics.response.total': 1,
'metrics.response.tt': analyticsData.responseTime as number,
'metrics.reaction.tt': analyticsData.reactionTime as number,
},
}),
};
private getAnalyticsUpdateQuery(
analyticsData: Record<string, string | number | Date> | undefined,
updater: Updater<IOmnichannelRoom> = this.getUpdater(),
) {
if (analyticsData) {
updater.set('metrics.response.avg', analyticsData.avgResponseTime);
updater.inc('metrics.response.total', 1);
updater.inc('metrics.response.tt', analyticsData.responseTime as number);
updater.inc('metrics.reaction.tt', analyticsData.reactionTime as number);
}

if (analyticsData?.firstResponseTime) {
updater.set('metrics.reaction.fd', analyticsData.firstReactionDate);
updater.set('metrics.reaction.ft', analyticsData.firstReactionTime);
updater.set('metrics.response.fd', analyticsData.firstResponseDate);
updater.set('metrics.response.ft', analyticsData.firstResponseTime);
}

return updater;
}

private getAnalyticsUpdateQueryBySentByAgent(
room: IOmnichannelRoom,
message: IMessage,
analyticsData: Record<string, string | number | Date> | undefined,
updater: Updater<IOmnichannelRoom> = this.getUpdater(),
) {
// livechat analytics : update last message timestamps
const visitorLastQuery = room.metrics?.v ? room.metrics.v.lq : room.ts;
const agentLastReply = room.metrics?.servedBy ? room.metrics.servedBy.lr : room.ts;

if (message.token) {
// update visitor timestamp, only if its new inquiry and not continuing message
if (agentLastReply >= visitorLastQuery) {
// if first query, not continuing query from visitor
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
update.$set!['metrics.v.lq'] = message.ts;
}
} else if (visitorLastQuery > agentLastReply) {
// update agent timestamp, if first response, not continuing
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
update.$set!['metrics.servedBy.lr'] = message.ts;
if (visitorLastQuery > agentLastReply) {
return this.getAnalyticsUpdateQuery(analyticsData, updater).set('metrics.servedBy.lr', message.ts);
}

return this.updateOne(
{
_id: room._id,
t: 'l',
},
update,
);
return updater;
}

private getAnalyticsUpdateQueryBySentByVisitor(
room: IOmnichannelRoom,
message: IMessage,
analyticsData: Record<string, string | number | Date> | undefined,
updater: Updater<IOmnichannelRoom> = this.getUpdater(),
) {
// livechat analytics : update last message timestamps
const visitorLastQuery = room.metrics?.v ? room.metrics.v.lq : room.ts;
const agentLastReply = room.metrics?.servedBy ? room.metrics.servedBy.lr : room.ts;

// update visitor timestamp, only if its new inquiry and not continuing message
if (agentLastReply >= visitorLastQuery) {
return this.getAnalyticsUpdateQuery(analyticsData).set('metrics.v.lq', message.ts);
}

return updater;
}

async getAnalyticsUpdateQueryByRoomId(
room: IOmnichannelRoom,
message: IMessage,
analyticsData: Record<string, string | number | Date> | undefined,
updater: Updater<IOmnichannelRoom> = this.getUpdater(),
) {
return isMessageFromVisitor(message)
? this.getAnalyticsUpdateQueryBySentByVisitor(room, message, analyticsData, updater)
: this.getAnalyticsUpdateQueryBySentByAgent(room, message, analyticsData, updater);
}

getTotalConversationsBetweenDate(t: 'l', date: { gte: Date; lt: Date }, { departmentId }: { departmentId?: string } = {}) {
Expand Down
3 changes: 3 additions & 0 deletions packages/core-typings/src/IRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,9 +275,12 @@ export interface IOmnichannelRoom extends IOmnichannelGenericRoom {
total: number;
avg: number;
ft: number;
fd?: number;
};
reaction?: {
tt: number;
ft: number;
fd?: number;
};
};

Expand Down
8 changes: 5 additions & 3 deletions packages/model-typings/src/models/ILivechatRoomsModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
import type { FindCursor, UpdateResult, AggregationCursor, Document, FindOptions, DeleteResult, Filter } from 'mongodb';

import type { FindPaginated } from '..';
import type { Updater } from '../updater';
import type { IBaseModel } from './IBaseModel';

type Period = {
Expand Down Expand Up @@ -208,11 +209,12 @@ export interface ILivechatRoomsModel extends IBaseModel<IOmnichannelRoom> {
setResponseByRoomId(roomId: string, responseBy: IOmnichannelRoom['responseBy']): Promise<UpdateResult>;
setNotResponseByRoomId(roomId: string): Promise<UpdateResult>;
setAgentLastMessageTs(roomId: string): Promise<UpdateResult>;
saveAnalyticsDataByRoomId(
getAnalyticsUpdateQueryByRoomId(
room: IOmnichannelRoom,
message: IMessage,
analyticsData?: Record<string, string | number | Date>,
): Promise<UpdateResult>;
analyticsData: Record<string, string | number | Date> | undefined,
updater?: Updater<IOmnichannelRoom>,
): Promise<Updater<IOmnichannelRoom>>;
getTotalConversationsBetweenDate(t: 'l', date: { gte: Date; lt: Date }, data?: { departmentId: string }): Promise<number>;
getAnalyticsMetricsBetweenDate(
t: 'l',
Expand Down

0 comments on commit 41b1407

Please sign in to comment.