Bỏ qua tới nội dung
Kiên

Thư viện gửi cảnh báo là một cửa dữ liệu đi ra

· 5 phút đọc

15 kênh · 0 dependency

Mình tách "quăng lỗi vào phòng chat" ra khỏi hơn chục service thành một package publish lên npm. Phần đáng nói không phải là mười lăm kênh — mà là lúc nhận ra việc của thư viện này là quyết định cái gì được phép rời khỏi process.

  • Node.js
  • observability
  • bảo mật
  • thiết kế thư viện
Nội dung
  1. Bắt đầu từ đâu
  2. 1. Chuyện bảo mật, mà mình nhận ra hơi muộn
  3. 2. Fail-soft không phải thứ có thì tốt
  4. 3. Zero dependency, có chủ đích
  5. 4. Cái bug làm cho chính cái alert báo lỗi bị lỗi
  6. 5. Định tuyến, để cảnh báo còn đọc được
  7. Cái sai mình để lộ ra ngoài
  8. Điều rút ra

Bắt đầu từ đâu

Service nào cũng có bản riêng của cùng bốn mươi dòng: bắt lỗi, dựng message, POST sang một webhook chat. Cùng một ý tưởng, mười hai bản hơi khác nhau. Khác tên trường, khác cách format, khác hành vi khi chính cái webhook đó lỗi — và một trong mười hai bản đó có thể làm chết luôn cái request mà nó đang định báo cáo.

Nên mình tách ra thành package rồi publish: @kien2k1/multi-channel-alert. Khoảng mười lăm đích đến — Telegram, Slack, Discord, Mattermost, email và các kiểu — sau đúng một interface, cộng thêm MultiChannelAlert để bắn nhiều kênh một lúc.

Con số mười lăm kênh là thứ ít thú vị nhất ở đây. Phần thật sự phải nghĩ là mấy chỗ dưới.

1. Chuyện bảo mật, mà mình nhận ra hơi muộn

Cách viết tự nhiên nhất khi gọi alert là:

alert.error(err)     // hoặc tệ hơn: alert.error({ ...context, err })

Giờ thử nghĩ xem trong cái object đó thật sự có gì vào một ngày xấu trời. Request body gửi lên nhà cung cấp. Headers. Một cái bearer token. Credential của một tài khoản, vì cái request bạn đang lắp buộc phải mang nó. Thông tin cá nhân của khách.

Thư viện gửi cảnh báo không phải chuyện logging. Nó là một đường dữ liệu đi ra — một kênh được cấp phép, luôn bật, chép dữ liệu từ trong process của bạn ra một phòng chat có nhiều thành viên hơn số user của DB production. Thư viện là chỗ quyết định cái gì đi qua đó, nên thư viện buộc phải có quan điểm.

Cuối cùng nó thành một cái allow-list:

new TelegramAlert({
  strictMode: true,               // không có gì đi ra trừ khi được kê tên dưới đây
  specific: [
    { key: 'trace_id',   title: 'Trace' },
    { key: 'error_code', title: 'Code' },
    { key: 'message',    title: 'Message' },
  ],
})

Bật strictMode thì những trường không nằm trong specific bị loại trước khi dựng message — không phải che lúc render, mà là bỏ khỏi object. Còn đặt được theo từng kênh, chỗ này quan trọng: cùng một sự cố có thể vào topic kỹ thuật nội bộ ở dạng đầy đủ, và vào phòng rộng hơn ở dạng đã cắt gọt.

Nếu thiết kế lại, thứ duy nhất mình đổi là giá trị mặc định. strictMode đang mặc định false — gửi tất — vì đó là hành vi của đống code mình thay thế, và mình không muốn làm vỡ call site khi nâng cấp. Lý do đó là thật, và mình vẫn cho rằng quyết định đó sai. Chế độ an toàn phải là chế độ bạn nhận được khi không nghĩ gì cả.

2. Fail-soft không phải thứ có thì tốt

Một cái alert mà ném exception thì biến một sự cố thành hai, và sự cố thứ hai là lỗi của bạn. Nên hành vi khi lỗi được chốt ở hai chỗ.

Trong thư viện: failSilently (mặc định true) — ba trong năm kênh chết thì bạn nhận về báo cáo gửi, không phải một exception.

Và chốt lại lần nữa ở call site, vì mặc định của thư viện không phải một bảo đảm:

class TelegramProvider {
  constructor({ botToken, chatId }) {
    if (!botToken || !chatId) {
      this.client = null
      console.warn('⚠️ TelegramProvider disabled: thiếu botToken/chatId')
      return                       // thiếu config thì tắt alert, không chặn boot
    }
    this.client = new TelegramClient({ botToken, chatId, timeout: 10000 })
  }
 
