Đọc stack trace: ngày đầu nhận bug trang đơn hàng bị sập

Cách đọc stack trace qua một bug thật: đọc thông điệp lỗi, tìm dòng code của bạn, lần ngược dữ liệu sai và sửa đúng chỗ. Có code demo chạy được.

Bạn gặp một màn hình đầy chữ đỏ và không biết đọc từ đâu? Đọc stack trace chỉ có bốn bước: đọc hết thông báo lỗi, tìm dòng đầu tiên thuộc code của bạn, lần ngược xem dữ liệu sai từ đâu ra, rồi mới sửa. Hãy cùng mình đi qua cả bốn bước với một bug cụ thể nhé!

Một tin nhắn lúc 7 giờ sáng

Đây là bài đầu tiên của series Nhận dự án cũ. Trong series này, bạn đóng vai một lập trình viên vừa nhận bảo trì hệ thống đơn hàng của Mộc, một quán cà phê nhỏ bán hàng online. Mộc là quán minh họa, còn hệ thống thì có code thật và chạy được. Người viết hệ thống đã nghỉ việc, và tài liệu để lại gần như không có.

Sáng đầu tiên, chủ quán nhắn cho bạn:

Em ơi, trang danh sách đơn hàng không mở được nữa, chỉ hiện "Lỗi máy chủ". Từ sáng tới giờ quán không xem được đơn nào.

Tin nhắn chỉ có vậy. Chủ quán không gửi ảnh chụp, không biết lỗi xảy ra từ lúc nào, cũng không biết ai đã thay đổi gì. Khi đi làm, bạn sẽ gặp kiểu tin nhắn này rất thường xuyên: người dùng chỉ mô tả được triệu chứng, và họ cần sửa gấp.

Hệ thống của quán khá nhỏ. Để chạy được code trong bài, bạn cần Node.js 22.18 trở lên, vì từ phiên bản này Node.js chạy thẳng được file TypeScript mà không phải cài thêm công cụ nào. Bạn tạo một thư mục với ba file như sau, rồi mở nó bằng VS Code:

Cấu trúc thư mục
don-hang-moc/
  data/orders.json   # danh sách đơn hàng
  src/orders.ts      # các hàm xử lý đơn: tên khách, tổng tiền, danh sách
  src/server.ts      # máy chủ trả danh sách đơn ở http://localhost:3000/don-hang

File đầu tiên chứa các hàm xử lý đơn. Kiểu Order mô tả một đơn hàng. Hàm customerLabel trả về tên khách viết hoa, orderTotal tính tổng tiền, còn listOrders gom hai thứ đó lại cho từng đơn. Bạn chép đúng từng dòng, vì stack trace ở phần sau sẽ chỉ vào số dòng của file này:

src/orders.ts
export type Customer = { name: string; phone: string };
export type OrderItem = { name: string; price: number; quantity: number };
export type Order = {
  id: number;
  createdAt: string;
  source: "web" | "may-cu";
  customer: Customer;
  items: OrderItem[];
};

export function customerLabel(order: Order): string {
  return order.customer.name.toUpperCase();
}

export function orderTotal(order: Order): number {
  return order.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}

export function listOrders(orders: Order[]) {
  return orders.map((order) => ({
    id: order.id,
    khach: customerLabel(order),
    tong: orderTotal(order),
  }));
}

File thứ hai là máy chủ. Nó đọc file JSON một lần lúc khởi động. Mỗi khi có người mở /don-hang, nó gọi listOrders và trả kết quả. Nếu có lỗi, nó in chi tiết lỗi ra terminal và trả về "Lỗi máy chủ" cho người dùng:

src/server.ts
import { readFileSync } from "node:fs";
import { createServer } from "node:http";

import { listOrders, type Order } from "./orders.ts";

const dataFile = new URL("../data/orders.json", import.meta.url);
const orders: Order[] = JSON.parse(readFileSync(dataFile, "utf8"));

export const server = createServer((req, res) => {
  if (req.url === "/don-hang") {
    try {
      const body = JSON.stringify(listOrders(orders), null, 2);
      res.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(body);
    } catch (error) {
      console.error(error);
      res.writeHead(500, { "content-type": "text/plain; charset=utf-8" }).end("Lỗi máy chủ");
    }
    return;
  }
  res.writeHead(404).end();
});

const port = Number(process.env.PORT ?? 3000);
server.listen(port, () => console.log(`Đơn hàng Mộc: http://localhost:${port}/don-hang`));

