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