Ghi log hoạt động người dùng (activity log): capture, hàng đợi bất đồng bộ, lưu trữ — so sánh Laravel & NestJS
Đề bài phỏng vấn: hệ thống cần ghi lại lịch sử hoạt động của người dùng (ai làm gì, lúc nào, trên dữ liệu nào) để phục vụ audit, hỗ trợ khách hàng khi có tranh chấp, và truy vết khi có sự cố an ninh. Em thiết kế cơ chế ghi activity log này thế nào? Viết log ngay trong request, cùng transaction với hành động nghiệp vụ, có ổn không? Và khi dữ liệu log lớn dần theo thời gian thì lưu ở đâu?
Trả lời ngắn:
- Activity log không phải error log, không phải metric. Error log kể chuyện hệ thống bị lỗi; metric đếm số đo theo thời gian để cảnh báo; activity log là bằng chứng nghiệp vụ — "user 42 đã đổi giá sản phẩm 7 từ 100.000 sang 120.000 lúc 10:03". Ba loại trả lời ba câu khác nhau, gộp chung là nguồn gây nhầm lẫn khi audit.
- Đừng ghi log đồng bộ, cùng transaction với hành động nghiệp vụ. Ghi thêm 1 dòng vào
activity_logsngay trong request làm transaction dài hơn, cộng thêm một write vào DB (write amplification), và khiến hành động nghiệp vụ thất bại theo nếu bảng log có vấn đề (hết disk, deadlock, index phình) — hai failure domain lẽ ra độc lập bị khoá chung vào nhau.- Tách thành 3 tầng: capture → hand-off bất đồng bộ → storage. Laravel: Middleware/Model Observer/Event bắt hành động,
ShouldQueuelistener (hoặc packagespatie/laravel-activitylog) đẩy việc ghi ra khỏi request. NestJS:NestInterceptor/Guard bắt hành động,EventEmitter2(@nestjs/event-emitter) hoặc BullMQ (@nestjs/bullmq) làm hàng đợi.- Lưu vào bảng riêng, có partition theo thời gian, index theo người dùng/đối tượng, và che PII trước khi ghi. Khi lưu lượng rất lớn (ghi hàng trăm event/giây liên tục trên một nguồn), thêm một buffer kiểu Redis Stream + worker gom batch, cùng cơ chế với bài Forum like không trùng với Redis, thay vì 1 queue job cho mỗi event.
Về số liệu trong bài: bài này không chạy lab thật trong lần viết này — không có môi trường Laravel/NestJS/Redis dựng sẵn ở máy viết bài để đo số. Mọi tên class, tên method, tên package được kiểm tra qua tài liệu chính thức (laravel.com/framework/docs, docs.nestjs.com, spatie.be/docs/laravel-activitylog, docs.bullmq.io) tại thời điểm viết (10/2026), ghi kèm số version khi nguồn có nêu. Con số nào xuất hiện trong bài mà không phải đo thật sẽ được gắn rõ "ví dụ minh hoạ, không phải số đo thật"; phần nào chỉ suy luận từ cơ chế, chưa dựng lại được trong lab sẽ ghi "chưa verify".
Thuật ngữ dùng trong bài
| Thuật ngữ | Nghĩa ngắn |
|---|---|
| activity log / audit log | Lịch sử hành động của người dùng trên hệ thống (login, CRUD, đổi quyền...), ghi lại để tra cứu sau. Khác log lỗi (error log) và số đo (metrics). |
| middleware | Lớp xử lý nằm giữa request và route handler (Laravel). Chạy trước/sau handler cho mọi route khớp. |
| interceptor | Khái niệm tương đương middleware ở NestJS, nhưng bọc quanh cả handler bằng RxJS Observable nên can thiệp được cả trước và sau khi handler trả kết quả. |
| event listener | Hàm/class chạy khi một event được phát ra, tách rời khỏi code phát event (decoupling). Laravel: Event::listen/class Listener. NestJS: @OnEvent. |
| queue / job | Hàng đợi tác vụ chạy sau, không chặn request. Laravel Queue (ShouldQueue), NestJS BullMQ (@nestjs/bullmq). |
| buffer | Chỗ giữ dữ liệu tạm trước khi ghi xuống nơi lưu lâu dài, thường để gom nhiều bản ghi thành một lô (batch) ghi rẻ hơn. |
| backpressure | Tín hiệu "đang quá tải, chậm lại" khi bên tiêu thụ (consumer/worker) không theo kịp bên sinh ra dữ liệu. Không xử lý thì hàng đợi phình vô hạn hoặc dữ liệu bị rớt. |
| idempotent | Chạy lại nhiều lần cho cùng kết quả như chạy một lần. Quan trọng với worker vì hàng đợi thường chỉ đảm bảo "giao ít nhất một lần" (at-least-once). |
| partitioning | Chia một bảng lớn thành nhiều bảng con theo tiêu chí (thường theo thời gian), để query và xoá dữ liệu cũ nhanh hơn, không phải quét cả bảng. |
| PII masking | Che/ẩn dữ liệu định danh cá nhân (PII — Personally Identifiable Information: email, số điện thoại, địa chỉ IP đầy đủ...) trước khi ghi vào nơi lưu lâu dài hoặc gửi đi nơi khác. |
| structured logging | Ghi log dưới dạng có cấu trúc (JSON) với field cố định, thay vì một chuỗi văn bản tự do — máy đọc/lọc được, không cần regex. |
| correlation id / request id | Một mã duy nhất gắn vào một request, in ra ở mọi tầng/log liên quan, để nối các dòng log của cùng một request lại với nhau. |
| retention policy | Quy định giữ dữ liệu bao lâu rồi xoá/lưu trữ lạnh (archive). Audit log thường có yêu cầu giữ tối thiểu (pháp lý), khác với log debug chỉ cần giữ vài ngày. |
| write amplification | Một hành động nghiệp vụ sinh ra nhiều hơn một lần ghi xuống đĩa/DB. Ghi activity log đồng bộ cùng transaction nghiệp vụ là một dạng write amplification. |
1. Bài toán gốc và bối cảnh ra đời
Hầu hết hệ thống B2B/SaaS, sau một thời gian chạy, đều nhận câu hỏi dạng: "Ai đã xoá đơn hàng này?", "Giá sản phẩm bị đổi lúc nào, ai đổi?", "Nhân viên X có quyền xem dữ liệu khách hàng Y không, họ có từng xem chưa?". Không có activity log, câu trả lời là không biết — chỉ có thể suy luận từ dữ liệu hiện tại (updated_at, version mới nhất), mất hết lịch sử trung gian.
Nhu cầu ghi activity log thường xuất hiện từ ba hướng:
- Compliance/audit — ngành tài chính, y tế, hoặc hợp đồng B2B yêu cầu lưu vết thao tác trên dữ liệu nhạy cảm, có khi bắt buộc theo luật (ví dụ các quy định kiểu GDPR về việc phải biết ai truy cập dữ liệu cá nhân nào).
- Hỗ trợ khách hàng / vận hành — admin cần xem "đơn hàng này đã qua những trạng thái gì" để trả lời khách, không phải hỏi lại kỹ sư.
- Điều tra an ninh — khi nghi có truy cập trái phép, activity log là nguồn duy nhất trả lời "tài khoản này đã làm gì" theo trình tự thời gian.
Sai lầm hay gặp nhất ở bước thiết kế đầu tiên là lẫn ba loại dữ liệu quan sát khác nhau (mô tả kỹ ở bài Bộ công cụ để vận hành):
| Loại | Trả lời câu hỏi | Ví dụ | Ai đọc |
|---|---|---|---|
| Activity / audit log | Ai đã làm gì, trên đối tượng nào, lúc nào? | "user 42 updated Product#7: price 100000 → 120000" | Admin, auditor, support, pháp lý |
| Error log | Hệ thống bị lỗi gì, ở đâu? | "SQLSTATE[23505] duplicate key..." | Kỹ sư vận hành |
| Metrics | Có bất thường không, từ khi nào? | p95 route /checkout = 820ms | Dashboard, alert |
Cách làm đơn giản nhất — và cũng là cách hay bị chọn nhầm cho production — là chèn thêm một
câu INSERT INTO activity_logs ngay trong cùng method, cùng transaction với hành
động nghiệp vụ:
DB::transaction(function () use ($order, $request) {
$order->update(['status' => 'cancelled']);
ActivityLog::create([ // ← ghi log ngay trong transaction nghiệp vụ
'user_id' => $request->user()->id,
'action' => 'order.cancelled',
'subject' => $order->id,
]);
});
Chạy đúng lúc ít request thì không sao. Nhưng cách này cõng theo ba vấn đề âm thầm, càng lớn càng đau:
- Thêm latency vào đường nóng của người dùng — mỗi request nghiệp vụ giờ cõng thêm một write, dù người dùng không quan tâm tới activity log của chính họ ngay lúc đó.
- Write amplification trên cùng một DB — bảng log tăng rất nhanh (mọi hành động đều ghi), cạnh tranh I/O và connection pool với bảng nghiệp vụ chính.
- Coupling hai failure domain lẽ ra độc lập — bảng
activity_logsphình, thiếu index, hoặc hết dung lượng đĩa thì hành động nghiệp vụ cũng rollback theo, dù bản chất activity log chỉ nên là "best effort ghi thêm", không phải điều kiện để đơn hàng huỷ thành công hay không.
2. Mô hình tư duy cốt lõi
Giải pháp chung cho cả Laravel và NestJS là tách activity logging ra khỏi đường nghiệp vụ chính bằng ba tầng độc lập:
- Capture — một điểm chặn đứng (middleware/interceptor) hoặc một điểm bắt sự kiện (model observer/event) ghi lại "cái gì vừa xảy ra", không tự ghi xuống DB ngay.
- Async hand-off — việc ghi thật được đẩy ra khỏi request bằng event + queued listener, hoặc một hàng đợi riêng (BullMQ, Redis Stream), chạy ở một process khác.
- Storage — một nơi lưu tối ưu cho việc ghi nhiều, đọc theo user/subject, hiếm khi update — khác hẳn đặc tính của bảng nghiệp vụ.
flowchart LR
subgraph WEB["Request - duong nong"]
CL["Client"] --> MW["Middleware / Interceptor
bat request, gan request_id"]
MW --> BIZ["Business handler
Controller / Service"]
BIZ --> DB1["DB nghiep vu
orders, products..."]
end
BIZ -->|"emit event, khong cho ghi log"| EV["Event
Laravel Event / EventEmitter2"]
BIZ -.->|"tra response ngay"| CL
subgraph ASYNC["Hand-off bat dong bo"]
EV --> Q["Queue
Laravel Queue / BullMQ"]
end
Q --> W["Worker
Queued Listener / Processor"]
subgraph STORE["Storage"]
W --> LOGDB["activity_logs
partition theo thang, index user_id"]
end
Điểm mấu chốt là nhánh BIZ -.-> CL: response trả về người dùng không
chờ việc ghi log hoàn tất. Nếu worker chết, request nghiệp vụ vẫn thành công — đổi lại
là activity log có thể trễ vài giây, hoặc trong trường hợp xấu nhất, mất một event (bàn kỹ ở mục
4 và 5).
3. Internals và quyết định thiết kế
3.1 Tầng capture — Laravel
Ba cách bắt sự kiện, dùng cho ba nhu cầu khác nhau, theo tài liệu chính thức Laravel (nhánh
laravel/docs, bản 12.x):
| Cách | Bắt được gì | Khi dùng |
|---|---|---|
| Middleware | Mọi request khớp route — method, URI, status code, ai gọi | Log truy cập API, không cần biết chi tiết nghiệp vụ bên trong (ai gọi GET /api/orders) |
| Model Observer / Eloquent event | Vòng đời model: created, updated, deleted, kèm field nào đổi | Log CRUD chi tiết trên một model cụ thể, không phải sửa code ở mọi controller gọi tới model đó |
Event tự phát (event(new ...)) | Đúng nghiệp vụ, đặt tên theo domain (ví dụ OrderCancelled) | Hành động không map 1-1 với một model (đổi quyền, xuất báo cáo, duyệt đơn nhiều bước) |
Middleware ghi context cho mọi log về sau (cách này trùng với kỹ thuật request id đã nói ở bài Bộ công cụ để vận hành):
class LogApiAccess
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
if ($request->user()) {
event(new ActivityLogged(
userId: $request->user()->id,
action: $request->method() . ' ' . $request->path(),
subject: null,
properties: ['status' => $response->getStatusCode()],
));
}
return $response; // response đã trả, event mới phát ra sau — không chặn client
}
}
Model Observer bắt chi tiết field nào đổi, dùng Illuminate\Support\Facades\Event
hoặc đăng ký observer trực tiếp trên model (API observer theo tài liệu Eloquent của
Laravel 12.x):
class Product extends Model
{
protected static function booted(): void
{
static::updated(function (Product $product) {
if ($product->isDirty('price')) {
event(new ActivityLogged(
userId: auth()->id(),
action: 'product.price_changed',
subject: $product->id,
properties: [
'from' => $product->getOriginal('price'),
'to' => $product->price,
],
));
}
});
}
}
Off-the-shelf: spatie/laravel-activitylog v5 (yêu cầu PHP
8.4+ và Laravel 12+ theo ghi chú phát hành v5 của Spatie) tự làm việc ở trên bằng một trait,
không cần tự viết observer cho từng model:
use Spatie\Activitylog\Models\Concerns\LogsActivity;
use Spatie\Activitylog\Support\LogOptions;
class Product extends Model
{
use LogsActivity;
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['price', 'name'])
->logOnlyDirty(); // chỉ ghi khi field thực sự đổi
}
}
Package này ghi đồng bộ vào bảng activity_log ngay trong request
theo mặc định — tiện cho audit trail đơn giản, lưu lượng thấp (ví dụ trang quản trị nội bộ),
nhưng quay lại đúng vấn đề ở mục 1 nếu site có lưu lượng lớn. Muốn bất đồng bộ phải tự thêm lớp
queue ở trên (mục 3.2), package không làm sẵn việc đó.
3.2 Tầng hand-off bất đồng bộ — Laravel Queue
Event ActivityLogged ở trên chỉ là một DTO; việc ghi DB nằm trong listener, và
listener đó implement ShouldQueue để Laravel tự đẩy nó vào hàng đợi thay vì chạy
ngay (theo tài liệu Events của Laravel):
class ActivityLogged
{
public function __construct(
public readonly ?int $userId,
public readonly string $action,
public readonly ?int $subject,
public readonly array $properties = [],
) {}
}
class LogActivityListener implements ShouldQueue
{
public $queue = 'activity-log'; // queue riêng, không cạnh tranh với job nghiệp vụ
public $tries = 3;
public $afterCommit = true; // chờ transaction nghiệp vụ COMMIT xong mới chạy
public function handle(ActivityLogged $event): void
{
ActivityLog::create([
'user_id' => $event->userId,
'action' => $event->action,
'subject_id' => $event->subject,
'properties' => $this->maskPii($event->properties),
]);
}
private function maskPii(array $properties): array
{
foreach (['email', 'phone', 'ip'] as $field) {
if (isset($properties[$field])) {
$properties[$field] = Str::mask($properties[$field], '*', 3);
}
}
return $properties;
}
}
$afterCommit = true là chi tiết hay bị quên: nếu event được phát ra trong
một DB::transaction() nghiệp vụ mà queue connection không chạy ngay (ví dụ driver
database/redis), listener có thể cố ghi log cho một bản ghi mà
transaction chứa nó chưa commit — ghi log cho dữ liệu "chưa từng tồn tại" nếu transaction sau đó
rollback. Theo tài liệu Queue của Laravel, đặt $afterCommit trên listener (hoặc
after_commit ở cấu hình connection) để job chỉ thực sự được đẩy vào hàng đợi sau khi
mọi transaction đang mở đã commit xong.
3.3 Tầng capture và hand-off — NestJS
Tương đương middleware là NestInterceptor, bọc quanh handler bằng
Observable của RxJS (theo tài liệu Interceptors của NestJS):
@Injectable()
export class ActivityLogInterceptor implements NestInterceptor {
constructor(private readonly events: EventEmitter2) {}
intercept(ctx: ExecutionContext, next: CallHandler): Observable<any> {
const req = ctx.switchToHttp().getRequest();
return next.handle().pipe(
tap(() => {
// fire-and-forget: không await, không chặn response đã trả cho client
this.events.emit('activity.logged', {
userId: req.user?.id,
action: `${req.method} ${req.path}`,
requestId: req.id,
});
}),
);
}
}
Lắng nghe event bằng @nestjs/event-emitter (EventEmitter2) — cách này không
đảm bảo bền (durable): nếu process Node chết giữa lúc event đang xử lý, event mất, không
có nơi nào giữ lại để chạy lại:
@Injectable()
export class ActivityLogListener {
constructor(private readonly repo: ActivityLogRepository) {}
@OnEvent('activity.logged', { suppressErrors: true })
async handle(payload: ActivityPayload): Promise<void> {
await this.repo.insert({
...payload,
properties: this.maskPii(payload.properties),
});
}
}
Khi cần bền hơn — chấp nhận delay vài giây nhưng không chấp nhận mất event
khi process chết — chuyển sang BullMQ (@nestjs/bullmq, dùng Redis để giữ job, theo
tài liệu Queues của NestJS):
@Injectable()
export class ActivityLogInterceptor implements NestInterceptor {
constructor(@InjectQueue('activity-log') private readonly queue: Queue) {}
intercept(ctx: ExecutionContext, next: CallHandler): Observable<any> {
const req = ctx.switchToHttp().getRequest();
return next.handle().pipe(
tap(() => {
this.queue.add('log', {
userId: req.user?.id,
action: `${req.method} ${req.path}`,
}, { removeOnComplete: true, attempts: 3 });
}),
);
}
}
@Processor('activity-log')
export class ActivityLogProcessor extends WorkerHost {
constructor(private readonly repo: ActivityLogRepository) { super(); }
async process(job: Job<ActivityPayload>): Promise<void> {
await this.repo.insert(this.maskPii(job.data));
}
}
Khác biệt cốt lõi so với EventEmitter2: job nằm trong Redis cho tới khi worker
ACK xong, nên worker restart giữa lúc xử lý thì BullMQ giao lại job đó — cùng bản
chất "giao ít nhất một lần" (at-least-once) như Redis Stream ở bài
Forum like không trùng với Redis.
3.4 Tầng storage
Bảng riêng, không chung với bảng nghiệp vụ, tối ưu cho ghi nhiều — đọc theo người dùng/đối tượng, hiếm khi update hoặc không bao giờ update (append-only):
CREATE TABLE activity_logs (
id bigint GENERATED ALWAYS AS IDENTITY,
occurred_at timestamptz NOT NULL DEFAULT now(),
user_id bigint, -- NULL nếu hành động hệ thống tự làm
action text NOT NULL, -- 'order.cancelled', 'product.price_changed'
subject_type text,
subject_id bigint,
properties jsonb NOT NULL DEFAULT '{}',
request_id text, -- nối ngược về log request/trace
PRIMARY KEY (id, occurred_at)
) PARTITION BY RANGE (occurred_at);
CREATE TABLE activity_logs_2026_10 PARTITION OF activity_logs
FOR VALUES FROM ('2026-10-01') TO ('2026-11-01');
CREATE INDEX ON activity_logs_2026_10 (user_id, occurred_at DESC);
CREATE INDEX ON activity_logs_2026_10 (subject_type, subject_id, occurred_at DESC);
Partition theo tháng để: (a) truy vấn "lịch sử của đối tượng X" chỉ cần quét partition liên
quan nếu kèm điều kiện thời gian; (b) xoá dữ liệu quá hạn retention là DROP
TABLE một partition — rẻ hơn rất nhiều so với DELETE hàng triệu dòng và để
lại dead tuple cho autovacuum dọn (xem cơ chế dead tuple ở bài
Bộ công cụ để vận hành).
Khi nào tách sang store riêng (Elasticsearch/ClickHouse/time-series)? Khi
nhu cầu đọc chính là "tìm kiếm full-text trong properties" hoặc "truy vấn phân tích tổng hợp
hàng tỉ dòng" — hai việc PostgreSQL làm được nhưng không phải thế mạnh ở quy mô lớn. Dấu hiệu
cụ thể: dashboard audit chạy chậm, hoặc jsonb trong properties bị
query bằng điều kiện không có index (properties->>'field' = ? trên hàng chục
triệu dòng) nằm trong top pg_stat_statements theo total_exec_time.
Không tách trước khi có dấu hiệu đó.
4. Đảm bảo và giới hạn
| Thuộc tính | Mô hình này đảm bảo gì | Giới hạn |
|---|---|---|
| Độ trễ đường nghiệp vụ | Không tăng đáng kể — việc ghi log không nằm trên critical path, response trả về ngay sau khi emit event/job | Nếu interceptor/listener vẫn await việc ghi log (quên fire-and-forget) thì mất hết lợi ích này |
| Tính bền của event | Laravel Queue (driver database/redis) và BullMQ: at-least-once — job còn trong hàng đợi tới khi worker ACK xong | EventEmitter2 thuần: không bền, event mất ngay nếu process chết giữa lúc xử lý |
| Thứ tự ghi log | Theo thứ tự request tới trong cùng một queue/worker đơn luồng | Nhiều worker chạy song song có thể ghi log không đúng thứ tự thời gian thực tế xảy ra; cần dựa vào occurred_at ghi tại thời điểm capture, không phải thời điểm worker xử lý |
| Idempotency khi worker chạy lại | Activity log vốn append-only, chạy lại một job y hệt sinh ra một dòng log trùng, không sinh ra dữ liệu sai lệch nghiêm trọng như counter | Nếu cần đúng-một-lần tuyệt đối (ví dụ activity log dùng để tính phí theo số lần thao tác), phải gắn thêm key idempotency (event id) và unique constraint |
| Tính đầy đủ (completeness) | Capture ở middleware/interceptor bắt được mọi request khớp route đã đăng ký | Hành động xảy ra ngoài tầng HTTP (job nền tự sửa dữ liệu, truy cập DB trực tiếp, script bảo trì) không được capture nếu không tự phát event ở đó |
5. Failure modes
| Failure mode | Vì sao xảy ra | Hậu quả | Giảm nhẹ |
|---|---|---|---|
| Queue backlog | Worker chậm hơn tốc độ sinh event (traffic tăng đột biến, worker ít hơn nhu cầu) | Log hiển thị trễ hàng phút tới hàng giờ; audit "gần real-time" nhưng thực tế không | Alert theo độ sâu hàng đợi (Laravel: đếm job theo queue; BullMQ: getJobCounts()); scale thêm worker theo chiều ngang |
| Worker crash giữa lúc xử lý, mất event | Dùng EventEmitter2 thuần (không bền) hoặc driver queue không persist (ví dụ Laravel sync/null driver ở production) | Một phần hành động không có trong activity log — audit trail có lỗ hổng, khó phát hiện vì hệ thống không báo lỗi gì | Dùng driver queue có persist (database/redis cho Laravel, BullMQ cho NestJS) cho mọi log bắt buộc phải đầy đủ; chấp nhận at-least-once + idempotency key nếu cần chính xác tuyệt đối |
| Write storm trên endpoint nóng | Một hành động hàng loạt (bulk update, import CSV, cron chạy duyệt hàng nghìn bản ghi) sinh ra hàng nghìn event activity log cùng lúc | Hàng đợi/DB log bị dồn đột ngột; nếu cùng Redis/DB với hệ thống chính thì ảnh hưởng luôn cả nghiệp vụ | Gom log của hành động bulk thành một bản ghi tổng hợp ("user 42 updated 5000 products via import #12") thay vì 5000 dòng; hoặc dùng buffer + batch worker (mục 7) cho endpoint biết trước là nóng |
| Bảng log phình, không index đúng, kéo chậm cả DB chính | Query báo cáo audit quét properties (jsonb) không có index, hoặc không partition nên mỗi query chạm toàn bảng nhiều năm dữ liệu | Query audit chạy hàng chục giây, chiếm hết connection pool ảnh hưởng tới cả truy vấn nghiệp vụ nếu dùng chung DB | Partition theo thời gian (mục 3.4); index đúng theo pattern đọc thật (user_id/subject); tách sang replica đọc hoặc store riêng nếu báo cáo audit là nguồn đọc nặng |
6. Vận hành trong production
6.1 Correlation id xuyên tầng
Activity log của một request phải nối được với log lỗi/trace của cùng request đó — dùng
đúng cơ chế request id đã mô tả ở bài
Bộ công cụ để vận hành: Laravel middleware gắn
Log::shareContext(['request_id' => $id]), truyền id đó kèm theo khi phát event
ActivityLogged để lưu luôn vào cột request_id của bảng log. NestJS
tương đương dùng nestjs-pino (sinh req.id tự động mỗi request) và
truyền req.id vào payload event/job giống cách interceptor ở mục 3.3 đã làm với
req.id.
6.2 Structured logging
Activity log nên ghi dạng có cấu trúc để lọc theo field (user_id, action) mà không cần
regex. Laravel: channel Monolog riêng với JsonFormatter (tương tự cấu hình đã nêu
ở bài Bộ công cụ để vận hành) cho log hệ thống; còn chính bảng
activity_logs vốn đã có cấu trúc qua cột + jsonb, không cần qua
Monolog. NestJS: nestjs-pino cho log vận hành (request/response), còn activity log
lưu DB như đã thiết kế ở mục 3.4 — hai luồng dữ liệu riêng, đừng trộn activity log vào file log
ứng dụng rồi grep lại, sẽ chậm và dễ sót như đã nói ở mục 1.
6.3 Dashboard và cảnh báo
| Theo dõi gì | Laravel | NestJS |
|---|---|---|
| Độ sâu hàng đợi | Đếm job theo queue name (driver database: SELECT count(*) FROM jobs WHERE queue = 'activity-log'; driver redis: LLEN) | BullMQ Queue.getJobCounts() — Bull Board/Taskforce để xem trực quan |
| Job thất bại | Bảng failed_jobs, alert khi tăng bất thường | BullMQ event failed, hoặc job vào trạng thái dead-letter sau hết attempts |
| Worker còn sống không | Supervisor/systemd giám sát process queue:work, alert nếu process chết | Giám sát process Node chạy @Processor, hoặc container healthcheck |
6.4 Xoá dữ liệu kiểu GDPR mà vẫn giữ được audit trail
Yêu cầu xoá dữ liệu cá nhân (quyền được xoá) và yêu cầu giữ audit trail đầy đủ (compliance) mâu thuẫn nhau nếu xử lý ngây thơ. Cách phổ biến: không xoá dòng log, mà ẩn danh hoá (anonymize) phần định danh — giữ lại hành động và thời điểm, xoá liên kết tới cá nhân cụ thể:
UPDATE activity_logs
SET properties = properties - 'email' - 'phone',
user_id = NULL -- hoặc trỏ sang một user "deleted" dùng chung
WHERE user_id = :deleted_user_id;
Việc này phải nằm trong quy trình xoá tài khoản (thường là một job chạy sau khi tài khoản bị
xoá), không phải xoá cứng DELETE FROM activity_logs — xoá cứng làm mất luôn bằng
chứng "ai đã làm gì" trước khi tài khoản bị xoá, đúng lúc dữ liệu đó có thể cần cho điều tra.
7. So sánh và ma trận quyết định
| Mức 1: Đồng bộ, insert thẳng | Mức 2: Event + queue (bài này tập trung) | Mức 3: Buffer + batch worker (Redis Stream) | |
|---|---|---|---|
| Cách làm | spatie/laravel-activitylog mặc định, hoặc insert trực tiếp trong request | Middleware/Interceptor emit event → ShouldQueue listener / BullMQ Processor ghi DB | Capture → XADD vào Redis Stream → worker đọc theo batch, gộp rồi ghi DB (giống mục 4 của bài Forum like) |
| Thêm latency request | Có — 1 write DB thêm, trong transaction nghiệp vụ | Gần như không — chỉ chi phí emit event (trong-process), ghi DB xảy ra ở worker | Gần như không — chỉ chi phí 1 lệnh Redis XADD (dưới 1 ms) |
| Chịu được write storm | Không — storm nào cũng đập thẳng DB nghiệp vụ | Chịu được mức vừa — mỗi event vẫn là 1 job, nhiều job đồng nghĩa nhiều lần ghi DB của worker, có thể dồn hàng đợi | Chịu tốt nhất — worker gom N event thành 1-vài câu INSERT theo lô |
| Số hệ thống phải vận hành thêm | 0 (dùng DB có sẵn) | 1 (queue driver + worker process) | 2 (Redis Stream + queue driver nếu vẫn giữ queue cho việc khác, + worker gom batch) |
| Chọn khi | Trang quản trị nội bộ, lưu lượng thấp (vài chục action/phút), chấp nhận thêm vài ms latency | Phần lớn ứng dụng web/API thông thường. Có queue sẵn cho việc khác (mail, notification) thì tận dụng luôn | Một vài endpoint biết trước là nóng (webhook nhận hàng loạt, API được gọi rất dày từ hệ thống khác), đã có Redis vận hành ổn |
flowchart TD
A["Luu luong ghi log co du lon de gay write storm khong?"] -->|"Khong"| B{"Co can log tuc thi, chap nhan them vai ms latency?"}
B -->|"Co, he thong noi bo, it dung"| M1["Muc 1: dong bo, insert thang
(hoac spatie/laravel-activitylog mac dinh)"]
B -->|"Khong, uu tien request nhanh"| M2["Muc 2: Event + Queue
ShouldQueue listener / BullMQ Processor"]
A -->|"Co, mot vai endpoint nong"| C["Da co Redis van hanh on, co nguoi truc?"]
C -->|"Chua"| M2
C -->|"Roi"| M3["Muc 3: Buffer Redis Stream + worker gom batch"]
Laravel vs NestJS — cùng vai trò, tên khác nhau
| Vai trò | Laravel | NestJS |
|---|---|---|
| Chặn ở tầng HTTP | Middleware | NestInterceptor (bọc bằng RxJS Observable, can thiệp được cả before và after) |
| Bắt vòng đời model | Model Observer / Eloquent event | Không có cơ chế tương đương sẵn trong ORM; thường tự phát event trong service sau khi repository ghi xong |
| Decouple logic | Event + Listener (Illuminate\Support\Facades\Event) | EventEmitter2 qua @nestjs/event-emitter (emit/@OnEvent) |
| Hàng đợi bền (persist) | Laravel Queue — driver database/redis, listener implement ShouldQueue | BullMQ qua @nestjs/bullmq (Queue.add / @Processor + WorkerHost) |
| Off-the-shelf activity log | spatie/laravel-activitylog v5 | Không có package "chuẩn de facto" tương đương phổ biến; thường tự viết theo pattern ở mục 3.3 |
| Structured logging | Monolog + JsonFormatter | nestjs-pino (Pino) |
8. Góc nhìn tech lead: tác động tổ chức
- Activity log không phải "observability nice-to-have", có thể là nghĩa vụ hợp đồng/pháp lý. Quyết định giữ bao lâu, field gì bắt buộc phải có, cần sign-off từ compliance/pháp lý, không nên để một kỹ sư tự quyết "giữ 30 ngày cho gọn".
- Mỗi lần team bảo mật/compliance yêu cầu thêm field cần log, đó là một lần đổi
schema trên một bảng đang phình. Dùng cột
properties jsonblinh hoạt (mục 3.4) để thêm field mới không cần migration, nhưng vẫn cần quy ước tên field rõ ràng, nếu không báo cáo audit sau này không tổng hợp được vì mỗi chỗ đặt tên field khác nhau. - Thêm một hệ thống (queue/worker) đồng nghĩa thêm một thứ phải có người trực. Nếu team chưa vận hành queue cho việc gì khác, activity log không nên là lý do đầu tiên để đưa BullMQ/Redis vào stack — cân nhắc mức 1 (đồng bộ) trước nếu lưu lượng còn thấp, đúng tinh thần "đừng thêm hệ thống trước khi thật sự cần" đã nói ở các bài trade-off khác trong repo.
- Audit log đầy đủ hay không ảnh hưởng trực tiếp tới thời gian điều tra sự cố an ninh. Thiếu log ở một nhánh action nào đó (ví dụ action chạy qua script nền, không qua tầng HTTP) chỉ lộ ra đúng lúc cần nó nhất — nên đưa "mọi đường thay đổi dữ liệu nhạy cảm đều có activity log" vào checklist review code, không chỉ review theo cảm tính.
- Chi phí lưu trữ tăng dần đều, âm thầm. Không ai để ý bảng
activity_logscho tới khi nó chiếm phần lớn dung lượng DB — cần một người (thường là tech lead) chịu trách nhiệm theo dõi tốc độ tăng và kích hoạt retention policy đúng hạn, không phải chờ disk báo động mới xử lý.
9. Anti-patterns và bài học thực tế
| Anti-pattern | Vì sao hấp dẫn lúc đầu | Hậu quả khi lớn | Cách đúng |
|---|---|---|---|
| Ghi activity log cùng transaction với business write | Đơn giản, chắc chắn "log và data luôn khớp nhau" | Request chậm hơn; bảng log có vấn đề làm business write rollback theo (xem mục 1); không scale khi traffic tăng | Tách bằng event + queued listener (mục 3), chấp nhận log là "best effort" / "eventual", không phải điều kiện thành công của nghiệp vụ |
Cột properties kiểu JSON/TEXT không index, rồi quét toàn bảng cho báo cáo audit | Linh hoạt, không cần migration khi thêm field mới | Báo cáo audit chạy hàng chục giây, quét hết pg_stat_statements.total_exec_time; dễ thành query tốn nhất hệ thống mà không ai để ý vì nó không nằm trên đường nghiệp vụ chính | Partition theo thời gian (mục 3.4), index các field tra cứu thường xuyên (user_id, subject) ở mức cột riêng, chỉ để field biến thiên vào jsonb và thêm GIN index nếu thật sự cần query theo field đó |
Lưu PII thô (email, số điện thoại, IP đầy đủ) trong log hoặc trong properties | Tiện tra cứu, không cần join sang bảng user | Mỗi bản sao log (backup, export cho debug, gửi sang công cụ giám sát bên thứ ba) là một bản sao PII phải bảo vệ; vi phạm nguyên tắc tối thiểu hoá dữ liệu cá nhân | Mask trước khi ghi (mục 3.2 — maskPii), chỉ giữ ID tham chiếu (user_id) để join khi thật sự cần, không copy nguyên văn thông tin định danh vào log |
| Gọi HTTP đồng bộ tới một SaaS logging bên thứ ba ngay trong request | Dễ tích hợp, có dashboard đẹp ngay, không cần tự vận hành gì | Request nghiệp vụ phụ thuộc vào uptime và latency của một dịch vụ bên ngoài; SaaS chậm hoặc timeout thì toàn bộ request bị treo theo, đúng kiểu coupling đã cảnh báo ở mục 1 | Gửi qua hàng đợi bất đồng bộ (mục 3.2/3.3) hoặc batch định kỳ, không bao giờ await một HTTP call ra ngoài hệ thống trên đường trả response cho người dùng |
10. Câu hỏi phụ interviewer hay hỏi tiếp
| Câu hỏi | Trả lời ngắn |
|---|---|
| Log bị mất khi worker chết thì sao? | Phụ thuộc driver. Laravel Queue driver database/redis và BullMQ đều persist job trước khi worker nhận — worker chết giữa lúc xử lý, job còn nằm trong hàng đợi, lần sau được giao lại (at-least-once). Chỉ mất nếu dùng cơ chế không bền như EventEmitter2 thuần hoặc queue driver sync. |
| Làm sao biết ai đã đổi field X lúc nào? | Model Observer (Laravel) bắt isDirty('X') trong updated, ghi cả giá trị cũ (getOriginal) và mới vào properties. NestJS: service so sánh entity trước/sau khi update rồi tự phát event tương đương, vì ORM không có hook sẵn kiểu Eloquent. |
| Audit log có cần transaction cùng business data không? | Không nên. Tách bằng event bất đồng bộ (mục 2–3). Cái cần chung transaction là business data với chính nó (ví dụ trừ tiền và tạo đơn hàng phải atomic); activity log là lớp quan sát thêm vào, chấp nhận trễ vài giây hoặc hiếm khi mất một dòng, đổi lại không làm chậm hay làm fail nghiệp vụ chính. |
| Vì sao không dùng package activity log có sẵn cho mọi trường hợp? | Package như spatie/laravel-activitylog mặc định ghi đồng bộ — tốt cho trang quản trị nội bộ, lưu lượng thấp. Traffic lớn thì phải tự thêm lớp queue phía trên nó (dispatch event rồi mới gọi logic ghi), package không tự làm bất đồng bộ. |
| Thứ tự các dòng log có đảm bảo đúng thứ tự xảy ra không? | Không đảm bảo theo thứ tự worker xử lý nếu chạy nhiều worker song song. Phải dựa vào timestamp ghi tại thời điểm capture (occurred_at gán lúc event phát ra), không phải lúc worker insert vào DB, và sort theo cột đó khi hiển thị. |
| Sao không ghi log thẳng vào file rồi dùng Loki/ELK, cần gì bảng DB riêng? | File log hợp cho log vận hành (debug, trace lỗi), nơi câu hỏi là "request X bị gì". Activity log cần truy vấn có cấu trúc theo user/subject/thời gian cho báo cáo audit và cho UI admin hiển thị lịch sử — SQL với index đúng làm việc đó nhanh và rẻ hơn quét log tập trung. |
| Batch ghi log có làm mất dữ liệu nếu một event trong batch bị lỗi (ví dụ subject đã bị xoá)? | Nếu dùng INSERT dạng INSERT ... SELECT ... JOIN lọc trước (giống cơ chế ở bài Forum like Redis), event có subject không còn tồn tại bị loại ra khỏi batch chứ không làm rollback cả batch — tránh "poison batch" làm worker kẹt. |
| Xoá tài khoản (GDPR) thì activity log của họ xử lý sao? | Ẩn danh hoá, không xoá cứng (mục 6.4): giữ lại hành động + thời điểm cho mục đích audit, xoá/thay field định danh cá nhân. Xoá cứng làm mất bằng chứng đúng lúc có thể cần nhất (ví dụ điều tra trước khi tài khoản bị xoá). |
| Có nên cho user tự xem activity log của chính họ? | Tuỳ sản phẩm — nhiều hệ thống SaaS B2B cho phép ("Lịch sử hoạt động"). Khi cho xem, càng phải mask kỹ các field nội bộ (IP nội bộ, id hệ thống khác) không liên quan tới trải nghiệm của chính người dùng đó. |
| Metrics (ví dụ Prometheus counter) có thay được activity log không? | Không. Metric đếm được "có bao nhiêu lần action X xảy ra" nhưng không giữ được "ai, trên đối tượng nào, giá trị cũ/mới là gì" — đúng câu hỏi activity log phải trả lời (mục 1). Hai loại bổ sung nhau, không thay nhau. |
Phụ lục: lab đã chạy
Không có lab chạy thật trong lần viết bài này. Không có sẵn môi trường
Laravel + NestJS + Redis dựng cạnh nhau ở máy viết bài để đo số liệu (latency, TPS, số event
mất khi kill worker...) như các bài khác trong repo (ví dụ
Forum like không trùng với Redis có PostgreSQL
và Redis container thật). Mọi nội dung kỹ thuật trong bài — tên class, tên method, tên package,
hành vi ($afterCommit, at-least-once của BullMQ, cách EventEmitter2
không bền) — được kiểm tra qua tài liệu chính thức tại thời điểm viết (10/2026):
- Laravel: laravel.com/framework/docs (mục Events, Queues, Eloquent — Mutators & Casting/Observers), bản 12.x.
- NestJS: docs.nestjs.com (mục Interceptors, Events, Queues), bản 12.x hiện hành tại thời điểm tra cứu.
spatie/laravel-activitylog: spatie.be/docs/laravel-activitylog/v5 — v5 yêu cầu PHP 8.4+ và Laravel 12+ theo ghi chú phát hành.- BullMQ/
@nestjs/bullmq: docs.bullmq.io và docs.nestjs.com/application/queues. nestjs-pino: trang npm/README của package, dùng làm ví dụ cho structured logging phía NestJS.
Các đoạn code trong bài là ví dụ viết tay theo đúng API của các nguồn trên, chưa được chạy/test thật trong một project cụ thể — khác với các bài có nhãn "đã chạy thật" trong repo này. Số liệu nào mang tính minh hoạ (ví dụ "hàng trăm event/giây", "vài giây trễ") đều là ước lượng để giải thích khái niệm, không phải số đo thật, và đã được ghi rõ ngay tại chỗ xuất hiện trong bài. Việc còn lại — nếu muốn verify đầy đủ — là dựng một project Laravel 12 + một project NestJS 12 cạnh nhau, cùng Redis/queue driver, rồi đo lại đúng các bảng ở mục 4, 5, 7 bằng số thật, theo đúng cách bài Forum like Redis đã làm.