<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Anubhav Rai</title>
    <description>The latest articles on DEV Community by Anubhav Rai (@acrticsludge).</description>
    <link>https://dev.to/acrticsludge</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3819468%2Fbb344ad0-04b6-4992-bf91-b806b2bf82c3.png</url>
      <title>DEV Community: Anubhav Rai</title>
      <link>https://dev.to/acrticsludge</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/acrticsludge"/>
    <language>en</language>
    <item>
      <title>I got surprised by a GitHub Actions quota. Built a tool to make sure it never happens again, here's how I built it</title>
      <dc:creator>Anubhav Rai</dc:creator>
      <pubDate>Wed, 25 Mar 2026 10:13:03 +0000</pubDate>
      <link>https://dev.to/acrticsludge/i-got-surprised-by-a-github-actions-quota-built-a-tool-to-make-sure-it-never-happens-again-heres-mb3</link>
      <guid>https://dev.to/acrticsludge/i-got-surprised-by-a-github-actions-quota-built-a-tool-to-make-sure-it-never-happens-again-heres-mb3</guid>
      <description>&lt;p&gt;Few weeks ago I was pushing a fix for a small project I made related to a minecraft server i play :p. My data updates just stopped... Turns out I burned through the free Actions minutes three days earlier. GitHub doesn't email you, they just silently stop running your jobs.&lt;/p&gt;

&lt;p&gt;I checked Vercel the next day. 91% bandwidth. Two days from getting throttled.&lt;/p&gt;

&lt;p&gt;That's when I realised I was doing manual laps of 4 different billing pages every week just to feel safe. GitHub, Vercel, Supabase, Railway and each buried under a different nav, none of them proactively alerting you. I just started college and wanted to build something meaningful, So with the help of Claude Code I built Stackwatch. Here's how it actually works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The polling worker&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The core is a standalone Node.js worker running on Railway. It's dead simple: a cron job (&lt;code&gt;node-cron&lt;/code&gt;) that fires every 5 minutes and loops through every connected integration in the database.&lt;/p&gt;

&lt;p&gt;The clever bit is tier-aware polling. Free users get 15-minute intervals, Pro gets 5. The worker runs on a 5-minute tick but filters out integrations that synced too recently for their tier:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;const dueIntegrations = integrations.filter((i) =&amp;gt; {&lt;br&gt;
  const tier = tierMap.get(i.user_id) ?? "free";&lt;br&gt;
  const interval = tier === "free" ? FREE_POLL_INTERVAL_MS : PRO_POLL_INTERVAL_MS;&lt;br&gt;
  if (!i.last_synced_at) return true;&lt;br&gt;
  return now - new Date(i.last_synced_at).getTime() &amp;gt;= interval;&lt;br&gt;
});&lt;/code&gt;&lt;br&gt;
One worker, two polling rates, no separate queues.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Storing API keys&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Users paste their tokens, which get encrypted before hitting the database. I went with AES-256-GCM so I get authenticated encryption and the auth tag catches tampering. Each encryption generates a fresh random IV, and the stored value is &lt;code&gt;iv:authTag:ciphertext&lt;/code&gt;. Decryption validates the tag before returning anything:&lt;/p&gt;

