跳到主要内容

用 manager-node 拉云函数日志,失败统一推企业微信

一句话定义:用一个监控用的云函数,定时调 @cloudbase/manager-nodegetFunctionLogsV2 拉每个目标函数过去 5 分钟的调用日志,把 RetCode != 200 的视为失败,过阈值 + 去重后推送到企业微信群机器人。

预计耗时:40 分钟 | 难度:进阶

适用场景

这篇和 batch 里前两篇运维 recipe 是不同切面,先把分工讲清:

场景recipe
怎么把消息推到企业微信(基础推送层)connect-wecom-webhook-cloud-function
单个 cron 任务自己 try/catch,失败时告警schedule-cloud-function-cron-job 第五步
从外部主动拉所有函数的日志,做全局错误监控本篇

适用:

  • 想要一个「全局监视器」,不用每个云函数都改代码就能监控错误
  • 已经有企业微信群,熟悉 webhook 推送流程
  • 函数数量在十几个到几十个之间(几百个的话需要再做分组)

不适用:

  • 单个函数自己处理失败逻辑就够了的场景。改自己的 try/catch 比拉日志成本低
  • 需要秒级实时告警。本方案是分钟级轮询,不是流式
  • 想监控函数性能 / 慢日志。这篇只看 RetCode,慢日志用控制台或 searchClsLog 单独查

环境要求

依赖版本
@cloudbase/manager-node5.0.0
@cloudbase/node-sdk3.18.1(写去重状态用)
@cloudbase/clilatest
Node.js(云函数运行时)16.13

另外需要:

  • 已部署 connect-wecom-webhook-cloud-function,企业微信 webhook 已通
  • 已配置过 schedule-cloud-function-cron-job,熟悉 7 字段 cron 表达式
  • 在腾讯云控制台「访问管理 / API 密钥」创建一对 secretId / secretKey,这是 manager-node 鉴权用的,和云开发的 apiKey 不是一回事
  • 控制台 → 云开发 → 高级 → CLS 日志服务 已开启(没开的话先 app.log.createLogService() 一次)

第一步:定位 manager-node 的日志接口

manager-node 提供两条路:

  • functions.getFunctionLogsV2({ name, startTime, endTime }):按函数名拉调用日志列表,返回每条调用的 RequestId / RetCode / StartTime。要看具体日志正文还要再调一次 getFunctionLogDetail
  • app.log.searchClsLog({ queryString, ... }):走 CLS 日志服务的搜索,可以跨函数检索,适合复杂条件

本篇用第一种方式,逻辑直观,适合按函数轮询的场景。完整签名见 getFunctionLogsV2

第二步:写监控函数

新建 cloudfunctions/monitorFnErrors,目录里两个文件。

cloudfunctions/monitorFnErrors/package.json:

{
"name": "monitorFnErrors",
"version": "1.0.0",
"main": "index.js",
"dependencies": {
"@cloudbase/manager-node": "^5.0.0",
"@cloudbase/node-sdk": "^3.18.1"
}
}

cloudfunctions/monitorFnErrors/index.js:

const CloudBase = require('@cloudbase/manager-node');
const cloudbase = require('@cloudbase/node-sdk');
const https = require('https');

const ENV_ID = process.env.TCB_ENV;
const SECRET_ID = process.env.TENCENT_SECRET_ID;
const SECRET_KEY = process.env.TENCENT_SECRET_KEY;
const WECOM_KEY = process.env.WECOM_WEBHOOK_KEY;

// 监控范围:列出要看的函数。也可以从一个集合里读
const TARGET_FUNCTIONS = (process.env.MONITOR_FN_LIST || '').split(',').filter(Boolean);

// 阈值:5 分钟内同一函数错误数超过这个才告警
const ERROR_THRESHOLD = Number(process.env.ERROR_THRESHOLD || 10);

// 去重 TTL:同指纹告警 1 小时内不重复
const DEDUP_TTL_MS = 60 * 60 * 1000;

const manager = CloudBase.init({
secretId: SECRET_ID,
secretKey: SECRET_KEY,
envId: ENV_ID,
});

const tcbApp = cloudbase.init({
env: ENV_ID || cloudbase.SYMBOL_CURRENT_ENV,
});
const db = tcbApp.database();