  async send(options) {
    if (!this.client) return
    try {
      await this.client.sendMessage(options)
    } catch (error) {
      console.error('#telegram gửi lỗi', error)   // nuốt có chủ đích
    }
  }
}

Hai quyết định trong đó đáng gọi tên. Thiếu credential thì tắt tính năng cảnh báo, chứ không chặn service khởi động — không thì một lần xoay token là sập production. Và cái timeout, vì một nhà cung cấp chat đang có buổi sáng chậm chạp không được phép trở thành độ trễ của bạn.

3. Zero dependency, có chủ đích

Package có khối dependencies rỗng. Nó được cài ở hơn chục service; bất cứ thứ gì nó kéo theo thì cả mười hai service phải mang, mãi mãi, kể cả vào báo cáo audit của chúng.

Thứ duy nhất nó thật sự cần là một HTTP client, nên nó tự có một cái — khoảng 180 dòng trên node:http/node:https, expose get/post/put/patch/delete với response đúng hình dạng axios ({ data, status, headers }) để call site đọc lên y hệt như cũ. Timeout thì destroy socket, non-2xx thì reject kèm response, JSON thì parse được thì parse, không thì trả về text.

Đoạn code đó không khó, và bỏ công viết nó là đáng, khi phương án còn lại là nhét một cây dependency vào mười hai service production để tiết kiệm một buổi chiều.

4. Cái bug làm cho chính cái alert báo lỗi bị lỗi

Bộ parse Markdown cũ của Telegram từ chối nguyên message nếu có *, _, ` hoặc [ chưa escape. Nghĩa là một cái stack trace — đúng cái payload bạn cần gửi nhất — sẽ nhận về 400 thay vì một thông báo. Bạn biết tin sự cố qua đường khác, và không bao giờ biết là hệ thống cảnh báo của mình đã hỏng âm thầm.

Cách sửa là bốn dòng escape. Bài học là đường gửi đi phải có test riêng với input xấu tính, vì nó là đoạn code mà theo định nghĩa chỉ chạy đúng lúc mọi thứ khác đã hỏng.

5. Định tuyến, để cảnh báo còn đọc được

Alert dồn hết vào một phòng thì cũng như không có alert. Mỗi provider map service → loại → thao tác sang một message thread riêng theo sản phẩm, để lỗi search và lỗi booking không cài răng lược vào nhau trong cùng một luồng. Làm rất rẻ, mà là khác biệt giữa một kênh người ta đọc và một kênh người ta mute.

Cái sai mình để lộ ra ngoài

Lúc ngồi viết bài này, mình đọc lại README của chính cái package và thấy hai ảnh chụp alert thật ở mục "Live Demo" — chụp từ một môi trường đang chạy, hiện cả tên bot thật lẫn hình dạng payload thật. Một ảnh thứ ba thì đã bị comment lại vì lỗi thời, tức là không ai gỡ hai ảnh kia một cách có chủ đích cả.

Không có gì thảm hoạ. Nhưng cũng không phải không có gì: đó là cách đặt tên service nội bộ và hình dạng một payload thật, được publish lên npm, được index, và được mirror bởi mọi cái proxy package trên internet.

Ảnh chụp production chính là dữ liệu production. Mình sẽ không dán cái payload đó vào một issue public, và dán nó vào README là đúng hành động đó với ánh sáng đẹp hơn. Việc gỡ đang nằm trong danh sách; mình để nguyên chuyện này ở đây vì một bài học chỉ giữ lại phần đẹp thì không đáng đăng.

Điều rút ra

Bất cứ thứ gì đẩy dữ liệu ra khỏi process đều là một cái biên, nên phải có chính sách. Logging, alerting, error reporting, analytics — cái nào cũng trông như đường ống, và cái nào cũng là cửa ra. Một allow-list các trường là thay đổi hai dòng và nó đóng được cả một lớp vấn đề.

Giá trị mặc định chính là thiết kế. Không ai đọc bảng option. Hành vi bạn nhận được khi không cấu hình gì mới là hành vi bạn đã ship, nên lựa chọn an toàn phải là lựa chọn miễn phí.

Hệ thống quan sát không bao giờ được phép làm hỏng thứ nó đang quan sát. Fail-soft, timeout mọi thứ, và để việc thiếu cấu hình làm giảm tính năng chứ đừng làm chết service.

Zero dependency là một tính năng khi thứ đó được cài ở khắp nơi. Giá của việc tự viết HTTP client: một buổi chiều. Giá của một cây dependency nằm trong mười hai service production: vĩnh viễn.