&lt;p&gt;`const ALGORITHM = "aes-256-gcm";&lt;/p&gt;

&lt;p&gt;export function encrypt(plaintext: string): string {&lt;br&gt;
  const iv = randomBytes(12);&lt;br&gt;
  const cipher = createCipheriv(ALGORITHM, key, iv);&lt;br&gt;
  const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);&lt;br&gt;
  const authTag = cipher.getAuthTag();&lt;br&gt;
  return '${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted.toString("hex")}';&lt;br&gt;
}`&lt;br&gt;
The encryption key is a 64-char hex env var (32 bytes). Raw API keys never touch logs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auth and data isolation&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Auth is Supabase Auth via email/password, magic link, GitHub and Google OAuth. Every table has Row Level Security enabled so users can only ever read their own rows. The worker uses a service-role key (bypasses RLS intentionally) because it needs to poll all users. The frontend client uses the anon key and relies on RLS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Alerts&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When usage crosses a threshold (default 80%, user-configurable per metric) the worker fires alerts via Resend (email), Slack webhooks, or Discord webhooks. It stores a record in &lt;code&gt;alert_history&lt;/code&gt; and won't re-alert on the same metric until it drops below threshold and crosses it again to prevent spam.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Frontend&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Next.js App Router, TypeScript throughout. Server components by default, client components only where there's interactivity. The dashboard auto-refreshes every 5 minutes. Usage history graphs are built with Recharts also: if you use a formatted date string (like "Mar 21") as your Recharts dataKey and you have multiple snapshots on the same day, the tooltip snaps to the first point of that date. Fix is to use the raw ISO timestamp as the dataKey and format it only in &lt;code&gt;tickFormatter&lt;/code&gt; and &lt;code&gt;labelFormatter&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stack summary&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Next.js (App Router) on Vercel&lt;/li&gt;
&lt;li&gt;Supabase for auth, database, and RLS&lt;/li&gt;
&lt;li&gt;Railway for the polling worker&lt;/li&gt;
&lt;li&gt;Resend for email&lt;/li&gt;
&lt;li&gt;Recharts for usage graphs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;TypeScript everywhere, no exceptions&lt;/p&gt;

&lt;p&gt;It's live at &lt;a href="https://stackwatch.pulsemonitor.dev" rel="noopener noreferrer"&gt;https://stackwatch.pulsemonitor.dev&lt;/a&gt; the free tier covers one account per service, which is enough for most solo founders. Happy to answer questions about any part of the build.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>nextjs</category>
      <category>react</category>
    </item>
    <item>
      <title>How I built root cause analysis into my free API uptime monitor</title>
      <dc:creator>Anubhav Rai</dc:creator>
      <pubDate>Thu, 12 Mar 2026 05:09:52 +0000</pubDate>
      <link>https://dev.to/acrticsludge/how-i-built-root-cause-analysis-into-my-free-api-uptime-monitor-56pe</link>
      <guid>https://dev.to/acrticsludge/how-i-built-root-cause-analysis-into-my-free-api-uptime-monitor-56pe</guid>
      <description>&lt;p&gt;Most uptime monitors tell you your API is down. Mine tells you why.&lt;br&gt;
