ปัญหาที่เจอ
แอปที่ใช้งานจริงต้องรู้ว่าเกิด error อะไรขึ้นบ้าง Sentry ทำเรื่องนี้ได้ดีมาก แต่บางงานส่งข้อมูล error ออกไปนอกเซิร์ฟเวอร์ไม่ได้ เพราะ stack trace และ request มักมีข้อมูลของลูกค้าติดไปด้วย ส่วนการตั้ง Sentry แบบ self-hosted เองก็ต้องดูแลระบบใหญ่อีกชุด ซึ่งเกินความจำเป็นสำหรับแอปเดียว
ผมเลยทำ Watchtower ให้รับข้อมูลในรูปแบบเดียวกับ Sentry แอปจึงใช้ Sentry SDK ตัวจริงต่อได้เลย และในโหมด standalone ทุกอย่างอยู่ในแอป Laravel ตัวเดียว ทั้งตารางเก็บ error หน้าดู issue และ MCP ไม่ต้องมีเซิร์ฟเวอร์เพิ่ม
ทำงานอย่างไร
flowchart LR
B[เบราว์เซอร์] -->|/api/watchtower-relay| I[Ingest]
L[Exception ใน Laravel] -->|self-capture| I
O[แอปอื่นที่ใช้ Sentry SDK] -->|DSN ของโปรเจกต์| I
I -->|scrub · ตัดขนาด · rate limit| Q[(queue)]
Q --> D[(ตาราง watchtower_*)]
D --> U[หน้า /watchtower]
D --> A[อีเมลแจ้งเตือน]
D --> M[MCP ให้ Claude]error จากทุกทางเข้าผ่านขั้นตอนเดียวกันก่อนลงฐานข้อมูลของแอป
- ฝั่ง server ใช้
sentry/sentry-laravelตามปกติ แต่ในโหมด standalone แพ็กเกจเปลี่ยน transport ของ SDK ให้ส่ง event เข้า ingest ในโปรเซสเดียวกันเลย ไม่ต้องยิง HTTP กลับเข้าแอปตัวเอง - ฝั่งเบราว์เซอร์ ใช้
@sentry/browserส่งเข้า/api/watchtower-relayซึ่งอยู่ใน origin เดียวกับแอป ad blocker จึงไม่รู้ว่าเป็น traffic ของ Sentry และไม่บล็อก - ก่อนลงฐานข้อมูล ทุก event ถูกลบข้อมูลลับ (รวมถึงค่าของแถวใน SQL error) ตัดขนาดไม่ให้เกิน 200 KB และนับ rate limit ต่อโปรเจกต์และต่อ issue
ติดตั้งแบบ standalone
composer require phattarachai/watchtower-laravel
php artisan watchtower:install --standalone
npm run build
php artisan watchtower:doctor
watchtower:install --standalone ทำให้ครบ
- migrate ตาราง
watchtower_*6 ตาราง แล้วสร้างโปรเจกต์แรกตามชื่อAPP_NAME - เขียน env และ patch
bootstrap/app.phpให้ Sentry จับ exception ระหว่างนี้จะถามว่าจะส่งข้อมูลส่วนตัว (PII) ไปกับ event ไหม ค่าเริ่มต้นคือไม่ส่ง - ถ้าแอปใช้ Inertia จะ publish หน้า
resources/js/pages/Watchtower.jsxเพิ่ม alias@watchtowerใน Vite และสร้างresources/css/watchtower.cssซึ่งเป็น stylesheet แยกของหน้า UI คลาสทุกตัวมี prefixtw:จึงไม่ชนกับ CSS ของแอป - ถ้าเครื่องมีคำสั่ง
claudeจะลงทะเบียน MCP server ให้ Claude Code ใน.mcp.jsonของโปรเจกต์
คำสั่งนี้รันซ้ำได้ไม่มีผลเสีย และใส่ --dry-run เพื่อดูก่อนว่าจะแก้อะไรบ้าง
ให้สิทธิ์เข้าหน้า /watchtower
สิ่งเดียวที่ต้องทำเองคือกำหนดว่าใครเข้าหน้า issue ได้ บนเครื่อง local เปิดให้ทุกคน ส่วน environment อื่นตอบ 403 จนกว่าจะกำหนด
use Phattarachai\WatchtowerLaravel\Watchtower;
public function boot(): void
{
Watchtower::auth(fn ($request): bool => $request->user()?->isAdmin() === true);
}
หรือจะประกาศ gate ชื่อ viewWatchtower แทนก็ได้
ตรวจว่าครบ
watchtower:doctor ไล่ตรวจทุกอย่างที่แอปต้องมี ได้แก่ ตาราง, route, หน้า UI, alias ของ Vite, บรรทัด Tailwind, โปรเจกต์ที่ active, mailer, queue, MCP และ self-capture แล้วบอกว่าข้อไหนยังขาด
หน้า /watchtower
รายการ issue
error ที่มีต้นเหตุเดียวกันถูกรวมเป็น issue เดียว พร้อมจำนวน event และจำนวนผู้ใช้ที่เจอ
- แท็บด้านบนแยก issue ตามสถานะ คือ unresolved, resolved, ignored และ snoozed
- ค้นจากชื่อ issue และกรองตามโปรเจกต์ ระดับ (debug ถึง fatal) และ environment ได้
- แต่ละแถวบอกว่าเป็น error ฝั่ง
phpหรือjavascriptเกิดครั้งแรกและล่าสุดเมื่อไหร่ เกิดไปกี่ครั้ง กับผู้ใช้กี่คน - ชื่อ issue ถูกแปลงตัวเลขยาว ๆ เป็น
<N>วันที่เป็น<DATE>และข้อความในเครื่องหมายคำพูดเป็น<STR>เพื่อให้ error แบบเดียวกันที่ต่างกันแค่ค่า เช่นเลขออเดอร์ รวมเป็น issue เดียว ข้อความเต็มดูได้ในหน้ารายละเอียด
จัดการหลาย issue พร้อมกัน
ติ๊กหลายแถวแล้วสั่ง Resolve, Ignore, Snooze หรือ Delete ได้ในครั้งเดียว
Snooze คือซ่อน issue ไว้ชั่วคราว 1 ชั่วโมงถึง 30 วัน เหมาะกับ error ที่รู้สาเหตุแล้วแต่ยังแก้ไม่ได้ทันที เช่น API ของผู้ให้บริการภายนอกล่ม
รายละเอียด issue
stack trace เปิดเฟรมแรกที่เป็นโค้ดของแอปไว้ให้ ไฮไลต์บรรทัดที่เกิด error และแสดง exception ต้นเหตุต่อท้าย
- แถบ event บอก event id, environment, release (
[email protected]) และ route ที่เกิด error กด Newer / Older เพื่อดู event อื่นของ issue เดียวกัน หรือเลือกจาก Recent events ด้านขวา - Stacktrace แสดง exception ที่ถูก throw ก่อน แล้วตามด้วยส่วน Caused by ของ exception ที่เป็นต้นเหตุ เช่น
PDOExceptionที่อยู่ใต้ error ของ Laravel ในแต่ละส่วน เฟรมของโค้ดแอปแสดงก่อน ส่วนเฟรมของ vendor ซ่อนไว้ใต้ "Show vendor frames" - Copy Markdown คัดลอกรายละเอียดของ issue เป็น Markdown ที่วางให้ Claude อ่านต่อได้ทันที
- Resolve, Ignore และ Snooze 24h อยู่มุมขวาบน ถ้า issue ที่ resolve หรือ ignore ไว้กลับมาเกิดอีก จะนับเป็น regression และกลับไปอยู่ในแท็บ unresolved
แท็บอื่นของ event เดียวกันช่วยให้รู้ว่าเกิดอะไรขึ้นก่อน error
Request: URL, method, header และข้อมูลที่ส่งมา cookie ถูกเปลี่ยนเป็น [Filtered] ก่อนบันทึก
Breadcrumbs: cache และ SQL ที่รันก่อน error ค่าในคำสั่ง SQL เป็น ? ทั้งหมด จึงไม่มีข้อมูลลูกค้าติดมา
ส่วนแท็บ User บอกว่า error เกิดกับผู้ใช้คนไหน (id, email, name จาก @watchtowerUser) และแท็บ Tags แสดง tag ที่ SDK ส่งมา เช่น locale และ route
error จากเบราว์เซอร์
error ฝั่ง JavaScript อยู่ในรายการเดียวกับ error ฝั่ง PHP แต่ละเฟรมบอกไฟล์ที่ Vite build ออกมา พร้อมบรรทัดและคอลัมน์
แจ้งเตือนทางอีเมล
ตั้งได้หลายกฎ แต่ละกฎเลือกโปรเจกต์ ระดับขั้นต่ำ environment และผู้รับ
กฎมี 4 แบบ
| แบบ | ส่งอีเมลเมื่อ |
|---|---|
| New issue | มี issue ใหม่ที่ไม่เคยเกิดมาก่อน |
| Regression | issue ที่ resolve หรือ ignore ไว้ หรือหมดเวลา snooze แล้ว กลับมาเกิดอีก |
| Threshold spike | เกิดเกินจำนวนที่ตั้งภายในช่วงเวลา เช่น 20 ครั้งใน 300 วินาที |
| Milestone | issue หนึ่งเกิดครบจำนวนที่ตั้ง เช่น 500 ครั้ง |
Cooldown กันไม่ให้กฎเดิมส่งอีเมลซ้ำถี่เกินไป ปุ่ม Send test ส่งอีเมลทดสอบให้ผู้รับได้ทันที และปิดกฎชั่วคราวได้โดยไม่ต้องลบ (กฎที่ปิดอยู่ขึ้นป้าย paused)
โปรเจกต์และ DSN
แต่ละโปรเจกต์มี DSN ของตัวเอง พร้อมตัวอย่างการตั้งค่าสำหรับ Laravel, JavaScript, Next.js และ WordPress
key ถูกซ่อนไว้ทุกที่ทั้งใน DSN และตัวอย่างการตั้งค่า กด Reveal key เมื่อต้องการดู ส่วนปุ่ม Copy คัดลอกค่าจริงให้เสมอ นอกจากนี้ยังสร้างโปรเจกต์ใหม่ เปลี่ยน key (Rotate key) หรือปิดรับ event ของโปรเจกต์ได้จากหน้านี้ ทำแบบเดียวกันจาก command line ได้ด้วย watchtower:project (ดูหัวข้อ "รับ error จากแอปอื่นด้วย")
ฝั่งเบราว์เซอร์
ตอนติดตั้ง แพ็กเกจจะวาง helper ไว้ที่ resources/js/vendor/watchtower.js ซึ่งตั้งค่า Sentry ไว้ให้แล้ว ทั้ง tunnel, ไม่เก็บข้อมูลส่วนตัว และไม่รับ error จาก browser extension ใน entry ของ Vite เรียกแค่นี้
import { initWatchtower } from './vendor/watchtower.js';
initWatchtower();
ใส่ directive นี้ใน <head> ของ layout หลัก error จากเบราว์เซอร์จะได้รู้ว่าเกิดกับผู้ใช้คนไหน
<head>
@watchtowerUser
</head>
@watchtowerUser สร้าง meta tag 3 ตัว คือ id, email และ name ของผู้ใช้ที่ล็อกอินอยู่ ถ้าเป็น Filament panel ซึ่งไม่ได้ใช้ layout หลัก ให้ใส่ผ่าน render hook
$panel->renderHook(
PanelsRenderHook::HEAD_END,
fn (): string => Blade::render('@watchtowerUser'),
);
ให้ Claude ช่วยไล่ issue ผ่าน MCP
ถ้าติดตั้ง laravel/mcp ไว้ แอปจะมี MCP server ที่ /watchtower/mcp ยืนยันตัวด้วย public key ของโปรเจกต์ และ watchtower:install ลงทะเบียนให้ Claude Code อัตโนมัติ
composer require laravel/mcp
จากนั้นถาม Claude ได้เลย เช่น "มี issue อะไรใหม่ตั้งแต่ deploy เมื่อวาน" Claude จะเรียก tool อย่าง list_issues, get_issue และ list_events เพื่ออ่าน stack trace จริง ไล่หาสาเหตุในโค้ด แล้วใช้ resolve_issue, ignore_issue หรือ snooze_issue ปิดงานให้ เมื่อแก้เสร็จ
รับ error จากแอปอื่นด้วย
แอปที่ติดตั้งแบบ standalone เป็นที่รับ error ให้ระบบอื่นที่ใช้ Sentry SDK ได้ด้วย เช่น เว็บ WordPress หรือ frontend Next.js ที่ทำงานคู่กัน สร้างโปรเจกต์ใหม่แล้วเอา DSN ไปใส่
php artisan watchtower:project create "Storefront"
php artisan watchtower:project list
DSN มีรูปแบบ https://{public_key}@{host}/watchtower/{project_id} คือมี prefix watchtower อยู่หน้าเลขโปรเจกต์ และไม่มี api เพราะ SDK เติม /api/{project_id}/envelope/ เอง ถ้าใส่ api เองหรือไม่ใส่ prefix event จะได้ 404 แบบเงียบ ๆ
ก่อนขึ้น production
เพิ่ม supervisor ใน Horizon และ deploy ให้เรียบร้อยก่อน แล้วค่อยชี้ Watchtower ไปที่ queue นั้น ถ้าทำกลับกัน event จะไปกองอยู่ใน queue ที่ไม่มีใครรับ
'app-supervisor-watchtower' => [
'connection' => 'redis',
'queue' => ['watchtower'],
'balance' => false,
'maxProcesses' => 1,
'tries' => 3,
'timeout' => 60,
],
WATCHTOWER_QUEUE_NAME=watchtower
maxmemory 1gb
maxmemory-policy noeviction
ทำไมต้องแยก queue และจำกัด memory: ตัวติดตาม error อยู่ในแอปเดียวกัน ใช้ queue และ Redis ชุดเดียวกับงานอื่นของแอป ถ้าวันไหน error ทะลักเข้ามาพร้อมกันจำนวนมาก งานของ Watchtower ต้องไม่ไปแย่งที่จนอีเมลหรือการชำระเงินของแอปค้าง และต้องไม่ทำให้ Redis เต็มจนทั้งแอปล่ม แพ็กเกจจึงกันไว้หลายชั้น
- ไม่รายงาน error ของตัวเอง ถ้า job ที่ประมวลผล event ล้มเหลว error นั้นจะถูกทิ้ง ไม่ถูกส่งกลับเข้า Watchtower เป็น job ใหม่ ซึ่งจะวนซ้ำไม่จบ
- จำกัดจำนวนที่รับ มี rate limit ต่อโปรเจกต์และต่อ issue และเมื่อมีงานค้างใน queue เกิน 5,000 ตัว event ที่เข้ามาใหม่จะถูกนับไว้แต่ไม่เพิ่มเข้า queue
- จำกัดขนาด แต่ละ event ถูกตัดให้ไม่เกิน 200 KB ก่อนเข้า queue
เมื่อตั้ง Redis เป็น noeviction ถ้า Redis เต็มจริง ๆ ระบบจะปฏิเสธการเขียน event ที่เข้ามาตอนนั้นจะไม่ถูกเก็บ แต่ request ของผู้ใช้ยังทำงานต่อได้ตามปกติ
ค่าที่ปรับได้ใน .env
| ตัวแปร | ค่าเริ่มต้น | ใช้ทำอะไร |
|---|---|---|
WATCHTOWER_RATE_LIMIT_PER_MIN |
300 |
event ต่อนาทีต่อโปรเจกต์ |
WATCHTOWER_RATE_LIMIT_PER_FINGERPRINT_PER_MIN |
20 |
event ต่อนาทีต่อ issue ส่วนที่เกินนับไว้แต่ไม่เก็บ |
WATCHTOWER_MAX_QUEUE_DEPTH |
5000 |
งานค้างเกินนี้จะนับไว้แต่ไม่เข้า queue |
WATCHTOWER_RETENTION_DAYS |
90 |
เก็บ event กี่วัน (watchtower:prune รันทุกวันเอง) |
WATCHTOWER_REDACT_SQL_VALUES |
true |
ลบค่าของแถวออกจาก SQL error |
ข้อควรรู้
- หน้า UI ต้องใช้ Inertia + React และ Tailwind v4 ผ่าน Vite แพ็กเกจไม่บังคับติดตั้ง Inertia ถ้าแอปเป็น Livewire, Filament หรือ Blade ให้ตั้ง
WATCHTOWER_UI_ENABLED=falseแอปจะยังรับ error, self-capture, ส่งอีเมลแจ้งเตือน และมี MCP ครบ แค่ไม่มีหน้า/watchtowerก็ใช้ Claude ไล่ issue แทน - หน้า UI ตามธีมของแอป ถ้าแอปใช้ dark mode ด้วยคลาส
.darkหน้า/watchtowerจะเป็นสีเข้มตาม ถ้าอยากล็อกไว้ ตั้งWATCHTOWER_UI_THEMEเป็นlightหรือdark - ไม่ต้องมี Redis ก็ใช้ได้ queue แบบ
syncก็รับ event ได้ แต่ event จะถูกประมวลผลระหว่าง request ที่รายงาน error นั้น บน production จึงควรมี queue worker - โหมด relay และ dual มีไว้สำหรับส่ง error ไปที่ Watchtower server กลาง ซึ่งเราใช้ภายในกับแอปหลายตัว โหมดที่ใช้งานได้ทันทีคือ standalone
เวอร์ชันที่รองรับ
- PHP 8.4 ขึ้นไป
- Laravel 12 และ 13
sentry/sentry-laravel4.x (ติดตั้งมาพร้อมแพ็กเกจ)
GitHub: phattarachai/watchtower-laravel


