Joker Watch
一个跑在 Cloudflare Workers 边缘上的个人错误监控网关。把任何项目(Node、TS、C#、Rust Oxide、浏览器、CF Workers)里的错误,通过一个 HTTP API 推到你自己的 Telegram 群和 Discord 频道。30 秒接入,零运维,自带去重 / 频率告警 / 历史查询。
核心概念#
Joker Watch 只有四种对象,理解它们就够了:
被监控的项目(例如 Map Wipe / 个人博客 / Discord Bot)。每个项目独享通知开关与一组 API Key。
用于上报错误的凭证。明文仅在创建时返回一次,DB 仅存 SHA-256 哈希。可按环境创建多把 key。
同一类错误的聚合实体。通过 message + stack + release 的指纹去重,重复上报只递增 event_count,不再骚扰你。
每一次具体的错误上报。承载发生时间、上下文、用户、环境,挂在所属 Issue 之下。
数据流
5 分钟接入#
从零到收到第一条错误推送,4 步。
1. 创建项目
用你的 ADMIN_TOKEN 调用一次 POST /api/v1/projects,记下返回的 api_key.plain(只显示一次):
# 替换 YOUR_ADMIN_TOKEN 为你的实际管理员令牌
curl -X POST "https://jw.lyc9.com/api/v1/projects" \
-H "Authorization: Bearer YOUR_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"My SaaS","slug":"my-saas"}'
响应体里把 api_key.plain 抠出来,存到目标项目的环境变量里(建议命名 JOKER_WATCH_KEY)。
2. 上报一个错误
curl -X POST "https://jw.lyc9.com/api/v1/report" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"level": "error",
"message": "Database connection timeout",
"stack": "Error: timeout\n at Pool.connect (db.ts:42)",
"context": { "userId": "u_123", "query": "SELECT * FROM users" },
"environment": "production",
"release": "v1.4.2"
}'
3. 检查推送
打开你的 Telegram 群和 Discord 频道,应该收到一张错误卡片,附"查看详情"链接。
4. 接入到代码里
把上面的 fetch 调用封装一下,挂到你项目的全局错误处理上。直接看右边的代码示例(或者跳到 Client Libraries 拿现成 helper)。
鉴权#
Joker Watch 有两种凭证,互不相通:
| 凭证 | 头部 | 用途 |
|---|---|---|
API Keyjwk_* |
Bearer jwk_… |
项目上报错误用。只能调 POST /api/v1/report。每个项目独立、可独立撤销、撤销不影响其他项目。 |
| Admin Token | Bearer … |
你自己管理用。调创建/列出/删除项目、查询历史 issue / event。所有非 ingest 接口都用它。 |
哈希存储
API Key 在数据库里只存 SHA-256 哈希,明文只在创建时返回一次。这意味着:
- 就算 D1 数据库被脱裤,攻击者也拿不到能上报的 key
- 你忘了 key 也找不回,只能撤销并新建
- 每次请求会做一次哈希 + 索引查询,毫秒级,可忽略
请求示例
# API Key(上报错误)
curl -H "Authorization: Bearer jwk_ca47b54...." ...
# Admin Token(管理项目)
curl -H "Authorization: Bearer LMPSI79rt4RDzr3..." ...
错误响应
// 401 - Missing Authorization header
{ "ok": false, "code": "unauthorized", "error": "Missing Authorization: Bearer <key>" }
// 401 - 用了错误的 key 类型
{ "ok": false, "code": "unauthorized", "error": "Invalid key format" }
// 401 - Key 已撤销或项目已禁用
{ "ok": false, "code": "unauthorized", "error": "Invalid or revoked API key" }
严重级别#
Joker Watch 支持 4 个级别,按行为差异分级:
| 级别 | 推送时机 | 特殊行为 | 典型场景 |
|---|---|---|---|
| FATAL | 每次都推 | Discord @ 你;TG 加高优先级 | 进程退出、数据丢失、付款失败、依赖完全挂掉 |
| ERROR | 新 issue / 里程碑 | 常规推送 | 未捕获异常、HTTP 5xx、DB 查询失败 |
| WARNING | 新 issue / 里程碑 | 常规推送 | 降级、重试中、慢查询、超时但已恢复 |
| INFO | 只入库,不推送 | 仅历史查询可见 | 埋点事件、关键路径打点、审计 |
选级别的心智模型
- 需要你立刻起床去看? →
fatal - 用户已经感知到了一个 bug? →
error - 没坏,但不对劲? →
warning - 只想留个痕? →
info
fatal。这等于没有分级,等真有事的时候你已经麻木了。一天 fatal 推送应该 ≤ 3 条,否则要么过敏,要么真的出大事了。
推送规则#
Joker Watch 的核心价值不在"收消息",在于"不被消息淹没"。规则如下:
指纹去重(Fingerprint Deduplication)
同一个 issue 的判定标准:
fingerprint = sha1(project_id + level + normalize(message) + normalize(stack_top_frame) + release)
命中已有 fingerprint 时,自增 event_count,更新 last_seen_at,但不立即推送。
里程碑告警(Milestone Alerts)
同一 issue 只有在 event_count 达到以下值时才会再次推送:
2, 10, 50, 100, 500, 1000, 5000, 10000, ...
这套节奏的设计意图:
- 第 1 次:你完全不知道有问题,必须立刻告诉你
- 第 2 次:可能是间歇性问题,再提醒一次确认
- 第 10 次:扩散了,影响一小批用户
- 第 50 / 100:大面积,必须立刻处理
- 第 500+:报告级别,意味着系统状态彻底改变
Mute(静默)
对某个已知会刷屏但不重要的 issue,可以静默 N 分钟:
curl -X PATCH "https://jw.lyc9.com/api/v1/issues/iss_xxx" \
-H "Authorization: Bearer YOUR_ADMIN_TOKEN" \
-d '{"mute_minutes": 60}'
Resolve(解决)
修好之后标记 resolved,下次再出现等同新 issue 重新走完整推送:
curl -X PATCH "https://jw.lyc9.com/api/v1/issues/iss_xxx" \
-H "Authorization: Bearer YOUR_ADMIN_TOKEN" \
-d '{"status": "resolved"}'
项目级开关
每个项目可以单独关闭 TG 或 Discord 推送:
curl -X PATCH "https://jw.lyc9.com/api/v1/projects/prj_xxx" \
-H "Authorization: Bearer YOUR_ADMIN_TOKEN" \
-d '{"notify_telegram": false, "notify_discord": true}'
限流#
每个项目最多 600 次上报 / 分钟。这个限额够任何正常项目用,又能防止失控循环把你的 Worker 配额烧光。
超限时返回:
HTTP 429 Too Many Requests
{
"ok": false,
"code": "too_many",
"error": "Rate limit: 600 events/min per project"
}
限流计数存在 KV 里(按项目 ID + 60 秒滑动窗口)。被限流的事件不会被存入 D1,也不会推送。客户端建议在收到 429 后退避(指数退避,初始 1 秒)。
try { fetch(…) } catch {} 把上报本身的异常吞掉。
错误卡片字段映射#
Telegram (HTML 格式)
🔴 [ERROR] Map Wipe
Wipe failed: cannot read map heightmap.dat
📍 第 1 次出现 · production · MapWipe v2.3.1
🕐 2026-05-13 02:50:35 UTC
[ 查看详情 ] ← 内联按钮,跳转到 issue 页
Discord (Embed)
使用 Discord Embed,左侧条颜色按级别变化:
- FATAL 红色
#ff5b6b,正文前 @DISCORD_USER_ID - ERROR 橙色
#ff8a47 - WARNING 琥珀
#ffb547 - INFO 蓝色
#5ea0ff(仅历史看,不发卡)
字段映射表
| Report 字段 | Telegram 显示 | Discord 显示 |
|---|---|---|
level | 顶部 emoji + 大写标签 | Embed 颜色 + 标签字段 |
message | 正文加粗主标题 | Embed Title |
stack | 不展示(点详情看) | Code block(截前 800 字) |
context | 不展示(点详情看) | Inline fields(前 5 个) |
environment | 第二行小字 | Footer |
release | 第二行小字 | Footer |
event_count | "第 N 次出现" | "Seen N times" |
tags | 不展示 | Footer 逗号串 |
上报错误#
核心 endpoint。所有错误上报都打这里。
请求体
| 字段 | 类型 | 说明 |
|---|---|---|
messagerequired | string | 错误简短描述。卡片标题用。最长 500 字 |
leveloptional | enum | fatal / error / warning / info。默认 error |
stackoptional | string | 堆栈跟踪原文。最长 8000 字 |
contextoptional | object | 任意键值对,比如 userId、requestId、endpoint。会被序列化存入 D1 |
environmentoptional | string | production / staging / dev 之类。会显示在卡片底部 |
releaseoptional | string | 版本号 / git sha / build id。参与 fingerprint 计算 |
tagsoptional | string[] | 分类标签,用于查询过滤 |
useroptional | object | { id, email, username }。会做脱敏(email 只保留 x***@domain) |
requestoptional | object | { method, url, headers }。注意 headers 会自动过滤掉 cookie / authorization |
fingerprintoptional | string[] | 手动指定去重指纹(高级用法,覆盖自动计算) |
响应
{
"ok": true,
"issue_id": "iss_6dc2b464669de669",
"event_id": "evt_f1a39e276aab94e8",
"deduped": false, // true 表示命中了已有 issue
"event_count": 1, // 该 issue 累计事件数(含这次)
"notified": true, // 这次有没有触发 TG/Discord 推送
"reason": "new issue" // 推送/不推送的原因
}
错误码
| HTTP | code | 原因 |
|---|---|---|
| 400 | bad_request | 缺 message 字段,或 JSON 不合法 |
| 401 | unauthorized | API key 无效 / 已撤销 / 项目已禁用 |
| 429 | too_many | 超过 600 events/min |
| 500 | internal | D1 / KV 异常(极少) |
创建项目#
创建一个新项目并自动生成首个 API Key。明文 key 只在本响应里返回一次,之后无法找回。
请求体
| 字段 | 类型 | 说明 |
|---|---|---|
namerequired | string | 显示名,卡片标题里出现 |
slugoptional | string | URL 友好短名。不填会自动从 name 生成 |
descriptionoptional | string | 项目说明,自留用 |
notify_telegramoptional | bool | 默认 true |
notify_discordoptional | bool | 默认 true |
响应
{
"project": {
"id": "prj_78155d80bd98f4e1",
"name": "Map Wipe",
"slug": "map-wipe",
"is_active": 1,
"notify_telegram": 1,
"notify_discord": 1,
"created_at": 1778637035
},
"api_key": {
"id": "key_de072c8601f4738b",
"plain": "jwk_ca47b546224222040cd5a98956a4941564bdfc5f",
"prefix": "jwk_ca47b546",
"warning": "请立即保存此 Key,关闭后将无法再次查看"
}
}
列出所有项目#
{
"projects": [
{ "id": "prj_...", "name": "...", "slug": "...", "is_active": 1, ... }
]
}
项目详情#
返回项目本体 + 该项目所有 API Key 列表(只含 prefix,不含明文)+ issue / event 总数统计。
{
"project": { ... },
"keys": [
{ "id": "key_...", "key_prefix": "jwk_ca47b546", "is_active": 1, "last_used_at": 1778637100 }
],
"stats": { "issue_count": 12, "event_count": 347 }
}
更新项目#
修改名称、描述、推送开关、启用状态。所有字段可选。
| 字段 | 类型 | 说明 |
|---|---|---|
name | string | 显示名 |
description | string | 说明 |
is_active | bool | 停用项目(API Key 立即失效) |
notify_telegram | bool | 关闭 TG 推送(仍入库) |
notify_discord | bool | 关闭 Discord 推送 |
删除项目#
PATCH 改 is_active=false。
新增 API Key#
给一个已有项目追加一个 API Key(按环境隔离推荐用这个,比如 name: "production" / "staging")。
请求体
{ "name": "production" } // 可选,方便后面识别
响应
{
"api_key": {
"id": "key_...",
"plain": "jwk_...",
"prefix": "jwk_..."
}
}
撤销 API Key#
立即失效该 key,但保留它在数据库里作为审计记录(is_active=0, revoked_at=<now>)。
列出 Issues#
Query 参数
| 参数 | 类型 | 说明 |
|---|---|---|
project_id | string | 按项目过滤 |
level | enum | fatal / error / warning / info |
status | enum | unresolved(默认)/ resolved / ignored / all |
limit | int | 1-200,默认 50 |
offset | int | 默认 0 |
响应
{
"issues": [
{
"id": "iss_...",
"project_id": "prj_...",
"level": "error",
"message": "...",
"event_count": 42,
"first_seen_at": 1778600000,
"last_seen_at": 1778637100,
"status": "unresolved",
"fingerprint": "..."
}
],
"limit": 50,
"offset": 0
}
Issue 详情#
{ "issue": { ... } }
Issue 下的 Events#
查询某个 issue 下的具体每一次事件(按时间倒序)。支持 limit(1-200,默认 50)和 offset。
{
"events": [
{
"id": "evt_...",
"issue_id": "iss_...",
"received_at": 1778637100,
"context": { "userId": "u_123" },
"stack": "...",
"environment": "production",
"release": "v1.4.2",
"source_ip": "a.b.c.d",
"user_agent": "..."
}
]
}
更新 Issue#
标记 resolved / 静默 N 分钟。
| 字段 | 类型 | 说明 |
|---|---|---|
status | enum | unresolved / resolved / ignored |
mute_minutes | int | 静默 N 分钟。0 表示解除静默 |
跨项目事件流#
不区分 issue,按时间倒序看所有事件。适合做"最近 1 小时全局发生了什么"的速览。
支持过滤:project_id、level,分页 limit(1-500,默认 100)、offset。
Issue HTML 详情页#
错误卡片"查看详情"按钮指向这里。这是一个 HTML 页,展示该 issue 的完整堆栈、context、最近事件列表。
URL 里带 ?sig=<HMAC> 签名,签名用 HMAC_SECRET 生成,30 天有效。无签名访问只能看脱敏后的非敏感字段。
健康检查#
{
"status": "ok",
"version": "0.1.1",
"db": "up",
"integrations": {
"telegram": true,
"discord": true
},
"time": 1778637035
}
可以接到 UptimeRobot / better-stack 之类的外部探活服务。
TypeScript 客户端#
适用于 Cloudflare Workers、Hono、Bun、Deno、浏览器、任何支持原生 fetch 的 TS 运行时。复制到你项目的 lib/joker-watch.ts 就能用。
完整版本:helpers/typescript/joker-watch.ts
// lib/joker-watch.ts
export type JokerLevel = 'fatal' | 'error' | 'warning' | 'info'
export interface JokerPayload {
message: string
level?: JokerLevel
stack?: string
context?: Record<string, unknown>
environment?: string
release?: string
tags?: string[]
user?: { id?: string; email?: string; username?: string }
}
export function createJoker(opts: { endpoint: string; apiKey: string; defaultEnv?: string }) {
return {
async report(p: JokerPayload): Promise<void> {
try {
await fetch(`${opts.endpoint}/api/v1/report`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${opts.apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ environment: opts.defaultEnv, ...p })
})
} catch { /* 上报错误本身被吞掉,避免雪崩 */ }
},
captureError(e: unknown, ctx?: Record<string, unknown>) {
const err = e instanceof Error ? e : new Error(String(e))
return this.report({
level: 'error',
message: err.message,
stack: err.stack,
context: ctx
})
}
}
}
在 CF Worker 里用
import { createJoker } from './lib/joker-watch'
export default {
async fetch(req: Request, env: Env): Promise<Response> {
const joker = createJoker({
endpoint: 'https://jw.lyc9.com',
apiKey: env.JOKER_WATCH_KEY,
defaultEnv: 'production'
})
try {
return await handleRequest(req)
} catch (e) {
await joker.captureError(e, { url: req.url, method: req.method })
return new Response('Internal Error', { status: 500 })
}
}
}
Node.js 客户端#
Node 18+ 内置 fetch,无依赖。完整版本:helpers/nodejs/joker-watch.js
// lib/joker-watch.js
function createJoker({ endpoint, apiKey, defaultEnv }) {
return {
async report(payload) {
try {
await fetch(`${endpoint}/api/v1/report`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ environment: defaultEnv, ...payload })
})
} catch {}
},
captureError(e, ctx) {
const err = e instanceof Error ? e : new Error(String(e))
return this.report({
level: 'error', message: err.message, stack: err.stack, context: ctx
})
}
}
}
module.exports = { createJoker }
挂到 Express 全局错误处理
const express = require('express')
const { createJoker } = require('./lib/joker-watch')
const joker = createJoker({
endpoint: 'https://jw.lyc9.com',
apiKey: process.env.JOKER_WATCH_KEY,
defaultEnv: process.env.NODE_ENV || 'development'
})
const app = express()
// 业务路由 ...
// 兜底错误中间件
app.use((err, req, res, _next) => {
joker.captureError(err, {
method: req.method, url: req.originalUrl, userId: req.user?.id
})
res.status(500).json({ error: 'Internal Error' })
})
// 兜底进程级
process.on('unhandledRejection', (r) => joker.captureError(r, { kind: 'unhandledRejection' }))
process.on('uncaughtException', (e) => joker.report({ level: 'fatal', message: e.message, stack: e.stack }))
C# / .NET 客户端#
用于 ASP.NET Core、Rust Oxide 插件、Unity 服务端、任何 .NET 5+ 项目。完整版本:helpers/csharp/JokerWatch.cs
using System;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
public class JokerWatch
{
private static readonly HttpClient _http = new HttpClient();
private readonly string _endpoint;
private readonly string _apiKey;
private readonly string _defaultEnv;
public JokerWatch(string endpoint, string apiKey, string defaultEnv = "production")
{
_endpoint = endpoint.TrimEnd('/');
_apiKey = apiKey;
_defaultEnv = defaultEnv;
}
public async Task ReportAsync(object payload)
{
try
{
var json = JsonSerializer.Serialize(payload);
var req = new HttpRequestMessage(HttpMethod.Post, $"{_endpoint}/api/v1/report");
req.Headers.Add("Authorization", $"Bearer {_apiKey}");
req.Content = new StringContent(json, Encoding.UTF8, "application/json");
await _http.SendAsync(req);
}
catch { /* 静默吞掉上报失败 */ }
}
public Task CaptureExceptionAsync(Exception e, object context = null)
{
return ReportAsync(new
{
level = "error",
message = e.Message,
stack = e.ToString(),
context,
environment = _defaultEnv
});
}
}
在 Rust Oxide 插件里用
// MyPlugin.cs - Oxide / uMod Rust plugin
using Oxide.Core.Plugins;
namespace Oxide.Plugins
{
[Info("MapWipe", "Joker", "2.3.1")]
public class MapWipe : RustPlugin
{
private JokerWatch _jw;
void Init()
{
_jw = new JokerWatch(
"https://jw.lyc9.com",
"YOUR_API_KEY",
"production"
);
}
void OnServerInitialized()
{
try { DoWipe(); }
catch (Exception e)
{
_jw.CaptureExceptionAsync(e, new {
server = ConVar.Server.hostname,
seed = ConVar.Server.seed
});
}
}
}
}
全局错误处理#
正确的接入方式不是"在每个 try/catch 里调一遍",而是 在框架最外层挂一个兜底。
Hono
app.onError(async (err, c) => {
await joker.captureError(err, {
method: c.req.method,
path: c.req.path,
cf_ray: c.req.header('cf-ray')
})
return c.json({ error: 'Internal Error' }, 500)
})
Fastify
app.setErrorHandler(async (err, req, reply) => {
await joker.captureError(err, { url: req.url, ip: req.ip })
reply.code(500).send({ error: 'Internal Error' })
})
ASP.NET Core
app.UseExceptionHandler(errorApp => {
errorApp.Run(async ctx => {
var e = ctx.Features.Get<IExceptionHandlerFeature>()?.Error;
if (e != null) await joker.CaptureExceptionAsync(e, new {
path = ctx.Request.Path.Value
});
ctx.Response.StatusCode = 500;
await ctx.Response.WriteAsync("Internal Error");
});
});
后端关键路径捕获#
除了全局兜底,对一些"出错你必须立刻知道"的路径(支付、数据库迁移、用户注册),单独 try/catch 并提升级别到 fatal。
try {
await processPayment(order)
} catch (e) {
await joker.report({
level: 'fatal', // 立刻 @ 你
message: `Payment failed: ${e.message}`,
stack: e.stack,
context: { orderId: order.id, amount: order.amount, userId: order.userId },
tags: ['payment', 'revenue-critical']
})
throw e // 继续往上抛,让上游也知道
}
定时任务 / 后台 Job#
定时任务出错没人看日志,最容易"静默死掉"。包一层非常重要:
// Cloudflare Workers Scheduled Handler
export default {
async scheduled(event, env, ctx) {
const joker = createJoker({ endpoint: ..., apiKey: env.JOKER_WATCH_KEY })
try {
await runDailyDigest()
} catch (e) {
await joker.captureError(e, { cron: event.cron })
throw e // 让 CF 也标记失败
}
}
}
多环境隔离#
三种做法,按需选:
方案 A:同项目,不同 environment 字段(推荐)
一个项目一个 API Key,上报时 environment 字段填 production / staging,查询时用 tag 或 context 过滤。
- ✅ 管理简单,dashboard 不分散
- ❌ staging 出错时也会推送,可能干扰
方案 B:同项目,不同 API Key
用 POST /api/v1/projects/:id/keys 给同一项目建多个 key(命名 "production" / "staging")。撤销 staging key 不影响 production。
方案 C:完全独立的项目(dev 用)
建一个 Foo (dev) 项目并关掉它的推送(notify_telegram=false, notify_discord=false),只入库不打扰。
错误码#
| HTTP | code | 说明 |
|---|---|---|
| 200 | — | OK |
| 400 | bad_request | 请求体不合法(JSON 解析失败 / 缺必填字段 / 字段值非法) |
| 401 | unauthorized | 缺 / 错 / 失效的认证凭证 |
| 403 | forbidden | 认证成功但没有权限(例:用 API Key 调 admin 接口) |
| 404 | not_found | 资源不存在 |
| 429 | too_many | 触发限流 |
| 500 | internal | 服务端异常(D1 / KV 失败) |
所有错误响应统一形状:
{ "ok": false, "code": "<code>", "error": "<human readable>" }
字段约定#
message — 一句话说错了什么
这是卡片标题,直接决定你"看一眼就能猜出哪儿坏了"。
- ✅ 好:
"Stripe webhook signature mismatch on /api/billing/webhook" - ❌ 坏:
"Error"/"Something went wrong"/"500"
context — 重现需要的信息
问自己:"我半夜被叫起来排查这个,最想立刻知道的信息是什么?" 把那些塞进去。
- 用户标识:
userId/tenantId - 请求标识:
requestId/traceId/cf_ray - 业务标识:
orderId/jobId - 关键参数:导致问题的具体输入
release — 这是哪个版本
必填。少了它你永远不知道"这个错是这次部署引入的,还是一直就有"。建议用 git short sha 或者语义版本:"a1b2c3d" / "v1.4.2"。
environment — 是哪套环境
固定使用三个值之一:production / staging / development。卡片底部会显示。
tags — 切片分类
用于事后查询。建议按"系统"+"性质"两轴分:["payment", "external-api"] / ["cron", "data-sync"]。
更新记录#
v0.2.1 — 2026-05-13
- 升级:企业级设计系统重构——排版层级、色彩令牌、间距重新校准到 Stripe / Linear 水准
- 新增:代码块 macOS 窗口 chrome(3 个颜色圆点)+ hover 浮出复制按钮
- 新增:左栏 active 项高亮条、HTTP method 按 REST 语义分色(GET 蓝 / POST 青绿 / PATCH 琥珀 / DELETE 红)
- 新增:顶栏滚动时渐现 1px 边线,body 底层加噪点纹理增加质感
- 重构:核心概念表格 → 4 卡片网格;Sentry 对比句 → 独立 callout 区
- 重构:Footer 扩充为四栏网格(Product / API / Resources / Brand)加状态点
v0.2.0 — 2026-05-13
- 新增:完整的开发者文档站 jw.lyc9.com(本页)
- 新增:
/docs、/docs/styles.css、/docs/script.js静态资源路由,全部带边缘缓存 - 新增:jw.lyc9.com Custom Domain绑定,CF 自动签发 SSL
v0.1.1 — 2026-05-13
- 修复:
app.use('*')中间件挂在/api/v1时吞掉所有子路由,导致/api/v1/projects、/api/v1/issues全部被 ingest auth 拦截 - 把各子路由的中间件收窄到具体路径前缀
v0.1.0 — 2026-05-13
- 首发版本
- 核心 ingest + 项目 / 历史管理接口
- Telegram + Discord 双通道推送
- Fingerprint 去重 + 里程碑告警
- HMAC 签名的 issue 详情页
FAQ#
Q: 它和 Sentry 有啥区别?
Joker Watch 是"个人级"的 Sentry。Sentry 给团队用、有看板、有用户管理、有付费 quota;Joker Watch 把所有这些都砍了,只做"出错了立刻消息给你"这一件事。
Q: 浏览器端的错误能上报吗?
能,但要小心。直接在前端调 POST /report 会暴露 API Key。两种安全做法:
- 前端把错误 POST 到自己的后端,后端再用 server-side API Key 上报
- 给前端建一个专用
public类型的 key(路线图中,目前未实现)
Q: 它会拖慢我的应用吗?
不会。客户端 helper 里 fetch 是异步的、没有 await 也行(fire-and-forget)。CF Worker 端处理一次 ingest 大概 5-15ms 不带通知,带通知 50-150ms(异步推送,不阻塞响应)。
Q: 数据存多久?
当前版本:永久。D1 免费层 5GB,足够十几个项目用好几年。如果担心,可以加定时任务清理 last_seen_at < now() - 90d 的 resolved issues。
Q: 能多人一起用吗?
设计上不行。Admin Token 是单一密钥,没有用户体系。如果要共享,把 token 给信任的人即可,但他能看到你所有项目。
Q: Discord 通知有 @everyone / @here 吗?
没有。只在 fatal 级别 @ 你的 DISCORD_USER_ID。设计上拒绝 @ 全员,避免骚扰团队成员。