I got tired of waking up to a vague "monitor failed" alert with zero context. Is it a DNS issue? Did the server crash? Is it a TLS problem? You have no idea until you log in, dig through logs, and piece it together yourself.&lt;br&gt;
So when I built Pulse — my own API monitoring tool — I made root cause analysis the core feature. Here's how I implemented it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Most monitors do something like this:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;const response = await axios.get(url);&lt;br&gt;
if (response.status !== 200) {&lt;br&gt;
  sendAlert('monitor is down');&lt;br&gt;
}&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;That tells you nothing. You know the request failed. You don't know where.&lt;br&gt;
An HTTP request isn't a single operation — it's a pipeline of stages. DNS lookup, TCP connection, TLS handshake, time to first byte. Each stage can fail independently and each failure means something completely different.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Switching to native http with timing hooks&lt;/strong&gt;&lt;br&gt;
Axios doesn't expose per-stage timing. Node's built-in http/https module does via socket events. I rewrote the ping function to capture each stage separately:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;const req = transport.request(options, (res) =&amp;gt; {&lt;br&gt;
  timings.ttfb = Date.now() - startTime;&lt;br&gt;
  res.on('end', () =&amp;gt; {&lt;br&gt;
    timings.total = Date.now() - startTime;&lt;br&gt;
  });&lt;br&gt;
});&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;req.on('socket', (socket) =&amp;gt; {&lt;br&gt;
  socket.on('lookup', () =&amp;gt; {&lt;br&gt;
    timings.dnsLookup = Date.now() - startTime;&lt;br&gt;
  });&lt;br&gt;
  socket.on('connect', () =&amp;gt; {&lt;br&gt;
    timings.tcpConnect = Date.now() - startTime - timings.dnsLookup;&lt;br&gt;
  });&lt;br&gt;
  socket.on('secureConnect', () =&amp;gt; {&lt;br&gt;
    timings.tlsHandshake = Date.now() - startTime - timings.tcpConnect;&lt;br&gt;
  });&lt;br&gt;
});&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Now every ping stores dns_lookup_ms, tcp_connect_ms, tls_handshake_ms, and ttfb_ms separately in the database alongside the usual status_code and response_time_ms&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The inference logic&lt;/strong&gt;&lt;br&gt;
With per-stage timings stored, I wrote a pure function that compares the failed ping against the historical baseline for that monitor and infers the likely cause:&lt;br&gt;
&lt;code&gt;// DNS spiked but TCP was fine — DNS issue&lt;br&gt;
if (dnsRatio &amp;gt; 3) {&lt;br&gt;
  return { cause: 'DNS resolution failure', confidence: 75 }&lt;br&gt;
}&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;// TCP failed entirely — server unreachable&lt;br&gt;
if (!tcpConnectMs) {&lt;br&gt;
  return { cause: 'Server unreachable — connection refused', confidence: 85 }&lt;br&gt;
}&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;// Everything fine until TTFB — server-side problem&lt;br&gt;
if (ttfbRatio &amp;gt; 5) {&lt;br&gt;
  return { cause: 'Upstream server overload or slow database query', confidence: 78 }&lt;br&gt;
}&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;// Status code tells us exactly what happened&lt;br&gt;
if (statusCode === 503) {&lt;br&gt;
  return { cause: 'Service unavailable — server overloaded or in maintenance', confidence: 92 }&lt;br&gt;
}&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;No ML, no black box. Just rule-based inference against a baseline. A 503 with normal DNS/TCP/TLS timings but a spiked TTFB looks completely different from a connection timeout with no TCP at all.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;What it looks like in practice&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When a monitor goes down, instead of just logging the failure, Pulse shows:&lt;/p&gt;

&lt;p&gt;Root Cause Analysis&lt;br&gt;
Likely cause: Upstream server overload (78% confidence)&lt;/p&gt;

&lt;p&gt;DNS Lookup      → 34ms   normal&lt;br&gt;
TCP Connect     → 28ms   normal&lt;br&gt;&lt;br&gt;
TLS Handshake   → 71ms   normal&lt;br&gt;
Time to First Byte → 8432ms  CRITICAL (56x baseline)`&lt;/p&gt;

&lt;p&gt;Suggestion: Server is responding but very slowly —&lt;br&gt;
check database queries and server load&lt;br&gt;
That's immediately actionable. You know it's not a network problem. It's not DNS. The server is reachable but something on the backend is choking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The baseline problem&lt;/strong&gt;&lt;br&gt;
The tricky part was making the comparisons meaningful. A 200ms TTFB is great for one endpoint and terrible for another. I compute a rolling baseline from the last 20 successful pings for each monitor individually, so the thresholds adapt to the normal behavior of that specific endpoint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I learned&lt;/strong&gt;&lt;br&gt;
The biggest insight was that most of the value isn't in the ML or the fancy inference — it's just in capturing the right data at ping time. Once you have per-stage timings stored, the analysis is mostly pattern matching. The hard part was switching from axios to raw http and making sure the timing hooks fired reliably across both HTTP and HTTPS endpoints.&lt;br&gt;
The second thing I learned: storing this data costs almost nothing. Four extra integer columns per ping row. The diagnostic value is completely disproportionate to the storage cost.&lt;/p&gt;

&lt;p&gt;Pulse is free — 5 monitors, no credit card. If you want to see the root cause analysis in action or poke around the implementation: &lt;a href="https://pulsemonitor.dev" rel="noopener noreferrer"&gt;Pulse&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Happy to hear opinions!&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>node</category>
      <category>javascript</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