File cuối là dữ liệu, bốn đơn hàng của một buổi sáng. Hai đơn đầu đặt qua website, hai đơn sau nhập từ máy bán hàng cũ ở quầy ("source": "may-cu"). Bạn chép nguyên file vào nhưng chưa cần đọc kỹ, vì cả bài là chuyện đi tìm xem dữ liệu này có gì bất thường:

data/orders.json
[
  {
    "id": 1041,
    "createdAt": "2026-11-03T08:12:00+07:00",
    "source": "web",
    "customer": { "name": "Nguyễn An", "phone": "0901000001" },
    "items": [
      { "name": "Cà phê sữa", "price": 29000, "quantity": 2 },
      { "name": "Bánh mì trứng", "price": 25000, "quantity": 1 }
    ]
  },
  {
    "id": 1042,
    "createdAt": "2026-11-03T09:40:00+07:00",
    "source": "web",
    "customer": { "name": "Trần Bình", "phone": "0901000002" },
    "items": [{ "name": "Bạc xỉu", "price": 32000, "quantity": 1 }]
  },
  {
    "id": 1043,
    "createdAt": "2026-11-03T10:05:00+07:00",
    "source": "may-cu",
    "items": [{ "name": "Cà phê đen", "price": 25000, "quantity": 1 }]
  },
  {
    "id": 1044,
    "createdAt": "2026-11-03T10:30:00+07:00",
    "source": "may-cu",
    "customer": { "name": "Lê Chi", "phone": "0901000004" },
    "items": [{ "name": "Cà phê muối", "price": "35.000", "quantity": 2 }]
  }
]

Vì sao phải làm lỗi xảy ra trên máy mình trước?

Khi nhận bug, ai cũng muốn mở code ra đọc ngay. Tuy nhiên, việc đầu tiên nên làm là khiến lỗi xảy ra trên chính máy của bạn. Nếu chưa tận mắt thấy lỗi, bạn sẽ không có cách nào biết chắc mình đã sửa được nó hay chưa.

Bạn mở hai cửa sổ terminal. Cửa sổ thứ nhất chạy máy chủ, cửa sổ thứ hai gọi thử trang danh sách đơn:

Terminal 1: chạy máy chủ
node src/server.ts
Terminal 2: gọi thử trang danh sách đơn
curl -i http://localhost:3000/don-hang

Terminal thứ hai trả về đúng như chủ quán mô tả: mã lỗi 500 và dòng chữ "Lỗi máy chủ". Khách hàng chỉ nhìn thấy đến đó. Còn bạn thì quay sang terminal thứ nhất, nơi máy chủ in ra chi tiết lỗi. Mình đã rút gọn đường dẫn cho dễ nhìn, và trên máy bạn phần đầu đường dẫn sẽ khác, nhưng tên file, số dòng và số cột thì giống hệt:

