You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
180 lines
8.7 KiB
180 lines
8.7 KiB
'use strict';
|
|
|
|
const crypto = require('crypto');
|
|
const superagent = require('superagent');
|
|
const { v4: uuidv4 } = require('uuid');
|
|
const { collectResponseDataNodes, unwrapResponseData } = require('../utils/fastgptResponseData');
|
|
|
|
const stableStringify = (value) => {
|
|
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
|
|
if (value && typeof value === 'object') return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`;
|
|
return JSON.stringify(value);
|
|
};
|
|
|
|
const getNumber = (value) => Math.max(0, Number.parseInt(value, 10) || 0);
|
|
const getText = (value, max = 128) => String(value ?? '').trim().slice(0, max);
|
|
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
|
|
|
const resolveApiKeyId = (config, appKey) => {
|
|
const value = String(appKey || '').trim();
|
|
if (!value) return '';
|
|
try {
|
|
const mapping = typeof config?.analytics?.fastgptKeyIds === 'string'
|
|
? JSON.parse(config.analytics.fastgptKeyIds)
|
|
: config?.analytics?.fastgptKeyIds || {};
|
|
return getText(mapping[sha256(value)]);
|
|
} catch (error) {
|
|
return '';
|
|
}
|
|
};
|
|
|
|
const resolveContextUserId = (ctx, fallbackUserId = '') => {
|
|
const user = ctx?.fs?.curUser?.userInfo || {};
|
|
return getText(
|
|
fallbackUserId || ctx?.fs?.userIdMapping?.internalUserId || user.id ||
|
|
user.pepUserId || user.pepId || ctx?.request?.body?.userId ||
|
|
ctx?.request?.body?.creator || ctx?.request?.query?.userId,
|
|
64,
|
|
);
|
|
};
|
|
|
|
const buildCallCompletedEvent = ({ eventId, applicationId, userId, traceId, occurredAt, durationMs = 0, apiKeyId = '', dataQuality = 'server_action' }) => ({
|
|
eventId: getText(eventId),
|
|
eventType: 'call_completed',
|
|
status: 'success',
|
|
occurredAt: occurredAt || new Date().toISOString(),
|
|
traceId: getText(traceId || eventId),
|
|
applicationId: getText(applicationId),
|
|
userId: getText(userId, 64),
|
|
apiKeyId: getText(apiKeyId),
|
|
durationMs: getNumber(durationMs),
|
|
dataQuality,
|
|
});
|
|
|
|
const getEventSuffix = (value) => sha256(value).slice(0, 20);
|
|
|
|
const buildFastgptEvents = ({ responseData, applicationId, userId, traceId, invocationId, occurredAt, apiKeyId = '' }) => {
|
|
const root = unwrapResponseData(responseData);
|
|
const requestId = getText(root.chatItemDataId || root.dataId || root.id, 128);
|
|
const effectiveInvocationId = getText(invocationId || traceId || uuidv4(), 128);
|
|
const effectiveTraceId = getText(traceId || requestId || effectiveInvocationId, 128);
|
|
const eventScope = getEventSuffix(stableStringify({
|
|
applicationId: getText(applicationId), userId: getText(userId, 64),
|
|
traceId: effectiveTraceId, requestId, invocationId: effectiveInvocationId,
|
|
}));
|
|
const rootEventId = `fastgpt:${eventScope}:call`;
|
|
const events = [{
|
|
eventId: rootEventId,
|
|
eventType: 'call_completed',
|
|
status: 'success',
|
|
occurredAt,
|
|
traceId: effectiveTraceId,
|
|
applicationId,
|
|
userId,
|
|
apiKeyId,
|
|
durationMs: getNumber(root.runningTime || root.durationMs),
|
|
dataQuality: 'fastgpt_detail',
|
|
}];
|
|
const nodes = collectResponseDataNodes(root);
|
|
const tokenNodes = nodes.filter((node) => node.kind === 'model' || node.kind === 'embedding');
|
|
tokenNodes.forEach((node, index) => {
|
|
const nodeSuffix = getEventSuffix(`${index}|${node.path}|${node.nodeId}|${node.model}`);
|
|
events.push({
|
|
eventId: `fastgpt:${eventScope}:usage:${nodeSuffix}`, eventType: 'model_usage', status: 'success', occurredAt,
|
|
traceId: effectiveTraceId, nodeId: node.nodeId, applicationId, userId, model: node.model,
|
|
apiKeyId,
|
|
inputTokens: node.inputTokens, outputTokens: node.outputTokens, totalTokens: node.totalTokens, dataQuality: 'fastgpt_detail',
|
|
});
|
|
});
|
|
nodes.filter((node) => node.kind === 'rag' || node.ragQueryCount).forEach((node, index) => {
|
|
const nodeSuffix = getEventSuffix(`${index}|${node.path}|${node.nodeId}|${node.knowledgeBaseId}`);
|
|
events.push({
|
|
eventId: `fastgpt:${eventScope}:rag:${nodeSuffix}`, eventType: 'knowledge_retrieval', status: 'success', occurredAt,
|
|
traceId: effectiveTraceId, nodeId: node.nodeId, applicationId, userId,
|
|
apiKeyId,
|
|
knowledgeBaseId: node.knowledgeBaseId, knowledgeBaseName: node.knowledgeBaseName,
|
|
ragQueryCount: node.ragQueryCount, ragHitCount: node.ragHitCount, dataQuality: 'fastgpt_detail',
|
|
});
|
|
});
|
|
return events;
|
|
};
|
|
|
|
const formatReportContext = (context = {}) => Object.entries(context)
|
|
.filter(([, value]) => value !== undefined && value !== null && value !== '')
|
|
.map(([key, value]) => `${key}=${getText(value, 128)}`)
|
|
.join(' ');
|
|
|
|
const reportUsage = async (conf, events, logger, reportContext = {}) => {
|
|
const centerUrl = String(conf?.analytics?.centerUrl || process.env.AI_CENTER_URL || '').replace(/\/$/, '');
|
|
const keyId = String(conf?.analytics?.keyId || process.env.ANALYTICS_HMAC_KEY_ID || '').trim();
|
|
const secret = String(conf?.analytics?.secret || process.env.ANALYTICS_HMAC_SECRET || '').trim();
|
|
const context = formatReportContext(reportContext);
|
|
if (!events.length) return false;
|
|
if (!centerUrl || !keyId || !secret) {
|
|
logger?.warn?.(`[dashboardReporter] report skipped: missing analytics configuration ${context}`.trim());
|
|
return false;
|
|
}
|
|
const body = { events };
|
|
const timestamp = String(Math.floor(Date.now() / 1000));
|
|
const nonce = uuidv4();
|
|
const bodyHash = sha256(stableStringify(body));
|
|
const canonical = `${timestamp}\n${nonce}\nPOST\n/analytics/token-usage/report\n${bodyHash}`;
|
|
const signature = crypto.createHmac('sha256', secret).update(canonical).digest('hex');
|
|
try {
|
|
await superagent.post(`${centerUrl}/analytics/token-usage/report`).set({
|
|
'Content-Type': 'application/json', 'X-Analytics-Key-Id': keyId,
|
|
'X-Analytics-Timestamp': timestamp, 'X-Analytics-Nonce': nonce, 'X-Analytics-Signature': signature,
|
|
}).send(body).timeout(10000);
|
|
return true;
|
|
} catch (error) {
|
|
logger?.warn?.(`[dashboardReporter] report failed: ${error.message} ${context}`.trim());
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const reportBusinessCall = async ({ ctx, applicationId, eventId, userId, traceId, occurredAt, durationMs = 0, reportContext = {} }) => {
|
|
const resolvedUserId = resolveContextUserId(ctx, userId);
|
|
if (!resolvedUserId || !applicationId || !eventId) {
|
|
ctx?.logger?.warn?.(`[dashboardReporter] business call skipped: missing ${!resolvedUserId ? 'userId' : !applicationId ? 'applicationId' : 'eventId'} applicationId=${getText(applicationId)} ${formatReportContext(reportContext)}`.trim());
|
|
return false;
|
|
}
|
|
return reportUsage(ctx?.app?.fs?.config, [buildCallCompletedEvent({
|
|
eventId, applicationId, userId: resolvedUserId, traceId, occurredAt, durationMs,
|
|
})], ctx?.logger, reportContext);
|
|
};
|
|
|
|
const reportFastgptResponse = async ({ ctx, applicationId, actionId, responseBody, appKey, userId, includeCall = true, occurredAt, reportContext = {} }) => {
|
|
const resolvedUserId = resolveContextUserId(ctx, userId);
|
|
if (!resolvedUserId || !applicationId || !actionId || !responseBody) {
|
|
ctx?.logger?.warn?.(`[dashboardReporter] FastGPT report skipped: missing ${!resolvedUserId ? 'userId' : !applicationId ? 'applicationId' : !actionId ? 'actionId' : 'responseBody'} applicationId=${getText(applicationId)} ${formatReportContext(reportContext)}`.trim());
|
|
return false;
|
|
}
|
|
const events = buildFastgptEvents({
|
|
responseData: responseBody?.responseData || responseBody,
|
|
applicationId,
|
|
userId: resolvedUserId,
|
|
traceId: actionId,
|
|
invocationId: actionId,
|
|
occurredAt: occurredAt || new Date().toISOString(),
|
|
apiKeyId: resolveApiKeyId(ctx?.app?.fs?.config, appKey),
|
|
});
|
|
const reportEvents = includeCall ? events : events.filter((event) => event.eventType !== 'call_completed');
|
|
if (!reportEvents.some((event) => event.eventType === 'model_usage')) {
|
|
ctx?.logger?.warn?.(`[dashboardReporter] FastGPT response has no usage nodes applicationId=${getText(applicationId)} ${formatReportContext(reportContext)}`.trim());
|
|
}
|
|
return reportUsage(ctx?.app?.fs?.config, reportEvents, ctx?.logger, reportContext);
|
|
};
|
|
|
|
const dashboardReporterService = (app) => {
|
|
app.fs = app.fs || {};
|
|
app.fs.dashboardReporter = dashboardReporterService;
|
|
};
|
|
|
|
dashboardReporterService.buildFastgptEvents = buildFastgptEvents;
|
|
dashboardReporterService.buildCallCompletedEvent = buildCallCompletedEvent;
|
|
dashboardReporterService.reportUsage = reportUsage;
|
|
dashboardReporterService.reportBusinessCall = reportBusinessCall;
|
|
dashboardReporterService.reportFastgptResponse = reportFastgptResponse;
|
|
dashboardReporterService.resolveApiKeyId = resolveApiKeyId;
|
|
dashboardReporterService.resolveContextUserId = resolveContextUserId;
|
|
module.exports = dashboardReporterService;
|
|
|