↓ Chuyển đến nội dung chính

React Native Expo Sentry

Dùng SDK của Sentry, nhận trên máy chủ của mình: báo cáo sự cố (crash reporting) cho ứng dụng React Native với khoảng 650 dòng Django

Ứng dụng của tôi chưa hề có gì báo về khi bị sập. Thay vì đăng ký sentry.io, tôi giữ lại SDK mã nguồn mở của Sentry và trỏ DSN của nó về một endpoint trong chính API Django của chúng tôi. Bài này nói về định dạng envelope, cách gom nhóm sự cố khi mã đã bị rút gọn mà không có source map, cách cảnh báo mà không làm ngập Discord, cách lọc bỏ dữ liệu cá nhân, cách tôi chứng minh một lỗi SIGABRT gốc (native) trên iOS thực sự về tới máy chủ, và vì sao mọi chuyện kết thúc bằng việc viết lại toàn bộ chính sách quyền riêng tư.

Cho tới tuần này, cách duy nhất để tôi biết ứng dụng của tôi bị sập là có ai đó nhắn cho tôi. Mà phần lớn mọi người không nhắn. Họ mở lại ứng dụng, và nếu nó sập thêm lần nữa thì họ thôi không mở nữa. Phía API thì đã có giám sát: mọi log mức ERROR đều được đẩy vào một kênh Discord. Nhưng một lần sập trên điện thoại thì không để lại dấu vết gì ở bất cứ chỗ nào tôi nhìn thấy được.

Thật ra ứng dụng đã có sẵn một cơ chế báo lỗi. Mỗi khi ứng dụng hiện một thông báo lỗi cho người dùng, nó gửi nội dung thông báo đó lên /client-errors/. Máy chủ ghi lại ở mức ERROR, nên lỗi đó cũng tới được Discord. Tôi thêm cơ chế này sau một lần thao tác xoá thực ra đã thành công, nhưng dòng đó vẫn nằm trong danh sách; người dùng bấm vào lần nữa và nhận được thông báo “No Item matches the given query”. Phía máy chủ chỉ ghi hai lỗi 404 bình thường và không báo gì cả. Cơ chế đó bao quát được những lỗi mà ứng dụng bắt được và hiện ra cho người dùng. Còn sập ứng dụng (crash) thì khác hẳn: chính đoạn JavaScript lẽ ra sẽ gửi báo cáo lại là thứ vừa chết.

Cách sửa hiển nhiên là dùng Sentry. Tôi rất thích SDK của Sentry; nhờ nó mà báo cáo sự cố trên React Native gần như là bài toán đã có lời giải. Điều tôi không muốn là thêm một nhà cung cấp nữa giữ dữ liệu người dùng, thêm một dòng trong chính sách quyền riêng tư, và thêm một hoá đơn cho một thứ mà với quy mô của chúng tôi thì nhiều lắm cũng chỉ vài báo cáo mỗi ngày. Vì vậy tôi giữ SDK và thay phần dịch vụ: DSN của ứng dụng trỏ về API của chính chúng tôi, và khoảng 650 dòng Django nhận những gì SDK gửi lên, gom nhóm, gửi cảnh báo và lưu lại.

Bài này kể cách hệ thống đó hoạt động, và cách tôi kiểm tra để chắc rằng một lần sập gốc (native crash) thật sự đi được tới máy chủ.

Mẹo với DSN
#

Một DSN của Sentry có dạng https://<public-key>@<host>/<project-id>. SDK biến nó thành một yêu cầu POST tới https://<host>/api/<project-id>/envelope/, với khoá nằm trong header X-Sentry-Auth. Không có chi tiết nào trong đó gắn riêng với sentry.io. Vì vậy DSN được đóng gói sẵn trong ứng dụng là:

https://<CRASH_REPORT_KEY>@api.example.com/1

và phía Django có một route khớp đúng với địa chỉ mà SDK sẽ gọi:

path("api/<int:project_id>/envelope/", crash_envelope, name="crash_envelope"),

Khoá này nằm trong mọi bản build của ứng dụng, nên nó không phải bí mật. Nó tồn tại vì hai lý do: nếu một ngày endpoint bắt đầu hứng rác thì tôi có thể đổi khoá, và nếu máy chủ chưa cấu hình khoá thì endpoint sẽ từ chối tất cả thay vì nhận bừa mọi thứ:

