<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/">
  <channel>
    <title>Sentry · Nhân Tài Đức</title>
    <link>https://jared.lynskey.co.nz/vi/tags/sentry/</link>
    <description>Những câu chuyện ngắn về kỹ thuật phần mềm và lãnh đạo đội ngũ kỹ sư — performance review, tuyển dụng và mở rộng team, CI/CD, giám sát DevOps và AI agent. Viết từ Seoul.</description>
    <generator>Hugo</generator>
    <language>vi</language>
    <copyright>© 2026 Nhân Tài Đức</copyright>
    <lastBuildDate>Sat, 07 Nov 2026 00:00:00 +1300</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/vi/tags/sentry/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Giám sát Django trên Fly.io: những công cụ DevOps tôi giữ lại và đã bỏ</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2026/2026-10-22-monitoring-tools-two-products/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/posts/2026/2026-10-22-monitoring-tools-two-products/</guid>
      <pubDate>Thu, 22 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>DevOps</category>
      <category>Monitoring</category>
      <category>Observability</category>
      <category>Site Reliability</category>
      <category>Fly.io</category>
      <category>Django</category>
      <category>Cloudflare</category>
      <category>Sentry</category>
      <category>Crash Reporting</category>
      <category>Small Teams</category>
      <category>Tech Stack</category>
      <description>Cách tôi giám sát hai ứng dụng Django nhỏ trên Fly.io với khoảng 2 đô la mỗi tháng: lỗi gửi vào chat, nhật ký lưu trên S3 truy vấn bằng DuckDB, chỉ số của Fly, báo cáo crash, và những gì tôi đã bỏ.</description>
      <content:encoded><![CDATA[<p>Tôi có hai sản phẩm nhỏ: một ứng dụng sàn giao dịch kiêm cộng đồng mà tôi đang làm, và một ứng dụng báo giá cho thợ làm nghề phải đo đạc, báo giá ngay tại nhà khách. Cả hai đều chạy trên loại máy rẻ nhất của Fly.io. Hệ thống giám sát DevOps của tôi rốt cuộc gói gọn trong năm thứ: một kênh chat nhận mọi nhật ký (log) mức ERROR, toàn bộ nhật ký được lưu vào object storage và truy vấn bằng DuckDB, bộ chỉ số (metric) Prometheus có sẵn của Fly, báo cáo sự cố ứng dụng trên điện thoại gửi qua SDK mã nguồn mở của Sentry về API của chính tôi, và một bản tóm tắt mỗi sáng.</p>
<p>Không có APM thuê ngoài, không có truy vết (tracing) trên production, cũng không có bảng điều khiển (dashboard) nào mà tôi phải nhớ để mở ra xem. Thứ duy nhất tốn thêm tiền là kho nhật ký, khoảng 2 đô la Mỹ mỗi tháng. Dưới đây là danh sách đầy đủ, cùng những thứ tôi đã bỏ hoặc thôi tin tưởng, trong đó có một thứ khiến tôi mất mấy ngày đuổi theo một sự cố mà chưa từng có người dùng nào gặp phải.</p>

<h2 class="relative group">Giám sát ở đây nghĩa là gì, và quy mô nhỏ đến mức nào
    <div id="giám-sát-ở-đây-nghĩa-là-gì-và-quy-mô-nhỏ-đến-mức-nào" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#gi%c3%a1m-s%c3%a1t-%e1%bb%9f-%c4%91%c3%a2y-ngh%c4%a9a-l%c3%a0-g%c3%ac-v%c3%a0-quy-m%c3%b4-nh%e1%bb%8f-%c4%91%e1%ba%bfn-m%e1%bb%a9c-n%c3%a0o" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Khi nói giám sát DevOps, ý tôi là hai việc: biết có gì đó hỏng trước khi có người báo cho mình, và tìm ra nguyên nhân mà không phải đoán mò. Mọi công cụ dưới đây đều được đánh giá theo đúng hai tiêu chí đó.</p>
<p>Năm ngoái tôi có viết bài <a href="https://jared.lynskey.co.nz/vi/posts/2025/2025-10-13-devops-monitoring/" >Dựng hệ thống giám sát mà lập trình viên thật sự dùng tới</a>, chủ yếu nghĩ cho các đội: đội nhỏ thì dùng công cụ giám sát có sẵn của nhà cung cấp đám mây, khi đã có hàng trăm dịch vụ thì dùng một APM thuê ngoài. Bài này là đầu nhỏ nhất của cùng một cách nghĩ. Cả hai sản phẩm đều là Django chạy trên máy shared-cpu-1x của Fly, như tôi đã kể trong <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-07-22-my-current-stack/" >Bộ công nghệ hiện tại của tôi</a>, và cả hai đều chỉ có một lập trình viên là tôi. Nếu coi giám sát là một sản phẩm và lập trình viên là người dùng của nó, thì hệ thống của tôi chỉ có đúng một người dùng: người đọc cảnh báo cũng chính là người sửa lỗi. Khi tôi lấy số liệu nền từ chỉ số của Fly vào tháng Chín, API của sàn giao dịch dùng trung bình 1% CPU. Đây không phải bộ công nghệ cho doanh nghiệp lớn, và tôi cũng không định tô vẽ nó thành như vậy.</p>
<p>Trong ba trụ cột quen thuộc là chỉ số, nhật ký và truy vết, tôi có hai. Cả hai sản phẩm đều không có truy vết phân tán (distributed tracing), vì mỗi sản phẩm chỉ là một codebase Django và một cơ sở dữ liệu, không có ranh giới dịch vụ nào để một trace phải đi qua. Dữ liệu truy vết duy nhất tôi xem là của reverse proxy trên <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-10-01-cloudflare-warp-dev-network/" >máy trạm của tôi</a>.</p>

<h2 class="relative group">Danh sách
    <div id="danh-sách" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#danh-s%c3%a1ch" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<table>
	<thead>
			<tr>
					<th>Công cụ</th>
					<th>Theo dõi cái gì</th>
					<th>Giữ hay bỏ, và vì sao</th>
					<th>Chi phí</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Kênh chat của đội, nhận dữ liệu từ một logging handler của Django</td>
					<td>Mọi lỗi ERROR từ API, máy chủ WebSocket và worker; các lần triển khai; nhập sai mã đăng nhập dồn dập</td>
					<td>Giữ. Đây là nơi duy nhất tôi tự xem mà không cần ai nhắc</td>
					<td>Miễn phí</td>
			</tr>
			<tr>
					<td>Log shipper của Fly đẩy sang một bucket tương thích S3, truy vấn bằng DuckDB</td>
					<td>Mọi dòng nhật ký của các ứng dụng thuộc sàn giao dịch</td>
					<td>Giữ. Bộ đệm của Fly chỉ giữ vài phút; chỗ này giữ tất cả, lại truy vấn được bằng SQL</td>
					<td>Khoảng 2 đô la/tháng, cộng phí lưu trữ</td>
			</tr>
			<tr>
					<td>Chỉ số có sẵn của Fly (Prometheus API)</td>
					<td>CPU, bộ nhớ, hạn mức burst, số phản hồi; exporter của Postgres được quản lý</td>
					<td>Giữ. Nó cho thấy các lần cơ sở dữ liệu bị treo không phải do code của tôi</td>
					<td>Có sẵn trong Fly</td>
			</tr>
			<tr>
					<td>Kiểm tra tình trạng (health check) của Fly trên <code>/api/health/</code> (cả hai ứng dụng)</td>
					<td>Một máy có nên nhận lưu lượng hay không</td>
					<td>Giữ để định tuyến. Bỏ, nếu coi nó là bằng chứng người dùng vẫn ổn</td>
					<td>Có sẵn trong Fly</td>
			</tr>
			<tr>
					<td>SDK của Sentry, với DSN trỏ về API của tôi</td>
					<td>Ứng dụng bị crash trên điện thoại</td>
					<td>Giữ SDK. Chưa bao giờ đăng ký sentry.io</td>
					<td>Không tốn thêm gì ngoài API</td>
			</tr>
			<tr>
					<td>Bản tóm tắt buổi sáng trong kênh chat của đội</td>
					<td>Lỗi, mức sử dụng, tìm kiếm, tin nhắn và bảng công việc của hôm trước</td>
					<td>Giữ. Chuyển từ một tác vụ cron trên CI sang máy Mac của tôi</td>
					<td>Miễn phí</td>
			</tr>
			<tr>
					<td>SDK phân tích ứng dụng (cả hai ứng dụng), và công cụ quản trị trang web của công cụ tìm kiếm</td>
					<td>Mức sử dụng trên web, iOS và Android; công cụ tìm kiếm nhìn trang web ra sao</td>
					<td>Giữ, đọc qua API</td>
					<td>Miễn phí</td>
			</tr>
			<tr>
					<td>Phân tích GraphQL của Cloudflare</td>
					<td>Ai đứng sau một mã trạng thái hay một đường dẫn</td>
					<td>Giữ, nhưng gom nhóm theo user agent trước khi tin</td>
					<td>Gói miễn phí, mỗi truy vấn một ngày</td>
			</tr>
			<tr>
					<td>Dashboard của một dịch vụ cập nhật OTA thuê ngoài</td>
					<td>Điện thoại đang chạy bản phát hành OTA nào; các lần tự quay lui khi crash</td>
					<td>Chuyển sang đây từ một danh sách trong trang quản trị chẳng cho biết gì</td>
					<td>Nằm trong dịch vụ cập nhật</td>
			</tr>
			<tr>
					<td>Một script thăm dò nhỏ</td>
					<td>Các yêu cầu bị lỗi trong lúc triển khai hoặc khởi động lại</td>
					<td>Giữ. Nó gọi thẳng vào origin nên bộ nhớ đệm không che được khoảng hở</td>
					<td>Miễn phí</td>
			</tr>
			<tr>
					<td><code>flyctl logs</code></td>
					<td>Vài phút đầu ra gần nhất của một ứng dụng</td>
					<td>Giữ để xem trực tiếp. Bỏ, nếu dùng làm nơi lưu trữ</td>
					<td>Có sẵn trong Fly</td>
			</tr>
			<tr>
					<td>Cloudflare Early Hints</td>
					<td>Không theo dõi gì cả; đây là tính năng tăng tốc</td>
					<td>Giữ lại, sau khi origin gửi header <code>Link</code>. Các lần cache miss của nó chính là toàn bộ lỗi 504 của zone</td>
					<td>Miễn phí</td>
			</tr>
	</tbody>
</table>
<p>Những dòng không ghi &ldquo;cả hai ứng dụng&rdquo; đều là của sàn giao dịch. Ứng dụng báo giá có ít hơn: health check, nhật ký và chỉ số của Fly, phân tích ứng dụng trên iOS, và một tác vụ xuất cơ sở dữ liệu ra tệp Parquet mỗi ngày để tôi truy vấn bằng DuckDB. Nó chưa có báo cáo crash, cũng chưa có kênh cảnh báo, và đó là chỗ hổng lớn nhất trong bài này. (Xin nói rõ về dòng dịch vụ cập nhật: công ty làm ra dịch vụ đó trả tiền cho tôi để viết bài trên blog của họ.)</p>

<h2 class="relative group">Lỗi: một logging handler gửi vào kênh chat
    <div id="lỗi-một-logging-handler-gửi-vào-kênh-chat" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#l%e1%bb%97i-m%e1%bb%99t-logging-handler-g%e1%bb%adi-v%c3%a0o-k%c3%aanh-chat" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Trước tháng Chín, một lỗi 500 chưa được xử lý, một thông báo đẩy (push notification) gửi thất bại hay một frame WebSocket bị hỏng chỉ xuất hiện ở đúng một chỗ là <code>flyctl logs</code>, mà luồng đó thì chẳng ai tự giác ngồi đọc. Giờ đây một logging handler gửi mọi bản ghi từ mức ERROR trở lên vào một kênh chat, dù nó đến từ API, máy chủ WebSocket hay worker chạy nền. Cảnh báo mức WARNING thì tôi cố ý không gửi.</p>
<p>Handler gửi tin từ một luồng nền, nên không bao giờ làm chậm một yêu cầu. Bên trong nó không ghi nhật ký gì, nên không thể tự gọi đệ quy. Và cùng một lỗi chỉ được gửi tối đa mười phút một lần, giới hạn 30 cảnh báo mỗi giờ, nên khi có sự cố bạn chỉ đọc một tin nhắn chứ không phải một nghìn.</p>
<p>Kênh này cũng chứa những thứ tôi muốn đặt cạnh lỗi để đối chiếu: các lần triển khai đều báo lúc bắt đầu, lúc chạy xong và chỗ bị hỏng, kèm commit và người chạy; các bản cập nhật OTA và <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-14-web-releases-without-redeploying/" >các lần phát hành giao diện web</a> cũng vậy. Khi một tài khoản bị nhập sai mã đăng nhập dồn dập, hệ thống cũng ghi ở mức ERROR, nên ai đó đang dò mã sẽ hiện ra ngay cạnh các sự cố.</p>
<p>Hồi tháng Mười có một cảnh báo đến mà không khớp với dòng nào trong bất kỳ nhật ký nào. Từ đó, mỗi cảnh báo mang theo một id ngẫu nhiên ngắn, tên máy và lệnh khởi động tiến trình, đồng thời ghi đúng dòng đó vào nhật ký của tiến trình. Nếu tôi không tìm thấy id ở đâu cả, thì cảnh báo đến từ một tiến trình không ghi nhật ký vào đó, chẳng hạn một phiên SSH. Mọi thứ vào kênh cũng được đẩy vào một luồng dữ liệu mà agent lập trình tôi làm việc cùng đọc được, nên agent đang làm việc với code cũng thấy lỗi cùng lúc với tôi.</p>

<h2 class="relative group">Nhật ký: log shipper của Fly đẩy lên S3, truy vấn bằng DuckDB
    <div id="nhật-ký-log-shipper-của-fly-đẩy-lên-s3-truy-vấn-bằng-duckdb" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#nh%e1%ba%adt-k%c3%bd-log-shipper-c%e1%bb%a7a-fly-%c4%91%e1%ba%a9y-l%c3%aan-s3-truy-v%e1%ba%a5n-b%e1%ba%b1ng-duckdb" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Bộ đệm nhật ký của Fly chỉ giữ vài phút đầu ra, khoảng một trăm dòng gần nhất, đủ để theo dõi một lần triển khai nhưng vô dụng với chuyện của ngày hôm qua. Vì vậy một ứng dụng Fly thứ ba chạy log shipper của Fly, tức là Vector đăng ký nghe luồng nhật ký của cả tổ chức, và tôi chỉ đổi một chỗ ở sink S3 của nó:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="p">[</span><span class="nx">sinks</span><span class="p">.</span><span class="nx">aws_s3</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">type</span> <span class="p">=</span> <span class="s2">&#34;aws_s3&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">bucket</span> <span class="p">=</span> <span class="s2">&#34;myapp-logs&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">compression</span> <span class="p">=</span> <span class="s2">&#34;gzip&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">key_prefix</span> <span class="p">=</span> <span class="s2">&#34;logs/{{fly.app.name}}/%F/&#34;</span>   <span class="c"># one folder per app per day</span>
</span></span><span class="line"><span class="cl">  <span class="nx">batch</span><span class="p">.</span><span class="nx">timeout_secs</span> <span class="p">=</span> <span class="mi">300</span>                  <span class="c"># a quiet app still lands its lines</span></span></span></code></pre></div></div>
<p>Mọi dòng nhật ký của các ứng dụng thuộc sàn giao dịch đều được ghi vào một bucket tương thích S3 dưới dạng JSON nén gzip. Shipper chạy trên một máy shared-cpu-1x 256 MB, khoảng 2 đô la mỗi tháng, phí lưu trữ chỉ vài xu. DuckDB đọc thẳng các tệp tại chỗ thông qua một macro, và dữ liệu của một ngày trả về trong khoảng một giây:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="n">MACRO</span><span class="w"> </span><span class="n">logs</span><span class="p">(</span><span class="n">app</span><span class="p">,</span><span class="w"> </span><span class="k">day</span><span class="w"> </span><span class="p">:</span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;*&#39;</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="k">TABLE</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">read_json_auto</span><span class="p">(</span><span class="s1">&#39;s3://myapp-logs/logs/&#39;</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="n">app</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="s1">&#39;/&#39;</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="k">day</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="s1">&#39;/*.log.gz&#39;</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                               </span><span class="n">union_by_name</span><span class="w"> </span><span class="p">:</span><span class="o">=</span><span class="w"> </span><span class="k">true</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="k">timestamp</span><span class="p">,</span><span class="w"> </span><span class="n">message</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">logs</span><span class="p">(</span><span class="s1">&#39;myapp-api&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;2026-09-06&#39;</span><span class="p">)</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">message</span><span class="w"> </span><span class="k">LIKE</span><span class="w"> </span><span class="s1">&#39;% 500 %&#39;</span><span class="p">;</span></span></span></code></pre></div></div>
<p>Tôi đã dùng chính macro này để đo thời gian khởi động nguội (cold start) trong <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-09-scale-to-zero-504s/" >bài về scale to zero</a>: script khởi động báo từng giai đoạn, dòng nào cũng mang id của máy, phần còn lại chỉ là một câu <code>GROUP BY</code>.</p>
<p>Nói gọn lại, đó là ba tầng của cùng một luồng, chia theo người đọc: kho lưu trữ giữ mọi thứ từ INFO trở lên (cũng là lý do các ứng dụng ghi nhật ký ở mức INFO trên production), một script xem trực tiếp chỉ hiện cảnh báo và lỗi, còn kênh chat nhận mức ERROR. Có điều, cả ba đều không biết ai là người gọi. Nhật ký truy cập không có IP hay user agent của client, nên muốn biết điều đó tôi phải sang Cloudflare.</p>

<h2 class="relative group">Chỉ số: Prometheus có sẵn của Fly
    <div id="chỉ-số-prometheus-có-sẵn-của-fly" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ch%e1%bb%89-s%e1%bb%91-prometheus-c%c3%b3-s%e1%ba%b5n-c%e1%bb%a7a-fly" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Fly chạy sẵn một Prometheus được quản lý cho mỗi tổ chức, nên tôi không phải vận hành hệ thống chỉ số riêng. Khi có câu hỏi, tôi gọi thẳng API của nó: bộ nhớ, CPU và hạn mức burst theo từng máy (trên CPU dùng chung thì <code>fly_instance_cpu_balance</code> rất quan trọng), số phản hồi và số yêu cầu đồng thời theo từng ứng dụng. Có hai điều tôi ước mình biết sớm hơn. Token tạo bằng <code>fly tokens create</code> phải gửi dưới dạng <code>Authorization: FlyV1 &lt;token&gt;</code>; <code>Bearer</code> chỉ dùng được với token cá nhân từ <code>flyctl auth token</code>, còn token chỉ đọc sẽ nhận về &ldquo;something went wrong resolving organization&rdquo;. Và dữ liệu chỉ được giữ khoảng hai tuần (Fly nói là khoảng 15 ngày).</p>
<p>Nó chứng minh giá trị của mình vào tháng Chín, khi nhật ký xuất hiện &ldquo;Could not open a database connection&rdquo; vài lần mỗi ngày, trông y hệt một lỗi của ứng dụng. Cụm Postgres được quản lý cũng nằm trong cùng Prometheus đó, và ở mỗi phút có lỗi, thời gian scrape của máy chủ chính (primary) nhảy từ khoảng 80 mili giây lên một đến mười giây, trong khi bản sao (replica) vẫn bình thường và thời điểm khởi động của Postgres không hề đổi. Tức là Postgres không khởi động lại; chính máy chủ vật lý bên dưới bị treo. Tôi gửi các mốc thời gian cho bộ phận hỗ trợ của Fly, và sau một lần khởi động lại để bảo trì vào đầu tháng Mười đưa primary sang máy khác, tình trạng treo hằng ngày đã chấm dứt.</p>

<h2 class="relative group">Báo cáo crash: SDK của Sentry, trỏ về API của chính tôi
    <div id="báo-cáo-crash-sdk-của-sentry-trỏ-về-api-của-chính-tôi" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#b%c3%a1o-c%c3%a1o-crash-sdk-c%e1%bb%a7a-sentry-tr%e1%bb%8f-v%e1%bb%81-api-c%e1%bb%a7a-ch%c3%adnh-t%c3%b4i" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Trước đây, khi ứng dụng crash trên điện thoại thì tôi không thấy dấu vết gì ở bất cứ đâu. Câu trả lời hiển nhiên là Sentry, và tôi lấy một nửa của nó: ứng dụng dùng SDK mã nguồn mở của Sentry, nhưng DSN trỏ vào một endpoint trong chính API Django của tôi. Endpoint này nhận định dạng envelope của Sentry, gom nhóm các lần crash và cảnh báo qua đúng handler gửi vào kênh chat ở trên. Truy vết, phiên (session) và replay đều tắt; chỉ nhận lỗi.</p>
<p>Tôi không đăng ký sentry.io vì với vài báo cáo mỗi ngày, làm vậy nghĩa là thêm một nhà cung cấp nắm 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 hóa đơn. Cái giá phải trả là symbolication (giải mã stack trace về mã nguồn), thứ tôi vẫn chưa thay thế được. Stack trace hoặc là JavaScript đã bị rút gọn (minify), hoặc là địa chỉ native thô, nên một issue cho tôi biết cái gì crash, trên bản phát hành và thiết bị nào, người dùng vừa làm gì ngay trước đó, nhưng không cho biết lỗi nằm ở đâu trong mã nguồn.</p>

<h2 class="relative group">Bảng điều khiển duy nhất là một tin nhắn
    <div id="bảng-điều-khiển-duy-nhất-là-một-tin-nhắn" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#b%e1%ba%a3ng-%c4%91i%e1%bb%81u-khi%e1%bb%83n-duy-nh%e1%ba%a5t-l%c3%a0-m%e1%bb%99t-tin-nh%e1%ba%afn" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Tôi không dựng bảng điều khiển cho ứng dụng nào cả. Bài năm ngoái có một phép thử cho dashboard: nếu không ai tự mở nó ra, nó chưa chứng minh được giá trị. Một tin nhắn tự gửi đến thì không cần ai mở, nên thứ gần với dashboard nhất mà tôi có là một bài đăng trong kênh chat của đội mỗi sáng.</p>
<p>Phần đầu là các lỗi production trong 24 giờ qua, đọc từ kho nhật ký chứ không phải từ kênh chat, vì cảnh báo đã bị giới hạn tần suất nên không thể cho biết một chuyện xảy ra bao nhiêu lần. Số, id và địa chỉ được che đi trước khi gom nhóm, nên một trăm lần cùng một lỗi chỉ là một dòng kèm số lần, và không địa chỉ email nào lọt vào kênh.</p>
<p>Tiếp theo là các lần health check thất bại, mức sử dụng hôm qua so với cùng thứ đó của tuần trước (Chủ nhật thì không bao giờ giống thứ Hai), lượt truy cập từ tìm kiếm của tuần gần nhất, các cuộc trò chuyện đang chờ trả lời, bảng công việc và khoản chi tiêu. Phần nào không tải được thì ghi rõ như vậy, còn bài đăng vẫn được gửi đi.</p>
<p>Đọc số liệu phân tích ứng dụng qua API dạy bạn nghi ngờ con số một chút. Tháng Tám có 121 lượt mở ứng dụng Android lần đầu từ Azerbaijan, kéo theo 119 lượt gỡ cài đặt, và tôi đọc tổng số Android của tháng đó sau khi đã trừ chúng ra.</p>
<p>Các bản phát hành có màn hình riêng. Hồi bản cập nhật OTA còn được phát từ máy chủ của tôi, cách duy nhất để xem một bản phát hành là một danh sách trong trang quản trị có ô &ldquo;is active&rdquo;, và tôi hoàn toàn không biết có bao nhiêu điện thoại đã nhận bản cập nhật. Dashboard của dịch vụ mới cho thấy các thiết bị thật sự đang chạy bản nào, việc cài đặt diễn ra ra sao, và cả lần quay lui khi một bundle lỗi bị crash và SDK đưa bản trước đó quay lại. Tôi đã viết riêng về <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-13-moving-to-codemagic-patch/" >việc chuyển sang một dịch vụ cập nhật thuê ngoài</a>.</p>

<h2 class="relative group">Những thứ tôi đã bỏ, hoặc thôi tin
    <div id="những-thứ-tôi-đã-bỏ-hoặc-thôi-tin" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#nh%e1%bb%afng-th%e1%bb%a9-t%c3%b4i-%c4%91%c3%a3-b%e1%bb%8f-ho%e1%ba%b7c-th%c3%b4i-tin" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Không có thứ nào trong số này là công cụ trả phí. Phần lớn là những thứ tôi vẫn coi là tín hiệu nhưng hóa ra không phải, và một tác vụ chạy sai chỗ.</p>

<h3 class="relative group">Tỷ lệ 504 của Cloudflare, cho tới khi tôi gom nhóm theo user agent
    <div id="tỷ-lệ-504-của-cloudflare-cho-tới-khi-tôi-gom-nhóm-theo-user-agent" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#t%e1%bb%b7-l%e1%bb%87-504-c%e1%bb%a7a-cloudflare-cho-t%e1%bb%9bi-khi-t%c3%b4i-gom-nh%c3%b3m-theo-user-agent" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Tháng Chín, dashboard của Cloudflare cho thấy có vẻ như 22% số yêu cầu trong một ngày thất bại với lỗi 504, dồn vào các endpoint mà ứng dụng thăm dò (poll) mỗi 30 giây, trong khi health check vẫn xanh hết và nhật ký của chính ứng dụng cho thấy các yêu cầu đó được xử lý rất nhanh. Tôi mất mấy ngày với nó, phát hiện máy cần 25 đến 33 giây để khởi động nguội và phải làm thế 92 lần mỗi ngày, sửa xong, rồi viết trong <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-09-scale-to-zero-504s/" >bài về scale to zero</a> rằng đó là nguyên nhân.</p>
<p>Sáng hôm sau tôi gom nhóm chính những lỗi 504 đó theo user agent. Toàn bộ 7,344 lỗi từ ngày 5 đến ngày 9 tháng Chín đều đến từ các lượt tra cứu cache Early Hints của chính Cloudflare (user agent là <code>bastion early hints</code> và <code>nginx-ssl early hints</code>); chúng không bao giờ đi tới origin, và mã 504 trên những dòng đó ghi lại một lần cache miss chứ không phải timeout. Chúng bắt chước đường dẫn của yêu cầu thật, nên rơi đúng vào những endpoint bận nhất và trông y như người dùng đang gặp lỗi. Ứng dụng, trình duyệt, curl và trình thu thập dữ liệu của công cụ tìm kiếm không có lấy một lỗi nào. Origin khi đó không gửi header <code>Link</code>, nên Early Hints thực ra chẳng làm được gì, và tôi đã tắt nó; về sau tôi <a href="https://jared.lynskey.co.nz/vi/posts/2026/cloudflare-504-early-hints/" >làm cho nó hoạt động thật sự</a>.</p>
<p>Công sức tối ưu khởi động nguội vẫn có ích, vì triển khai và đánh thức máy đều nhanh hơn, nhưng sự cố mà tôi tưởng mình đang sửa thì chưa từng chạm tới người dùng nào.</p>
<p>Vì thế, quy tắc tôi muốn chia sẻ nhất là: trước khi tin vào tỷ lệ lỗi của CDN, hãy gom nhóm theo user agent và kiểm tra xem đó có phải do chính cơ chế của CDN sinh ra không. Trên gói miễn phí của Cloudflare, đó là một truy vấn GraphQL vào <code>httpRequestsAdaptiveGroups</code>, mỗi lần một ngày. Lần hoảng tiếp theo cũng được giải quyết bằng đúng truy vấn đó, khi một endpoint thăm dò tăng từ 70–260 yêu cầu mỗi ngày lên 2,880. Không phải công cụ cào dữ liệu (scraper) nào cả. Đó là chính máy Mac của tôi, với ứng dụng vẫn đang chạy trong trình giả lập iOS sau khi kiểm thử.</p>

<h3 class="relative group">Một health check báo xanh
    <div id="một-health-check-báo-xanh" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#m%e1%bb%99t-health-check-b%c3%a1o-xanh" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Fly kiểm tra <code>/api/health/</code> của cả hai ứng dụng mỗi 30 giây và dùng kết quả để quyết định máy nào được nhận lưu lượng, và nó làm việc này tốt. Nhưng health check chạy trên một máy đã khởi động xong, nên nó không thấy những gì xảy ra trước đó: một lần khởi động nguội, một hàng đợi ở proxy, một yêu cầu không bao giờ tới nơi. Giờ tôi chỉ nhờ nó định tuyến.</p>
<p>Khi triển khai, tôi dùng script thăm dò thay thế: nó gọi vào origin suốt quá trình và thoát với mã 1 nếu có bất kỳ yêu cầu nào lỗi. Script này đã bắt được một lần <code>flyctl deploy</code> thông thường để lại khoảng hở 7.3 giây, rồi ghi nhận không lỗi nào trong 2,514 yêu cầu khi chạy qua script triển khai, vốn đánh thức một máy dự phòng trước.</p>
<p>Bản thân health check cũng từng gây ra hai sự cố. Tháng Bảy, thiết lập <code>SECURE_SSL_REDIRECT</code> của Django trả lời yêu cầu thăm dò HTTP thường của Fly bằng mã 301, mọi máy đều trượt kiểm tra, và ai cũng nhận về lỗi 503. Tháng Tám, việc thu hẹp một <code>except Exception</code> trong view health check khiến lỗi &ldquo;connection already closed&rdquo; lọt ra thành 500, và Fly gỡ máy ra đúng trong trường hợp mà cơ chế thử lại của view được viết ra để xử lý. Giờ health check chỉ hỏi một câu: tiến trình này có phục vụ được yêu cầu không, và khi cơ sở dữ liệu bị treo thì cả hai ứng dụng không còn bị gỡ khỏi định tuyến nữa.</p>
<p>Bản tóm tắt buổi sáng cũng phải học bài học tương tự về cách đếm. Sàn giao dịch tạm ngưng (suspend) các máy đang rảnh, nên một máy bị đánh thức bởi yêu cầu sẽ trượt lần kiểm tra đầu tiên vài trăm mili giây sau khi khởi động và qua ở lần kế tiếp. Đếm cả những lần đó khiến sáng nào đọc cũng như có sự cố, nên giờ lỗi nào qua lại được trong vòng một phút sẽ được liệt kê riêng, ghi là máy thức dậy và các lần triển khai.</p>

<h3 class="relative group">Một tác vụ định kỳ trên CI
    <div id="một-tác-vụ-định-kỳ-trên-ci" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#m%e1%bb%99t-t%c3%a1c-v%e1%bb%a5-%c4%91%e1%bb%8bnh-k%e1%bb%b3-tr%c3%aan-ci" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Bản tóm tắt buổi sáng ban đầu là một workflow CI chạy theo lịch cron, và chỉ trong vòng một ngày đã phải chuyển đi. Khi nó cần đọc số liệu phân tích và tìm kiếm, khóa cần dùng là khóa tài khoản dịch vụ (service account) của dự án phân tích, thứ làm được mọi việc trong dự án đó, và tôi không muốn để nó trong secrets của CI. Giờ một trình lập lịch trên máy Mac của tôi chạy nó mỗi 20 phút, và nó chỉ đăng bài khi đã qua tám giờ sáng theo giờ Auckland mà bài của hôm nay chưa được gửi, nên một chiếc Mac đang ngủ lúc tám giờ sẽ đăng khi nó thức dậy. Workflow vẫn được giữ làm phương án dự phòng chạy tay, chỉ thiếu hai phần phân tích và tìm kiếm.</p>

<h2 class="relative group">Nếu được bắt đầu lại
    <div id="nếu-được-bắt-đầu-lại" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#n%e1%ba%bfu-%c4%91%c6%b0%e1%bb%a3c-b%e1%ba%aft-%c4%91%e1%ba%a7u-l%e1%ba%a1i" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Tôi sẽ dựng ba thứ trước khi có người dùng đầu tiên, chứ không đợi tới sau sự cố đầu tiên: gửi lỗi vào một kênh chat mà đội vốn đã đọc, có giới hạn tần suất; một kho nhật ký trên object storage truy vấn được bằng SQL; và những chỉ số mà nền tảng vốn đã thu thập sẵn. Tất cả chỉ tốn vài đô la mỗi tháng. Báo cáo crash thì thêm vào đúng tuần ứng dụng lên điện thoại, vì nếu không, một lần crash trên điện thoại chẳng báo cho ai biết điều gì. Bản tóm tắt buổi sáng thì thêm vào khi số chỗ phải kiểm tra đã nhiều tới mức tôi sắp chẳng buồn kiểm tra chỗ nào nữa.</p>
<p>Truy vết thì tôi để dành cho tới khi có dịch vụ thứ hai để một yêu cầu phải đi qua, còn APM dạng dịch vụ thuê ngoài thì đợi tới khi có một đội cần dashboard dùng chung, lịch trực (on-call) và ngân sách để trả hóa đơn cho người khác. Đó là lúc những công cụ trong bài năm ngoái bắt đầu đáng đồng tiền. Trước lúc đó, thói quen hữu ích nhất lại miễn phí: khi một con số trông đáng sợ, hãy tìm xem cái gì tạo ra nó trước khi kết luận cái gì bị hỏng. Không ít lần hoảng hốt trong bài này đến từ chính hệ thống giám sát, chứ không phải từ sản phẩm.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-22-monitoring-tools-two-products/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>Không cần Sentry tự host (self-hosted): nhận báo cáo crash React Native trên máy chủ của mình</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2026/2026-10-09-sentry-sdk-own-server/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/posts/2026/2026-10-09-sentry-sdk-own-server/</guid>
      <pubDate>Fri, 09 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>React Native</category>
      <category>Expo</category>
      <category>Sentry</category>
      <category>Django</category>
      <category>Crash Reporting</category>
      <category>Self-Hosted</category>
      <category>iOS</category>
      <category>Android</category>
      <category>Privacy</category>
      <description>Không cần chạy Sentry tự host hay trả phí sentry.io: giữ SDK mã nguồn mở của Sentry, trỏ DSN về API Django của mình để nhận báo cáo crash React Native, rồi tự gom nhóm và cảnh báo.</description>
      <content:encoded><![CDATA[<p>Nếu bạn muốn tự nhận báo cáo crash (crash reporting) cho ứng dụng React Native, bạn không cần chạy máy chủ Sentry tự host (self-hosted), cũng không cần trả tiền cho sentry.io. SDK của Sentry là mã nguồn mở, và DSN của nó có thể trỏ tới bất kỳ máy chủ nào. Tôi trỏ nó về một endpoint trong API Django của mình; ở đó báo cáo được nhận, gom nhóm, gửi cảnh báo và lưu lại, không tốn thêm gì ngoài API vốn đã chạy sẵn. Thứ tôi chưa thay được là khôi phục ký hiệu (symbolication), nên stack trace vẫn chưa chỉ ra được dòng nào trong mã nguồn.</p>
<p>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 chat của đội. 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.</p>
<p>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 một endpoint nhỏ của API. Máy chủ ghi lại ở mức ERROR, nên lỗi đó cũng tới được kênh chat. Tôi thêm cơ chế này sau một lần thao tác xoá thực ra đã <em>thành công</em>, 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 &ldquo;No Item matches the given query&rdquo;. 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.</p>
<p>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à một phần nhỏ trong ứng 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.</p>
<p>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ủ.</p>

<h2 class="relative group">Mẹo với DSN
    <div id="mẹo-với-dsn" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#m%e1%ba%b9o-v%e1%bb%9bi-dsn" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Một DSN của Sentry có dạng <code>https://&lt;public-key&gt;@&lt;host&gt;/&lt;project-id&gt;</code>. SDK biến nó thành một yêu cầu POST tới <code>https://&lt;host&gt;/api/&lt;project-id&gt;/envelope/</code>, với khoá nằm trong header <code>X-Sentry-Auth</code>. 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à:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">https://&lt;CRASH_REPORT_KEY&gt;@api.example.com/1</span></span></code></pre></div></div>
<p>và phía Django có một route khớp đúng với địa chỉ mà SDK sẽ gọi:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">path</span><span class="p">(</span><span class="s2">&#34;api/&lt;int:project_id&gt;/envelope/&#34;</span><span class="p">,</span> <span class="n">crash_envelope</span><span class="p">,</span> <span class="n">name</span><span class="o">=</span><span class="s2">&#34;crash_envelope&#34;</span><span class="p">),</span></span></span></code></pre></div></div>
<p>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ứ:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">crash_envelope</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="n">project_id</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">expected</span> <span class="o">=</span> <span class="n">settings</span><span class="o">.</span><span class="n">CRASH_REPORT_KEY</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">expected</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">Response</span><span class="p">({</span><span class="s2">&#34;detail&#34;</span><span class="p">:</span> <span class="s2">&#34;Crash reporting is not configured&#34;</span><span class="p">},</span> <span class="n">status</span><span class="o">=</span><span class="mi">503</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">hmac</span><span class="o">.</span><span class="n">compare_digest</span><span class="p">(</span><span class="n">_key</span><span class="p">(</span><span class="n">request</span><span class="p">)</span><span class="o">.</span><span class="n">encode</span><span class="p">(),</span> <span class="n">expected</span><span class="o">.</span><span class="n">encode</span><span class="p">()):</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">Response</span><span class="p">({</span><span class="s2">&#34;detail&#34;</span><span class="p">:</span> <span class="s2">&#34;Unknown key&#34;</span><span class="p">},</span> <span class="n">status</span><span class="o">=</span><span class="mi">401</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="o">...</span></span></span></code></pre></div></div>
<p>Hàm <code>_key</code> đọc giá trị <code>sentry_key=</code> trong header <code>X-Sentry-Auth</code>; nếu không có thì lấy từ query string, vì một số SDK đặt khoá ở đó.</p>

<h2 class="relative group">SDK gửi gì lên: envelope
    <div id="sdk-gửi-gì-lên-envelope" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#sdk-g%e1%bb%adi-g%c3%ac-l%c3%aan-envelope" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Định dạng truyền dữ liệu của Sentry là <a href="https://develop.sentry.dev/sdk/data-model/envelopes/"  target="_blank" rel="noreferrer">envelope</a>: 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 <code>length</code> 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.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">{&#34;event_id&#34;:&#34;9ec79c33...&#34;,&#34;dsn&#34;:&#34;https://...@api.example.com/1&#34;}
</span></span><span class="line"><span class="cl">{&#34;type&#34;:&#34;event&#34;,&#34;length&#34;:4123}
</span></span><span class="line"><span class="cl">{ ...the event JSON, 4123 bytes... }
</span></span><span class="line"><span class="cl">{&#34;type&#34;:&#34;client_report&#34;}
</span></span><span class="line"><span class="cl">{&#34;timestamp&#34;:1791400000.5,&#34;discarded_events&#34;:[]}</span></span></code></pre></div></div>
<p>Có hai chi tiết khiến tôi vấp:</p>
<ul>
<li><strong>SDK gốc (native) nén gzip phần thân yêu cầu, còn SDK JavaScript thì không.</strong> Một lỗi JS mà <code>@sentry/react-native</code> 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 <code>Content-Encoding: gzip</code>. 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ố.</li>
<li><strong>Payload có tiền tố độ dài có thể chứa ký tự xuống dòng.</strong> Nếu bạn tách theo <code>\n</code> thì sẽ làm hỏng mọi sự kiện mà SDK gốc gửi lên. Khi có <code>length</code> thì bắt buộc phải tôn trọng nó.</li>
</ul>
<p>Bộ phân tích cú pháp (parser) khá ngắn. Nó chỉ giữ các mục <code>event</code>; session, transaction, tệp đính kèm và client report đều được nhận rồi bỏ đi.</p>

<h2 class="relative group">Trả lời theo cách SDK hiểu được
    <div id="trả-lời-theo-cách-sdk-hiểu-được" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#tr%e1%ba%a3-l%e1%bb%9di-theo-c%c3%a1ch-sdk-hi%e1%bb%83u-%c4%91%c6%b0%e1%bb%a3c" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>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:</p>
<ul>
<li><strong>200</strong> khi đã lưu, <em>hoặc khi cố ý bỏ đi</em>. 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.</li>
<li><strong>429 kèm <code>Retry-After</code></strong> 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.</li>
<li><strong>400</strong> 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.</li>
</ul>
<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 <code>event_id</code> 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ẽ.</p>

<h2 class="relative group">Gom nhóm khi không có source map
    <div id="gom-nhóm-khi-không-có-source-map" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#gom-nh%c3%b3m-khi-kh%c3%b4ng-c%c3%b3-source-map" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Đâ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.</p>
<p>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:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">_MASKS</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;0x[0-9a-fA-F]+&#34;</span><span class="p">),</span> <span class="s2">&#34;&lt;addr&gt;&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;\b[0-9a-fA-F]</span><span class="si">{8}</span><span class="s2">-[0-9a-fA-F]</span><span class="si">{4}</span><span class="s2">-[0-9a-fA-F]</span><span class="si">{4}</span><span class="s2">-[0-9a-fA-F]</span><span class="si">{4}</span><span class="s2">-[0-9a-fA-F]</span><span class="si">{12}</span><span class="s2">\b&#34;</span><span class="p">),</span> <span class="s2">&#34;&lt;uuid&gt;&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;\b[0-9a-fA-F]{16,}\b&#34;</span><span class="p">),</span> <span class="s2">&#34;&lt;hex&gt;&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;\d+&#34;</span><span class="p">),</span> <span class="s2">&#34;&lt;n&gt;&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl"><span class="p">]</span></span></span></code></pre></div></div>
<p><code>Cannot read property 'title' of undefined (reading index 3)</code> và <code>(reading index 17)</code> sẽ thành cùng một issue. Nếu SDK tự gửi kèm <code>fingerprint</code> mà không có <code>{{ default }}</code> 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 <code>Signal 6, Code 0</code>, 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).</p>

<h2 class="relative group">Cảnh báo mà không làm ngập kênh
    <div id="cảnh-báo-mà-không-làm-ngập-kênh" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#c%e1%ba%a3nh-b%c3%a1o-m%c3%a0-kh%c3%b4ng-l%c3%a0m-ng%e1%ba%adp-k%c3%aanh" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Mỗi dấu vân tay ứng với một issue, và mỗi báo cáo được lưu thành một sự kiện nằm dưới issue đó. Issue chỉ ghi log ở mức ERROR (đó là thứ cảnh báo trên kênh chat lắng nghe) đúng trong những trường hợp sau:</p>
<ul>
<li>một issue <strong>mới</strong></li>
<li>một issue <strong>đã đánh dấu xử lý xong (resolved) nhưng quay lại</strong>, khi đó issue được mở lại</li>
<li>lần xuất hiện thứ <strong>10, 100, 1.000 và 10.000</strong></li>
</ul>
<p>Một lần lặp lại bình thường chỉ tăng bộ đếm. Issue bị đánh dấu <em>ignored</em> 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 gửi lên kênh chat, 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ữ.</p>
<p>Dung lượng lưu trữ cũng được giới hạn theo cùng cách: mỗi issue chỉ giữ <strong>50 sự kiện gần nhất</strong> và xoá các sự kiện cũ hơn khi có sự kiện mới. Điều này làm hỏng biểu đồ đầu tiên của tôi, 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 mỗi issue giữ thêm số đếm theo từng ngày trong 90 ngày, được cập nhật trong cùng giao dịch (transaction) với bộ đếm. Biểu đồ vẽ từ các số đếm này; các sự kiện chỉ để đọc chi tiết.</p>
<p>Nếu bạn tự làm một hệ thống như vậy, có hai tình huống tranh chấp (race) nhỏ đáng 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, nên hãy đặt ràng buộc duy nhất cho dấu vân tay và để báo cáo chậm chân hơn đọc lại dòng đã có. Ngoài ra, khung 90 ngày nên tính từ ngày hôm nay theo đồng hồ của máy chủ, chứ không tính từ báo cáo mới nhất, 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.</p>
<p>Payload bị giới hạn ở 128KB. Sự kiện nào vượt quá thì bị bỏ breadcrumb và siêu dữ liệu gỡ lỗi (debug metadata) trước; nếu vẫn còn quá lớn thì chỉ giữ exception, thông điệp, phiên bản phát hành và nền tảng, kèm một dấu đánh dấu để tôi biết nó đã bị cắt.</p>

<h2 class="relative group">Lọc bỏ dữ liệu cá nhân
    <div id="lọc-bỏ-dữ-liệu-cá-nhân" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#l%e1%bb%8dc-b%e1%bb%8f-d%e1%bb%af-li%e1%bb%87u-c%c3%a1-nh%c3%a2n" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>SDK chạy với <code>sendDefaultPii: false</code>, 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ữ <code>id</code> và <code>username</code> trong <code>user</code> và bỏ hẳn <code>request</code>, 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.</p>
<p>Kiểm thử trên thiết bị thật làm lộ ra thêm hai thứ cần loại bỏ:</p>
<ul>
<li><strong>Breadcrumb ghi lại URL đầy đủ, kể cả query string.</strong> Các yêu cầu tìm kiếm của ứng dụng đặt từ khoá ngay trong query string, 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 <code>?</code>.</li>
<li><strong>Breadcrumb từ console mang theo nguyên các đối số</strong> đã truyền vào <code>console.log</code>, 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ỏ.</li>
</ul>
<p>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.</p>

<h2 class="relative group">Phía client
    <div id="phía-client" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ph%c3%ada-client" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Trong ứng dụng, mọi thứ đều đi qua một module nhỏ duy nhất, cùng lý do với việc tôi bọc SDK cập nhật qua mạng (OTA) lại: một bundle OTA 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 <code>import * as Sentry from &quot;@sentry/react-native&quot;</code> ở cấp cao nhất trên bản build không có <code>RNSentry</code> 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:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ts" data-lang="ts"><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">NativeModules</span><span class="p">,</span> <span class="nx">Platform</span><span class="p">,</span> <span class="nx">TurboModuleRegistry</span> <span class="p">}</span> <span class="kr">from</span> <span class="s2">&#34;react-native&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="kr">import</span> <span class="nx">Constants</span> <span class="kr">from</span> <span class="s2">&#34;expo-constants&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">DSN</span> <span class="o">=</span> <span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">EXPO_PUBLIC_CRASH_DSN</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">function</span> <span class="nx">nativeModulePresent</span><span class="p">()</span><span class="o">:</span> <span class="kr">boolean</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nb">Boolean</span><span class="p">(</span><span class="nx">TurboModuleRegistry</span><span class="p">.</span><span class="kr">get</span><span class="p">(</span><span class="s2">&#34;RNSentry&#34;</span><span class="p">)</span> <span class="o">??</span> <span class="nx">NativeModules</span><span class="p">.</span><span class="nx">RNSentry</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span> <span class="k">catch</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="kc">false</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">export</span> <span class="kd">function</span> <span class="nx">initCrashReporting</span><span class="p">()</span><span class="o">:</span> <span class="kr">boolean</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">DSN</span> <span class="o">||</span> <span class="nx">Platform</span><span class="p">.</span><span class="nx">OS</span> <span class="o">===</span> <span class="s2">&#34;web&#34;</span> <span class="o">||</span> <span class="o">!</span><span class="nx">nativeModulePresent</span><span class="p">())</span> <span class="k">return</span> <span class="kc">false</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">Sentry</span> <span class="o">=</span> <span class="kr">require</span><span class="p">(</span><span class="s2">&#34;@sentry/react-native&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="nx">Sentry</span><span class="p">.</span><span class="nx">init</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">    <span class="nx">dsn</span>: <span class="kt">DSN</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">release</span>: <span class="kt">Constants.expoConfig?.version</span> <span class="o">??</span> <span class="s2">&#34;unknown&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">sendDefaultPii</span>: <span class="kt">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">tracesSampleRate</span>: <span class="kt">0</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">enableAutoSessionTracking</span>: <span class="kt">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="c1">// ...turn off the other tracing options the same way
</span></span></span><span class="line"><span class="cl">    <span class="nx">maxBreadcrumbs</span>: <span class="kt">50</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">beforeSend</span><span class="p">(</span><span class="nx">event</span><span class="p">)</span> <span class="p">{</span> <span class="cm">/* tag the OTA bundle */</span> <span class="k">return</span> <span class="nx">event</span><span class="p">;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="kc">true</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>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 (build profile) đặt sẵn DSN và một môi trường (<code>staging</code> hoặc <code>production</code>), nên cả hai loại bản build đều báo về cùng một endpoint mà vẫn phân biệt được với nhau. Người dùng đã đăng nhập được gắn bằng ID tài khoản, không bao giờ bằng tên hay email.</p>
<p>Các error boundary cần được gọi báo lỗi một cách tường minh. <code>ErrorBoundary</code> 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. Cách sửa là một lớp bọc nhỏ: gọi <code>captureException</code> trong một <code>useEffect</code> rồi render boundary của router như cũ, dùng chung cho cả boundary gốc lẫn boundary của từng route. 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 <code>Sentry.wrap</code>, và nếu việc bọc ném lỗi thì quay về dùng component trần.</p>
<p>Trên web có một phiên bản <code>.web.ts</code> của module, 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 đó.</p>

<h2 class="relative group">Chứng minh một lần sập gốc thật sự về tới nơi
    <div id="chứng-minh-một-lần-sập-gốc-thật-sự-về-tới-nơi" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ch%e1%bb%a9ng-minh-m%e1%bb%99t-l%e1%ba%a7n-s%e1%ba%adp-g%e1%bb%91c-th%e1%ba%adt-s%e1%bb%b1-v%e1%bb%81-t%e1%bb%9bi-n%c6%a1i" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>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 <em>nghĩ</em> 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 cập nhật, nên con số 0 là hợp lý, nhưng nhìn từ phía máy chủ thì &ldquo;chưa ai bị sập&rdquo; và &ldquo;báo cáo sập không về tới&rdquo; trông giống hệt nhau. Tôi muốn thấy một báo cáo thật.</p>
<p>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 8000 cùng một khoá dùng một lần:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">EXPO_PUBLIC_CRASH_DSN</span><span class="o">=</span>http://localcrashtestkey0000@localhost:8000/1 <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="nv">EXPO_PUBLIC_CRASH_ENVIRONMENT</span><span class="o">=</span>local-verify <span class="se">\
</span></span></span><span class="line"><span class="cl">xcodebuild -workspace ios/MyApp.xcworkspace -scheme MyApp -configuration Release <span class="se">\
</span></span></span><span class="line"><span class="cl">  -destination <span class="s1">&#39;platform=iOS Simulator,id=&lt;udid&gt;&#39;</span> -derivedDataPath /tmp/iosbuild</span></span></code></pre></div></div>
<p>(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:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">xcrun simctl install booted /tmp/iosbuild/Build/Products/Release-iphonesimulator/MyApp.app
</span></span><span class="line"><span class="cl">xcrun simctl launch booted com.example.myapp      <span class="c1"># prints the pid</span>
</span></span><span class="line"><span class="cl"><span class="nb">kill</span> -ABRT &lt;pid&gt;</span></span></code></pre></div></div>
<p>Ứ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. Ở <strong>lần khởi động tiếp theo</strong>, 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 <code>:8000</code>.</p>
<p>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ó:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">SIGABRT: Signal 6, Code 0 | cocoa 1.4.0 iOS 27.1 iPhone19,4 handled=False
</span></span><span class="line"><span class="cl">received 09:19:53, crashed 09:19:38
</span></span><span class="line"><span class="cl">contexts: app, device, os, ota_updates, trace · 24 breadcrumbs</span></span></code></pre></div></div>
<p>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ể:</p>
<ul>
<li><strong>ID người dùng không phải ID tài khoản.</strong> 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.</li>
<li><strong>Không có múi giờ.</strong> Tôi đã cho rằng sẽ có context về ngôn ngữ và vùng (culture). Context thiết bị có <code>locale</code>, 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ờ.</li>
<li><strong>Sập gốc bỏ qua <code>beforeSend</code>.</strong> Hàm <code>beforeSend</code> phía JS của tôi gắn thêm tag <code>ota</code> 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 <code>setTag</code> ngay khi biết bundle, để nó được đồng bộ xuống tầng gốc.</li>
<li><strong>Breadcrumb cho thấy ứng dụng đang làm gì.</strong> 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 SDK phân tích (analytics) tới endpoint thu thập dữ liệu của nó. Đó 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.</li>
</ul>

<h2 class="relative group">Đọc báo cáo
    <div id="đọc-báo-cáo" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%c4%91%e1%bb%8dc-b%c3%a1o-c%c3%a1o" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Báo cáo được đọc trong trang quản trị Django, trên một trang issue mô phỏng đại khái theo Sentry: phần đầu có các con số đếm và các nút Resolve / Ignore / Reopen, tiếp theo là biểu đồ cột theo ngày, tỷ lệ theo phiên bản phát hành, hệ điều hành, thiết bị và bundle OTA, rồi tới 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), breadcrumb và context thiết bị. Không cần thêm thư viện phụ thuộc nào.</p>
<p>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 &ldquo;thủ phạm&rdquo; (culprit) là đườ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, nên cùng một lần sập lại trông khác nhau trên mỗi điện thoại. Giờ nó chỉ hiện địa chỉ và tên tệp thực thi, kiểu <code>0x1000a1b2c (MyApp)</code>.</p>

<h2 class="relative group">Chính sách quyền riêng tư, toàn bộ
    <div id="chính-sách-quyền-riêng-tư-toàn-bộ" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ch%c3%adnh-s%c3%a1ch-quy%e1%bb%81n-ri%c3%aang-t%c6%b0-to%c3%a0n-b%e1%bb%99" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>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 <code>?</code>, 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.</p>
<p>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 token thông báo đẩy và 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 &ldquo;chưa được xoá tự động; hãy email cho chúng tôi&rdquo;. Nghe kém yên tâm hơn, nhưng đúng sự thật.</p>
<p>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 cửa hàng ứng dụng, 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.</p>

<h2 class="relative group">Còn thiếu gì
    <div id="còn-thiếu-gì" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#c%c3%b2n-thi%e1%ba%bfu-g%c3%ac" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p><strong>Giải mã ký hiệu (symbolication).</strong> Đâ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:</p>
<ul>
<li><strong>JavaScript:</strong> 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.</li>
<li><strong>iOS gốc:</strong> địa chỉ lệnh cần dSYM của bản build.</li>
<li><strong>Android:</strong> frame Java bị R8 làm rối (obfuscate) khi bật rút gọn mã (cần <code>mapping.txt</code>), còn frame C++ cần ký hiệu NDK.</li>
</ul>
<p>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 <code>beforeSend</code>. Đặ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.</p>
<p>Hiện tại, một issue cho tôi biết <em>cái gì</em> đã 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 <em>ở đâu</em> 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 đề.</p>
<p>Bộ nhận, trang quản trị và phần kiểm thử cộng lại cũng không nhiều mã, 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.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-09-sentry-sdk-own-server/featured.jpg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