Terminal 1
Đơn hàng Mộc: http://localhost:3000/don-hang
TypeError: Cannot read properties of undefined (reading 'name')
    at customerLabel (file:///…/ky-nang/00-ban-dau/src/orders.ts:12:25)
    at file:///…/ky-nang/00-ban-dau/src/orders.ts:22:12
    at Array.map (<anonymous>)
    at listOrders (file:///…/ky-nang/00-ban-dau/src/orders.ts:20:17)
    at Server.<anonymous> (file:///…/ky-nang/00-ban-dau/src/server.ts:12:35)
    at Server.emit (node:events:519:28)
    at parserOnIncoming (node:_http_server:1186:12)
    at HTTPParser.parserOnHeadersComplete (node:_http_common:125:17)

Đoạn chữ này chính là stack trace, tức danh sách các hàm đang chạy dở đúng vào lúc lỗi xảy ra. Tám dòng bắt đầu bằng at trông khá đáng sợ, nhưng bạn không cần hiểu hết từng dòng. Bạn chỉ cần đọc chúng theo đúng thứ tự.

Máy chủ in được những dòng này là nhờ khối catch trong file server.ts có gọi console.error(error). Nhiều hệ thống cũ bắt lỗi xong lại không in ra gì cả, nên lỗi bị "nuốt" mất. Nếu terminal của bạn trống trơn, hãy tìm khối catch đó và thêm một dòng in lỗi trước khi làm gì khác.

Một stack trace gồm những phần nào?

Dòng đầu tiên, TypeError, cho biết loại lỗi. Loại lỗi gợi ý cho bạn hướng tìm: TypeError nghĩa là code đang dùng một giá trị sai kiểu, ReferenceError nghĩa là code dùng một biến chưa được khai báo, còn SyntaxError nghĩa là code viết sai cú pháp. Ngay sau loại lỗi là thông báo lỗi. Tiếp theo là các dòng at, mỗi dòng ứng với một hàm và kèm theo vị trí của hàm đó theo dạng file:dòng:cột.

Trong JavaScript, dòng at trên cùng là nơi lỗi xảy ra, và mỗi dòng bên dưới là hàm đã gọi dòng phía trên nó. Vì vậy, nếu đọc từ dưới lên, bạn sẽ thấy toàn bộ đường đi của chương trình. Máy chủ nhận yêu cầu và gọi listOrders. Hàm listOrders gọi map, map gọi một hàm mũi tên, hàm mũi tên gọi customerLabel, và lỗi xảy ra bên trong customerLabel.

Bước 1: đọc hết câu thông báo lỗi

Khi thấy chữ đỏ, người mới thường nhảy ngay vào code. Bạn hãy thử đọc chậm câu thông báo này trước:

Cannot read properties of undefined (reading 'name')

Câu này có nghĩa là chương trình không đọc được thuộc tính của một giá trị undefined, và thuộc tính đang được đọc là name. Nói cách khác, ở đâu đó trong code có một đoạn viết theo dạng cái-gì-đó.name, và cái-gì-đó đang là undefined. Như vậy, bạn chưa mở file nào nhưng đã biết mình cần tìm cái gì.

Khi gặp một thông báo lỗi lạ, bạn có thể tra trong danh sách lỗi JavaScript của MDN. Mỗi lỗi ở đó đều có phần giải thích và ví dụ, đáng tin hơn nhiều so với câu trả lời đầu tiên bạn tìm thấy trên diễn đàn.

Bước 2: tìm dòng đầu tiên là code của bạn

Bạn đi từ trên xuống và gạch bỏ những dòng không phải code của dự án. Dòng Array.map (<anonymous>) là hàm có sẵn của JavaScript. Các dòng node:events và node:_http_server thuộc về lõi của Node.js. Nếu có đường dẫn chứa node_modules, đó là code của thư viện. Bạn có thể bỏ qua tất cả những dòng này.

Sau khi gạch bỏ, còn lại bốn dòng thuộc dự án, và dòng đầu tiên trong số đó là customerLabel ở file src/orders.ts, dòng 12, cột 25. Bạn mở file ra:

src/orders.ts (dòng 11–13)
export function customerLabel(order: Order): string {
  return order.customer.name.toUpperCase();
}

Cột 25 trỏ đúng vào .name. Kết hợp với điều đã biết ở bước 1, bạn có thể kết luận rằng order.customer đang là undefined.

Có một chi tiết nhỏ nhưng hữu ích: khi chạy file TypeScript, Node.js xóa phần khai báo kiểu và thay bằng khoảng trắng, chứ không dồn code lại. Nhờ vậy, số dòng và số cột trong stack trace khớp đúng với file .ts mà bạn đang mở. Với các công cụ build khác, bạn cần có source map thì mới được như vậy, và mình sẽ nói về source map ở cuối bài.

Bước 3: dữ liệu sai đến từ đâu?

Đến đây, nhiều người sẽ sửa thành order.customer?.name, thấy trang chạy lại bình thường và đóng việc. Lỗi đúng là biến mất, nhưng câu hỏi "vì sao một đơn hàng lại không có khách" thì vẫn chưa ai trả lời. Dòng 12 chỉ là nơi lỗi lộ ra, còn nơi lỗi bắt đầu nằm ở chỗ khác.

Để tìm nơi lỗi bắt đầu, bạn đi ngược các dòng at và hỏi xem giá trị này do ai truyền vào. Hàm customerLabel nhận order từ hàm mũi tên ở dòng 22. Hàm mũi tên đó được map gọi cho từng đơn hàng bên trong listOrders. Hàm listOrders nhận danh sách đơn từ server.ts, và server.ts đọc danh sách này từ file data/orders.json. Vì vậy, câu hỏi đúng lúc này là: trong file JSON, đơn hàng nào không có customer?

Thay vì mở file ra dò bằng mắt, bạn hãy hỏi thẳng dữ liệu:

Tìm các đơn không có khách hàng
node -e 'const orders = require("./data/orders.json"); console.log(orders.filter((o) => !o.customer).map((o) => ({ id: o.id, source: o.source })))'
Kết quả
[ { id: 1043, source: 'may-cu' } ]

Chỉ có đúng một đơn, và đơn này đến từ may-cu, tức máy bán hàng cũ của quán. Khi bạn nhắn hỏi, chủ quán mới cho biết máy cũ được dùng cho khách mua tại quầy, và nhân viên không nhập tên hay số điện thoại của những khách này. Như vậy, đơn hàng không có khách là chuyện bình thường, và dữ liệu không hề bị hỏng.

Thứ thật sự có vấn đề là đoạn khai báo kiểu này:

src/orders.ts — kiểu ban đầu
export type Order = {
  id: number;
  createdAt: string;
  source: "web" | "may-cu";
  customer: Customer;
  items: OrderItem[];
};

Kiểu Order khẳng định rằng đơn hàng nào cũng có customer, trong khi dữ liệu thật lại cho thấy điều ngược lại. TypeScript không cảnh báo gì, vì TypeScript chỉ kiểm tra code lúc bạn viết. Dữ liệu được đọc từ file lúc chương trình chạy thì không ai kiểm tra cả, và JSON.parse trả về bất cứ thứ gì có trong file. Nói cách khác, kiểu dữ liệu đã mô tả sai thực tế, và trình biên dịch thì tin vào mô tả đó.

Nên sửa ở đâu cho đúng?

Bạn có ba hướng sửa: thêm ?. ở dòng 12, xóa đơn 1043 cho xong, hoặc sửa kiểu Order cho đúng với thực tế rồi xử lý trường hợp không có khách.

Hướng xóa dữ liệu thì không thể chọn, vì đó là đơn hàng thật của quán. Hướng thêm ?. nghe có vẻ hợp lý, nên mình sẽ thử luôn để xem kết quả. Bạn đổi dòng 12 thành order.customer?.name.toUpperCase(), gọi lại trang và xem đơn 1043:

Kết quả khi chỉ thêm ?. (đơn 1043)
{
  "id": 1043,
  "tong": 25000
}

Trang không còn sập, và tổng tiền vẫn đúng. Nhưng trường khach biến mất hoàn toàn chứ không phải là một chuỗi rỗng. Nguyên nhân là order.customer?.name trả về undefined, và JSON.stringify tự động bỏ qua mọi thuộc tính có giá trị undefined. Giao diện của quán luôn chờ có trường khach, nên bây giờ nó sẽ hiện một ô trống hoặc chữ "undefined", tùy cách người làm giao diện viết code. Lỗi không mất đi mà chỉ chuyển sang một chỗ khó thấy hơn.

Chưa dừng ở đó, giả sử tháng sau có người làm tính năng nhắn tin nhắc khách. Người đó mở kiểu Order ra, thấy customer lúc nào cũng có, nên viết order.customer.phone:

Tính năng mới, cùng một lỗi cũ (minh họa)
Đơn 1043: Cannot read properties of undefined (reading 'phone')

Cùng một lỗi lại xuất hiện ở một chỗ khác, do một người khác gây ra, và người đó lại phải đọc stack trace từ đầu. Đây là cái giá của việc vá ở nơi lỗi lộ ra. Vì vậy, mình chọn hướng thứ ba. Bạn trả dòng 12 về như cũ (bỏ ?.), rồi thay kiểu Order và hàm customerLabel trong src/orders.ts bằng đoạn dưới đây. Hai hàm orderTotal và listOrders giữ nguyên:

src/orders.ts — kiểu Order và customerLabel sau khi sửa
export type Order = {
  id: number;
  createdAt: string;
  source: "web" | "may-cu";
  // Đơn từ máy bán hàng cũ (source "may-cu") bán tại quầy có thể không có khách hàng.
  customer?: Customer;
  items: OrderItem[];
};

export function customerLabel(order: Order): string {
  if (!order.customer) return "KHÁCH TẠI QUẦY";
  return order.customer.name.toUpperCase();
}

Lợi ích lớn nhất nằm ở dấu ? sau customer. Từ bây giờ, bất kỳ chỗ nào trong dự án đọc order.customer mà chưa kiểm tra đều bị TypeScript báo lỗi, kể cả tính năng nhắn tin của tháng sau. Chỉ một ký tự đã chặn trước được cả loạt lỗi chưa kịp xảy ra.

Dòng chú thích phía trên cũng có giá trị không kém dòng code. Sáu tháng sau, người đọc lại đoạn code này (rất có thể chính là bạn) sẽ hiểu ngay vì sao khách hàng có thể không có, và không phải đi lại con đường vừa rồi.

Chạy lại để kiểm tra

Bạn khởi động lại máy chủ với code đã sửa, rồi gọi lại trang danh sách đơn:

Terminal
node src/server.ts
curl http://localhost:3000/don-hang
Kết quả: mã 200 (gộp dòng cho gọn)
[
  { "id": 1041, "khach": "NGUYỄN AN", "tong": 83000 },
  { "id": 1042, "khach": "TRẦN BÌNH", "tong": 32000 },
  { "id": 1043, "khach": "KHÁCH TẠI QUẦY", "tong": 25000 },
  { "id": 1044, "khach": "LÊ CHI", "tong": 70 }
]

Trang đã hoạt động trở lại. Bạn nhắn cho chủ quán một câu, và ghi lại nguyên nhân vào commit để người sau không phải đoán. Bài viết commit message dễ đọc có mẫu cho đúng tình huống này: dòng đầu nói bạn đã sửa gì, phần thân nói vì sao phải sửa.

Khi stack trace không dễ đọc như hôm nay thì sao?

Bug hôm nay khá dễ chịu, vì stack trace đầy đủ và chỉ thẳng vào dòng lỗi. Thực tế thì không phải lúc nào cũng như vậy.

Trường hợp thứ nhất là stack trace bị cụt. V8, engine chạy JavaScript trong Chrome và Node.js, mặc định chỉ giữ lại 10 dòng (giá trị Error.stackTraceLimit bằng 10). Stack trace ở trên chỉ có 8 dòng nên vẫn còn nguyên, nhưng nếu lỗi nằm sâu trong một chuỗi gọi hàm dài, phần đầu chuỗi sẽ bị cắt mất. Khi debug trên máy, bạn có thể tạm thời tăng giới hạn này lên:

Chỉ dùng khi debug
Error.stackTraceLimit = 50;

Trường hợp thứ hai là lỗi gốc bị mất. Code cũ thường có đoạn như sau:

Mất thông tin
try {
  await loadOrders();
} catch (err) {
  throw new Error("Tải đơn hàng thất bại"); // lỗi gốc biến mất
}

Lúc này, stack trace chỉ trỏ vào dòng throw mới. Không ai còn biết loadOrders hỏng vì file không tồn tại, vì thiếu quyền truy cập hay vì nội dung JSON bị lỗi. Nếu bạn truyền lỗi gốc vào tùy chọn `cause`, bạn sẽ giữ được cả câu giải thích mới lẫn nguyên nhân cũ:

Giữ lỗi gốc
try {
  await loadOrders();
} catch (err) {
  throw new Error("Tải đơn hàng thất bại", { cause: err });
}

Trường hợp thứ ba là code trên production đã bị nén (minify). Khi đó tên hàm bị đổi thành a, b, và cả file bị dồn vào dòng 1. Bạn cần có source map, tức một file ánh xạ ngược từ code đã nén về code gốc. Next.js, Vite và hầu hết các công cụ build đều tạo được source map, và trình duyệt cũng như các dịch vụ theo dõi lỗi dùng nó để hiển thị lại đúng tên file và số dòng.

Trường hợp cuối cùng là mọi dòng at đều nằm trong node_modules. Thư viện hiếm khi sai, nên thường là chính bạn đã truyền dữ liệu sai vào thư viện. Bạn hãy tìm dòng cuối cùng thuộc code của mình, ngay trước khi đi vào thư viện, và kiểm tra dữ liệu ở đó. Nếu debug từng bước trong trình duyệt, Chrome DevTools mặc định bỏ qua các script trong `/node_modules/`, nên bạn sẽ không bị kéo vào code của thư viện.

Nếu đã làm đủ bốn bước mà vẫn chưa tìm ra nguyên nhân, đó là lúc bạn nên hỏi người khác. Hãy mang theo stack trace và những gì bạn đã thử; bài ba câu tự hỏi trước khi nhờ senior giúp hướng dẫn cách chuẩn bị. Nếu muốn tự kiểm tra lại kiến thức, bạn có thể thử các câu hỏi JavaScript trong ngân hàng câu hỏi phỏng vấn.

Còn một đơn hàng chỉ có 70 đồng

Bạn hãy nhìn lại kết quả ở phần kiểm tra. Đơn 1044 của chị Lê Chi gồm hai ly cà phê muối, nhưng tổng tiền chỉ là 70 đồng.

Lần này chương trình không báo lỗi, không có stack trace, và trang vẫn trả về mã 200. Chương trình chạy bình thường nhưng cho ra một con số sai. Theo mình, đây là loại bug khó chịu hơn nhiều, vì không có gì chỉ đường cho bạn cả. Bài viết tiếp theo của series sẽ dùng breakpoint trong VS Code để dừng chương trình giữa chừng và xem tận mắt từng giá trị. Bạn có thể theo dõi cả câu chuyện ở series Nhận dự án cũ.

Nhận bài tiếp theo qua email.

Một email mỗi tuần, chỉ khi có điều đáng đọc. Không spam.