def crash_envelope(request, project_id):
    expected = settings.CRASH_REPORT_KEY
    if not expected:
        return Response({"detail": "Crash reporting is not configured"}, status=503)
    if not hmac.compare_digest(_key(request).encode(), expected.encode()):
        return Response({"detail": "Unknown key"}, status=401)
    ...

Hàm _key đọc giá trị sentry_key= trong header X-Sentry-Auth; nếu không có thì lấy từ query string, vì một số SDK đặt khoá ở đó.

SDK gửi gì lên: envelope
#

Định dạng truyền dữ liệu của Sentry là envelope: một dòng JSON làm header, sau đó là bao nhiêu mục (item) cũng được, mỗi mục gồm một dòng JSON header rồi tới phần nội dung (payload). Nếu header của mục có trường length thì payload dài đúng bằng số byte đó; nếu không thì payload kéo dài tới ký tự xuống dòng kế tiếp.

{"event_id":"9ec79c33...","dsn":"https://[email protected]/1"}
{"type":"event","length":4123}
{ ...the event JSON, 4123 bytes... }
{"type":"client_report"}
{"timestamp":1791400000.5,"discarded_events":[]}

Có hai chi tiết khiến tôi vấp:

  • SDK gốc (native) nén gzip phần thân yêu cầu, còn SDK JavaScript thì không. Một lỗi JS mà @sentry/react-native bắt được sẽ tới dưới dạng văn bản thường, trong khi một lần sập gốc từ sentry-cocoa hoặc sentry-android tới kèm Content-Encoding: gzip. Bộ giải mã xử lý được gzip, deflate có bọc zlib và deflate thô, và dừng giải nén ở mức 1MB, vì một sự kiện sập chỉ nặng vài kilobyte, thứ gì lớn hơn thế thì không phải báo cáo sự cố.
  • Payload có tiền tố độ dài có thể chứa ký tự xuống dòng. Nếu bạn tách theo \n thì sẽ làm hỏng mọi sự kiện mà SDK gốc gửi lên. Khi có length thì bắt buộc phải tôn trọng nó.

Bộ phân tích cú pháp (parser) chỉ khoảng bốn mươi dòng. Nó chỉ giữ các mục event; session, transaction, tệp đính kèm và client report đều được nhận rồi bỏ đi.

Trả lời theo cách SDK hiểu được
#

Phần này quan trọng hơn tôi tưởng. SDK giữ một hàng đợi trên đĩa và gửi lại bất cứ thứ gì nó không chắc là đã tới nơi, nên câu trả lời của máy chủ quyết định SDK có tiếp tục thử lại hay không:

  • 200 khi đã lưu, hoặc khi cố ý bỏ đi. Nếu tôi trả 4xx cho một mục session, SDK sẽ giữ nó lại và thử gửi mãi mãi.
  • 429 kèm Retry-After khi bị giới hạn tần suất. Endpoint có giới hạn theo từng IP (120 lần mỗi giờ), vì một chiếc điện thoại bị sập liên tục sẽ gửi một báo cáo mỗi lần khởi động, và SDK lùi lại khi gặp 429 đúng như mình mong.
  • 400 cho envelope sai định dạng, ghi log ở mức cảnh báo (warning) chứ không phải lỗi: một báo cáo hỏng chỉ cho biết có một client đang hỏng, và báo cáo tốt tiếp theo sẽ nói cái gì đã sập.

SDK cũng gửi lại cùng một sự kiện nếu nó không chắc lần gửi đầu đã tới nơi, nên event_id có ràng buộc duy nhất (unique constraint) và một bản trùng lặp sẽ được bỏ qua lặng lẽ.

Gom nhóm khi không có source map
#

Đây là chỗ một bộ nhận tự viết phải đưa ra lựa chọn mà Sentry vốn làm sẵn cho bạn. Sentry gom nhóm các lần sập theo stack trace. Nhưng bundle Hermes ở bản phát hành đã bị rút gọn (minify), nên tên các frame chỉ còn một chữ cái và vị trí dòng/cột thay đổi sau mỗi lần build. Nếu gom theo frame, cùng một lỗi sẽ mở ra một issue mới ở mỗi bản phát hành.

Khi chưa có source map (phần cuối bài sẽ nói thêm), thứ ổn định nhất trong một lỗi JavaScript là kiểu lỗi và thông điệp lỗi. Vì vậy dấu vân tay (fingerprint) là kiểu exception cộng với thông điệp, trong đó những phần thay đổi giữa các lần xuất hiện được che đi:

_MASKS = [
    (re.compile(r"0x[0-9a-fA-F]+"), "<addr>"),
    (re.compile(r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b"), "<uuid>"),
    (re.compile(r"\b[0-9a-fA-F]{16,}\b"), "<hex>"),
    (re.compile(r"\d+"), "<n>"),
]

Cannot read property 'title' of undefined (reading index 3) và (reading index 17) sẽ thành cùng một issue. Nếu SDK tự gửi kèm fingerprint mà không có {{ default }} bên trong thì giá trị đó được ưu tiên, nhờ vậy tôi có thể ép cách gom nhóm từ phía client khi cách mặc định chưa đúng. Sự kiện nào không có exception thì dùng thông điệp log đã che làm dấu vân tay, còn nếu không có cả hai thì dùng frame đầu tiên thuộc mã của ứng dụng (in-app). Với một lần sập do tín hiệu (signal) ở tầng gốc, thông điệp thường kiểu Signal 6, Code 0, nên các lần sập này được gom theo tín hiệu. Cách đó hơi thô, nhưng trung thực cho tới khi có giải mã ký hiệu (symbolication).

Cảnh báo mà không làm ngập kênh
#

Mỗi dấu vân tay là một dòng CrashIssue, và mỗi báo cáo là một CrashEvent nằm dưới nó. Issue ghi log ở mức ERROR (đó là thứ handler Discord lắng nghe) đúng trong những trường hợp sau:

  • một issue mới
  • một issue đã đánh dấu xử lý xong (resolved) nhưng quay lại, khi đó issue được mở lại
  • lần xuất hiện thứ 10, 100, 1.000 và 10.000

Một lần lặp lại bình thường chỉ tăng bộ đếm. Issue bị đánh dấu ignored vẫn tiếp tục được đếm nhưng không bao giờ cảnh báo. Cộng thêm giới hạn tần suất riêng của handler Discord (một thông điệp lặp lại chỉ gửi mỗi mười phút một lần, tối đa 30 cảnh báo mỗi giờ), một vòng lặp sập trên một bản phát hành lỗi chỉ hiện ra thành vài tin nhắn chứ không thành một bức tường chữ.

Dung lượng lưu trữ cũng được giới hạn theo cùng cách: mỗi issue chỉ giữ 50 sự kiện gần nhất và xoá phần còn lại khi có sự kiện mới. Điều này làm hỏng phiên bản đầu tiên của biểu đồ trong trang quản trị (admin), vì 50 sự kiện không thể cho bạn biết tuần trước lỗi đó xảy ra bao nhiêu lần. Vì vậy dòng issue còn giữ thêm một dict nhỏ daily_counts, dạng {"2026-10-09": 3, ...}, cắt bớt còn 90 ngày, được cập nhật dưới cùng khoá dòng (row lock) dùng để tăng bộ đếm. Biểu đồ vẽ từ dict này; các sự kiện chỉ để đọc chi tiết.

Có hai tình huống tranh chấp (race) nhỏ cần xử lý. Khi một lần sập hoàn toàn mới xảy ra trên nhiều điện thoại cùng lúc, hai báo cáo có thể cùng cố tạo issue. Báo cáo chậm chân hơn sẽ gặp IntegrityError do dấu vân tay là duy nhất, và đọc lại dòng đó bằng select_for_update. Khung 90 ngày được tính từ ngày hôm nay theo đồng hồ của máy chủ, chứ không tính từ khoá mới nhất trong dict, vì một chiếc điện thoại cài sai đồng hồ có thể ghi ngày cho báo cáo là bất kỳ ngày nào, kể cả năm sau.

Payload được lưu bị giới hạn ở 128KB. Nếu một sự kiện vượt quá mức đó, breadcrumb và debug_meta bị bỏ trước; nếu vẫn còn quá thì chỉ giữ exception, thông điệp, phiên bản phát hành và nền tảng, kèm cờ _truncated để tôi biết nó đã bị cắt.

Lọc bỏ dữ liệu cá nhân
#

SDK chạy với sendDefaultPii: false, nên về nguyên tắc không có địa chỉ IP, cookie hay nội dung yêu cầu nào được gửi lên. Máy chủ không dựa vào điều đó: trước khi lưu bất cứ thứ gì, nó chỉ giữ id và username trong user và bỏ hẳn request, phòng khi một phiên bản SDK hoặc một thiết lập sau này bắt đầu gửi chúng.

Kiểm thử trên thiết bị thật làm lộ ra thêm hai thứ cần loại bỏ:

  • Breadcrumb ghi lại URL đầy đủ, kể cả query string. Các lệnh gọi tìm kiếm của ứng dụng có dạng /api/v1/items/?search=..., nên một lần sập ngay sau khi tìm kiếm sẽ lưu luôn từ khoá người dùng đã gõ. Giờ máy chủ cắt mọi URL trong breadcrumb tại dấu ?.
  • Breadcrumb từ console mang theo nguyên các đối số đã truyền vào console.log, mà đó có thể là bất kỳ object nào ứng dụng tình cờ ghi log. Phần văn bản thông điệp được giữ (vì nó hữu ích), còn các đối số thì bị bỏ.

Tôi chỉ phát hiện ra hai điều đó khi nhìn vào một báo cáo thật, và đó cũng là lý do chính cho phần tiếp theo.

Phía client
#

Trong ứng dụng, mọi thứ đều đi qua một tệp duy nhất là services/crashReporting.ts, cùng lý do với việc SDK OTA chỉ được gọi qua một tệp: một bundle cập nhật qua mạng (over-the-air) cũng chạy trên các bản build được tạo ra trước khi module gốc tồn tại. Một câu import * as Sentry from "@sentry/react-native" ở cấp cao nhất trên bản build không có RNSentry sẽ ném lỗi ngay khi import, và kết quả là màn hình trắng. Vì vậy SDK được nạp lười (lazy require), và chỉ sau khi đã kiểm tra module gốc thực sự có mặt:

function nativeModulePresent(): boolean {
  try {
    return Boolean(TurboModuleRegistry.get("RNSentry") ?? NativeModules.RNSentry);
  } catch {
    return false;
  }
}

export function initCrashReporting(): boolean {
  if (!DSN || Platform.OS === "web" || !nativeModulePresent()) return false;
  const Sentry = require("@sentry/react-native");
  Sentry.init({
    dsn: DSN,
    release: Constants.expoConfig?.version ?? "unknown",
    sendDefaultPii: false,
    tracesSampleRate: 0,
    enableAutoSessionTracking: false,
    enableAutoPerformanceTracing: false,
    enableAppStartTracking: false,
    enableNativeFramesTracking: false,
    enableStallTracking: false,
    enableUserInteractionTracing: false,
    maxBreadcrumbs: 50,
    beforeSend(event) { /* tag the OTA bundle */ return event; },
  });
  return true;
}

Chỉ báo lỗi. Máy chủ không lưu trace, session, profile hay bản ghi phát lại (replay), nên không tính năng nào trong số đó được bật. Không có DSN (khi phát triển, khi chạy kiểm thử E2E) thì SDK không bao giờ khởi động. Các cấu hình build trên EAS (build profile) đặt sẵn DSN và một môi trường (staging hoặc production), nên cả hai loại bản build đều báo về cùng một endpoint mà trang quản trị vẫn phân biệt được. Người dùng đã đăng nhập được gắn bằng Firebase UID, không bao giờ bằng tên hay email.

Các error boundary cần được gọi báo lỗi một cách tường minh. ErrorBoundary gốc của Expo Router bắt lỗi khi render và hiện màn hình của riêng nó, nên lỗi đó không bao giờ tới được handler toàn cục mà SDK móc vào. Giờ layout xuất ra một lớp bọc nhỏ, gọi captureException trong một useEffect rồi render boundary của router như cũ. Boundary của từng route cũng làm y như vậy. Lời gọi được đặt trong effect chứ không nằm trong hàm render, để nó chỉ báo một lần thay vì báo lại sau mỗi lần render lại. Component gốc cũng được bọc bằng Sentry.wrap, và nếu việc bọc ném lỗi thì quay về dùng component trần.

Trên web có một tệp crashReporting.web.ts nằm cạnh, trong đó mọi hàm đều bị tắt. Metro chọn tệp theo nền tảng, nên SDK hoàn toàn không lọt vào bundle web. Ứng dụng web vốn không có module gốc nào để SDK chạy trên đó.

Cơ chế /client-errors/ cũ vẫn được giữ. Trên các bản build cũ hơn 2.5.9, nó vẫn là thứ duy nhất báo lỗi về, và nó bao quát cả những lỗi không phải sập ứng dụng.

Không có sentry-cli, không có build phase, và không có expo prebuild; thư mục ios/ và android/ trong dự án này được sửa tay và commit thẳng vào repo, và cơ chế tự liên kết (autolinking) tự nhận module gốc. Tính năng này ra mắt ở bản 2.5.9.

Chứng minh một lần sập gốc thật sự về tới nơi
#

Các unit test gửi những envelope viết tay, tức là chỉ chứng minh parser xử lý được thứ mà tôi nghĩ SDK sẽ gửi. Hai ngày sau khi phát hành, trên production chưa có báo cáo sự cố nào. Lúc đó mới có ba người dùng bản 2.5.9, nên con số 0 là hợp lý, nhưng nhìn từ phía máy chủ thì “chưa ai bị sập” và “báo cáo sập không về tới” trông giống hệt nhau. Tôi muốn thấy một báo cáo thật.

Vì vậy tôi build một bản Release cho simulator, với DSN trỏ về một Django chạy cục bộ ở cổng 8002 cùng một khoá dùng một lần:

EXPO_PUBLIC_CRASH_DSN=http://localcrashtestkey0000@localhost:8002/1 \
EXPO_PUBLIC_CRASH_ENVIRONMENT=local-verify \
xcodebuild -workspace ios/MyApp.xcworkspace -scheme MyApp -configuration Release \
  -destination 'platform=iOS Simulator,id=<udid>' -derivedDataPath /tmp/iosbuild

(Dùng Release chứ không phải Debug: bản Debug có liên kết dev launcher và hoạt động khác đi, còn Release mới là thứ người dùng thật sự chạy.) Sau đó tôi cài, mở ứng dụng, chờ vài giây, rồi giết tiến trình theo đúng cách một lỗi thật sẽ làm:

xcrun simctl install booted /tmp/iosbuild/Build/Products/Release-iphonesimulator/MyApp.app
xcrun simctl launch booted com.example.myapp      # prints the pid
kill -ABRT <pid>

Ứng dụng trong simulator là các tiến trình macOS bình thường, nên tín hiệu tới được handler của SentryCrash y như trên thiết bị thật. Handler ghi báo cáo xuống đĩa; trong lúc tiến trình đang chết thì không có gì được gửi đi. Ở lần khởi động tiếp theo, SDK tìm thấy báo cáo đó và gửi lên. Tôi mở lại ứng dụng, và một giây sau nhật ký mạng của simulator cho thấy có kết nối tới :8002.

Rồi trong log của Django chẳng có gì, khiến tôi bối rối mất một phút: ở mức log tôi đang đặt, máy chủ phát triển chỉ ghi lại các phản hồi không phải 2xx. Nhưng trong cơ sở dữ liệu thì có:

SIGABRT: Signal 6, Code 0 | cocoa 2.5.9 iOS 27.1 iPhone19,4 handled=False
received 09:19:53, crashed 09:19:38
contexts: app, device, os, ota_updates, trace · 24 breadcrumbs

Mười lăm giây từ lúc sập tới lúc được lưu, mà phần lớn thời gian đó là tôi mở lại ứng dụng. Và chính việc nhìn vào payload thật đã dạy tôi những điều mà kiểm thử không thể:

  • ID người dùng không phải Firebase UID. Bản build thử nghiệm không thật sự đăng nhập, nên sentry-cocoa dùng ID riêng cho từng lượt cài đặt của nó. Điều này đáng biết, và đáng ghi vào phần lưu ý quyền riêng tư: một báo cáo mang ID tài khoản nếu bạn đã đăng nhập, và một ID cài đặt ngẫu nhiên nếu chưa.
  • Không có múi giờ. Tôi đã cho rằng sẽ có context về ngôn ngữ và vùng (culture). Context thiết bị có locale, bộ nhớ, kích thước màn hình, chế độ tiết kiệm pin và phiên bản kernel, nhưng không có múi giờ.
  • Sập gốc bỏ qua beforeSend. Hàm beforeSend phía JS của tôi gắn thêm tag ota ghi bundle OTA đang chạy, nhưng một lần sập gốc được SDK gốc lắp ráp ở lần khởi động sau và không bao giờ đi qua hook JS này. Muốn gắn tag cho sập gốc thì phải đặt tag trên scope bằng setTag ngay khi biết bundle, để nó được đồng bộ xuống tầng gốc.
  • Breadcrumb cho thấy ứng dụng đang làm gì. Cảnh báo trên console, vòng đời của view controller, và các lệnh gọi HTTP, kể cả các yêu cầu của chính Firebase Analytics tới app-analytics-services.com. Đó là lời nhắc tốt về việc ứng dụng còn nói chuyện với những ai khác. Và các URL có kèm query string, đó là cách tôi phát hiện ra vấn đề từ khoá tìm kiếm ở trên.

Đọc báo cáo
#

Trang quản trị Django có một trang issue mô phỏng theo Sentry: phần đầu trang hiện các con số đếm, số người dùng khác nhau trong các sự kiện đang giữ, cùng các nút Resolve / Ignore / Reopen; một cột cho mỗi ngày trong 30 ngày, lấy từ daily_counts; tỷ lệ các sự kiện đang giữ theo phiên bản phát hành, hệ điều hành, thiết bị, bundle OTA, môi trường và sập hẳn hay đã được xử lý (mỗi mục hiện bốn giá trị phổ biến nhất, phần còn lại gộp vào “other”); và từng sự kiện một với exception, các frame (frame của ứng dụng được tô nổi, frame của thư viện được thu gọn), dòng thời gian breadcrumb và context thiết bị, cuối trang là JSON thô. Danh sách issue có một biểu đồ nhỏ (sparkline) 14 ngày cho mỗi dòng. Tất cả chỉ là một template và một module đọc payload đã lưu; không cần thêm thư viện phụ thuộc nào.

Trong lúc làm trang này có một chỗ phải sửa: với một lần sập gốc, frame “thủ phạm” (culprit) được lưu lại hiện là ? (/private/var/containers/Bundle/Application/<uuid>/MyApp.app/MyApp), tức đường dẫn của tệp thực thi trên thiết bị, trong đó có một UUID riêng cho mỗi lượt cài đặt. Giờ nó hiện là 0x1000a1b2c (MyApp). URL quản trị của một báo cáo riêng lẻ sẽ chuyển hướng về trang issue của nó với sự kiện đó được chọn sẵn, để chỉ có một chỗ đọc một lần sập thay vì hai.

Chính sách quyền riêng tư, toàn bộ
#

Báo cáo sự cố gần như chắc chắn là thông tin cá nhân: một ID tài khoản, một mẫu điện thoại, và những gì một người đã làm trong phút cuối trước khi ứng dụng sập. Vì vậy chính sách quyền riêng tư cần thêm một đoạn. Viết cho đàng hoàng nghĩa là phải nói chính xác một báo cáo mang theo những gì, nên tôi liệt kê dựa trên payload vừa xem: lỗi, phiên bản ứng dụng và bản cập nhật OTA, mẫu điện thoại, hệ điều hành và ngôn ngữ, bộ nhớ trống và kích thước màn hình, vài chục bước gần nhất (các màn hình, địa chỉ web đã bỏ mọi thứ sau dấu ?, các dòng log của chính ứng dụng), và ID tài khoản hoặc một ID cài đặt ngẫu nhiên. Chính sách cũng nói rõ báo cáo được gửi về máy chủ của chúng tôi, không gửi tới Sentry hay bất kỳ công ty báo cáo sự cố nào khác. Thời hạn lưu giữ có một dòng riêng trong bảng: 50 báo cáo gần nhất cho mỗi vấn đề, cộng với số đếm theo ngày trong 90 ngày.

Một khi đã đối chiếu một đoạn với mã nguồn, thật khó mà không đối chiếu phần còn lại. Và chính sách hiện có không đứng vững. Trang xoá tài khoản ghi rằng token thông báo đẩy (push notification) và passkey bị xoá ngay khi bạn đóng tài khoản, và log máy chủ sẽ biến mất trong vòng 90 ngày. Không điều nào trong số đó đúng. Đóng tài khoản sẽ gỡ liên kết các phương thức đăng nhập và ẩn danh hoá hồ sơ, nhưng các dòng token và bản ghi passkey vẫn còn đó cho tới khi có người xoá bằng tay, và kho lưu log hiện chưa có hạn xoá. Vì vậy toàn bộ chính sách được viết lại dựa trên bản kiểm kê những gì mã nguồn thực sự làm, và những dòng đó giờ ghi “chưa được xoá tự động; hãy email cho chúng tôi”. Nghe kém yên tâm hơn, nhưng đúng sự thật.

Giờ repo có hẳn một quy tắc cho việc này: chính sách mô tả những gì mã nguồn làm, chứ không phải những gì chúng tôi dự định, và mọi thay đổi khiến dữ liệu cá nhân được thu thập, gửi đi hay lưu giữ ở một chỗ mới đều phải cập nhật chính sách trong cùng commit. Việc cập nhật đó bao gồm cả màn hình tóm tắt trong ứng dụng, trang xoá tài khoản, và các biểu mẫu quyền riêng tư trên App Store và Play, vốn chỉ nằm trong bảng điều khiển của cửa hàng. Báo cáo sự cố chính là thay đổi khiến tôi phải viết quy tắc đó ra.

Còn thiếu gì
#

Giải mã ký hiệu (symbolication). Đây là khoảng trống thật sự, và là thứ duy nhất sentry.io làm mà tôi chưa thay thế được. Stack của JavaScript thì đã bị rút gọn, còn của mã gốc thì chưa được giải mã ký hiệu:

  • JavaScript: các frame trỏ vào bundle Hermes. Muốn sửa thì phải giữ source map Hermes đã ghép (composed) cho mọi bản build lên cửa hàng và mọi bundle OTA, đánh khoá theo phiên bản phát hành và bundle, rồi áp vào các frame JS khi render trang. Đó là phần làm tiếp theo, vì phần lớn các lần sập của chúng tôi sẽ là JavaScript.
  • iOS gốc: địa chỉ lệnh cần dSYM của bản build.
  • Android: frame Java bị R8 làm rối (obfuscate) khi bật rút gọn mã (cần mapping.txt), còn frame C++ cần ký hiệu NDK.

Còn một khoảng trống nhỏ hơn mà tôi đã nhắc ở trên: các lần sập gốc chưa mang tag OTA, vì tag này mới chỉ được đặt trong beforeSend. Đặt nó trên scope ngay khi bundle đang chạy được xác nhận chỉ mất vài dòng, nhưng tôi chưa viết.

Hiện tại, một issue cho tôi biết cái gì đã sập, trên phiên bản phát hành, bundle OTA và thiết bị nào, và người dùng đang làm gì ngay trước đó. Nó chưa cho tôi biết lỗi nằm ở đâu trong mã nguồn. Với một ứng dụng nhỏ phát hành vài lần mỗi tuần, cho tới giờ kiểu lỗi, thông điệp và breadcrumb vẫn đủ để tìm ra vấn đề.

Bộ nhận khoảng 650 dòng (parser, gom nhóm, cảnh báo, model và view), trang quản trị thêm khoảng 700 dòng nữa, phần kiểm thử khoảng 400 dòng, và nó không tốn thêm đồng nào ngoài chi phí của API vốn đã chạy sẵn. SDK làm phần khó nhất: bắt được lần sập trên điện thoại và gửi báo cáo đi ở lần khởi động kế tiếp.