exports.main = async (event) => {
const now = new Date();
const fiveMinAgo = new Date(now.getTime() - 5 * 60 * 1000);

const range = {
startTime: fmt(fiveMinAgo),
endTime: fmt(now),
};

console.log('[monitor] window', range);

const reports = [];
for (const name of TARGET_FUNCTIONS) {
try {
const failed = await pullErrors(name, range);
if (failed.length >= ERROR_THRESHOLD) {
reports.push({ name, count: failed.length, sample: failed[0] });
}
} catch (e) {
console.error('[monitor] pull failed for', name, e.message);
}
}

// 阈值过线的,逐个去重 → 推送
for (const r of reports) {
const key = `${r.name}::${(r.sample?.RetMsg || '').slice(0, 80)}`;
if (await hasRecentlyAlerted(key)) {
console.log('[monitor] dedup skip', key);
continue;
}
await sendWecomAlert(r);
await markAlerted(key);
}

return { ok: true, alerted: reports.map((r) => r.name) };
};

async function pullErrors(name, range) {
const list = await manager.functions.getFunctionLogsV2({
name,
startTime: range.startTime,
endTime: range.endTime,
limit: 100,
});

// RetCode 200 是成功,其余视为失败
const failed = (list.LogList || []).filter((item) => Number(item.RetCode) !== 200);

// 取一条详情样本,用来生成告警卡片
if (failed.length === 0) return [];

try {
const detail = await manager.functions.getFunctionLogDetail({
logRequestId: failed[0].RequestId,
startTime: range.startTime,
endTime: range.endTime,
});
failed[0].RetMsg = detail.RetMsg || '';
} catch (e) {
// 详情拉失败不影响主流程
console.warn('[monitor] detail failed', e.message);
}

return failed;
}

async function hasRecentlyAlerted(key) {
const since = Date.now() - DEDUP_TTL_MS;
const res = await db
.collection('alert_state')
.where({ key, alertedAt: db.command.gte(new Date(since)) })
.limit(1)
.get();
return res.data.length > 0;
}

async function markAlerted(key) {
await db.collection('alert_state').add({
key,
alertedAt: db.serverDate(),
});
}

function sendWecomAlert(r) {
const body = {
msgtype: 'markdown',
markdown: {
content:
`## 云函数错误告警\n\n` +
`**函数**:\`${r.name}\`\n` +
`**5 分钟内错误数**:<font color="warning">${r.count}</font>\n` +
`**最近 RequestId**:\`${r.sample?.RequestId || 'n/a'}\`\n` +
(r.sample?.RetMsg ? `**摘要**:${r.sample.RetMsg.slice(0, 200)}\n` : ''),
},
};

return new Promise((resolve, reject) => {
const url = `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=${WECOM_KEY}`;
const data = JSON.stringify(body);
const req = https.request(
url,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(data),
},
},
(res) => {
let raw = '';
res.on('data', (c) => (raw += c));
res.on('end', () => resolve(raw));
},
);
req.on('error', reject);
req.write(data);
req.end();
});
}

function fmt(d) {
// manager-node 期望 'YYYY-MM-DD HH:mm:ss'
return d.toISOString().replace('T', ' ').slice(0, 19);
}

要点:

  • RetCode === 200 是成功,其他视为失败。这个语义参考 getFunctionLogsV2 返回结构
  • 时间格式必须是 YYYY-MM-DD HH:mm:ss,且 startTime / endTime 必须在 1 天之内
  • getFunctionLogsV2 不返回函数返回值正文,要看具体错误信息得再调一次 getFunctionLogDetail,这里只取第一条样本省 quota

第三步:配定时触发器

cloudbaserc.jsonfunctions 数组里加:

{
"name": "monitorFnErrors",
"timeout": 60,
"memorySize": 256,
"runtime": "Nodejs16.13",
"handler": "index.main",
"triggers": [
{
"name": "every-5min",
"type": "timer",
"config": "0 */5 * * * * *"
}
]
}

0 */5 * * * * * 是 7 字段 cron,意思是每 5 分钟跑一次。完整 cron 语法见 schedule-cloud-function-cron-job 第二步。

第四步:部署 + 配环境变量

