Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: saveAnalyticsData with accumulator #32961

Merged
merged 6 commits into from
Aug 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading