J Joker Watch v0.2.1
⌘K
GitHub Get Started
v0.2.1 Cloudflare Workers Hono v4 D1 · KV

Joker Watch

一个跑在 Cloudflare Workers 边缘上的个人错误监控网关。把任何项目(Node、TS、C#、Rust Oxide、浏览器、CF Workers)里的错误,通过一个 HTTP API 推到你自己的 Telegram 群和 Discord 频道。30 秒接入,零运维,自带去重 / 频率告警 / 历史查询。

为什么不用 Sentry?因为你只是想知道自己的项目挂没挂,不需要团队管理、看板、采样规则、付费 quota。Joker Watch 把这个需求做到极致简单 —— 一个 endpoint、一把 API key、消息直接推到你天天看的那两个 IM。

核心概念#

Joker Watch 只有四种对象,理解它们就够了:

Project prj_*

被监控的项目(例如 Map Wipe / 个人博客 / Discord Bot)。每个项目独享通知开关与一组 API Key。

API Key jwk_*

用于上报错误的凭证。明文仅在创建时返回一次,DB 仅存 SHA-256 哈希。可按环境创建多把 key。

Issue iss_*

同一类错误的聚合实体。通过 message + stack + release 的指纹去重,重复上报只递增 event_count,不再骚扰你。

Event evt_*

每一次具体的错误上报。承载发生时间、上下文、用户、环境,挂在所属 Issue 之下。

数据流

┌──────────────┐ POST /api/v1/report ┌─────────────────┐ │ Your App │ ───────────────────────────▶│ Joker Watch │ │ (TS/Node/C#) │ Authorization: Bearer ... │ on CF Edge │ └──────────────┘ └────────┬────────┘ │ ┌───────────────────────────────┼───────────────────────────────┐ ▼ ▼ ▼ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │ D1: events │ │ Telegram │ │ Discord │ │ D1: issues │ │ 群 / 频道 │ │ Webhook │ │ KV: dedupe │ └────────────────┘ └────────────────┘ └────────────────┘
原则:同一个 issue 第一次出现立刻推送,之后只在 event_count 命中里程碑(2, 10, 50, 100, 500, 1000…)时再推一次。fatal 级别每次都推并 @ 你。

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)。

就这些。不需要 SDK、不需要采样配置、不需要看板权限管理。出错了你就知道。

鉴权#

Joker Watch 有两种凭证,互不相通:

凭证头部用途
API Key
jwk_*
Bearer jwk_… 项目上报错误用。只能调 POST /api/v1/report。每个项目独立、可独立撤销、撤销不影响其他项目。
Admin Token Bearer … 你自己管理用。调创建/列出/删除项目、查询历史 issue / event。所有非 ingest 接口都用它。

哈希存储

API Key 在数据库里只存 SHA-256 哈希,明文只在创建时返回一次。这意味着:

  • 就算 D1 数据库被脱裤,攻击者也拿不到能上报的 key
  • 你忘了 key 也找不回,只能撤销并新建
  • 每次请求会做一次哈希 + 索引查询,毫秒级,可忽略
Admin Token 不要 commit 到 Git,不要写在前端代码里。它是 Cloudflare Secret,应该只在你本地命令行或者运维脚本里出现。

请求示例

# 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 秒)。

千万不要在错误处理里直接 throw 进入再触发一次错误上报。客户端 helper 必须用 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 逗号串

上报错误#

POST /api/v1/report API Key

核心 endpoint。所有错误上报都打这里。

请求体

字段类型说明
messagerequiredstring错误简短描述。卡片标题用。最长 500 字
leveloptionalenumfatal / error / warning / info。默认 error
stackoptionalstring堆栈跟踪原文。最长 8000 字
contextoptionalobject任意键值对,比如 userIdrequestIdendpoint。会被序列化存入 D1
environmentoptionalstringproduction / staging / dev 之类。会显示在卡片底部
releaseoptionalstring版本号 / git sha / build id。参与 fingerprint 计算
tagsoptionalstring[]分类标签,用于查询过滤
useroptionalobject{ id, email, username }。会做脱敏(email 只保留 x***@domain
requestoptionalobject{ method, url, headers }。注意 headers 会自动过滤掉 cookie / authorization
fingerprintoptionalstring[]手动指定去重指纹(高级用法,覆盖自动计算)

响应

{
  "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"    // 推送/不推送的原因
}

错误码

HTTPcode原因
400bad_requestmessage 字段,或 JSON 不合法
401unauthorizedAPI key 无效 / 已撤销 / 项目已禁用
429too_many超过 600 events/min
500internalD1 / KV 异常(极少)

创建项目#

POST /api/v1/projects Admin

创建一个新项目并自动生成首个 API Key。明文 key 只在本响应里返回一次,之后无法找回。

请求体

字段类型说明
namerequiredstring显示名,卡片标题里出现
slugoptionalstringURL 友好短名。不填会自动从 name 生成
descriptionoptionalstring项目说明,自留用
notify_telegramoptionalbool默认 true
notify_discordoptionalbool默认 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,关闭后将无法再次查看"
  }
}

列出所有项目#

GET /api/v1/projects Admin
{
  "projects": [
    { "id": "prj_...", "name": "...", "slug": "...", "is_active": 1, ... }
  ]
}

项目详情#

GET /api/v1/projects/:id Admin

返回项目本体 + 该项目所有 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 }
}

更新项目#

PATCH /api/v1/projects/:id Admin

修改名称、描述、推送开关、启用状态。所有字段可选。

字段类型说明
namestring显示名
descriptionstring说明
is_activebool停用项目(API Key 立即失效)
notify_telegrambool关闭 TG 推送(仍入库)
notify_discordbool关闭 Discord 推送

删除项目#

DELETE /api/v1/projects/:id Admin
不可逆。同时删除该项目的所有 API Keys、Issues、Events。如果只是想暂时关掉,用 PATCHis_active=false

新增 API Key#

POST /api/v1/projects/:id/keys Admin

给一个已有项目追加一个 API Key(按环境隔离推荐用这个,比如 name: "production" / "staging")。

请求体

{ "name": "production" }  // 可选,方便后面识别

响应

{
  "api_key": {
    "id": "key_...",
    "plain": "jwk_...",
    "prefix": "jwk_..."
  }
}

撤销 API Key#

DELETE /api/v1/projects/:id/keys/:keyId Admin

立即失效该 key,但保留它在数据库里作为审计记录(is_active=0, revoked_at=<now>)。


列出 Issues#

GET /api/v1/issues Admin

Query 参数

参数类型说明
project_idstring按项目过滤
levelenumfatal / error / warning / info
statusenumunresolved(默认)/ resolved / ignored / all
limitint1-200,默认 50
offsetint默认 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 详情#

GET /api/v1/issues/:id Admin
{ "issue": { ... } }

Issue 下的 Events#

GET /api/v1/issues/:id/events Admin

查询某个 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#

PATCH /api/v1/issues/:id Admin

标记 resolved / 静默 N 分钟。

字段类型说明
statusenumunresolved / resolved / ignored
mute_minutesint静默 N 分钟。0 表示解除静默

跨项目事件流#

GET /api/v1/events Admin

不区分 issue,按时间倒序看所有事件。适合做"最近 1 小时全局发生了什么"的速览。

支持过滤:project_idlevel,分页 limit(1-500,默认 100)、offset

Issue HTML 详情页#

GET /issues/:id Public + Signed

错误卡片"查看详情"按钮指向这里。这是一个 HTML 页,展示该 issue 的完整堆栈、context、最近事件列表。

URL 里带 ?sig=<HMAC> 签名,签名用 HMAC_SECRET 生成,30 天有效。无签名访问只能看脱敏后的非敏感字段。

健康检查#

GET /health Public
{
  "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),只入库不打扰。


错误码#

HTTPcode说明
200OK
400bad_request请求体不合法(JSON 解析失败 / 缺必填字段 / 字段值非法)
401unauthorized缺 / 错 / 失效的认证凭证
403forbidden认证成功但没有权限(例:用 API Key 调 admin 接口)
404not_found资源不存在
429too_many触发限流
500internal服务端异常(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。两种安全做法:

  1. 前端把错误 POST 到自己的后端,后端再用 server-side API Key 上报
  2. 给前端建一个专用 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。设计上拒绝 @ 全员,避免骚扰团队成员。