tcb login --apiKeyId your-key-id --apiKey your-key
tcb fn deploy monitorFnErrors -e your-env-id

控制台 → 云函数 → monitorFnErrors → 环境变量,加这几个:

变量名来源
TCB_ENV当前环境 IDCloudbase 控制台首页
TENCENT_SECRET_IDsecretId腾讯云「访问管理 / API 密钥」
TENCENT_SECRET_KEYsecretKey同上
WECOM_WEBHOOK_KEYwebhook key UUID企业微信群机器人,见 connect-wecom-webhook-cloud-function
MONITOR_FN_LISTgetLoginTicket,wecomNotify,dailyReport要监控的函数名,逗号分隔
ERROR_THRESHOLD10(默认)5 分钟内错误数超过这个才告警

第五步:误报规避

定时跑起来之后会发现一些「不是真错」的情况,几个常见过滤:

1. 部署期间的瞬时失败

tcb fn deploy 期间会有几秒钟 502。最简单的做法是开一个「维护窗口」环境变量:

const MAINT_END = Number(process.env.MAINT_END_AT || 0); // 时间戳,过这个时间之前不告警
if (Date.now() < MAINT_END) {
console.log('[monitor] in maintenance window, skip');
return { ok: true, skipped: true };
}

部署前手动改一下 MAINT_END_AT(把它设为部署完成预计时间 + 5 分钟)。

2. 已知错误白名单

某些错误是预期的(用户输入校验失败、某些业务路径就是会抛 400)。在 pullErrors 里加过滤:

const KNOWN_OK = [
'INVALID_INPUT',
'permission denied for read', // 已知用户没登录拿数据,不算 bug
];

const failed = (list.LogList || []).filter((item) => {
if (Number(item.RetCode) === 200) return false;
if (KNOWN_OK.some((k) => (item.RetMsg || '').includes(k))) return false;
return true;
});

3. 第一次部署后的「冷启动毛刺」

新部署的函数前几次调用可能会因为冷启动慢踩到 timeout,这个不算业务问题。可以在告警卡片里加一句「最近发布时间」给值班人参考,不用做特殊处理。

运行验证

  1. 部署完成后,控制台 → 云函数 → monitorFnErrors → 触发器,看到 every-5min
  2. 故意让一个被监控的函数报错(比如临时改一行代码 throw),触发它 11 次,等下一次 monitor 轮询
  3. 企业微信群应该收到一条 ## 云函数错误告警 的 markdown 卡片
  4. 同样的错误再触发 11 次,1 小时内不应该再收到第二条(去重生效)
  5. 控制台 → 数据库 → alert_state 集合,有一条 key<fnName>::<errMsg>alertedAt 是刚才时间的记录

常见错误

错误现象原因修复
getFunctionLogsV2Authentication failuresecretId / secretKey 错,或这对 key 没绑定该云开发环境的角色控制台「访问管理 / 用户」给这对 key 加 QcloudTCBFullAccess 类似的策略
start time is more than 1 day before end time时间窗口超过 1 天manager-node 强制限制,改成 5 分钟窗口足够
群里收到一堆重复告警alert_state 集合权限不允许写把集合权限改成「仅管理端可读写」(云函数访问没问题)
MONITOR_FN_LIST 配了但有些函数从来没告警那些函数最近 5 分钟没有调用 = LogList 为空 = 0 个错误正常情况;阈值 10 个错误也才触发,低频函数本来就不容易告警
告警里 RetMsg 是空getFunctionLogDetail 拿不到详情(日志还没投递到 CLS)CLS 日志有几秒到 1 分钟的投递延迟,等下一轮再看
部署后第一次跑卡了几秒冷启动 + manager-node 第一次调腾讯云 API 慢timeout 调到 60s 即可

相关文档

下一步

  • 把告警规则做成可配置(从一个集合读阈值 / 函数列表):参考 secure-database-multi-tenant-rules 的多租户配置思路
  • 给关键业务函数加分级告警(P0 / P1 用不同 webhook):在第二步 sendWecomAlert 里按函数名路由 webhook key
  • 监控数据库慢查询:换用 app.log.searchClsLog({ queryString: 'module:database AND eventType:MongoSlowQuery' })