From eb1096ce54a0373e2a41ea4047155014b12a2e15 Mon Sep 17 00:00:00 2001 From: empty Date: Sat, 27 Dec 2025 03:08:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=9B=86=E6=88=90=E9=98=BF=E9=87=8C?= =?UTF-8?q?=E4=BA=91=E6=97=A5=E5=BF=97=E6=9C=8D=E5=8A=A1(SLS)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 aliyun-log SDK 依赖 - 新增 sls-logger.js 模块,支持批量日志上报、静默降级 - 在四个 API 处理函数中集成请求日志记录 - 更新 .env.example 添加 SLS 配置示例 --- .env.example | 7 ++ package-lock.json | 173 +++++++++++++++++++++++++++++++++++++++++++++- package.json | 11 ++- routes.js | 71 +++++++++++-------- sls-logger.js | 149 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 379 insertions(+), 32 deletions(-) create mode 100644 sls-logger.js diff --git a/.env.example b/.env.example index df36a41..983f86d 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,10 @@ FACTORY_API_KEY=your_factory_api_key_here # 方式2:使用refresh token自动刷新(次优先级) DROID_REFRESH_KEY=your_refresh_token_here + +# 阿里云日志服务配置 +ALIYUN_ACCESS_KEY_ID=your_access_key_id +ALIYUN_ACCESS_KEY_SECRET=your_access_key_secret +ALIYUN_SLS_ENDPOINT=cn-hangzhou.log.aliyuncs.com +ALIYUN_SLS_PROJECT=your_project_name +ALIYUN_SLS_LOGSTORE=your_logstore_name diff --git a/package-lock.json b/package-lock.json index cdfedc5..03a2514 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,99 @@ { "name": "droid2api", - "version": "1.3.5", + "version": "1.3.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "droid2api", - "version": "1.3.5", + "version": "1.3.7", "license": "MIT", "dependencies": { + "aliyun-log": "github:aliyun/aliyun-log-nodejs-sdk", "express": "^4.18.2", "https-proxy-agent": "^7.0.2", "node-fetch": "^3.3.2" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.27", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz", + "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz", @@ -36,6 +116,18 @@ "node": ">= 14" } }, + "node_modules/aliyun-log": { + "name": "@alicloud/log", + "version": "1.2.6", + "resolved": "git+ssh://git@github.com/aliyun/aliyun-log-nodejs-sdk.git#f5c2ab9cf5e0c7d3edd2fa1a15fc7f0a9946cd05", + "license": "MIT", + "dependencies": { + "debug": "^2.6.8", + "httpx": "^2.1.2", + "kitx": "^1.2.1", + "protobufjs": "^6.8.8" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/array-flatten/-/array-flatten-1.1.1.tgz", @@ -502,6 +594,39 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/httpx": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/httpx/-/httpx-2.3.3.tgz", + "integrity": "sha512-k1qv94u1b6e+XKCxVbLgYlOypVP9MPGpnN5G/vxFf6tDO4V3xpz3d6FUOY/s8NtPgaq5RBVVgSB+7IHpVxMYzw==", + "license": "MIT", + "dependencies": { + "@types/node": "^20", + "debug": "^4.1.1" + } + }, + "node_modules/httpx/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/httpx/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -529,6 +654,18 @@ "node": ">= 0.10" } }, + "node_modules/kitx": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/kitx/-/kitx-1.3.0.tgz", + "integrity": "sha512-fhBqFlXd0GkKTB+8ayLfpzPUw+LHxZlPAukPNBD1Om7JMeInT+/PxCAf1yLagvD+VKoyWhXtJR68xQkX/a0wOQ==", + "license": "MIT" + }, + "node_modules/long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", + "license": "Apache-2.0" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -690,6 +827,32 @@ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, + "node_modules/protobufjs": { + "version": "6.11.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.4.tgz", + "integrity": "sha512-5kQWPaJHi1WoCpjTGszzQ32PG2F4+wRY6BmAT4Vfw56Q2FZ4YZzK20xUYQH4YkfehY1e6QSICrJquM6xXZNcrw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/long": "^4.0.1", + "@types/node": ">=13.7.0", + "long": "^4.0.0" + }, + "bin": { + "pbjs": "bin/pbjs", + "pbts": "bin/pbts" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -931,6 +1094,12 @@ "node": ">= 0.6" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz", diff --git a/package.json b/package.json index 3cb915c..dc14d0c 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,17 @@ "start": "node server.js", "dev": "node server.js" }, - "keywords": ["openai", "api", "proxy"], + "keywords": [ + "openai", + "api", + "proxy" + ], "author": "", "license": "MIT", "dependencies": { "express": "^4.18.2", "https-proxy-agent": "^7.0.2", - "node-fetch": "^3.3.2" + "node-fetch": "^3.3.2", + "aliyun-log": "github:aliyun/aliyun-log-nodejs-sdk" } -} +} \ No newline at end of file diff --git a/routes.js b/routes.js index 8574a29..b5f80a2 100644 --- a/routes.js +++ b/routes.js @@ -9,6 +9,7 @@ import { AnthropicResponseTransformer } from './transformers/response-anthropic. import { OpenAIResponseTransformer } from './transformers/response-openai.js'; import { getApiKey } from './auth.js'; import { getNextProxyAgent } from './proxy-manager.js'; +import { logRequest as slsLogRequest } from './sls-logger.js'; const router = express.Router(); @@ -52,7 +53,7 @@ function convertResponseToChatCompletion(resp) { router.get('/v1/models', (req, res) => { logInfo('GET /v1/models'); - + try { const config = getConfig(); const models = config.models.map(model => ({ @@ -81,6 +82,7 @@ router.get('/v1/models', (req, res) => { // 标准 OpenAI 聊天补全处理函数(带格式转换) async function handleChatCompletions(req, res) { logInfo('POST /v1/chat/completions'); + const startTime = Date.now(); try { const openaiRequest = req.body; @@ -108,7 +110,7 @@ async function handleChatCompletions(req, res) { authHeader = await getApiKey(req.headers.authorization); } catch (error) { logError('Failed to get API key', error); - return res.status(500).json({ + return res.status(500).json({ error: 'API key not available', message: 'Failed to get or refresh API key. Please check server logs.' }); @@ -166,9 +168,9 @@ async function handleChatCompletions(req, res) { if (!response.ok) { const errorText = await response.text(); logError(`Endpoint error: ${response.status}`, new Error(errorText)); - return res.status(response.status).json({ + return res.status(response.status).json({ error: `Endpoint returned ${response.status}`, - details: errorText + details: errorText }); } @@ -187,6 +189,7 @@ async function handleChatCompletions(req, res) { } res.end(); logInfo('Stream forwarded (common type)'); + slsLogRequest({ method: 'POST', endpoint: '/v1/chat/completions', model: modelId, status: 200, duration_ms: Date.now() - startTime }); } catch (streamError) { logError('Stream error', streamError); res.end(); @@ -206,6 +209,7 @@ async function handleChatCompletions(req, res) { } res.end(); logInfo('Stream completed'); + slsLogRequest({ method: 'POST', endpoint: '/v1/chat/completions', model: modelId, status: 200, duration_ms: Date.now() - startTime }); } catch (streamError) { logError('Stream error', streamError); res.end(); @@ -228,13 +232,15 @@ async function handleChatCompletions(req, res) { logResponse(200, null, data); res.json(data); } + slsLogRequest({ method: 'POST', endpoint: '/v1/chat/completions', model: modelId, status: 200, duration_ms: Date.now() - startTime }); } } catch (error) { logError('Error in /v1/chat/completions', error); - res.status(500).json({ + slsLogRequest({ method: 'POST', endpoint: '/v1/chat/completions', model: req.body?.model, status: 500, duration_ms: Date.now() - startTime, error: error.message }); + res.status(500).json({ error: 'Internal server error', - message: error.message + message: error.message }); } } @@ -242,6 +248,7 @@ async function handleChatCompletions(req, res) { // 直接转发 OpenAI 请求(不做格式转换) async function handleDirectResponses(req, res) { logInfo('POST /v1/responses'); + const startTime = Date.now(); try { const openaiRequest = req.body; @@ -258,7 +265,7 @@ async function handleDirectResponses(req, res) { // 只允许 openai 类型端点 if (model.type !== 'openai') { - return res.status(400).json({ + return res.status(400).json({ error: 'Invalid endpoint type', message: `/v1/responses 接口只支持 openai 类型端点,当前模型 ${modelId} 是 ${model.type} 类型` }); @@ -280,17 +287,17 @@ async function handleDirectResponses(req, res) { authHeader = await getApiKey(req.headers.authorization || clientAuthFromXApiKey); } catch (error) { logError('Failed to get API key', error); - return res.status(500).json({ + return res.status(500).json({ error: 'API key not available', message: 'Failed to get or refresh API key. Please check server logs.' }); } const clientHeaders = req.headers; - + // Get provider from model config const provider = getModelProvider(modelId); - + // 获取 headers const headers = getOpenAIHeaders(authHeader, clientHeaders, provider); @@ -340,8 +347,8 @@ async function handleDirectResponses(req, res) { if (claudeCodeTools.includes(tool.name)) return false; // 过滤所有 mcp__ 开头的工具和 MCP 相关工具 return !tool.name.startsWith('mcp__') && - !tool.name.includes('Mcp') && - !tool.name.includes('MCP'); + !tool.name.includes('Mcp') && + !tool.name.includes('MCP'); }); } @@ -366,9 +373,9 @@ async function handleDirectResponses(req, res) { if (!response.ok) { const errorText = await response.text(); logError(`Endpoint error: ${response.status}`, new Error(errorText)); - return res.status(response.status).json({ + return res.status(response.status).json({ error: `Endpoint returned ${response.status}`, - details: errorText + details: errorText }); } @@ -387,6 +394,7 @@ async function handleDirectResponses(req, res) { } res.end(); logInfo('Stream forwarded successfully'); + slsLogRequest({ method: 'POST', endpoint: '/v1/responses', model: modelId, status: 200, duration_ms: Date.now() - startTime }); } catch (streamError) { logError('Stream error', streamError); res.end(); @@ -396,13 +404,15 @@ async function handleDirectResponses(req, res) { const data = await response.json(); logResponse(200, null, data); res.json(data); + slsLogRequest({ method: 'POST', endpoint: '/v1/responses', model: modelId, status: 200, duration_ms: Date.now() - startTime }); } } catch (error) { logError('Error in /v1/responses', error); - res.status(500).json({ + slsLogRequest({ method: 'POST', endpoint: '/v1/responses', model: req.body?.model, status: 500, duration_ms: Date.now() - startTime, error: error.message }); + res.status(500).json({ error: 'Internal server error', - message: error.message + message: error.message }); } } @@ -410,6 +420,7 @@ async function handleDirectResponses(req, res) { // 直接转发 Anthropic 请求(不做格式转换) async function handleDirectMessages(req, res) { logInfo('POST /v1/messages'); + const startTime = Date.now(); try { const anthropicRequest = req.body; @@ -426,7 +437,7 @@ async function handleDirectMessages(req, res) { // 只允许 anthropic 类型端点 if (model.type !== 'anthropic') { - return res.status(400).json({ + return res.status(400).json({ error: 'Invalid endpoint type', message: `/v1/messages 接口只支持 anthropic 类型端点,当前模型 ${modelId} 是 ${model.type} 类型` }); @@ -448,17 +459,17 @@ async function handleDirectMessages(req, res) { authHeader = await getApiKey(req.headers.authorization || clientAuthFromXApiKey); } catch (error) { logError('Failed to get API key', error); - return res.status(500).json({ + return res.status(500).json({ error: 'API key not available', message: 'Failed to get or refresh API key. Please check server logs.' }); } const clientHeaders = req.headers; - + // Get provider from model config const provider = getModelProvider(modelId); - + // 获取 headers const isStreaming = anthropicRequest.stream === true; const headers = getAnthropicHeaders(authHeader, clientHeaders, isStreaming, modelId, provider); @@ -519,7 +530,7 @@ async function handleDirectMessages(req, res) { 'high': 24576, 'xhigh': 40960 }; - + modifiedRequest.thinking = { type: 'enabled', budget_tokens: budgetTokens[reasoningLevel] @@ -575,8 +586,8 @@ async function handleDirectMessages(req, res) { if (claudeCodeTools.includes(tool.name)) return false; // 过滤所有 mcp__ 开头的工具和 MCP 相关工具 return !tool.name.startsWith('mcp__') && - !tool.name.includes('Mcp') && - !tool.name.includes('MCP'); + !tool.name.includes('Mcp') && + !tool.name.includes('MCP'); }); } @@ -601,9 +612,9 @@ async function handleDirectMessages(req, res) { if (!response.ok) { const errorText = await response.text(); logError(`Endpoint error: ${response.status}`, new Error(errorText)); - return res.status(response.status).json({ + return res.status(response.status).json({ error: `Endpoint returned ${response.status}`, - details: errorText + details: errorText }); } @@ -620,6 +631,7 @@ async function handleDirectMessages(req, res) { } res.end(); logInfo('Stream forwarded successfully'); + slsLogRequest({ method: 'POST', endpoint: '/v1/messages', model: modelId, status: 200, duration_ms: Date.now() - startTime }); } catch (streamError) { logError('Stream error', streamError); res.end(); @@ -629,10 +641,12 @@ async function handleDirectMessages(req, res) { const data = await response.json(); logResponse(200, null, data); res.json(data); + slsLogRequest({ method: 'POST', endpoint: '/v1/messages', model: modelId, status: 200, duration_ms: Date.now() - startTime }); } } catch (error) { logError('Error in /v1/messages', error); + slsLogRequest({ method: 'POST', endpoint: '/v1/messages', model: req.body?.model, status: 500, duration_ms: Date.now() - startTime, error: error.message }); res.status(500).json({ error: 'Internal server error', message: error.message @@ -643,6 +657,7 @@ async function handleDirectMessages(req, res) { // 处理 Anthropic count_tokens 请求 async function handleCountTokens(req, res) { logInfo('POST /v1/messages/count_tokens'); + const startTime = Date.now(); try { const anthropicRequest = req.body; @@ -686,10 +701,10 @@ async function handleCountTokens(req, res) { } const clientHeaders = req.headers; - + // Get provider from model config const provider = getModelProvider(modelId); - + const headers = getAnthropicHeaders(authHeader, clientHeaders, false, modelId, provider); // 构建 count_tokens 端点 URL @@ -728,9 +743,11 @@ async function handleCountTokens(req, res) { const data = await response.json(); logResponse(200, null, data); res.json(data); + slsLogRequest({ method: 'POST', endpoint: '/v1/messages/count_tokens', model: modelId, status: 200, duration_ms: Date.now() - startTime }); } catch (error) { logError('Error in /v1/messages/count_tokens', error); + slsLogRequest({ method: 'POST', endpoint: '/v1/messages/count_tokens', model: req.body?.model, status: 500, duration_ms: Date.now() - startTime, error: error.message }); res.status(500).json({ error: 'Internal server error', message: error.message diff --git a/sls-logger.js b/sls-logger.js new file mode 100644 index 0000000..0bf6555 --- /dev/null +++ b/sls-logger.js @@ -0,0 +1,149 @@ +/** + * 阿里云日志服务(SLS)日志模块 + * + * 功能: + * - 将 API 请求/响应日志上报到阿里云 SLS + * - 批量上报,减少 API 调用 + * - 环境变量缺失时静默降级 + */ + +import aliyunLog from 'aliyun-log'; +const { Client, PutLogsRequest, LogItem, LogContent } = aliyunLog; + +// SLS 配置 +const SLS_CONFIG = { + accessKeyId: process.env.ALIYUN_ACCESS_KEY_ID, + accessKeySecret: process.env.ALIYUN_ACCESS_KEY_SECRET, + endpoint: process.env.ALIYUN_SLS_ENDPOINT, + project: process.env.ALIYUN_SLS_PROJECT, + logstore: process.env.ALIYUN_SLS_LOGSTORE +}; + +// 检查配置是否完整 +const isConfigured = Object.values(SLS_CONFIG).every(v => v); + +let client = null; +let logQueue = []; +const BATCH_SIZE = 10; +const FLUSH_INTERVAL_MS = 5000; + +// 初始化 SLS Client +function initClient() { + if (!isConfigured) { + console.warn('[SLS] 阿里云日志服务未配置,日志将仅输出到控制台'); + return null; + } + + try { + client = new Client({ + accessKeyId: SLS_CONFIG.accessKeyId, + accessKeySecret: SLS_CONFIG.accessKeySecret, + endpoint: SLS_CONFIG.endpoint + }); + console.log('[SLS] 阿里云日志服务客户端初始化成功'); + return client; + } catch (error) { + console.error('[SLS] 初始化失败:', error.message); + return null; + } +} + +// 刷新日志队列 +async function flushLogs() { + if (!client || logQueue.length === 0) return; + + const logsToSend = logQueue.splice(0, BATCH_SIZE); + + try { + const logItems = logsToSend.map(log => { + const contents = Object.entries(log).map(([key, value]) => { + return new LogContent({ + key, + value: String(value ?? '') + }); + }); + return new LogItem({ + time: Math.floor(Date.now() / 1000), + contents + }); + }); + + const request = new PutLogsRequest({ + projectName: SLS_CONFIG.project, + logStoreName: SLS_CONFIG.logstore, + logGroup: { + logs: logItems + } + }); + + await client.putLogs(request); + console.log(`[SLS] 成功上报 ${logsToSend.length} 条日志`); + } catch (error) { + console.error('[SLS] 日志上报失败:', error.message); + // 失败的日志重新入队(可选:限制重试次数) + logQueue.unshift(...logsToSend); + } +} + +// 定时刷新 +let flushTimer = null; +function startFlushTimer() { + if (flushTimer || !isConfigured) return; + flushTimer = setInterval(flushLogs, FLUSH_INTERVAL_MS); +} + +/** + * 记录 API 请求日志 + * @param {Object} logData - 日志数据 + * @param {string} logData.method - HTTP 方法 + * @param {string} logData.endpoint - 请求路径 + * @param {string} logData.model - 模型 ID + * @param {number} logData.status - 响应状态码 + * @param {number} logData.duration_ms - 请求耗时 + * @param {number} [logData.input_tokens] - 输入 Token 数 + * @param {number} [logData.output_tokens] - 输出 Token 数 + * @param {string} [logData.error] - 错误信息 + */ +export function logRequest(logData) { + const enrichedLog = { + timestamp: new Date().toISOString(), + ...logData + }; + + // 始终输出到控制台 + console.log('[SLS]', JSON.stringify(enrichedLog)); + + if (!isConfigured) return; + + logQueue.push(enrichedLog); + + // 队列满时立即刷新 + if (logQueue.length >= BATCH_SIZE) { + flushLogs(); + } +} + +/** + * 优雅关闭,刷新剩余日志 + */ +export async function shutdown() { + if (flushTimer) { + clearInterval(flushTimer); + flushTimer = null; + } + await flushLogs(); + console.log('[SLS] 已关闭'); +} + +// 初始化 +initClient(); +startFlushTimer(); + +// 进程退出时优雅关闭 +process.on('SIGTERM', shutdown); +process.on('SIGINT', shutdown); + +export default { + logRequest, + shutdown +};