<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet href="/vendor/feed/atom.xsl" type="text/xsl"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en-US">
                        <id>https://ohdear.app/feed</id>
                                <link href="https://ohdear.app/feed" rel="self"></link>
                                <title><![CDATA[ohdear.app]]></title>
                    
                                <subtitle>The description of the feed.</subtitle>
                                                    <updated>2026-07-08T05:25:13+00:00</updated>
                        <entry>
            <title><![CDATA[Status pages can now email your subscribers]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/status-pages-can-now-email-your-subscribers" />
            <id>https://ohdear.app/194</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>You've been asking for this one for years, and it's finally here: your visitors can now subscribe to your status page by <strong>email</strong>. Alongside the existing Slack, RSS, JSON, and XML options, people can drop their address on your status page and get a heads-up in their inbox the moment something breaks or gets resolved.</p>
<p>Want to test it? Have a look at <a href="https://status.ohdear.app">our own status.ohdear.app status page</a>.</p>
<p><img src="/media/blog/isoOqchHZTW3SPx1CsTH2V7SiiZhu1JM9zkrbzHX.png" alt="" /></p>
<p>So why did it take so long? Honestly, it came down to cost. Sending email at scale on our old provider was expensive enough that a &quot;let anyone subscribe&quot; feature didn't make sense. We've since moved our email sending to Cloudflare, which is a lot cheaper, and that's what finally made this practical to build for everyone.</p>
<h2 id="how-it-works">How it works <a href="#how-it-works" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#how-it-works">#</a></h2>
<p>People click &quot;Subscribe to updates&quot; on your status page, enter their email, and confirm the address via a link we send them. Nothing lands in their inbox until they've confirmed (opt-in). After that, they get an email whenever you post an update or a monitored site changes state.</p>
<p>We built the safety rails in from day one, because email is the channel most likely to get abused or annoy people:</p>
<ul>
<li><strong>Double opt-in.</strong> We send a confirmation link first, so only real, confirmed addresses ever receive updates. Keeps your list clean and your sender reputation healthy.</li>
<li><strong>Flap protection.</strong> If a site goes down and back up in quick succession, we hold off for a moment and send a single update instead of flooding inboxes with every flip.</li>
<li><strong>One-click unsubscribe.</strong> Every email has a proper unsubscribe link, no login required. Hard bounces and spam complaints get removed automatically.</li>
<li><strong>Anti-spam on the form.</strong> Honeypot, rate limiting, and a Turnstile challenge keep bots from stuffing your list with junk.</li>
</ul>
<p>Unconfirmed addresses that never click the link are pruned after seven days, so your list only holds people who really opted in.</p>
<h2 id="about-the-limits">About the limits <a href="#about-the-limits" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#about-the-limits">#</a></h2>
<p>As we carefully monitor this usage, we're starting with a conservative limit per status page:</p>
<table>
<thead>
<tr>
<th>Limit</th>
<th>Default</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
<tr>
<td>Confirmed email subscribers per team</td>
<td>500</td>
<td>Pending and bounced addresses don't count. We email you before you hit it.</td>
</tr>
</tbody>
</table>
<p>We're generous about this. The 500 is a sensible default for most, not a hard limit. If your status page is popular enough to blow past it, that's a good problem to have. Just <a href="mailto:support@ohdear.app">get in touch</a> and we'll raise it for you, no worries.</p>
<h2 id="managing-your-subscribers">Managing your subscribers <a href="#managing-your-subscribers" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#managing-your-subscribers">#</a></h2>
<p>Open your status page's &quot;Notifications&quot; section and head to the &quot;Subscribers&quot; tab. You'll see everyone who's subscribed and their state (confirmed, pending, or bounced), and you can add someone manually, resend a confirmation, remove a subscriber, or export the whole list as a CSV.</p>
<p>Email subscriptions are live now on every status page. Head to <a href="https://ohdear.app/status-pages">your status pages</a> to try it, or read the <a href="/docs/status-pages/subscriptions">subscriptions docs</a> for the details.</p>
<h2 id="more-status-page-updates-coming-soon">More status page updates coming soon <a href="#more-status-page-updates-coming-soon" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#more-status-page-updates-coming-soon">#</a></h2>
<p>Behind the scenes, we're working on a much bigger update to our status pages, with a lot more flexibility on how your page looks &amp; behaves. We'll be introducing a more elaborate <em>incident manager</em>, allow <em>grouping of monitors</em> and overall allow much more customisation to our status pages than today. Stay tuned!</p>
]]>
            </summary>
                                    <updated>2026-07-08T05:25:13+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Using Oh Dear to monitor your API responses]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/using-oh-dear-to-monitor-your-api-responses" />
            <id>https://ohdear.app/193</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>Oh Dear can now better tell whether your API is actually up. On top of checking the status code, it inspects the JSON your API returns and makes sure the right values are in there.</p>
<p>A <code>200</code> only means the server answered, not that the answer was any good. Your API can return a <code>200</code> with an empty list or a <code>&quot;status&quot;: &quot;maintenance&quot;</code> body, and a plain uptime check stays green. For an API, the body is the product, so we added response body assertions.</p>
<p>On the new body assertions tab in the uptime check settings, you pick a path to a field in the JSON, choose a condition, and give us a value.</p>
<p>Let's demonstrate how you can  the <a href="https://swapi.info/api/films">Star Wars API</a>. To check that the first film is still A New Hope, set the path to <code>0.title</code>, the condition to <code>equals</code>, and the value to <code>A New Hope</code>.</p>
<p><img src="/media/blog/8YUpr1zvtfIQWoAWmtSOwzqqRWUW4gUST34jI5nK.png" alt="Configuring a body assertion on the Star Wars API: path 0.title equals A New Hope, with the live response shown below" /></p>
<p>You don't have to guess at your paths. The &quot;Test against your URL&quot; button runs your assertions against the live response right in the form.</p>
<p>When the body assertions doesn't match what you expect, we fail the uptime check and show you the reason in the report (and all notifications)</p>
<p><img src="/media/blog/nsPjrst022JdwIRmTYk8Xejhq9WmpqSMBzWO26MM.png" alt="A failing body assertion: the live response shows the assertion did not pass because 0.title is A New Hope, not A Bad Hope" /></p>
<p>Hope you'll like this new feature!</p>
]]>
            </summary>
                                    <updated>2026-06-17T21:24:59+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[The ghost domain problem in DNS, and what we're doing about it]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/the-ghost-domain-problem-in-dns-and-what-were-doing-about-it" />
            <id>https://ohdear.app/191</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>There's a DNS edge case that even people who know DNS well tend not to have run into: a domain pulled by its registry can keep appearing healthy to uptime checkers for days after it's gone. The .de zone triggers it when contact verification fails, and other registries have their own versions. We don't always catch it, and most other monitoring services don't either.</p>
<p>It's called the ghost domain problem. It's documented in DNS research circles and has an active IETF draft (<a href="https://datatracker.ietf.org/doc/draft-ietf-dnsop-ns-revalidation/">draft-ietf-dnsop-ns-revalidation</a>), but it's niche enough that an uptime product can run for years without anyone noticing it. This post is about why, and the small set of changes we're making on our end.</p>
<h2 id="how-it-shows-up">How it shows up <a href="#how-it-shows-up" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#how-it-shows-up">#</a></h2>
<p>The .de zone has a strict holder-data verification policy. If the registrant's details aren't verified within <a href="https://www.denic.de/en/products/holder-data-verification/general-information/">DENIC</a>'s deadline, DENIC first removes the domain from the .de zone and deletes it later. And .de is far from alone. <a href="https://eurid.eu/en/about-eurid/data-quality/">EURid</a> suspends a .eu domain and, if the data still isn't verified, moves it to a withdrawn (no longer registered) state. <a href="https://www.afnic.fr/en/domain-names-and-support/resolve-a-dispute/holder-eligibility-and-contact/">AFNIC</a> runs an eligibility and contactability check on .fr holders and can suspend, then eventually delete, a domain whose holder doesn't correct or substantiate their data.</p>
<p>And across ICANN gTLDs like .com, .net, and .org, the <a href="https://www.icann.org/resources/pages/non-response-2014-01-29-en">2013 WHOIS Accuracy Program</a> requires the registrar (not the registry) to suspend, terminate, or place a domain on <code>clientHold</code> after more than 15 days without a response to a verification request, which pulls it from zone publication. Failed verification, expired registration, registrar disputes, abuse takedowns: the names differ, the shape doesn't. The registry or registrar pulls the name from the parent zone, but the domain's own authoritative nameservers keep answering as if nothing changed.</p>
<p>For anyone whose resolver no longer holds the child-side records, that means NXDOMAIN (RCODE 3, &quot;name does not exist&quot;). A fat error in the address bar, no site. For an uptime checker sitting behind a long-lived recursive resolver cache that does still hold them, it can mean nothing at all. The difference isn't browser versus checker, it's a cold cache versus a warm one. The site looks fine. It isn't.</p>
<h2 id="where-the-bug-lives">Where the bug lives <a href="#where-the-bug-lives" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#where-the-bug-lives">#</a></h2>
<p>This was the DNS chain we had on one of our uptime workers (Frankfurt, in this case):</p>
<pre data-lang="txt" class="notranslate">PHP/curl
  → glibc nsswitch (files dns)
  → /etc/resolv.conf (127.0.0.53)
  → systemd-resolved stub
  → upstream recursive resolver (shared, run by the hosting provider)
  → authoritative nameservers (the domain's DNS provider)
</pre>
<p>I'll spare you the full diagnostic. The summary: <code>systemd-resolved</code> is a local caching stub. The real resolution work happens in a recursive resolver upstream, and that's where the bug lives. Not on our box, and not on yours. This is inherent to how recursive DNS works, no matter who runs the resolver: the caching layer that makes DNS fast is the same layer that can keep a dead delegation warm.</p>
<p>That detail matters. We could ship the most diligent uptime checker on the planet and still get the wrong answer, because we're trusting a cache we don't control.</p>
<h2 id="ghost-domains-briefly">Ghost domains, briefly <a href="#ghost-domains-briefly" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#ghost-domains-briefly">#</a></h2>
<p>The mechanism is documented in academic literature (the NDSS <a href="https://www.ndss-symposium.org/ndss2012/ndss-2012-programme/ghost-domain-names-revoked-yet-still-resolvable/">Ghost Domain Names</a> paper from 2012, and a 2023 follow-up <a href="https://www.ndss-symposium.org/ndss-paper/ghost-domain-reloaded-vulnerable-links-in-domain-name-delegation-and-revocation/">Ghost Domain Reloaded</a>). It currently sits in an active IETF draft, <a href="https://datatracker.ietf.org/doc/draft-ietf-dnsop-ns-revalidation/">Delegation Revalidation by DNS Resolvers</a>. The credibility rule it depends on is in <a href="https://datatracker.ietf.org/doc/rfc2181/">RFC 2181</a>, and the delegation-versus-glue terminology in <a href="https://www.rfc-editor.org/info/rfc9499">RFC 9499</a> (which obsoleted RFC 8499).</p>
<p>Here's how it plays out, step by step:</p>
<p><img src="/media/blog/LzPcrqZtbDOd74uJwfRQOJjc3LqulGI798laNYR6.png" alt="DNS sequence diagram of the ghost domain problem showing resolvers still using a cached NS RRset after delegation is removed" /></p>
<h3 id="step-1-the-first-lookup">Step 1: The first lookup</h3>
<p>The first time a recursive resolver looks up <code>example.de</code>, it walks down to the <code>.de</code> (parent) servers, which hand back a referral: &quot;I don't host <code>example.de</code>, but its nameservers are <code>ns1.example-dns.net</code> and <code>ns2.example-dns.net</code>.&quot; That's the parent-side delegation NS RRset, the NS records <em>for</em> <code>example.de</code>, as published by <code>.de</code>.</p>
<p>The resolver then asks those nameservers directly, and their authoritative answer usually carries the same two-name list again, this time published by <code>example.de</code> itself from its own zone apex. That copy is the apex NS RRset. Auth servers aren't required to include it, but they usually do, and the ghost effect depends on it being cached.</p>
<h3 id="step-2-the-childs-copy-outranks-the-parents">Step 2: The child's copy outranks the parent's</h3>
<p>Both RRsets name the same two servers; the difference is who said so. Per RFC 2181's ranking rules, the child's authoritative apex copy (from <code>example.de</code>) outranks the parent's referral copy (from <code>.de</code>), so it replaces the parent-side one in cache. To be clear, this is the NS RRset for <code>example.de</code> itself, not the NS records of the <code>.de</code> zone.</p>
<h3 id="step-3-the-a-record-expires">Step 3: The A record expires</h3>
<p>Later, when the A record expires, the resolver asks &quot;where do I find example.de?&quot;, finds the cached apex NS RRset (still pointing at <code>ns1</code> and <code>ns2.example-dns.net</code>), and queries those nameservers directly.</p>
<h3 id="step-4-the-authoritative-servers-still-answer">Step 4: The authoritative servers still answer</h3>
<p>The authoritative nameservers still answer positively. They have no idea the registry pulled the delegation. Why would they?</p>
<h3 id="step-5-the-loop">Step 5: The loop</h3>
<p>A fresh A record gets cached. Another TTL window starts. Loop.</p>
<p>Absent any revalidation, the parent zone is ordinarily not consulted again as long as the apex NS RRset stays warm. And the defaults don't help:</p>
<table>
<thead>
<tr>
<th>Resolver</th>
<th>Cache window</th>
<th>Parent revalidation</th>
</tr>
</thead>
<tbody>
<tr>
<td>BIND</td>
<td><code>max-cache-ttl</code> = 1 week</td>
<td>No equivalent delegation-revalidation control</td>
</tr>
<tr>
<td>Unbound</td>
<td><code>cache-max-ttl</code> = 1 day</td>
<td><code>harden-referral-path</code> off by default</td>
</tr>
<tr>
<td>PowerDNS Recursor</td>
<td><code>max-cache-ttl</code> = 1 day</td>
<td><code>save-parent-ns-set</code> (a failure fallback, not revalidation)</td>
</tr>
<tr>
<td>systemd-resolved</td>
<td>caching stub, inherits upstream</td>
<td>n/a</td>
</tr>
</tbody>
</table>
<p>It's tempting to assume frequent uptime checks are what keep the cache hot. Not quite. What keeps a ghost alive is the resolver reacquiring the apex NS RRset before it ages out, and how long that RRset lives is bounded by its own TTL and the resolver's cap. Check frequency only matters as opportunities to reacquire it, so cutting back on checks won't save you: a domain with a long NS TTL can keep ghosting off very few lookups per cache window.</p>
<h2 id="what-every-other-monitoring-service-does">What every other monitoring service does <a href="#what-every-other-monitoring-service-does" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#what-every-other-monitoring-service-does">#</a></h2>
<p>I went looking. I read the public docs, FAQs, and architecture pages of Pingdom, UptimeRobot, StatusCake, Site24x7, Datadog Synthetics, and Better Stack.</p>
<p>I couldn't find a documented defense against this in any of their public materials.</p>
<p>Pingdom actually documents the architecture that <em>causes</em> the bug, in their <a href="https://www.pingdom.com/blog/pingdom-say-my-site-is-down/">own blog post</a>:</p>
<blockquote>
<p>Each of Pingdom's probe servers run their own individual Bind9 caching DNS server as their DNS resolver, so DNS records will be cached.</p>
</blockquote>
<p>We don't know how they've tuned it, and a per-probe BIND is a perfectly reasonable setup. But BIND's default <code>max-cache-ttl</code> is one week, and unless that's been lowered, it's exactly the kind of cache horizon where a ghost domain can stick around for days.</p>
<p>A few of these tools monitor domain expiry, and so do we: Oh Dear has <a href="https://ohdear.app/features/domain-monitoring">domain expiration monitoring</a> that warns you well before a renewal date slips past. But that's adjacent, not the same thing. Expiry monitoring watches the registration date, not whether a still-paid-for domain has been pulled from the zone over a failed verification. I couldn't find any tool, ours included, that treats registry-level delegation removal as a distinct case in its uptime checks.</p>
<p>This isn't a gotcha against any of them. It stayed off our radar for the same reason it probably has for theirs: it's a DNS-layer problem hiding under an HTTP-layer product, invisible until someone goes looking.</p>
<h2 id="what-were-tightening">What we're tightening <a href="#what-were-tightening" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#what-were-tightening">#</a></h2>
<p>We're keeping this narrow.</p>
<p><strong>A local recursive resolver on each checker, with a tight cache.</strong> We're running <a href="https://nlnetlabs.nl/projects/unbound/about/">Unbound</a> on each worker, doing full recursion itself instead of forwarding to a shared upstream resolver. The point isn't &quot;ours is better.&quot; It's that we get to set the knobs.</p>
<p>Two of them matter here:</p>
<table>
<thead>
<tr>
<th>Knob</th>
<th>Default</th>
<th>Our setting</th>
<th>Why</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>cache-max-ttl</code></td>
<td>1 day</td>
<td>1 hour</td>
<td>Caps how long a stale apex NS RRset can ghost a domain</td>
</tr>
<tr>
<td><code>harden-referral-path</code></td>
<td>off</td>
<td>on</td>
<td>Re-checks NS data along the referral path, not just cache</td>
</tr>
</tbody>
</table>
<p>Unbound caps cached records at a day by default; we clamp that to an hour. Once the cached apex NS RRset expires, the next lookup has to restart iteration from the closest cached cut, typically the TLD's nameservers, to find the delegation again, and that's exactly where a pulled delegation now comes up empty. <code>harden-referral-path</code> (paired with a deeper <code>target-fetch-policy</code>, as the Unbound docs recommend) makes the resolver re-check the NS data it meets on the way down instead of trusting stale child-side state.</p>
<p>That's the main change. It doesn't make ghost domains impossible, but it shortens the window in which a stale delegation can lie to us from days down to about an hour.</p>
<p>We've thought about heavier options. Cross-resolver verification on failed checks, where we'd cross-check with <code>1.1.1.1</code>, <code>8.8.8.8</code>, and <code>9.9.9.9</code> in parallel. Dedicated parent-zone delegation polling per monitored domain, à la <code>dig @f.nic.de example.de NS</code>. Both are real fixes, and we may come back to them. But the operational cost (extra DNS load, public resolver rate limits) is hard to justify when tightening our own resolver already covers the realistic cases.</p>
<h2 id="what-this-doesnt-fix">What this doesn't fix <a href="#what-this-doesnt-fix" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#what-this-doesnt-fix">#</a></h2>
<p>DNS caches exist for good reasons. The whole reason a recursive resolver works the way it does is to keep the global DNS from melting every time someone reloads a page. We're not going to &quot;fix DNS.&quot; What we can do is narrow the window where a stale delegation goes unnoticed.</p>
<p>If a ghost domain's apex NS RRset keeps refreshing inside our shorter cache window, it can still slip past us. Other layers (the registry's own propagation cadence, the authoritative DNS host, the resolvers downstream from us) all behave their own way. We're reducing surface area, not eliminating it.</p>
<p>And we're honest about the tools we reached for. <code>harden-referral-path</code> is experimental and not an RFC standard, so we run DNSSEC validation alongside it in permissive, log-only mode: bogus answers get logged for us to review, not turned into SERVFAIL, so a signing glitch on a customer's domain can't show up as false downtime. That's a deliberate first step. Once the logs stay clean and the rollout proves stable, we plan to flip DNSSEC to enforcing over the coming weeks.</p>
<h2 id="what-you-should-do">What you should do <a href="#what-you-should-do" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#what-you-should-do">#</a></h2>
<p>If you run anything important on a domain you don't own at the registrar level, run <a href="https://ohdear.app/features/dns-monitoring">DNS monitoring</a> alongside <a href="https://ohdear.app/features/website-uptime-monitoring">uptime monitoring</a>. Uptime monitoring answers &quot;can my site be reached?&quot; DNS monitoring answers &quot;is the DNS layer healthy?&quot; Together they catch problems neither one would on its own.</p>
<p>In Oh Dear, you turn DNS monitoring on per monitor in the <a href="https://ohdear.app/docs/features/dns-monitoring">DNS check settings</a>. It watches your A, AAAA, MX, NS, and TXT records and alerts when they drift. It's part of your plan, not a paid add-on: every monitoring check we offer is included, at no extra cost.</p>
<p>If you're not an Oh Dear customer yet, you can try it for 10 days, free, no credit card needed. And if you hit a monitoring edge case we don't catch yet, show us. We'll make Oh Dear better by building the fix in.</p>
]]>
            </summary>
                                    <updated>2026-06-12T08:58:01+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Improvements to our status pages as we tackle a DDoS]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/improvements-to-our-status-pages-as-we-tackle-a-ddos" />
            <id>https://ohdear.app/187</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>The uptime &amp; availability of our status pages hasn't been great these past few days. The root cause is a persistent and pretty aggressive DDoS attack targeted at our own status page, <a href="https://status.ohdear.app">status.ohdear.app</a>. As a result, the overload on our systems also affected all other status pages we host for clients.</p>
<p><img src="/media/blog/rHKx1lapGHHv1H41OOgjNoSdzZYcbqZRFkijFn37.png" alt="Status page monitoring bar for status.ohdear.app showing 99.04% uptime over 60 days with recent yellow and red days" /></p>
<p>We're not yet at Github or Claude levels of uptime sadness, but this isn't acceptable to us. In this post, I'll share what's happening and what steps we've already taken.</p>
<h2 id="the-scale-of-the-ddos-attack">The scale of the DDoS attack <a href="#the-scale-of-the-ddos-attack" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#the-scale-of-the-ddos-attack">#</a></h2>
<p>The attack varies in size, but is (still) generating roughly 1M requests per minute. That's about 16,000 requests per second hitting our status pages. The requests are for both legitimate &amp; random URLs, some returning 200's and some 404's.</p>
<p>This certainly isn't a <em>huge</em> attack, but enough to throw our 100% uptime stats out the window.</p>
<p><img src="/media/blog/PzDeGWAklcZ43Wv4YDh8bJAiZ5lpcn5IsRONdF7V.png" alt="Line graph of incoming requests per minute spiking between 650k and 1.3M during a DDoS attack, then dropping to zero" /></p>
<h2 id="were-the-target-not-our-customers">We're the target, not our customers <a href="#were-the-target-not-our-customers" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#were-the-target-not-our-customers">#</a></h2>
<p>I'm not quite sure who we managed to offend, but the attack is directly targeting the <code>Host: status.ohdear.app</code>, our status pages.</p>
<p>This isn't an attack on any of our clients or their infrastructure - we've seen those too, but usually at smaller scale, and less persistent.</p>
<p>In this case, it meant we had the most flexibility to combat this attack: we control our domain, DNS and infrastructure. We only needed to synchronise <em>internally</em> on the measures, there wasn't any need for client communication to help resolve the issue.</p>
<h2 id="caddy-overload-cloudflare-to-the-rescue">Caddy overload, Cloudflare to the rescue <a href="#caddy-overload-cloudflare-to-the-rescue" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#caddy-overload-cloudflare-to-the-rescue">#</a></h2>
<p>Our status pages are served via <a href="https://caddyserver.com/">Caddy</a> running in our own infrastructure. Caddy handles the on-demand TLS to generate certificates for new domains that are starting to use our status pages. It's rock solid and has <em>incredible</em> throughput, but even our infrastructure has a limit on how many new TLS connections it can terminate a second, and we were hitting that ceiling pretty consistently.</p>
<p>With our CPU usage already upgraded and still pegged to 100% of capacity, we decided to offload TLS termination back to Cloudflare. We'd rather <em>not</em> do that for status pages: when <a href="https://ohdear.app/news-and-updates/the-effects-of-the-cloudflare-outage-on-oh-dear#our-web-ui-was-offline-our-status-pages-amp-monitoring-werent">Cloudflare had a long outage last year</a>, our status pages remained up exactly because we didn't route that traffic over their network.</p>
<p>But drastic times call for slightly more drastic measures. With Cloudflare handling TLS termination and the CPU load that comes with it, the attack highlighted the next bottleneck: legitimate traffic overload.</p>
<h2 id="backend-overload-varnish-to-the-rescue">Backend overload, Varnish to the rescue <a href="#backend-overload-varnish-to-the-rescue" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#backend-overload-varnish-to-the-rescue">#</a></h2>
<p>Our status pages proxy traffic back to our main infrastructure &amp; serve the results to visitors. Even with Cloudflare handling TLS, that's still &gt; 16,000 requests/second hitting our backend infrastructure.</p>
<p>To combat this, we installed and configured Varnish on the same status-page infrastructure that runs Caddy. I wouldn't recommend anyone try this as a &quot;quick fix&quot; solution, it comes with its own set of challenges and caching - as the saying goes - is hard.</p>
<p>But, having previously maintained the go-to templates for Varnish for versions <a href="https://github.com/mattiasgeniar/varnish-3.0-configuration-templates">3</a>, <a href="https://github.com/mattiasgeniar/varnish-4.0-configuration-templates">4</a>, <a href="https://github.com/mattiasgeniar/varnish-5.0-configuration-templates">5</a> and <a href="https://github.com/mattiasgeniar/varnish-6.0-configuration-templates">6</a>, I feel pretty confident rolling this out in a hurry - understanding the gotchas.</p>
<p>Caddy, instead of proxying to our backend infrastructure, now proxies to Varnish - which caches the results in memory and only retrieves data from the backend when it doesn't have a cached version available.</p>
<p>Varnish will also serve slightly stale content, if our backend infrastructure is temporarily overloaded.</p>
<pre data-lang="txt" class="notranslate">sub vcl_backend_response {
    # Cache-Control: s-maxage=60 from the backend sets TTL automatically.
    # Grace = how long we'll keep serving the stale object beyond TTL if
    # the backend is slow or unhealthy. With grace=600s, a backend outage of
    # up to 10 minutes is invisible to clients hitting cached status pages.
    set beresp.grace = 600s;

    # On backend 5xx, abandon the fetch instead of caching/delivering the
    # error. The existing stale object stays in cache and keeps serving
    # via grace until the backend recovers (or grace expires).
    if (beresp.status &gt;= 500) {
        return (abandon);
    }
}
</pre>
<p>Cloudflare also adheres to the <code>Cache-Control</code> headers, but only <em>our</em> domain is behind Cloudflare - all other clients are served directly by us, so Varnish mostly catches this issue for non-Cloudflare routed domains.</p>
<h2 id="random-urls-varnish-to-the-rescue-again">Random URLs, Varnish to the rescue (again) <a href="#random-urls-varnish-to-the-rescue-again" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#random-urls-varnish-to-the-rescue-again">#</a></h2>
<p>Caching is great, but all an attacker has to do is start randomizing URLs to completely bypass a cache. Even non-existent pages still cause load on our systems, so requests for <code>status.ohdear.app/wp-admin/login.php</code> won't work - we obviously don't run on WordPress - but they still pass through the routing layer &amp; trigger a 404.</p>
<p>This problem we're solving in Varnish by <em>allowlisting</em> only certain URL patterns. We control our infrastructure &amp; code, so we know <em>exactly</em> what URLs are valid and which ones aren't even worth bothering our backend for.</p>
<pre data-lang="txt" class="notranslate"># Allowlist of URL shapes that status pages legitimately serve. Anything
# outside this set is 404'd at Varnish so noise (wp-login.php, .env probes,
# random crawlers) never reaches the PHP backend.
if (req.url !~ &quot;^/(\?.*)?$&quot; &amp;&amp;
    req.url !~ &quot;^/(json|xml|rss)(\?.*)?$&quot; &amp;&amp;
    req.url !~ &quot;^/status-page/[a-z0-9-]+(/(json|xml|history|subscribe-slack(/create)?|subscribe-(rss|json|xml)))?(\?.*)?$&quot; &amp;&amp;
    req.url !~ &quot;^/status-page-feed/[a-z0-9-]+(\?.*)?$&quot; &amp;&amp;
    req.url !~ &quot;^/status-page-subscription/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(/delete)?(\?.*)?$&quot; &amp;&amp;
    req.url !~ &quot;^/build/assets/[A-Za-z0-9._-]+\.(js|mjs|css|svg|woff2?|png|webp|jpe?g|gif|ico|map)(\?.*)?$&quot; &amp;&amp;
    req.url !~ &quot;^/build/manifest\.json(\?.*)?$&quot; &amp;&amp;
    req.url !~ &quot;^/favicon\.ico$&quot;) {
    return (synth(404, &quot;Not a status page route&quot;));
}
</pre>
<p>Now, when someone tries to retrieve a page we <em>know</em> we'll never serve on our status pages, they just get a <code>HTTP/2 404</code> back.</p>
<pre data-lang="txt" class="notranslate">$ curl -i https://status.ohdear.app/wp-admin/login.php
HTTP/2 404
[...]

&lt;!DOCTYPE html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;title&gt;404 Not a status page route&lt;/title&gt;
  &lt;/head&gt;
  [...]
&lt;/html&gt;
</pre>
<p>This requires a bit of maintenance, but the public URL surface of our status pages is actually tiny, so the overhead to maintain this is negligible.</p>
<h2 id="what-doesnt-kill-us-makes-us-faster">What doesn't kill us, makes us faster <a href="#what-doesnt-kill-us-makes-us-faster" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#what-doesnt-kill-us-makes-us-faster">#</a></h2>
<p>These changes have a positive effect on all our customers, too. Their status pages - now that Varnish sits in between - have all become substantially faster!</p>
<p><img src="/media/blog/An5GDZGEQNokkCnwj3MQlEiQsl1fGax40fu9DYox.png" alt="Oh Dear performance history over the last week with a 314ms average and a sharp spike toward 1820ms during the attack" /></p>
<p>Before these changes, a status page would usually be served with a TTFB of around 250-300ms. Not slow, but also not exceptionally fast.</p>
<p>With these changes, we're serving status pages between 30-50ms now - fast enough to not even realize the page needed time to load, and resilient enough to weather the next attack with a lot more headroom.</p>
<h2 id="what-if-it-happens-to-a-client-status-page">What if it happens to a client status page? <a href="#what-if-it-happens-to-a-client-status-page" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#what-if-it-happens-to-a-client-status-page">#</a></h2>
<p>We've had the benefit of being able to easily slide a Cloudflare in front of the status pages, to help with offloading the CPU cycles related to TLS termination. We obviously can't just do that for a clients' status page, we don't own or control the DNS of those domain names.</p>
<p>In those cases, we'll reach out to the affected client(s) and work with them to resolve the problem, likely taking <em>their</em> status page offline for the time being. We can do this with an SNI-based filter that drops connections targeting their domain before the TLS handshake completes - the SNI (Server Name Indication) is sent in the TLS ClientHello in plaintext, so we can inspect it without spending CPU on crypto. Tooling-wise, that's either <a href="https://github.com/mholt/caddy-l4"><code>caddy-l4</code></a> compiled into our Caddy binary, or HAProxy in TCP mode sitting in front. Both let us block a single customer's hostname surgically without affecting anyone else.</p>
<p>Not great, we know - and we welcome any other solutions to this problem - but we serve status pages to thousands of customers, and in this case the needs of the many outweigh those of the few. Longer term, we're looking at offering a CNAME-able Cloudflare-fronted endpoint for customers who want it, so their custom domain inherits the same DDoS protection without them needing a Cloudflare account.</p>
]]>
            </summary>
                                    <updated>2026-06-12T08:58:01+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Connect any AI assistant to your Oh Dear data]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/connect-any-ai-assistant-to-your-oh-dear-data" />
            <id>https://ohdear.app/186</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>Oh Dear keeps a watchful eye on your websites: uptime, performance, SSL certificates, broken links, DNS, cron jobs. If something can quietly break, we're already checking it for you. Today we're connecting that data to a new place: your AI assistant.</p>
<p>We just shipped an <a href="https://ohdear.app/docs/tools-and-sdks/mcp-server">MCP integration</a>. If you use Claude, Cursor, or any other client that speaks the Model Context Protocol, you can now ask questions like &quot;any broken links on my site?&quot; or &quot;when does my certificate expire?&quot; in plain language. Your assistant pulls the answer from us directly, with your real monitoring data, scoped to the team you choose.</p>
<h2 id="connecting-your-ai-assistant">Connecting your AI assistant <a href="#connecting-your-ai-assistant" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#connecting-your-ai-assistant">#</a></h2>
<p>Let's try out our MCP server. You don't need to fiddle with API tokens, as we've also shipped our OAuth integration. In most tools you can just create a connection and point it to our MCP endpoint.</p>
<p>Let's try it in Claude code</p>
<p><img src="/media/blog/1jPAC7RRMrJuHomlyRHpBMGz24XoSdxA4inKKF6l.jpg" alt="Claude desktop Add custom connector dialog with the https://ohdear.app/mcp server URL entered" /></p>
<p>When pressing add it will open an authorization screen at Oh Dear.</p>
<p><img src="/media/blog/khvfGXSxYMiZZxoyLHTi5iARreTBcldnWMxbem5O.jpg" alt="Oh Dear OAuth authorize screen where Claude requests team access with read and write access options" /></p>
<p>Read access is granted by default. Write access is opt-in.</p>
<p>Let's try it out and ask Claude about the things it knows about freek.dev. This will make Claude use our MCP endpoint and fetch any data we have.</p>
<p><img src="/media/blog/wFhjqTemebvezxyhVL5nY445Wd1SqK8qyMqvRjLt.jpg" alt="Claude chat using the Oh Dear MCP to plot freek.dev weekly performance, 40ms average and 100% uptime, with a summary" /></p>
<p><img src="/media/blog/XuipkHEz6b5imq8KTtOPDn00LwsPw6Wpwa9AatTi.jpg" alt="Claude summarizing Oh Dear data for freek.dev including DNS records, SSL certificate and Lighthouse category scores" /></p>
<p>Everything that we monitor (uptime, performance, SSL, lighthouse, dns records, domain, ...) can be used by Claude now. Pretty nice!</p>
<p>On your Oh Dear account you can see which connections have been made, and you can also revoke a connection you don't want or need anymore.</p>
<p><img src="/media/blog/yRGWnIPCaBJ5BhS3o5gJPp1xW1WZmncA6EIY4gHm.jpg" alt="Oh Dear Connected AI assistants page showing the MCP URL and an active Claude connection with read access" /></p>
<h2 id="try-it-out">Try it out <a href="#try-it-out" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#try-it-out">#</a></h2>
<p>The MCP endpoint lives at <code>https://ohdear.app/mcp</code>. Point your AI client at it, follow the consent flow, and start asking. If your client has its own MCP setup wizard (Claude Desktop and Cursor both do), it'll discover the rest automatically. For step-by-step setup instructions for Claude.ai, Claude Code, Cursor, and other popular clients, see the <a href="https://ohdear.app/docs/tools-and-sdks/mcp-server">MCP server docs</a>.</p>
<p>If you're already an Oh Dear customer, your existing data is immediately available. Not a customer yet? You can try it for 10 days, free, no credit card needed.</p>
]]>
            </summary>
                                    <updated>2026-06-22T09:00:03+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Enhancing our API for better agentic consumption]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/enhancing-our-api-for-better-agentic-consumption" />
            <id>https://ohdear.app/183</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>AI coding agents like Claude Code and Codex are becoming a real part of developer workflows. They don't just write code, they call APIs, interpret responses, and take action based on what they find. That means the quality of your API responses directly affects how useful an agent can be.</p>
<p>We've shipped a series of improvements to the Oh Dear API with this in mind. Every change helps humans too, but we specifically optimized for how agents consume and reason about data.</p>
<p>This is part of <a href="/news-and-updates/why-we-will-be-making-breaking-changes-in-our-api-soon">the breaking API changes</a> we flagged earlier.</p>
<h2 id="historical-check-runs">Historical check runs <a href="#historical-check-runs" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#historical-check-runs">#</a></h2>
<p>Until recently, our API only returned the latest results for each check. That's fine for a quick status check, but it makes it impossible for an agent to answer questions like &quot;compare yesterday's broken links to today's, show me the diff.&quot;</p>
<p>Now you can list all completed runs for any check type:</p>
<pre data-lang="bash" class="notranslate"><span class="hl-keyword">curl</span> https://ohdear.app/api/monitors/1/checks/broken_links/runs \
    <span class="hl-attribute">-H</span> <span class="hl-value">&quot;Authorization: Bearer $OHDEAR_TOKEN&quot;</span> \
    <span class="hl-attribute">-H</span> <span class="hl-value">'Accept: application/json'</span>
</pre>
<pre data-lang="json" class="notranslate"><span class="hl-property">{</span>
  <span class="hl-keyword">&quot;data&quot;</span>: <span class="hl-property">[</span>
    <span class="hl-property">{</span>
      <span class="hl-keyword">&quot;id&quot;</span>: 98765,
      <span class="hl-keyword">&quot;check_type&quot;</span>: <span class="hl-value">&quot;broken_links&quot;</span>,
      <span class="hl-keyword">&quot;result&quot;</span>: <span class="hl-value">&quot;succeeded&quot;</span>,
      <span class="hl-keyword">&quot;started_at&quot;</span>: <span class="hl-value">&quot;2026-03-17T00:30:00+00:00&quot;</span>,
      <span class="hl-keyword">&quot;ended_at&quot;</span>: <span class="hl-value">&quot;2026-03-17T00:53:58+00:00&quot;</span>,
      <span class="hl-keyword">&quot;ohdear_url&quot;</span>: <span class="hl-value">&quot;https://ohdear.app/monitors/1234/check/broken-links/report/98765&quot;</span>
    <span class="hl-property">}</span>,
    <span class="hl-property">{</span>
      <span class="hl-keyword">&quot;id&quot;</span>: 98710,
      <span class="hl-keyword">&quot;check_type&quot;</span>: <span class="hl-value">&quot;broken_links&quot;</span>,
      <span class="hl-keyword">&quot;result&quot;</span>: <span class="hl-value">&quot;failed&quot;</span>,
      <span class="hl-keyword">&quot;started_at&quot;</span>: <span class="hl-value">&quot;2026-03-16T00:30:00+00:00&quot;</span>,
      <span class="hl-keyword">&quot;ended_at&quot;</span>: <span class="hl-value">&quot;2026-03-16T00:48:12+00:00&quot;</span>,
      <span class="hl-keyword">&quot;ohdear_url&quot;</span>: <span class="hl-value">&quot;https://ohdear.app/monitors/1234/check/broken-links/report/98710&quot;</span>
    <span class="hl-property">}</span>
  <span class="hl-property">]</span>
<span class="hl-property">}</span>
</pre>
<p>You can filter by date range and result status:</p>
<table>
<thead>
<tr>
<th>Parameter</th>
<th>Example</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>filter[started_after]</code></td>
<td><code>20260315000000</code></td>
<td>Runs started at or after this UTC timestamp</td>
</tr>
<tr>
<td><code>filter[started_before]</code></td>
<td><code>20260320000000</code></td>
<td>Runs started at or before this UTC timestamp</td>
</tr>
<tr>
<td><code>filter[result]</code></td>
<td><code>failed</code></td>
<td>Only <code>succeeded</code>, <code>warning</code>, or <code>failed</code> runs</td>
</tr>
</tbody>
</table>
<p>Once you have a run ID, pass it to the check endpoint to get the detailed results from that specific run:</p>
<pre data-lang="bash" class="notranslate"><span class="hl-keyword">curl</span> <span class="hl-value">&quot;https://ohdear.app/api/broken-links/1?run_id=98710&quot;</span> \
    <span class="hl-attribute">-H</span> <span class="hl-value">&quot;Authorization: Bearer $OHDEAR_TOKEN&quot;</span>
</pre>
<p>This works for broken links, mixed content, certificate health, and sitemap checks. Runs are kept for approximately 10 days.</p>
<p>For an agent, the workflow becomes straightforward: list runs, pick two, fetch both, diff the results. That's a powerful debugging tool when you're trying to figure out when a new broken link appeared or when your SSL certificate chain changed.</p>
<h2 id="dashboard-links-in-every-response">Dashboard links in every response <a href="#dashboard-links-in-every-response" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#dashboard-links-in-every-response">#</a></h2>
<p>When an agent fetches data from an API, it often needs to show the user where to look for more details. But constructing dashboard URLs from API data is error-prone. Different check types have different URL structures, and an agent would need to know our routing conventions.</p>
<p>We solved this by including an <code>ohdear_url</code> field in every response that links directly to the right dashboard page:</p>
<pre data-lang="json" class="notranslate"><span class="hl-property">{</span>
  <span class="hl-keyword">&quot;meta&quot;</span>: <span class="hl-property">{</span>
    <span class="hl-keyword">&quot;run_id&quot;</span>: 98765,
    <span class="hl-keyword">&quot;run_started_at&quot;</span>: <span class="hl-value">&quot;2026-03-17T00:30:00+00:00&quot;</span>,
    <span class="hl-keyword">&quot;run_ended_at&quot;</span>: <span class="hl-value">&quot;2026-03-17T00:53:58+00:00&quot;</span>,
    <span class="hl-keyword">&quot;ohdear_url&quot;</span>: <span class="hl-value">&quot;https://ohdear.app/monitors/1234/check/broken-links/report/98765&quot;</span>
  <span class="hl-property">}</span>
<span class="hl-property">}</span>
</pre>
<p>The URL points to the exact report page for that specific run. An agent can now say &quot;you have 3 new broken links since yesterday, <a href="https://ohdear.app/monitors/1234/check/broken-links/report/98765">view the full report</a>&quot; with a link that actually works.</p>
<p>This is a small addition, but it removes a whole category of guesswork. The API tells you exactly where to look.</p>
<h2 id="documentation-urls-and-markdown-friendly-docs">Documentation URLs and markdown-friendly docs <a href="#documentation-urls-and-markdown-friendly-docs" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#documentation-urls-and-markdown-friendly-docs">#</a></h2>
<p>Every API response now includes a <code>documentation_url</code> in the <code>links</code> section:</p>
<pre data-lang="json" class="notranslate"><span class="hl-property">{</span>
  <span class="hl-keyword">&quot;links&quot;</span>: <span class="hl-property">{</span>
    <span class="hl-keyword">&quot;documentation_url&quot;</span>: <span class="hl-value">&quot;https://ohdear.app/docs/features/broken-links-detection&quot;</span>
  <span class="hl-property">}</span>
<span class="hl-property">}</span>
</pre>
<p>When an agent encounters a response it doesn't fully understand, it can fetch the documentation URL to learn more. And to top it all off, our docs are served as clean markdown when you ask for it.</p>
<pre data-lang="bash" class="notranslate"><span class="hl-keyword">curl</span> <span class="hl-attribute">-H</span> <span class="hl-value">'Accept: text/markdown'</span> \
    https://ohdear.app/docs/features/broken-links-detection
</pre>
<p>Instead of getting a full HTML page (with navigation, footers, scripts, and all the chrome), the agent gets just the content as markdown. The token savings are significant:</p>
<table>
<thead>
<tr>
<th>Page</th>
<th>HTML</th>
<th>Markdown</th>
<th>Saving</th>
</tr>
</thead>
<tbody>
<tr>
<td><a href="/docs/api/monitors">Monitor API docs</a></td>
<td>386 KB</td>
<td>22.9 KB</td>
<td><strong>-94%</strong></td>
</tr>
<tr>
<td><a href="/docs/features/uptime-and-performance-monitoring">Uptime feature docs</a></td>
<td>370 KB</td>
<td>14.5 KB</td>
<td><strong>-96%</strong></td>
</tr>
</tbody>
</table>
<p>We wrote about <a href="/news-and-updates/making-our-docs-ai-friendly-a-tale-of-two-caches">the caching challenges we ran into</a> when building this feature. The short version: both Cloudflare and our Laravel response cache needed to learn that <code>Accept: text/markdown</code> requests should get different responses than regular browser requests.</p>
<p>The detection logic is simple. We support two ways to request markdown:</p>
<ul>
<li>Append <code>.md</code> to any docs URL: <code>https://ohdear.app/docs/api/monitors.md</code></li>
<li>Send the <code>Accept: text/markdown</code> header</li>
</ul>
<p>Both return plain markdown with a <code>text/markdown</code> content type and a <code>noindex</code> robots tag.</p>
<h2 id="full-list-of-crawled-urls">Full list of crawled URLs <a href="#full-list-of-crawled-urls" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#full-list-of-crawled-urls">#</a></h2>
<p>Our broken links endpoint has always returned the URLs that are broken. But sometimes you need the full picture: how many URLs were crawled, how many are internal vs. external, and what types of resources were found.</p>
<p>We've added two new endpoints for this.</p>
<p><strong>Summary</strong> gives you the counts without the weight of individual URLs:</p>
<pre data-lang="bash" class="notranslate"><span class="hl-keyword">curl</span> https://ohdear.app/api/crawled-urls/1/summary \
    <span class="hl-attribute">-H</span> <span class="hl-value">&quot;Authorization: Bearer $OHDEAR_TOKEN&quot;</span>
</pre>
<pre data-lang="json" class="notranslate"><span class="hl-property">{</span>
  <span class="hl-keyword">&quot;data&quot;</span>: <span class="hl-property">{</span>
    <span class="hl-keyword">&quot;total&quot;</span>: 1234,
    <span class="hl-keyword">&quot;internal&quot;</span>: 800,
    <span class="hl-keyword">&quot;external&quot;</span>: 434,
    <span class="hl-keyword">&quot;by_type&quot;</span>: <span class="hl-property">{</span>
      <span class="hl-keyword">&quot;image&quot;</span>: 250,
      <span class="hl-keyword">&quot;link&quot;</span>: 900,
      <span class="hl-value">&quot;og:image&quot;</span>: 14,
      <span class="hl-keyword">&quot;script&quot;</span>: 40,
      <span class="hl-keyword">&quot;stylesheet&quot;</span>: 30
    <span class="hl-property">}</span>
  <span class="hl-property">}</span>
<span class="hl-property">}</span>
</pre>
<p><strong>Details</strong> returns the full paginated list (100 per page) with the same fields as the broken links response:</p>
<pre data-lang="bash" class="notranslate"><span class="hl-keyword">curl</span> https://ohdear.app/api/crawled-urls/1/details \
    <span class="hl-attribute">-H</span> <span class="hl-value">&quot;Authorization: Bearer $OHDEAR_TOKEN&quot;</span>
</pre>
<p>Each entry now also includes <code>type</code> (link, image, script, stylesheet, og:image) and <code>error_message</code> fields, so you know exactly what kind of resource failed and why.</p>
<p>Both endpoints support <code>run_id</code> for historical data, just like broken links.</p>
<p>This is particularly useful for agents doing site audits. Instead of just knowing what's broken, they can understand the full scope of a crawl and reason about the ratio of broken to healthy URLs.</p>
<h2 id="what-this-unlocks">What this unlocks <a href="#what-this-unlocks" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#what-this-unlocks">#</a></h2>
<p>These changes aren't just incremental API improvements. Together, they create a feedback loop that makes agents more capable when working with Oh Dear.</p>
<p>An agent using Claude Code or Codex can now:</p>
<ol>
<li><strong>List historical runs</strong> and compare results across time periods</li>
<li><strong>Link users to the right dashboard page</strong> instead of generic &quot;check Oh Dear&quot; suggestions</li>
<li><strong>Read our documentation as markdown</strong> when it needs more context, without wasting tokens on HTML chrome</li>
<li><strong>Audit all crawled URLs</strong>, not just the broken ones</li>
</ol>
<p>The pattern we're following is: give the agent everything it needs to be self-sufficient. If it can answer its own questions by following links in the API response, it doesn't need hardcoded knowledge about our URL structure or documentation layout.</p>
<p>We also provide ~~an llms.txt file~~ (Update: this has been deprecated) a <a href="/docs/tools-and-sdks/mcp-server">full MCP server</a> for AI assistants that want deeper integration.</p>
<h2 id="try-it-out">Try it out <a href="#try-it-out" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#try-it-out">#</a></h2>
<p>Agentic access pairs well with <a href="/news-and-updates/our-api-tokens-can-now-be-scoped-by-site-or-status-page">scoped API tokens</a>.</p>
<p>If you're building with AI agents and want to integrate site monitoring, here's how to get started:</p>
<pre data-lang="bash" class="notranslate"><span class="hl-comment"># List your monitors</span>
<span class="hl-keyword">curl</span> https://ohdear.app/api/monitors \
    <span class="hl-attribute">-H</span> <span class="hl-value">&quot;Authorization: Bearer $OHDEAR_TOKEN&quot;</span>

<span class="hl-comment"># Check historical broken link runs</span>
<span class="hl-keyword">curl</span> https://ohdear.app/api/monitors/1/checks/broken_links/runs \
    <span class="hl-attribute">-H</span> <span class="hl-value">&quot;Authorization: Bearer $OHDEAR_TOKEN&quot;</span>

<span class="hl-comment"># Get a crawl summary</span>
<span class="hl-keyword">curl</span> https://ohdear.app/api/crawled-urls/1/summary \
    <span class="hl-attribute">-H</span> <span class="hl-value">&quot;Authorization: Bearer $OHDEAR_TOKEN&quot;</span>

<span class="hl-comment"># Read our docs as markdown</span>
<span class="hl-keyword">curl</span> <span class="hl-attribute">-H</span> <span class="hl-value">'Accept: text/markdown'</span> https://ohdear.app/docs/api/monitors
</pre>
<p>All of these changes are live now and documented in our <a href="/docs/api/introduction">API docs</a>.</p>
<h2 id="feedback">Feedback? <a href="#feedback" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#feedback">#</a></h2>
<p>If you run into anything unexpected or have ideas for making the API more agent-friendly, reach out via <a href="mailto:mattias@ohdear.app">mattias@ohdear.app</a> or ping <a href="https://x.com/mattiasgeniar">@mattiasgeniar</a> or <a href="https://x.com/ohdearapp">@OhDearApp</a>. We'd love to hear how you're using these features!</p>
]]>
            </summary>
                                    <updated>2026-05-18T11:25:44+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[An Oh Dear skill for use in Claude Code or Codex]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/an-oh-dear-skill-for-use-in-claude-code-or-codex" />
            <id>https://ohdear.app/182</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>AI coding agents are getting good at calling tools. Claude Code, Codex, and others can run shell commands, parse JSON, and reason about the results. But they need to know what tools are available and how to use them.</p>
<p>That's what skills are for. A skill is a small package of documentation that teaches an AI agent how to use a specific tool. We've built one for Oh Dear.</p>
<h2 id="what-this-gives-you">What this gives you <a href="#what-this-gives-you" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#what-this-gives-you">#</a></h2>
<p>Once installed, your AI coding agent can do anything the Oh Dear CLI can do. That covers every endpoint in the Oh Dear API: listing monitors, checking uptime, reviewing broken links, managing maintenance windows, posting status page updates, and more.</p>
<p>You don't need to remember command names or look up parameter syntax. You describe what you want in plain language, and the agent figures out the right commands.</p>
<h2 id="installing-the-skill">Installing the skill <a href="#installing-the-skill" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#installing-the-skill">#</a></h2>
<p>Two steps. First, install the Oh Dear CLI if you haven't already:</p>
<pre data-lang="bash" class="notranslate">$ <span class="hl-keyword">composer</span> global require ohdearapp/ohdear-cli
$ ohdear login
</pre>
<p>You'll be prompted for your API token. Generate one at <a href="/user/api-tokens">your API token page</a>.</p>
<p>Then install the skill:</p>
<pre data-lang="bash" class="notranslate">$ ohdear install-skill
</pre>
<p>That's it. Your AI agent now knows how to work with Oh Dear.</p>
<h2 id="what-you-can-ask">What you can ask <a href="#what-you-can-ask" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#what-you-can-ask">#</a></h2>
<p>Here's where it gets interesting. These are real prompts, not hypotheticals.</p>
<h3 id="quotis-examplecom-up-right-nowquot">&quot;Is example.com up right now?&quot;</h3>
<p>The simplest question. The agent runs <code>ohdear get-monitor</code>, checks the status, and tells you. No need to open a browser or switch tabs.</p>
<h3 id="quotcompare-yesterdays-broken-links-to-todaysquot">&quot;Compare yesterday's broken links to today's&quot;</h3>
<p>This is the kind of task that used to require manual work: opening two reports side by side in the dashboard. Now the agent lists historical runs, fetches both, diffs the results, and tells you exactly which links are new, which got fixed, and which are still broken.</p>
<pre data-lang="txt" class="notranslate">&gt; Compare the broken links from March 17 to March 20 for ohdear.app

Tool calls:
  ohdear list-check-runs --monitor-id=42 --check-type=broken_links --json
  ohdear list-broken-links --monitor-id=42 --run-id=98765 --json
  ohdear list-broken-links --monitor-id=42 --run-id=98710 --json

Result:
  3 new broken links found
  2 links fixed since March 17
  7 persistent broken links across both runs
</pre>
<p>We built <a href="/docs/api/check-history">historical check run endpoints</a> specifically to make this kind of workflow possible.</p>
<p>This builds on <a href="/news-and-updates/enhancing-our-api-for-better-agentic-consumption">our API work for better agentic consumption</a>.</p>
<h3 id="quotstart-a-maintenance-window-and-post-a-status-updatequot">&quot;Start a maintenance window and post a status update&quot;</h3>
<p>Deploying? Tell your agent to handle the communication:</p>
<pre data-lang="txt" class="notranslate">&gt; Start a 30-minute maintenance window on ohdear.app
  and post a status update: &quot;Deploying v2.4&quot;

Tool calls:
  ohdear start-maintenance --monitor-id=42
    --field stop_maintenance_after_seconds=1800
  ohdear create-status-page-update
    --field status_page_id=123
    --field title=&quot;Deploying v2.4&quot;

Result:
  Maintenance window started (30 min)
  Status page updated: &quot;Deploying v2.4&quot;
</pre>
<p>No false downtime alerts. Your users see the status update. You stay in your terminal.</p>
<h3 id="quotour-checkout-is-down-post-a-status-update-and-snooze-the-uptime-check-for-1-hourquot">&quot;Our checkout is down. Post a status update and snooze the uptime check for 1 hour.&quot;</h3>
<p>Incident response from your editor. The agent posts the update to your status page and snoozes the check so you're not getting pinged while you're actively fixing the problem.</p>
<h3 id="quotcross-reference-sitescsv-with-our-oh-dear-monitors-which-ones-are-missingquot">&quot;Cross-reference sites.csv with our Oh Dear monitors. Which ones are missing?&quot;</h3>
<p>This is one of my favorites. The agent reads your file, fetches your monitor list as JSON, compares the two, and tells you which sites aren't being monitored. Try doing that in a web dashboard. 😅</p>
<h3 id="quotwhat-do-the-latest-lighthouse-scores-look-like-for-my-homepagequot">&quot;What do the latest Lighthouse scores look like for my homepage?&quot;</h3>
<p>Quick performance check without leaving your editor. The agent fetches the latest Lighthouse report and summarizes the scores for performance, accessibility, best practices, and SEO.</p>
<pre data-lang="txt" class="notranslate">ohdear.app Lighthouse scores (March 22, desktop from Frankfurt):

┌────────────────┬───────┐
│    Category    │ Score │
├────────────────┼───────┤
│ Performance    │ 81    │
├────────────────┼───────┤
│ Accessibility  │ 96    │
├────────────────┼───────┤
│ Best Practices │ 100   │
├────────────────┼───────┤
│ SEO            │ 100   │
└────────────────┴───────┘

Core Web Vitals:
- FCP: 495ms, LCP: 862ms (both good)
- CLS: 0 (perfect)
- TBT: 415ms (the main drag on the performance score)
- TTI: 1.3s
</pre>
<h3 id="quotshow-me-the-certificate-health-for-all-my-monitorsquot">&quot;Show me the certificate health for all my monitors&quot;</h3>
<p>Bulk SSL overview. The agent loops through your monitors, checks certificate health on each, and flags anything expiring soon or with chain issues.</p>
<pre data-lang="txt" class="notranslate">All done. Here's the summary:

80 monitors with certificate health enabled. All clear.

- 79 monitors: All certificates healthy, no issues found
- 1 monitor (18953, uptime-checker-bahrain-1): Certificate check hasn't run yet, no data available

Nearest expirations to keep an eye on:
- uptime-checker-frankfurt-2 (11108): Apr 14 (3 weeks)
- uptime-checker-frankfurt-4 (13332): Apr 15
- uptime-checker-paris-4 (13335): Apr 17
- uptime-checker-toronto-2 (13333): Apr 15

These are all Let's Encrypt certs that auto-renew, so nothing to worry about.
</pre>
<p>The Bahrain node is interesting, it's hosted at the Bahrain AWS datacenter that's been down for a while due to bombing/collatoral damage near the UAE.</p>
<h2 id="how-the-cli-stays-in-sync-with-the-api">How the CLI stays in sync with the API <a href="#how-the-cli-stays-in-sync-with-the-api" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#how-the-cli-stays-in-sync-with-the-api">#</a></h2>
<p>One thing worth mentioning: the Oh Dear CLI is auto-generated from our <a href="https://ohdear.app/api-docs/ohdear-openapi.yml">OpenAPI specification</a>. When we add a new API endpoint, it automatically becomes a CLI command. No manual work, no lag between API and CLI.</p>
<p>This is powered by <a href="https://github.com/spatie/laravel-openapi-cli">spatie/laravel-openapi-cli</a>, which reads the OpenAPI spec at runtime and generates commands from it. The operation IDs in the spec map directly to CLI command names in kebab-case: <code>listMonitors</code> becomes <code>list-monitors</code>, <code>getCertificateHealth</code> becomes <code>get-certificate-health</code>.</p>
<p>For the skill, this means it always covers the full API surface. If we ship a new endpoint today, the CLI has it tomorrow and the skill can use it immediately.</p>
<p>It builds on the work of <a href="/news-and-updates/making-our-docs-ai-friendly-a-tale-of-two-caches">making our docs AI-friendly</a>.</p>
<h2 id="lessons-from-building-the-skill">Lessons from building the skill <a href="#lessons-from-building-the-skill" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#lessons-from-building-the-skill">#</a></h2>
<p>The skill went through several iterations before we landed on something that works well. The commit history tells the story:</p>
<p><strong>First attempt: document everything.</strong> We started with exhaustive documentation of every command, every parameter, every output format. The result was a massive prompt that burned through context window tokens.</p>
<p><strong>Second iteration: trim it down.</strong> We realized agents are good at discovery. Instead of documenting every flag, we document the patterns: how commands are named, how to use <code>--json</code> for structured output, how to use <code>--help</code> to learn more. The commit message says it well: <em>&quot;let agents auto-discover with enough hints on what's available.&quot;</em></p>
<p><strong>Third iteration: add workflows.</strong> Raw command documentation wasn't enough. We added step-by-step workflows for common tasks: investigating downtime, comparing historical data, managing maintenance windows. These give agents a playbook to follow.</p>
<p><strong>Final tweaks: enforce good habits.</strong> We added rules like &quot;always use <code>--json</code> when you need to analyze data&quot; and &quot;when you see an ohdear.app URL, use the CLI to fetch the data.&quot; These small nudges make the agent significantly more useful in practice.</p>
<p>The takeaway: good skill design isn't about completeness, it's about giving the agent the right level of guidance. Too little and it guesses wrong. Too much and you waste context tokens on information the agent can discover on its own.</p>
<p>You can see the <a href="https://github.com/ohdearapp/ohdear-cli/tree/main/skills/ohdear">entire SKILL.md and worfklows</a> on Github.</p>
<h2 id="skills-protocol">Skills protocol <a href="#skills-protocol" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#skills-protocol">#</a></h2>
<p>The Oh Dear skill uses the <a href="https://skills.sh">Skills protocol</a>, an open standard for teaching AI agents how to use tools. Any agent that supports the protocol can use our skill, not just Claude Code.</p>
<h2 id="try-it">Try it <a href="#try-it" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#try-it">#</a></h2>
<pre data-lang="bash" class="notranslate"><span class="hl-keyword">composer</span> global require ohdearapp/ohdear-cli
ohdear login
ohdear install-skill
</pre>
<p>Then ask your agent something like &quot;are there any broken links on my site?&quot; and watch it work.</p>
<p>The skill, CLI source, and all documentation are open source at <a href="https://github.com/ohdearapp/ohdear-cli">github.com/ohdearapp/ohdear-cli</a>.</p>
<h2 id="feedback">Feedback? <a href="#feedback" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#feedback">#</a></h2>
<p>If you run into anything unexpected or have ideas for the skill, reach out via <a href="mailto:support@ohdear.app">support@ohdear.app</a> or <a href="https://x.com/ohdearapp">@OhDearApp</a>. We'd love to hear how you're using this.</p>
]]>
            </summary>
                                    <updated>2026-03-25T09:00:03+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Dark mode is now available for the Oh Dear dashboard]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/dark-mode-is-now-available-for-the-oh-dear-dashboard" />
            <id>https://ohdear.app/181</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>Oh Dear's dashboard now supports dark mode. You can choose between light, dark, or system-based theming, and your preference is saved to your profile so it follows you everywhere.</p>
<h2 id="three-ways-to-theme-your-dashboard">Three ways to theme your dashboard <a href="#three-ways-to-theme-your-dashboard" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#three-ways-to-theme-your-dashboard">#</a></h2>
<p>We've added three theme options to your profile settings:</p>
<ul>
<li><strong>Light</strong>: The classic Oh Dear look you know.</li>
<li><strong>Dark</strong>: A carefully tuned dark palette across the entire app.</li>
<li><strong>System</strong>: Follows your operating system's preference automatically.</li>
</ul>
<p><img src="/media/blog/8X1penS6JIk3P9YDywgJdkcn97LhkvPgL7E2vwnD.gif" alt="Oh Dear - configure dark mode" /></p>
<p>Your theme preference is saved to your account, not just your browser. That means it applies on every device, every session, and every tab you open.</p>
<p><img src="/media/blog/0uJgNHkAPIIy0cWQC1w3wN3AoKBgWjPWhFAQXtPo.png" alt="Oh Dear - Dashboard in dark mode" /></p>
<h2 id="no-more-flashbangs-at-3-am">No more flashbangs at 3 AM <a href="#no-more-flashbangs-at-3-am" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#no-more-flashbangs-at-3-am">#</a></h2>
<p>When your site goes down at 3 AM and you open the Oh Dear alert from Slack, the last thing you want is a blinding white screen burning your eyes.</p>
<p>If you've set your theme to &quot;dark&quot; or &quot;system&quot; (and your OS is in dark mode), your dashboard stays dark. Always. No matter how you got there, no matter which device you're on. Your eyes will thank you.</p>
<p>We specifically made sure the theme is saved on the profile, not just in a cookie or localStorage. When you click through from a Slack notification at midnight, your dark mode is already waiting for you.</p>
<p>Additional benefit: now that <a href="/news-and-updates/oh-dear-is-now-mobile-friendly">the dashboard is also mobile friendly</a>, you won't only get a good-looking dark mode, but a well-rendered version of any page you visit.</p>
<h2 id="it-just-looks-really-good">It just looks really good <a href="#it-just-looks-really-good" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#it-just-looks-really-good">#</a></h2>
<p>Dark mode isn't just a comfort feature. It looks great.</p>
<p><img src="/media/blog/7cy7FQzOc67u0MKTslzVrrmx7cU5WaLhCp9oNvTP.png" alt="Oh Dear - Uptime page in dark mode" /></p>
<p>Charts, tables, status indicators, alert boxes, everything got its own carefully chosen dark palette. We didn't just invert colors. We picked backgrounds, borders, text contrasts, and accent colors that feel right together.</p>
<p><img src="/media/blog/j9Cp8UY9cPWJTalJSyHTR3WZ2wSPyPPu9HR0j4J9.png" alt="Oh Dear - Broken links page in dark mode" /></p>
<p>Even the performance charts have dedicated dark mode color sets, so your timing breakdowns are just as readable at any hour.</p>
<h2 id="349-files-and-a-lot-of-dark-prefixes">349 files, and a lot of <code>dark:</code> prefixes <a href="#349-files-and-a-lot-of-dark-prefixes" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#349-files-and-a-lot-of-dark-prefixes">#</a></h2>
<p>This was a big update. The PR touched 349 files with 2,718 additions and 1,531 deletions across the entire app. Every template, every component, every form, every table needed dark mode variants.</p>
<p>That's the kind of work where agentic coding really shines.</p>
<h2 id="how-we-built-it-ai-for-the-repetitive-parts-humans-for-the-creative-parts">How we built it: AI for the repetitive parts, humans for the creative parts <a href="#how-we-built-it-ai-for-the-repetitive-parts-humans-for-the-creative-parts" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#how-we-built-it-ai-for-the-repetitive-parts-humans-for-the-creative-parts">#</a></h2>
<p>If you read our <a href="https://ma.ttias.be/mobile-friendly-oh-dear-ui-with-ai/">previous post about making Oh Dear mobile-friendly</a>, this will sound familiar. The pattern was the same: let AI handle the tedious, repetitive work, and focus human effort on the parts that actually matter.</p>
<h3 id="the-agentic-scaffold">The agentic scaffold</h3>
<p>We used Claude Code to generate the initial dark mode implementation across the entire codebase. The work was broken down into 8 user stories:</p>
<ol>
<li>Add theme preference to the user model</li>
<li>Serve the theme preference to the app layout</li>
<li>Build a theme switcher on the profile page</li>
<li>Dark mode styles for the app layout shell</li>
<li>Dark mode styles for content cards and panels</li>
<li>Dark mode styles for form elements</li>
<li>Dark mode styles for tables and data displays</li>
<li>Dark mode styles for check report pages</li>
</ol>
<p>Each of these was committed individually, with Claude as co-author. The agent worked through every Blade template, adding <code>dark:</code> Tailwind classes to backgrounds, text colors, borders, shadows, and interactive states. Hundreds of files, thousands of class additions, all done in a single session.</p>
<p>This is where AI is genuinely useful: applying a known pattern (<code>dark:bg-gray-900</code>, <code>dark:text-gray-200</code>, <code>dark:border-gray-700</code>) consistently across 349 files. It's not creative work. It's precise, repetitive, and easy to get wrong if you're doing it by hand.</p>
<h3 id="the-human-side-getting-the-colors-right">The human side: getting the colors right</h3>
<p>Adding <code>dark:</code> classes is the easy part. Choosing <em>which</em> colors, and making them feel cohesive across the entire app, that's where human judgment comes in. In our case, that human is called <a href="https://x.com/nckrtl">Nick</a>.</p>
<p>The initial AI pass gave us structurally correct dark mode. Every element had a dark variant. But the palette didn't feel right. Backgrounds were too dark or too light. Text contrast was inconsistent. Borders disappeared or felt too harsh. The charts looked washed out.</p>
<p>Getting the theme right required sitting with the app, clicking through every page, and fine-tuning values until the whole thing felt intentional. Not just technically correct, but aesthetically coherent.</p>
<p>That's design work, and it's the part AI can't (yet?) do for you. It doesn't know that a particular shade of gray makes a card feel heavy, or that reducing border opacity by 10% makes a table feel lighter. It doesn't have opinions about whether a status indicator should be muted or vibrant in dark mode. Those are judgment calls that need a human eye.</p>
<h3 id="the-technical-foundation">The technical foundation</h3>
<p>A few things we got right from the start:</p>
<p><strong>Tailwind's <code>class</code> strategy</strong>: We use <code>darkMode: 'class'</code> in our Tailwind config, which toggles dark mode by adding a <code>dark</code> class to the <code>&lt;html&gt;</code> element. This gives us full control and avoids relying solely on the system media query.</p>
<p><strong>No flash on page load</strong>: The theme script runs in the <code>&lt;head&gt;</code>, before any content renders. Whether you're in dark or light mode, you'll never see a white flash followed by a dark switch. It's instant.</p>
<p><strong>Real-time system detection</strong>: If you've chosen &quot;system&quot; mode, we listen for OS-level theme changes. Switch your Mac to dark mode, and the Oh Dear dashboard follows immediately, no refresh needed.</p>
<p><strong>Persistent preference</strong>: Your theme choice is saved to the database via your profile, with a localStorage fallback for immediate application. This means it works everywhere, instantly.</p>
<h2 id="give-it-a-try">Give it a try <a href="#give-it-a-try" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#give-it-a-try">#</a></h2>
<p>Dark mode is available now for all Oh Dear users. Head to your profile settings and pick the theme that works for you.</p>
<p><img src="/media/blog/t8WZWWImECAHrfluX5AlLBcp047cynHWNwqdHRnr.png" alt="Oh Dear - Theme switcher on mobile" /></p>
<p>And if you're the type who keeps their phone on dark mode 24/7, just set it to &quot;system&quot; and forget about it. Oh Dear will match your preference automatically.</p>
]]>
            </summary>
                                    <updated>2026-03-23T14:11:23+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Oh Dear is now mobile-friendly]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/oh-dear-is-now-mobile-friendly" />
            <id>https://ohdear.app/180</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>Oh Dear has always been a desktop-first tool. If you checked your monitors on your phone, you'd get the full desktop layout squeezed into a tiny screen, with lots of horizontal scrolling and tiny tap targets. That's fixed now. Every page in the app works on mobile.</p>
<p><img src="/media/blog/npkyKRnXD3ZTkt8eiLZiYOmWBxJVXV46PE4D3dp0.png" alt="The Oh Dear app is optimized for mobile!" /></p>
<h2 id="what-changed">What changed <a href="#what-changed" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#what-changed">#</a></h2>
<p>We didn't just slap some media queries on the existing layout. Some parts of the app needed a completely different approach on small screens. Here's what we did:</p>
<ul>
<li><strong>New mobile navigation</strong> with a floating action button and full-screen menu overlay</li>
<li><strong>Dedicated mobile card layouts</strong> for monitor lists and status pages</li>
<li><strong>Scrollable tables</strong> with a visual hint that there's more to see</li>
<li><strong>Larger touch targets</strong> for toggles, checkboxes, and dropdown items</li>
<li><strong>Responsive check report pages</strong> for uptime, SSL, DNS, performance, broken links, and all other checks</li>
<li><strong>Mobile-friendly modals, forms, and settings pages</strong></li>
</ul>
<p>We touched over 160 Blade templates across the entire app.</p>
<p><img src="/media/blog/LCishW9OcaV4OqfbX2l6OZjkP9XW2rpPKaAfi4Gi.png" alt="Oh Dear - Dashboard" /></p>
<h2 id="optimize-mobile-views-when-you-need-them-the-most">Optimize mobile views when you need them the most <a href="#optimize-mobile-views-when-you-need-them-the-most" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#optimize-mobile-views-when-you-need-them-the-most">#</a></h2>
<p>We send you alerts when we notice something doesn't seem right. That can be website downtime, broken links, failing scheduled tasks, ... You're probably going to click through on these alerts from inside your Slack, Discord, Teams or any other notification type we offer.</p>
<p>The page you're landing on, likely on your mobile device, needs to be optimized for that <em>unhappy state</em>: you want all the details of the problem we're reporting, at your fingertips.</p>
<p>The new layout is optimized for exactly that: getting all the necessary details, clearly visible, on mobile.</p>
<ul>
<li>Tables with multiple columns are replaced by stacked, labeled cards, so nothing gets clipped or overlaps</li>
<li>Download and action buttons are always visible, never hidden behind menus</li>
<li>Page headers and action buttons stack naturally instead of wrapping awkwardly</li>
<li>&quot;Copy to clipboard&quot; icons are available where relevant, to easily share error messages, HTTP headers or reproduction steps with colleagues</li>
</ul>
<p>When you get that alert that something is down, you're already in a stressful moment. The last thing you need is to fight the UI to find the details. Everything you need to diagnose and communicate the problem is right there, on whatever device you happen to have in your hand.</p>
<p><img src="/media/blog/iL2wO4zveXhfG2bUymbkCWgX8jgB5OECSTeIqN3r.png" alt="Oh Dear - Before and after site dashboard" /></p>
<h2 id="the-floating-menu">The floating menu <a href="#the-floating-menu" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#the-floating-menu">#</a></h2>
<p>On desktop, Oh Dear has a horizontal navbar with dropdowns for switching teams, accessing settings, and navigating between monitors and status pages. That doesn't work on a phone.</p>
<p>Instead of cramming the navbar into a hamburger menu at the top, we went with a floating action button (FAB) fixed to the bottom-right corner. It's always within thumb reach.</p>
<pre data-lang="blade" class="notranslate">&lt;<span class="hl-keyword">button</span>
    <span class="hl-property">class</span>=&quot;fixed bottom-6 right-6 z-[60] flex items-center justify-center
           w-12 h-12 text-white rounded-full transform-gpu bg-primary
           md:hidden shadow-glow-primary&quot;
    <span class="hl-keyword">@click</span>=&quot;$store.appMobileMenu.isOpen = !$store.appMobileMenu.isOpen&quot;
&gt;
</pre>
<p><img src="/media/blog/XAKVxvPEAPuCI62Nv2AZHik6wUCejC5b9obNnnIX.png" alt="Oh Dear - Floating Menu Button" /></p>
<p>Tapping it opens a full-screen overlay with all the navigation options, organized vertically with generous padding for easy tapping. The menu uses an Alpine store (<code>$store.appMobileMenu</code>) as a shared state between the button and the overlay, which keeps things simple.</p>
<p>One nice detail: when the menu is open, we add <code>overflow-hidden</code> to the body so the page behind it doesn't scroll.</p>
<pre data-lang="blade" class="notranslate">&lt;<span class="hl-keyword">div</span>
    x-data
    <span class="hl-property">x-effect</span>=&quot;document.body.classList.toggle('overflow-hidden', $store.appMobileMenu.isOpen);
              document.body.classList.toggle('mobile-menu-open', $store.appMobileMenu.isOpen)&quot;
&gt;
</pre>
<p>The <code>mobile-menu-open</code> class also repositions our support bubble widget so it doesn't overlap with the menu button.</p>
<p><img src="/media/blog/Ep0RsggBrM9dccHUpDUzHdhln8kf76ySwwfCBDcx.png" alt="Oh Dear mobile navigation menu on an iPhone showing monitors, status pages, profile, team settings and team switcher options" /></p>
<h2 id="two-layouts-instead-of-one">Two layouts instead of one <a href="#two-layouts-instead-of-one" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#two-layouts-instead-of-one">#</a></h2>
<p>The monitor list is probably the most complex page in the app. On desktop, it's a multi-column table with performance sparklines, issue counts, and a hover-revealed actions menu.</p>
<p><img src="/media/blog/ybNvmkNldE3mojsM5b0cQJPBI63Y9ILzDC6d85cb.png" alt="The desktop monitor list showing the multi-column table with performance sparklines" /></p>
<p>Making that responsive with pure CSS would mean hiding columns, stacking cells, and losing the at-a-glance overview that makes the table useful. So we went with a different approach: render two completely separate layouts and show the right one based on screen size.</p>
<pre data-lang="blade" class="notranslate"><span class="hl-comment">{{-- Desktop table layout --}}</span>
&lt;<span class="hl-keyword">table</span> <span class="hl-property">class</span>=&quot;hidden md:table w-full tabular-nums table-fixed&quot;&gt;
    <span class="hl-keyword">@include</span>(<span class="hl-value">'app.monitors.list.components.monitorListRow'</span>)
&lt;/<span class="hl-keyword">table</span>&gt;

<span class="hl-comment">{{-- Mobile card layout --}}</span>
&lt;<span class="hl-keyword">div</span> <span class="hl-property">class</span>=&quot;md:hidden divide-y divide-gray-150&quot;&gt;
    <span class="hl-keyword">@foreach</span>(<span class="hl-variable">$monitorsInGroup</span> <span class="hl-keyword">as</span> <span class="hl-variable">$monitor</span>)
        <span class="hl-keyword">@include</span>(<span class="hl-value">'app.monitors.list.components.monitorListRowMobile'</span>)
    <span class="hl-keyword">@endforeach</span>
&lt;/<span class="hl-keyword">div</span>&gt;
</pre>
<p>The mobile card packs the essential info into a vertical layout: status indicator and name on top, tags inline, a mini performance chart below, and check summary messages as a list. Every row has a minimum height of 44px for reliable touch targets.</p>
<p>This is more code to maintain, but the result is clean on both screen sizes. No CSS hacks, no overflow issues, no squished text.</p>
<h2 id="scrollable-tables-with-a-fade-hint">Scrollable tables with a fade hint <a href="#scrollable-tables-with-a-fade-hint" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#scrollable-tables-with-a-fade-hint">#</a></h2>
<p>Not every table needed a mobile-specific layout. For simpler tables (DNS records, certificate chains, downtime history), horizontal scrolling works fine. But users need to know the table extends beyond what they can see.</p>
<p>We built a small <code>scrollable-table</code> component that wraps any table and adds a gradient fade on the right edge:</p>
<pre data-lang="blade" class="notranslate">&lt;<span class="hl-keyword">div</span>
    <span class="hl-property">x-data</span>=&quot;{
        canScroll: false,
        atEnd: false,
        check() {
            const <span class="hl-property">el</span> = this.$refs.scroller;
            this.<span class="hl-property">canScroll</span> = el.scrollWidth &gt; el.clientWidth;
            this.<span class="hl-property">atEnd</span> = el.scrollLeft + el.clientWidth &gt;= el.scrollWidth - 1;
        }
    }&quot;
    <span class="hl-property">x-init</span>=&quot;check()&quot;
    x-on:resize.<span class="hl-property">window</span>=&quot;check()&quot;
&gt;
    &lt;<span class="hl-keyword">div</span> <span class="hl-property">class</span>=&quot;scrollable-table-inner&quot; <span class="hl-property">x-ref</span>=&quot;scroller&quot; <span class="hl-property">x-on:scroll</span>=&quot;check()&quot;&gt;
        {{ <span class="hl-variable">$slot</span> }}
    &lt;/<span class="hl-keyword">div</span>&gt;
    &lt;<span class="hl-keyword">div</span> <span class="hl-property">class</span>=&quot;scrollable-table-fade&quot; <span class="hl-property">x-show</span>=&quot;canScroll &amp;&amp; !atEnd&quot; x-cloak&gt;&lt;/<span class="hl-keyword">div</span>&gt;
&lt;/<span class="hl-keyword">div</span>&gt;
</pre>
<p>The fade automatically disappears once you scroll to the end. It only shows on screens where the table actually overflows, and it's hidden entirely on desktop where tables fit comfortably.</p>
<h2 id="bigger-touch-targets">Bigger touch targets <a href="#bigger-touch-targets" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#bigger-touch-targets">#</a></h2>
<p>Small toggles and checkboxes are a constant frustration on mobile. We scaled up all form controls on small screens:</p>
<pre data-lang="css" class="notranslate"><span class="hl-keyword">@media (width &lt; theme(--breakpoint-mg)) </span>{<span class="hl-keyword">
    .check-toggle </span>{
        @apply w-11 h-6;
    }<span class="hl-keyword">

    [type='checkbox'],
    [type='radio'] </span>{
        <span class="hl-property">height</span>: 1.25rem;
        <span class="hl-property">width</span>: 1.25rem;
    }
}
</pre>
<p>Dropdown menu items also get extra vertical padding on mobile, bringing them to a comfortable 44px touch height.</p>
<p><img src="/media/blog/KSblWuG9WlEoNhsNfVYYT79ZGNYd3gU4omKDPjOO.png" alt="Oh Dear - Mobile view certificate health" /></p>
<h2 id="try-it-out">Try it out <a href="#try-it-out" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#try-it-out">#</a></h2>
<p>If you're an Oh Dear user, open the app on your phone. Everything should just work. Monitor lists, check reports, settings, notifications, status pages, all of it.</p>
<p>If something looks off on your device, let us know at <a href="mailto:support@ohdear.app">support@ohdear.app</a> or at <a href="https://x.com/ohdearapp">@OhDearApp</a> on X.</p>
]]>
            </summary>
                                    <updated>2026-06-12T08:58:01+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[DNS blocklist monitoring now available to all Oh Dear users]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/dns-blocklist-monitoring-now-available-to-all-oh-dear-users" />
            <id>https://ohdear.app/179</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p><em>Your domain is on a spam blocklist. Password reset emails aren't arriving, order confirmations land in spam, and customers are complaining that &quot;your site doesn't work.&quot; By the time you hear about it, the damage has been building for days.</em></p>
<p>We've shipped <strong>DNS blocklist monitoring</strong> to catch this early. Oh Dear now checks your domain against 11 major blocklists and notifies you the moment you're listed, with direct links to get removed.</p>
<h2 id="two-kinds-of-blocklists-two-kinds-of-pain">Two kinds of blocklists, two kinds of pain <a href="#two-kinds-of-blocklists-two-kinds-of-pain" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#two-kinds-of-blocklists-two-kinds-of-pain">#</a></h2>
<p>Not all blocklists are the same. We monitor two categories:</p>
<p><strong>Security blocklists (RBLs)</strong> are the ones that wreck your email deliverability. If your IP or domain appears on Barracuda, SpamCop, SURBL, or URLhaus, email servers will reject or spam-folder your messages. Password resets, order confirmations, support replies: silently gone.</p>
<p><strong>Content filter DNS services</strong> block your site entirely for users behind DNS-level filtering. If Quad9, Cloudflare Family, AdGuard, or OpenDNS blocks your domain, anyone using those services (directly, or through tools like Pi-hole and AdGuard Home) simply can't reach you. That's millions of privacy-conscious users, corporate networks, and families with parental controls.</p>
<h2 id="the-11-blocklists-we-check">The 11 blocklists we check <a href="#the-11-blocklists-we-check" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#the-11-blocklists-we-check">#</a></h2>
<table>
<thead>
<tr>
<th>Category</th>
<th>Provider</th>
<th>What it catches</th>
</tr>
</thead>
<tbody>
<tr>
<td>Security</td>
<td>Barracuda</td>
<td>IP reputation</td>
</tr>
<tr>
<td>Security</td>
<td>SpamCop</td>
<td>Real-time spam reports</td>
</tr>
<tr>
<td>Security</td>
<td>SURBL</td>
<td>Spam, phishing, malware domains</td>
</tr>
<tr>
<td>Security</td>
<td>URLhaus</td>
<td>Malware distribution URLs</td>
</tr>
<tr>
<td>Content filter</td>
<td>AdGuard DNS</td>
<td>Ads, trackers, malware</td>
</tr>
<tr>
<td>Content filter</td>
<td>AdGuard Family</td>
<td>Ads, trackers, adult content</td>
</tr>
<tr>
<td>Content filter</td>
<td>CleanBrowsing</td>
<td>Malware and phishing</td>
</tr>
<tr>
<td>Content filter</td>
<td>Cloudflare Family</td>
<td>Malware and adult content</td>
</tr>
<tr>
<td>Content filter</td>
<td>OpenDNS</td>
<td>Malware and phishing</td>
</tr>
<tr>
<td>Content filter</td>
<td>OpenDNS Family</td>
<td>Malware, phishing, adult content</td>
</tr>
<tr>
<td>Content filter</td>
<td>Quad9</td>
<td>Malware and phishing</td>
</tr>
</tbody>
</table>
<h2 id="how-it-works">How it works <a href="#how-it-works" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#how-it-works">#</a></h2>
<p>For security blocklists, we perform standard DNSBL lookups. IP-based lists like Barracuda get a reversed-IP query against their DNS zone. Domain-based lists like SURBL get a direct domain query. A DNS response means you're listed.</p>
<p>For content filter services, we resolve your domain through each provider's DNS server. If the response is <code>NXDOMAIN</code> or a known block-page IP (like <code>0.0.0.0</code> for AdGuard), your domain is blocked by that service.</p>
<p>Checks run daily by default. You can increase the frequency to every 4 hours if you want tighter coverage, or pick specific blocklists instead of monitoring all 11.</p>
<h2 id="what-happens-when-youre-listed">What happens when you're listed <a href="#what-happens-when-youre-listed" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#what-happens-when-youre-listed">#</a></h2>
<p>You get a notification through whichever channels you've configured: email, Slack, Discord, Teams, Telegram, PagerDuty, Opsgenie, Pushover, SMS, webhooks, ntfy, or Google Chat.</p>
<p>The notification tells you exactly <strong>which</strong> blocklist flagged you and why. Your dashboard shows direct links to each provider's delisting form, so you can start the removal process immediately instead of researching where to go. All the info you need, at your fingertips.</p>
<p>When you're removed from all blocklists, you'll get a recovery notification confirming the all-clear.</p>
<p>If you're working on a fix and don't want repeated alerts, you can snooze the check.</p>
<h2 id="its-probably-not-your-fault">It's probably not your fault <a href="#its-probably-not-your-fault" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#its-probably-not-your-fault">#</a></h2>
<p>Domains get blacklisted for reasons beyond your control:</p>
<ul>
<li><strong>Shared hosting</strong>: a neighbor on your IP sends spam, your IP gets flagged</li>
<li><strong>Domain history</strong>: you registered a domain previously owned by bad actors</li>
<li><strong>IP history</strong>: you provisioned a new VM and got an IP that was previously abusive</li>
<li><strong>Compromised sites</strong>: attackers inject content you don't even know about</li>
<li><strong>Email misconfiguration</strong>: missing SPF/DKIM records or an open relay</li>
</ul>
<p>Regardless of the cause, you need to know fast. The longer you stay listed, the more damage accumulates.</p>
<h2 id="api-access">API access <a href="#api-access" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#api-access">#</a></h2>
<p>DNS Blocklist history is also available through our API. Fetch all check results for a monitor:</p>
<pre data-lang="txt" class="notranslate">GET /api/monitors/{id}/dns-blocklist-history-items
</pre>
<p>Each result includes the checked domain, resolved IPs, and per-provider results with listing status, return codes, and reasons. Full details in our <a href="/docs/api/dns-blocklist">API documentation</a>, or use our <a href="/docs/tools-and-sdks/the-oh-dear-php-sdk">PHP-SDK</a> for convenience.</p>
<h2 id="ready-to-be-enabled-by-everyone">Ready to be enabled by everyone <a href="#ready-to-be-enabled-by-everyone" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#ready-to-be-enabled-by-everyone">#</a></h2>
<p>DNS blocklist monitoring is available for all monitors. Head to your monitor's settings, enable the DNS Blocklist check, and you're covered. All 11 blocklists are monitored by default.</p>
<p>Read the full <a href="/docs/features/dns-blocklist-monitoring">documentation</a> for configuration options and remediation guides.</p>
<p>Want to bulk-update this check for all your monitors? Just reach out <a href="/contact">via support</a>.</p>
]]>
            </summary>
                                    <updated>2026-02-23T09:00:02+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Kickstarting our reseller program with Zone & Ploi Cloud]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/kickstarting-our-reseller-program-with-zone-ploi-cloud" />
            <id>https://ohdear.app/178</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>We've quietly been building a reseller program over the past months. The idea is simple: let hosting providers, deployment platforms, and agencies offer Oh Dear monitoring directly inside their own products. Not a stripped-down version, the full thing.</p>
<p>We're working with several partners to bring Oh Dear into more products and platforms. <strong>Zone</strong> and <strong>Ploi Cloud</strong> are the first two out the door.</p>
<h2 id="whats-the-reseller-program">What's the reseller program? <a href="#whats-the-reseller-program" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#whats-the-reseller-program">#</a></h2>
<p>If you run a hosting company, a deployment platform, or an agency managing client sites, you probably want monitoring baked into your product. Building that from scratch is a massive undertaking. <a href="/features/website-uptime-monitoring">Uptime checks</a> from multiple global locations, <a href="/features/continuous-certificate-monitoring">SSL monitoring</a>, <a href="/features/broken-page-and-mixed-content-detection">broken link detection</a>, <a href="/features/performance-monitoring">performance tracking</a>, <a href="/features/dns-monitoring">DNS change alerts</a>, <a href="/features/scheduled-task-monitoring">cron monitoring</a>... that's years of work.</p>
<p>Our reseller program lets you skip all of that. You integrate with our API, and your customers get the full Oh Dear experience inside your platform. You handle the billing and the relationship with your customer. We handle the monitoring infrastructure.</p>
<h2 id="zone-monitoring-as-a-native-hosting-feature">Zone: monitoring as a native hosting feature <a href="#zone-monitoring-as-a-native-hosting-feature" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#zone-monitoring-as-a-native-hosting-feature">#</a></h2>
<p><img src="/media/blog/WHELmtLHKPGKe4otH84lJNVwfPD6mSAUZCKpYNCs.webp" alt="Zone promo image of a man leaping mid-air with bold text 'Oh Dear! Less stress, more time' and the Zone logo" /></p>
<p><a href="https://www.zone.eu/">Zone</a> is one of the largest domain registration and hosting providers in the Baltics, operating in <a href="https://www.zone.ee/et/">Estonia</a>, <a href="https://www.speedzone.fi/fi/">Finland</a> and <a href="https://www.zone.lv/lv/">Latvia</a>. They now offer Oh Dear monitoring as a native service to all their customers, covering uptime, SSL certificates, broken links, performance, domain expiry, DNS changes, and cron job monitoring.</p>
<p>Their approach to <a href="/features/notifications">notifications</a> is seamless: Zone customers get a one-click login into the full Oh Dear dashboard, where they can configure exactly which alerts they want and where they should go. Email, SMS, Slack, webhooks, you name it. The customer controls their own notification preferences directly in Oh Dear, without ever needing to create a separate account.</p>
<p>What we love about Zone's integration: they don't just resell it, they use Oh Dear internally too. When your own hosting provider trusts the same monitoring tool they're offering you, that says something.</p>
<blockquote>
<p>&quot;Oh Dear is the easy one to implement, because the API makes sense and is easy to work with.&quot;</p>
<p>Kaarel Urva, Chief Project Officer at Zone Media</p>
</blockquote>
<p>You can read more about their offering in <a href="https://www.zone.eu/blog/oh-dear-a-simple-way-to-truly-understand-how-your-website-is-doing/">Zone's announcement post</a>.</p>
<h2 id="ploi-cloud-three-clicks-to-monitoring">Ploi Cloud: three clicks to monitoring <a href="#ploi-cloud-three-clicks-to-monitoring" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#ploi-cloud-three-clicks-to-monitoring">#</a></h2>
<p><img src="/media/blog/eYJtt6XwqpJhMC5dQYvOzgMVnPxeOZrucAyXngDD.png" alt="Ploi Cloud and Oh Dear logos linked by a heart, announcing a partnership for uptime monitoring in Ploi Cloud" /></p>
<p><a href="https://ploi.cloud">Ploi Cloud</a> is a deployment platform built for developers. Their Oh Dear integration is beautifully simple: navigate to the Monitoring tab, click &quot;Enable monitoring&quot;, and you're done. Uptime checks, SSL monitoring, performance metrics, broken link detection, and mixed content scanning, all running within seconds.</p>
<p>Ploi took a different approach to notifications. Instead of sending users to the Oh Dear dashboard, they receive all monitoring alerts through our <a href="/docs/tools-and-sdks/webhooks">webhooks</a> and pipe them into their existing notification system. If you've already set up Slack, Discord, Google Chat, or email notifications in Ploi, your monitoring alerts just show up there too. Nothing extra to configure.</p>
<p>Their team described it well: &quot;This isn't a third-party plugin or a workaround, it's a first-class integration built together.&quot;</p>
<p>Check out <a href="https://ploi.cloud/blog/introducing-uptime-monitoring-powered-by-oh-dear">Ploi Cloud's announcement</a> for the full details.</p>
<h2 id="built-on-our-reseller-api">Built on our reseller API <a href="#built-on-our-reseller-api" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#built-on-our-reseller-api">#</a></h2>
<p>Both integrations are powered by our <a href="/docs/api/reseller">reseller API</a>. It gives partners everything they need to build a seamless experience:</p>
<ul>
<li><strong>Team provisioning</strong>: create and manage separate client teams programmatically</li>
<li><strong>User management</strong>: add users with roles (admin, member, guest) to each team</li>
<li><strong>Secure login links</strong>: generate time-limited login links so customers can access the full Oh Dear dashboard without creating separate credentials</li>
<li><strong>Monitor management</strong>: create, configure, and manage monitors across all client teams through the standard monitors API</li>
</ul>
<p>The beauty is in the flexibility. Zone gives their customers direct access to the Oh Dear dashboard through login links, so users can configure their own <a href="/features/notifications">notification preferences</a>. Ploi keeps everything inside their own platform and uses <a href="/docs/tools-and-sdks/webhooks">webhooks</a> to feed alerts into their existing notification channels. Both approaches work, both are valid. You tailor it to what fits your product best.</p>
<h2 id="more-to-come">More to come <a href="#more-to-come" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#more-to-come">#</a></h2>
<p>Zone and Ploi Cloud are the first two reseller partners we can publicly share, but there are more in the making. We'll announce them as they go live.</p>
<p>If you run a hosting company, a deployment platform, or a service where website monitoring would be a natural fit for your customers, we'd love to talk. Reach out at <strong><a href="mailto:support@ohdear.app">support@ohdear.app</a></strong> and we'll walk you through the program.</p>
]]>
            </summary>
                                    <updated>2026-06-12T08:58:01+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Making our docs AI-friendly: a tale of two caches]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/making-our-docs-ai-friendly-a-tale-of-two-caches" />
            <id>https://ohdear.app/177</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>Our documentation, FAQ, and blog posts can now be served as clean markdown to AI agents. Send <code>Accept: text/markdown</code> or append <code>.md</code> to the URL, and you get structured content instead of a full HTML page.</p>
<p>It worked great in development. Then we deployed, and two separate caching layers broke everything. Here's the journey.</p>
<h2 id="the-implementation-surprisingly-easy">The implementation (surprisingly easy) <a href="#the-implementation-surprisingly-easy" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#the-implementation-surprisingly-easy">#</a></h2>
<p>Just last week, we added <code>.md</code> suffixes to all our docs URLs and listed them in our /llms.txt. That felt like a solid approach. A week later, the convention has already moved on. <code>Accept: text/markdown</code> is now the preferred way. It's cleaner HTTP and works with standard content negotiation. We're keeping the <code>.md</code> suffixes for tools that can't set custom headers.</p>
<p>The actual implementation is a small trait:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">trait</span> <span class="hl-type">DetectsMarkdownRequest</span>
{
    <span class="hl-keyword">protected</span> <span class="hl-keyword">function</span> <span class="hl-property">shouldReturnMarkdown</span>(): <span class="hl-type">bool</span>
    {
        <span class="hl-keyword">if</span> (<span class="hl-property">str_ends_with</span>(<span class="hl-property">request</span>()-&gt;<span class="hl-property">path</span>(), <span class="hl-value">'.md'</span>)) {
            <span class="hl-keyword">return</span> <span class="hl-keyword">true</span>;
        }

        <span class="hl-keyword">if</span> (<span class="hl-property">str_contains</span>(<span class="hl-property">request</span>()-&gt;<span class="hl-property">header</span>(<span class="hl-value">'Accept'</span>, <span class="hl-value">''</span>), <span class="hl-value">'text/markdown'</span>)) {
            <span class="hl-keyword">return</span> <span class="hl-keyword">true</span>;
        }

        <span class="hl-keyword">return</span> <span class="hl-keyword">false</span>;
    }
}
</pre>
<p>Each controller just checks this early and returns markdown when needed:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">__invoke</span>(<span class="hl-injection"><span class="hl-type">string</span> $slug = ''</span>): <span class="hl-type">View|RedirectResponse|Response</span>
{
    <span class="hl-keyword">if</span> (<span class="hl-property">str_ends_with</span>(<span class="hl-variable">$slug</span>, <span class="hl-value">'.md'</span>)) {
        <span class="hl-variable">$slug</span> = <span class="hl-type">Str</span>::<span class="hl-property">chopEnd</span>(<span class="hl-variable">$slug</span>, <span class="hl-value">'.md'</span>);
    }

    <span class="hl-keyword">if</span> (<span class="hl-variable">$this</span>-&gt;<span class="hl-property">shouldReturnMarkdown</span>()) {
        <span class="hl-keyword">return</span> <span class="hl-variable">$this</span>-&gt;<span class="hl-property">markdownResponse</span>(<span class="hl-variable">$slug</span>);
    }

    <span class="hl-comment">// ... regular HTML response</span>
}
</pre>
<p>That's it. Add the trait, add the early return, done. The <code>markdownResponse()</code> method returns the raw content with the right headers:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">return</span> <span class="hl-property">response</span>(<span class="hl-variable">$markdown</span>, 200, [
    <span class="hl-value">'Content-Type'</span> =&gt; <span class="hl-value">'text/markdown; charset=UTF-8'</span>,
    <span class="hl-value">'X-Robots-Tag'</span> =&gt; <span class="hl-value">'noindex'</span>,
]);
</pre>
<h2 id="the-savings">The savings <a href="#the-savings" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#the-savings">#</a></h2>
<p>Here's what our pages look like as HTML versus markdown:</p>
<table>
<thead>
<tr>
<th>Page</th>
<th>HTML</th>
<th>Markdown</th>
<th>Saved</th>
</tr>
</thead>
<tbody>
<tr>
<td><a href="/docs/api/monitors">Monitor API docs</a></td>
<td>386 KB<br/>~99K tokens</td>
<td>22.9 KB<br/>~5.9K tokens</td>
<td><strong>94%</strong></td>
</tr>
<tr>
<td><a href="/docs/features/uptime-and-performance-monitoring">Uptime feature docs</a></td>
<td>370 KB<br/>~95K tokens</td>
<td>14.5 KB<br/>~3.7K tokens</td>
<td><strong>96%</strong></td>
</tr>
<tr>
<td><a href="/news-and-updates/our-3-part-series-on-sql-performance-optimisations">SQL performance post</a></td>
<td>333 KB<br/>~85K tokens</td>
<td>16.1 KB<br/>~4.1K tokens</td>
<td><strong>95%</strong></td>
</tr>
<tr>
<td><a href="/docs/faq/which-payment-methods-do-you-accept">Payment methods FAQ</a></td>
<td>295 KB<br/>~76K tokens</td>
<td>0.9 KB<br/>~230 tokens</td>
<td><strong>99%</strong></td>
</tr>
</tbody>
</table>
<p>What that means in API costs, per request for the Monitor API docs page:</p>
<table>
<thead>
<tr>
<th>Model</th>
<th>HTML cost</th>
<th>Markdown cost</th>
<th>You save</th>
</tr>
</thead>
<tbody>
<tr>
<td>Claude Opus 4.6 ($5/1M)</td>
<td>$0.50</td>
<td>$0.030</td>
<td><strong>$0.47</strong></td>
</tr>
<tr>
<td>Claude Sonnet 4.5 ($3/1M)</td>
<td>$0.30</td>
<td>$0.018</td>
<td><strong>$0.28</strong></td>
</tr>
<tr>
<td>GPT-5.2 ($1.75/1M)</td>
<td>$0.17</td>
<td>$0.010</td>
<td><strong>$0.16</strong></td>
</tr>
</tbody>
</table>
<p>Per request, the difference is small. But agents crawling multiple docs pages per session add up, and there's no reason to ship navigation chrome and script tags to something that just wants the content.</p>
<p>And then production happened.</p>
<h2 id="cache-layer-1-cloudflare">Cache layer 1: Cloudflare <a href="#cache-layer-1-cloudflare" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#cache-layer-1-cloudflare">#</a></h2>
<p>Cloudflare caches by URL. It doesn't vary by <code>Accept</code> header. So the first browser visit cached the HTML, and every subsequent request, including <code>Accept: text/markdown</code>, got that cached HTML back.</p>
<p>First instinct: add <code>Vary: Accept</code>. Nope. Cloudflare Free/Pro doesn't respect <code>Vary: Accept</code> for non-image content.</p>
<p>There's a clever workaround using <strong>Transform Rules</strong> to append a query parameter to markdown requests, giving them a different cache key. Mike Olson wrote a <a href="https://mwolson.org/blog/2026-02-14-markdown-for-agents-on-cloudflare-free-plan/">great post about this approach</a> for the Cloudflare Free plan.</p>
<p>We went simpler: a <strong>Cloudflare Cache Rule</strong> that bypasses cache entirely for markdown requests.</p>
<ul>
<li><strong>Rule name</strong>: &quot;Do not cache text/markdown requests&quot;</li>
<li><strong>Expression</strong>: <code>any(http.request.headers[&quot;accept&quot;][*] contains &quot;text/markdown&quot;)</code></li>
<li><strong>Action</strong>: Bypass cache</li>
</ul>
<p>Available on all plans, including Free. Why not the Transform Rule? Markdown requests are a tiny fraction of traffic. Bypassing the CDN for those has zero noticeable impact, and the rule is dead simple. Sometimes the boring solution is the right one.</p>
<h2 id="cache-layer-2-spatielaravel-responsecache">Cache layer 2: spatie/laravel-responsecache <a href="#cache-layer-2-spatielaravel-responsecache" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#cache-layer-2-spatielaravel-responsecache">#</a></h2>
<p>Fixed Cloudflare. Tested again. Still HTML. <em>What.</em></p>
<p>We use <a href="https://github.com/spatie/laravel-responsecache">spatie/laravel-responsecache</a> on our docs routes. First attempt: override <code>shouldCacheRequest()</code> to return <code>false</code> for markdown. Still HTML.</p>
<p>Here's why. The middleware does this on every request:</p>
<pre data-lang="txt" class="notranslate">if enabled() &amp;&amp; hasBeenCached($request)
    → return cached response          // our check never runs

if enabled() &amp;&amp; shouldCacheRequest($request)
    → store the fresh response         // only controls STORING
</pre>
<p><code>shouldCacheRequest()</code> only controls storing, not serving. The cached HTML was already returned before our override ever ran.</p>
<p>The fix: vary the cache key using <code>useCacheNameSuffix()</code>, the documented way to differentiate requests:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">useCacheNameSuffix</span>(<span class="hl-injection"><span class="hl-type">Request</span> $request</span>): <span class="hl-type">string</span>
{
    <span class="hl-variable">$suffix</span> = <span class="hl-keyword">parent</span>::<span class="hl-property">useCacheNameSuffix</span>(<span class="hl-variable">$request</span>);

    <span class="hl-keyword">if</span> (<span class="hl-property">str_contains</span>(<span class="hl-variable">$request</span>-&gt;<span class="hl-property">header</span>(<span class="hl-value">'Accept'</span>, <span class="hl-value">''</span>), <span class="hl-value">'text/markdown'</span>)) {
        <span class="hl-keyword">return</span> <span class="hl-variable">$suffix</span>.<span class="hl-value">'-markdown'</span>;
    }

    <span class="hl-keyword">return</span> <span class="hl-variable">$suffix</span>;
}
</pre>
<p>HTML and markdown now get separate cache entries. Both cached, both served correctly.</p>
<p>We also added a <code>Vary: Accept</code> middleware on the docs routes. Cloudflare ignores it on our plan, but it's correct HTTP semantics for browsers and other proxies.</p>
<h2 id="what-we-learned">What we learned <a href="#what-we-learned" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#what-we-learned">#</a></h2>
<ol>
<li><strong><code>Vary: Accept</code> doesn't work on Cloudflare Free/Pro.</strong> Use Cache Rules with <code>any(http.request.headers[&quot;accept&quot;][*] contains &quot;text/markdown&quot;)</code> instead.</li>
<li><strong><code>shouldCacheRequest()</code> in spatie/laravel-responsecache only controls storing, not serving.</strong> Use <code>useCacheNameSuffix()</code> to vary the cache key.</li>
<li><strong>Test with all cache layers enabled.</strong> Our local dev had response cache disabled, masking the bug entirely. Worked in dev, failed in prod. Classic.</li>
<li><strong>Caching bugs are sneaky.</strong> Valid response, wrong variant. No errors, no 500s, nothing in the logs.</li>
</ol>
<h2 id="try-it-out">Try it out <a href="#try-it-out" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#try-it-out">#</a></h2>
<pre data-lang="bash" class="notranslate"><span class="hl-comment"># Regular HTML</span>
<span class="hl-keyword">curl</span> <span class="hl-attribute">-I</span> https://ohdear.app/docs/api/monitors

<span class="hl-comment"># Markdown for AI agents</span>
<span class="hl-keyword">curl</span> <span class="hl-attribute">-H</span> <span class="hl-value">'Accept: text/markdown'</span> https://ohdear.app/docs/api/monitors

<span class="hl-comment"># Also works with .md suffix</span>
<span class="hl-keyword">curl</span> https://ohdear.app/docs/api/monitors.md
</pre>
<p>Our /llms.txt lists all available documentation. This works for feature docs, API docs, FAQ items, and blog posts. Everything a coding agent needs when integrating with Oh Dear.</p>
<p>If you're running Cloudflare + Laravel response cache and want to serve different content based on headers, hopefully this saves you the debugging time we spent on it.</p>
]]>
            </summary>
                                    <updated>2026-05-19T19:25:45+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Our 3-part series on SQL performance optimisations]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/our-3-part-series-on-sql-performance-optimisations" />
            <id>https://ohdear.app/176</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>A few weeks ago, we massively improved the performance of the Oh Dear dashboard and website by optimising our SQL queries. We documented everything we learned along the way in a 3-part series - from finding problematic queries, to fixing them with indexes, to preventing regressions with automated testing.</p>
<p>The same instinct drives our <a href="/features/performance-monitoring">performance monitoring</a>: find the regression before your users do.</p>
<p>Here's what each part covers:</p>
<h2 id="part-1-finding-the-right-queries-to-fix">Part 1: Finding the right queries to fix <a href="#part-1-finding-the-right-queries-to-fix" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#part-1-finding-the-right-queries-to-fix">#</a></h2>
<p>Before you can optimise anything, you need to know <em>what</em> to optimise. This post covers the tools and techniques we use to identify slow queries: Laravel's debug bar for local development, MySQL's slow query log, logging queries that don't use indexes, inspecting currently running queries, and using <code>pt-query-digest</code> to analyse query patterns. We also cover Laravel's built-in lazy loading prevention for catching N+1 issues early.</p>
<p><a href="/news-and-updates/sql-performance-improvements-finding-the-right-queries-to-fix-part-1">Read part 1: Finding the right queries to fix →</a></p>
<h2 id="part-2-analysing-amp-fixing-the-slow-queries">Part 2: Analysing &amp; fixing the slow queries <a href="#part-2-analysing-amp-fixing-the-slow-queries" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#part-2-analysing-amp-fixing-the-slow-queries">#</a></h2>
<p>Once you've found the problematic queries, how do you actually fix them? This post dives deep into MySQL indexes: how <code>EXPLAIN</code> works, what each output field means, how to read query execution plans, and how to design effective composite indexes. We cover the difference between full table scans, index scans, and index seeks - and show real examples of queries going from seconds to milliseconds with the right index.</p>
<p><a href="/news-and-updates/sql-performance-improvements-analysing-fixing-the-slow-queries-part-2">Read part 2: Analysing &amp; fixing the slow queries →</a></p>
<h2 id="part-3-automatic-detection-amp-regression-testing">Part 3: Automatic detection &amp; regression testing <a href="#part-3-automatic-detection-amp-regression-testing" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#part-3-automatic-detection-amp-regression-testing">#</a></h2>
<p>Fixing performance issues is one thing - keeping them fixed is another. This post introduces our open-source package <code>phpunit-query-count-assertions</code> that lets you assert SQL performance in your test suite. Catch N+1 queries, duplicate queries, and missing indexes automatically in CI before they reach production. We've eliminated over 15% of our SQL queries by catching duplicates that slipped through code review.</p>
<p><a href="/news-and-updates/sql-performance-improvements-automatic-detection-regression-testing-part-3">Read part 3: Automatic detection &amp; regression testing →</a></p>
<hr />
<p>Whether you're just starting to think about database performance or looking to prevent regressions in an already-optimised application, there's something in this series for you.</p>
]]>
            </summary>
                                    <updated>2026-01-25T18:46:04+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[SQL performance: automated detection & testing (part 3)]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/sql-performance-improvements-automatic-detection-regression-testing-part-3" />
            <id>https://ohdear.app/175</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>This is the final part of our 3-part series on SQL performance improvements. In <a href="/news-and-updates/sql-performance-improvements-finding-the-right-queries-to-fix-part-1">part 1</a>, we covered how to identify slow queries. In <a href="/news-and-updates/sql-performance-improvements-analysing-fixing-the-slow-queries-part-2">part 2</a>, we explored how to fix them with indexes. In this post, we'll share how we prevent those performance issues from ever reaching production again.</p>
<p>A few weeks ago, we <a href="/news-and-updates/speed-improvements-to-the-dashboard-website-job-processing">massively improved the performance</a> of the dashboard &amp; website by optimizing our SQL queries. The improvements were significant - but how do we make sure we don't accidentally undo all that work with future code changes?</p>
<p>In this post, you'll see:</p>
<ul>
<li><a href="#the-problem-performance-regressions-slip-through">The problem: performance regressions slip through</a></li>
<li><a href="#the-solution-test-driven-sql-performance">The solution: test-driven SQL performance</a></li>
<li><a href="#installing-the-package">Installing the package</a></li>
<li><a href="#quick-start-the-one-assertion-you-need">Quick start: the one assertion you need</a></li>
<li><a href="#detecting-duplicate-queries">Detecting duplicate queries</a></li>
<li><a href="#catching-n1-lazy-loading-issues">Catching N+1 / lazy loading issues</a></li>
<li><a href="#verifying-index-usage">Verifying index usage</a></li>
<li><a href="#query-count-thresholds">Query count thresholds</a></li>
<li><a href="#multi-connection-support">Multi-connection support</a></li>
<li><a href="#the-paranoid-mode-automatic-checks-on-every-test">The paranoid mode: automatic checks on every test</a></li>
<li><a href="#real-world-results-from-oh-dear">Real-world results from Oh Dear</a></li>
<li><a href="#what-this-wont-catch">What this won't catch</a></li>
</ul>
<p>Let's go!</p>
<h2 id="what-these-results-look-like">What these results look like <a href="#what-these-results-look-like" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#what-these-results-look-like">#</a></h2>
<p>Before diving into the tooling, here's what we've achieved by implementing automated SQL performance testing:</p>
<p>We eliminated <strong>over 15% of our SQL read queries</strong> by catching duplicate queries that slipped through manual code review. These weren't obvious N+1 loops - they were subtle cases where the same query was executed in different parts of the request lifecycle.</p>
<p><img src="/media/blog/8NvZI5C5xmZ4MDOIJmMSmgTPOpQUb5N4tDpIfxNn.png" alt="MySQL operations graph for dispatcher01.ohdearapp.com showing a green line steady around 5 Kops over time" /></p>
<p>We've caught index regressions in CI <em>before</em> they hit production. When a developer removes an index in a migration or writes a query that can't use existing indexes, the tests fail with a clear message explaining why.</p>
<p>The feedback loop is immediate: write code, run tests, see if your queries are efficient. No more waiting for production metrics to reveal problems days or weeks later.</p>
<h2 id="the-problem-performance-regressions-slip-through">The problem: performance regressions slip through <a href="#the-problem-performance-regressions-slip-through" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#the-problem-performance-regressions-slip-through">#</a></h2>
<p>Here's a scenario we've all experienced: you spend time optimising queries, ship the improvements, celebrate the faster response times - and then a few weeks later, performance starts degrading again.</p>
<p>Why? Because nothing was preventing new inefficient queries from being introduced. Code review helps, but reviewers don't always catch:</p>
<ul>
<li>Duplicate queries executed in different service classes</li>
<li>Lazy loading that only triggers with certain data combinations</li>
<li>Missing indexes on new columns added in migrations</li>
<li>Queries that worked fine on small datasets but don't scale</li>
</ul>
<p>The problem isn't fixing performance issues - we covered that in parts 1 and 2. The problem is <em>keeping</em> them fixed.</p>
<h2 id="the-solution-test-driven-sql-performance">The solution: test-driven SQL performance <a href="#the-solution-test-driven-sql-performance" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#the-solution-test-driven-sql-performance">#</a></h2>
<p>We built a package that lets us assert SQL performance characteristics in our test suite. When tests run (locally or in CI), they fail if queries are inefficient.</p>
<p>The package is called <code>phpunit-query-count-assertions</code> and it's <a href="https://github.com/mattiasgeniar/phpunit-query-count-assertions">available on GitHub</a>. We've been using it internally at Oh Dear for months, and it's caught more issues than we'd like to admit.</p>
<p>Here's the core idea: wrap your code in a tracking block, then assert that the queries were efficient.</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$this</span>-&gt;<span class="hl-property">trackQueries</span>();

<span class="hl-property">app</span>(<span class="hl-type">CertificateHealthChecker</span>::<span class="hl-keyword">class</span>)-&gt;<span class="hl-property">perform</span>(<span class="hl-variable">$run</span>);

<span class="hl-variable">$this</span>-&gt;<span class="hl-property">assertQueriesAreEfficient</span>();
</pre>
<p>That single assertion checks for N+1 queries, duplicate queries, and missing indexes - all at once.</p>
<h2 id="installing-the-package">Installing the package <a href="#installing-the-package" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#installing-the-package">#</a></h2>
<p>The package requires PHP 8.2+ and Laravel 11 or 12. Install it as a dev dependency:</p>
<pre data-lang="bash" class="notranslate">$ <span class="hl-keyword">composer</span> require <span class="hl-attribute">--dev</span> mattiasgeniar/phpunit-query-count-assertions
</pre>
<p>Then add the trait to your test class:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">use</span> <span class="hl-type">Mattiasgeniar\PhpunitQueryCountAssertions\AssertsQueryCounts</span>;

<span class="hl-keyword">class</span> <span class="hl-type">CertificateHealthCheckTest</span> <span class="hl-keyword">extends</span> <span class="hl-type">TestCase</span>
{
    <span class="hl-keyword">use</span> <span class="hl-type">AssertsQueryCounts</span>;
}
</pre>
<p>That's it. No configuration files, no service providers - just a trait.</p>
<h2 id="quick-start-the-one-assertion-you-need">Quick start: the one assertion you need <a href="#quick-start-the-one-assertion-you-need" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#quick-start-the-one-assertion-you-need">#</a></h2>
<p>For most cases, <code>assertQueriesAreEfficient()</code> is all you need. It combines three checks into one:</p>
<ol>
<li><strong>No lazy loading</strong> - catches N+1 queries</li>
<li><strong>No duplicate queries</strong> - same query shouldn't run twice</li>
<li><strong>All queries use indexes</strong> - no full table scans</li>
</ol>
<p>Here's a real example from our certificate health checker tests:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">test_health_checker_is_efficient</span>(): <span class="hl-type">void</span>
{
    <span class="hl-comment">// Setup - create test data (these queries aren't tracked)</span>
    <span class="hl-variable">$certificate</span> = <span class="hl-type">Certificate</span>::<span class="hl-property">factory</span>()-&gt;<span class="hl-property">expired</span>()-&gt;<span class="hl-property">create</span>();
    <span class="hl-variable">$run</span> = <span class="hl-keyword">new</span> <span class="hl-type">InMemoryRun</span>();

    <span class="hl-comment">// Track only the code under test</span>
    <span class="hl-variable">$this</span>-&gt;<span class="hl-property">trackQueries</span>();
    <span class="hl-property">app</span>(<span class="hl-type">CertificateHealthChecker</span>::<span class="hl-keyword">class</span>)-&gt;<span class="hl-property">perform</span>(<span class="hl-variable">$run</span>);
    <span class="hl-variable">$this</span>-&gt;<span class="hl-property">assertQueriesAreEfficient</span>();
}
</pre>
<p>The key insight: call <code>trackQueries()</code> <em>after</em> your test setup. Factory calls and seeders execute queries too - we don't want those triggering false positives.</p>
<p>When something fails, you get actionable output:</p>
<pre data-lang="txt" class="notranslate">Query efficiency issues detected:

Lazy loading violations detected:
Violations:
  1. App\Models\Certificate::$site
  2. App\Models\Certificate::$site

Duplicate queries detected:
  1. Executed 2 times: SELECT * FROM sites WHERE id = ?
     Bindings: [42]
     Locations:
       #1: app/Checkers/CertificateHealthChecker.php:67
       #2: app/Checkers/CertificateHealthChecker.php:89
</pre>
<p>The locations tell you exactly where each query was triggered - no more guessing.</p>
<h2 id="detecting-duplicate-queries">Detecting duplicate queries <a href="#detecting-duplicate-queries" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#detecting-duplicate-queries">#</a></h2>
<p>Duplicate query detection is surprisingly powerful. It catches cases that aren't traditional N+1 loops but still waste database resources.</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$this</span>-&gt;<span class="hl-property">assertNoDuplicateQueries</span>(<span class="hl-keyword">function</span> () {
    <span class="hl-comment">// This code executes the same query twice</span>
    <span class="hl-variable">$user</span> = <span class="hl-type">User</span>::<span class="hl-property">find</span>(1);

    <span class="hl-comment">// ... 50 lines of other code ...</span>

    <span class="hl-variable">$sameUser</span> = <span class="hl-type">User</span>::<span class="hl-property">find</span>(1); <span class="hl-comment">// Duplicate!</span>
});
</pre>
<p>The assertion considers bindings: <code>User::find(1)</code> and <code>User::find(2)</code> are <em>different</em> queries. But <code>User::find(1)</code> called twice is a duplicate.</p>
<p>When we first enabled this at Oh Dear, we found duplicate queries we'd missed in code review. The same <code>Monitor</code> model was being loaded in three different places during a single request - each time fetching from the database instead of reusing the already-loaded instance.</p>
<p>Fixing these duplicates removed over 15% of our SELECT queries.</p>
<h2 id="catching-n1-lazy-loading-issues">Catching N+1 / lazy loading issues <a href="#catching-n1-lazy-loading-issues" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#catching-n1-lazy-loading-issues">#</a></h2>
<p>N+1 queries are the classic performance killer. You load a list of models, then loop through them accessing a relationship - triggering a new query for each iteration.</p>
<pre data-lang="php" class="notranslate"><span class="hl-comment">// This will fail</span>
<span class="hl-variable">$this</span>-&gt;<span class="hl-property">assertNoLazyLoading</span>(<span class="hl-keyword">function</span> () {
    <span class="hl-variable">$sites</span> = <span class="hl-type">Site</span>::<span class="hl-property">all</span>();

    <span class="hl-keyword">foreach</span> (<span class="hl-variable">$sites</span> <span class="hl-keyword">as</span> <span class="hl-variable">$site</span>) {
        <span class="hl-keyword">echo</span> <span class="hl-variable">$site</span>-&gt;<span class="hl-property">checks</span>-&gt;<span class="hl-property">count</span>(); <span class="hl-comment">// N+1 query!</span>
    }
});
</pre>
<p>The fix is eager loading:</p>
<pre data-lang="php" class="notranslate"><span class="hl-comment">// This will pass</span>
<span class="hl-variable">$this</span>-&gt;<span class="hl-property">assertNoLazyLoading</span>(<span class="hl-keyword">function</span> () {
    <span class="hl-variable">$sites</span> = <span class="hl-type">Site</span>::<span class="hl-property">with</span>(<span class="hl-value">'checks'</span>)-&gt;<span class="hl-property">get</span>();

    <span class="hl-keyword">foreach</span> (<span class="hl-variable">$sites</span> <span class="hl-keyword">as</span> <span class="hl-variable">$site</span>) {
        <span class="hl-keyword">echo</span> <span class="hl-variable">$site</span>-&gt;<span class="hl-property">checks</span>-&gt;<span class="hl-property">count</span>();
    }
});
</pre>
<p>The package uses Laravel's built-in lazy loading prevention under the hood, so it catches exactly what Laravel would catch - but only within your test assertion, not globally.</p>
<p>Ps; if you only need the count, <code>Site::withCount('checks')-&gt;get()</code> is even more efficient since it doesn't load the related models at all.</p>
<h2 id="verifying-index-usage">Verifying index usage <a href="#verifying-index-usage" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#verifying-index-usage">#</a></h2>
<p>This is where it gets interesting. The package runs <code>EXPLAIN</code> on your queries and checks for performance red flags.</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$this</span>-&gt;<span class="hl-property">assertAllQueriesUseIndexes</span>(<span class="hl-keyword">function</span> () {
    <span class="hl-type">Site</span>::<span class="hl-property">where</span>(<span class="hl-value">'team_id'</span>, 1)-&gt;<span class="hl-property">get</span>();
});
</pre>
<p>If <code>team_id</code> doesn't have an index, you'll see:</p>
<pre data-lang="txt" class="notranslate">Queries with index issues detected:

  1. SELECT * FROM sites WHERE team_id = ?
     Bindings: [1]
     Issues:
       - [ERROR] Full table scan on 'sites'
     Locations:
       #1: tests/Feature/SiteTest.php:42
</pre>
<p>The severity levels help prioritise:</p>
<table>
<thead>
<tr>
<th>Severity</th>
<th>Meaning</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>[ERROR]</code></td>
<td>Critical: full table scans, unused available indexes</td>
</tr>
<tr>
<td><code>[WARNING]</code></td>
<td>Potential issue: filesort, temporary tables</td>
</tr>
<tr>
<td><code>[INFO]</code></td>
<td>Informational: low filter efficiency (non-failing)</td>
</tr>
</tbody>
</table>
<h3 id="what-gets-detected">What gets detected</h3>
<p>For MySQL and MariaDB:</p>
<ul>
<li>Full table scans (<code>type=ALL</code>)</li>
<li>Full index scans (<code>type=index</code>)</li>
<li>Index available but not used</li>
<li>Using filesort</li>
<li>Using temporary tables</li>
<li>Using join buffer (missing index for joins)</li>
</ul>
<p>For SQLite (useful for fast local tests):</p>
<ul>
<li>Full table scans (<code>SCAN table</code>)</li>
<li>Temporary B-tree usage</li>
<li>Foreign key constraint check scans</li>
</ul>
<h3 id="small-table-optimisation">Small table optimisation</h3>
<p>Caution: full table scans on tiny tables (&lt; 10 rows) are often <em>faster</em> than index lookups. The package ignores these by default - MySQL's optimiser knows what it's doing on small datasets.</p>
<p>If you need to adjust this threshold:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">use</span> <span class="hl-type">Mattiasgeniar\PhpunitQueryCountAssertions\QueryAnalysers\MySQLAnalyser</span>;

<span class="hl-type">self</span>::<span class="hl-property">registerQueryAnalyser</span>(
    (<span class="hl-keyword">new</span> <span class="hl-type">MySQLAnalyser</span>)-&gt;<span class="hl-property">withMinRowsForScanWarning</span>(100)
);
</pre>
<h2 id="query-count-thresholds">Query count thresholds <a href="#query-count-thresholds" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#query-count-thresholds">#</a></h2>
<p>Sometimes you want precise control over how many queries run:</p>
<pre data-lang="php" class="notranslate"><span class="hl-comment">// Exact count</span>
<span class="hl-variable">$this</span>-&gt;<span class="hl-property">assertQueryCountMatches</span>(3, <span class="hl-keyword">fn</span>() =&gt; <span class="hl-variable">$this</span>-&gt;<span class="hl-property">loadDashboard</span>());

<span class="hl-comment">// Upper bound - useful for &quot;don't make this worse&quot;</span>
<span class="hl-variable">$this</span>-&gt;<span class="hl-property">assertQueryCountLessThan</span>(10, <span class="hl-keyword">fn</span>() =&gt; <span class="hl-variable">$this</span>-&gt;<span class="hl-property">loadDashboard</span>());

<span class="hl-comment">// Range</span>
<span class="hl-variable">$this</span>-&gt;<span class="hl-property">assertQueryCountBetween</span>(5, 15, <span class="hl-keyword">fn</span>() =&gt; <span class="hl-variable">$this</span>-&gt;<span class="hl-property">complexOperation</span>());

<span class="hl-comment">// No queries at all (cached responses)</span>
<span class="hl-variable">$this</span>-&gt;<span class="hl-property">assertNoQueriesExecuted</span>(<span class="hl-keyword">fn</span>() =&gt; <span class="hl-variable">$this</span>-&gt;<span class="hl-property">getCachedData</span>());
</pre>
<p>When assertions fail, you see exactly what ran:</p>
<pre data-lang="txt" class="notranslate">Expected 3 queries, got 5.
Queries executed:
  1. [0.45ms] SELECT * FROM users WHERE id = ?
      Bindings: [1]
      Locations:
        #1: app/Services/Dashboard.php:42
  2. [0.32ms] SELECT * FROM sites WHERE user_id = ?
      ...
</pre>
<p>The timing information helps identify which queries are worth optimising - a 0.3ms query that runs once matters less than a 0.1ms query that runs 100 times.</p>
<h2 id="multi-connection-support">Multi-connection support <a href="#multi-connection-support" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#multi-connection-support">#</a></h2>
<p>If your application uses read replicas, separate analytics databases, or tenant-specific connections, you'll appreciate this: <code>trackQueries()</code> captures queries from <strong>all database connections</strong> by default.</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$this</span>-&gt;<span class="hl-property">trackQueries</span>();

<span class="hl-type">DB</span>::<span class="hl-property">select</span>(<span class="hl-value">'SELECT 1'</span>);                         <span class="hl-comment">// Tracked</span>
<span class="hl-type">DB</span>::<span class="hl-property">connection</span>(<span class="hl-value">'replica'</span>)-&gt;<span class="hl-property">select</span>(<span class="hl-value">'SELECT 2'</span>);  <span class="hl-comment">// Also tracked</span>

<span class="hl-variable">$queries</span> = <span class="hl-type">self</span>::<span class="hl-property">getQueriesExecuted</span>();
<span class="hl-comment">// $queries[0]['connection'] === 'mysql'</span>
<span class="hl-comment">// $queries[1]['connection'] === 'replica'</span>
</pre>
<p>Each tracked query includes its connection name, so you can verify queries are hitting the right database.</p>
<h3 id="filtering-to-specific-connections">Filtering to specific connections</h3>
<p>Sometimes you want to focus on just one connection - maybe your test setup runs migrations on a different connection that you don't want to count:</p>
<pre data-lang="php" class="notranslate"><span class="hl-comment">// Track only the replica connection</span>
<span class="hl-variable">$this</span>-&gt;<span class="hl-property">trackQueries</span>(<span class="hl-value">'replica'</span>);

<span class="hl-comment">// Track multiple specific connections</span>
<span class="hl-variable">$this</span>-&gt;<span class="hl-property">trackQueries</span>([<span class="hl-value">'mysql'</span>, <span class="hl-value">'replica'</span>]);
</pre>
<p>This is useful when:</p>
<ul>
<li>Your test setup runs queries on different connections that you don't want to count</li>
<li>You want to verify that specific queries go to the right connection</li>
<li>You're debugging connection routing in read/write split setups</li>
</ul>
<p>At Oh Dear, we generally track <em>all</em> queries, across all connections.</p>
<h2 id="the-paranoid-mode-automatic-checks-on-every-test">The paranoid mode: automatic checks on every test <a href="#the-paranoid-mode-automatic-checks-on-every-test" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#the-paranoid-mode-automatic-checks-on-every-test">#</a></h2>
<p>If you want to enforce query efficiency across your entire test suite, you can enable automatic checking in <code>beforeEach</code> and <code>afterEach</code> hooks.</p>
<p>For Pest (in <code>tests/Pest.php</code>):</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">use</span> <span class="hl-type">Mattiasgeniar\PhpunitQueryCountAssertions\AssertsQueryCounts</span>;

<span class="hl-property">pest</span>()-&gt;<span class="hl-property">extend</span>(<span class="hl-type">Tests\TestCase</span>::<span class="hl-keyword">class</span>)
    -&gt;<span class="hl-property">use</span>(<span class="hl-type">AssertsQueryCounts</span>::<span class="hl-keyword">class</span>)
    -&gt;<span class="hl-property">beforeEach</span>(<span class="hl-keyword">fn</span> () =&gt; <span class="hl-type">self</span>::<span class="hl-property">trackQueries</span>())
    -&gt;<span class="hl-property">afterEach</span>(<span class="hl-keyword">fn</span> () =&gt; <span class="hl-variable">$this</span>-&gt;<span class="hl-property">assertQueriesAreEfficient</span>())
    -&gt;<span class="hl-property">in</span>(<span class="hl-value">'Feature'</span>);
</pre>
<p>For PHPUnit (in your base test class):</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">abstract</span> <span class="hl-keyword">class</span> <span class="hl-type">TestCase</span> <span class="hl-keyword">extends</span> <span class="hl-type">BaseTestCase</span>
{
    <span class="hl-keyword">use</span> <span class="hl-type">AssertsQueryCounts</span>;

    <span class="hl-keyword">protected</span> <span class="hl-keyword">function</span> <span class="hl-property">setUp</span>(): <span class="hl-type">void</span>
    {
        <span class="hl-keyword">parent</span>::<span class="hl-property">setUp</span>();
        <span class="hl-variable">$this</span>-&gt;<span class="hl-property">trackQueries</span>();
    }

    <span class="hl-keyword">protected</span> <span class="hl-keyword">function</span> <span class="hl-property">tearDown</span>(): <span class="hl-type">void</span>
    {
        <span class="hl-variable">$this</span>-&gt;<span class="hl-property">assertQueriesAreEfficient</span>();
        <span class="hl-keyword">parent</span>::<span class="hl-property">tearDown</span>();
    }
}
</pre>
<p>I know some will #YOLO this on their entire test suite - don't @ me when hundreds of tests suddenly fail. Consider starting with a subset of critical tests first. Keep in mind this might trigger warnings for queries executed purely during the <em>setup</em> and <em>seeding</em> of the tests, although you can make an argument efficiency matters then, too.</p>
<h2 id="real-world-results-from-oh-dear">Real-world results from Oh Dear <a href="#real-world-results-from-oh-dear" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#real-world-results-from-oh-dear">#</a></h2>
<p>Here's what we've caught since implementing this:</p>
<p><strong>Duplicate query in the Scheduled Task Checker</strong>: Our scheduled task checker called <code>-&gt;refresh()</code> twice on the main model, to ensure we operate on <em>all</em> the received pings in our checker. This was a legacy from our older setup, before we refactored it to our new setup more than a year ago. The <code>refresh()</code> was needed in the old system, not our new one, so we could safely clean it up. This is hard to spot, because the code never <em>broke</em>, it was just being a little too aggressive in refreshing state.</p>
<p><strong>N+1 in certificate expiry notifications</strong>: When sending expiry warnings, we were lazy-loading the team for each certificate instead of eager loading. Only triggered when multiple certificates expired simultaneously - exactly the kind of edge case that slips through manual testing.</p>
<p>The pattern is clear: these aren't bugs that crash the application. They're inefficiencies that slowly accumulate until someone notices &quot;the app feels slower than it used to.&quot;</p>
<h2 id="what-this-wont-catch">What this won't catch <a href="#what-this-wont-catch" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#what-this-wont-catch">#</a></h2>
<p>Let's be honest about the limitations. This package catches a lot, but it's not a silver bullet.</p>
<p><strong>Small dataset blindspot</strong>: By default, the package ignores full table scans on tables with fewer than 10 rows. MySQL's optimiser often <em>chooses</em> to scan tiny tables because it's faster than an index lookup. This means your tests - which typically have small datasets - might pass while production (with millions of rows) suffers.</p>
<p><strong>EXPLAIN isn't execution</strong>: The index analysis runs <code>EXPLAIN</code> on your queries, which shows MySQL's <em>plan</em>, not what actually happened. In rare cases, the plan and reality diverge - especially with complex joins or when statistics are stale.</p>
<p><strong>Timing is unreliable in tests</strong>: Query timing assertions (<code>assertMaxQueryTime</code>) work, but test databases are usually local SSDs with no contention. A query that runs in 5ms locally might take 500ms in production under load.</p>
<p><strong>You need representative test scenarios</strong>: If your test only creates one user with one site, you won't catch the N+1 that only triggers when a user has multiple sites. The package can only catch what your tests actually exercise.</p>
<p>That said, we've found it catches roughly 80% of performance issues before they reach production. The remaining 20% still need production monitoring - but that's a much smaller haystack to search through.&lt;@</p>
<h2 id="wrapping-up">Wrapping up <a href="#wrapping-up" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#wrapping-up">#</a></h2>
<p>The three-part journey is complete:</p>
<ol>
<li><strong>Part 1</strong>: <a href="/news-and-updates/sql-performance-improvements-finding-the-right-queries-to-fix-part-1">Find slow queries using debug bars, slow query logs, and process monitoring</a></li>
<li><strong>Part 2</strong>: <a href="/news-and-updates/sql-performance-improvements-analysing-fixing-the-slow-queries-part-2">Fix them with proper indexes</a></li>
<li><strong>Part 3</strong>: <a href="/news-and-updates/sql-performance-improvements-automatic-detection-regression-testing-part-3">Keep them fixed with automated testing</a></li>
</ol>
<p>The package is open source and available at <a href="https://github.com/mattiasgeniar/phpunit-query-count-assertions">github.com/mattiasgeniar/phpunit-query-count-assertions</a>. We'd love to hear how it works for your applications.</p>
<p>Automated regression testing pairs naturally with continuous <a href="/features/performance-monitoring">performance monitoring</a> in production.</p>
<h2 id="feedback">Feedback? <a href="#feedback" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#feedback">#</a></h2>
<p>If this article contains any errors or if it should clarify certain sections more, do reach out via either mattias@ohdear.app or ping me via <a href="https://x.com/mattiasgeniar">@mattiasgeniar</a> or our <a href="https://x.com/ohdearapp">@OhDearApp</a> account. Any feedback is appreciated!</p>
<p>Curious to hear about performance issues you've caught with automated testing that we don't yet know about!</p>
]]>
            </summary>
                                    <updated>2026-06-12T08:58:01+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[The effects of the Cloudflare outage on Oh Dear]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/the-effects-of-the-cloudflare-outage-on-oh-dear" />
            <id>https://ohdear.app/174</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>Last night, Cloudflare <a href="https://www.cloudflarestatus.com/incidents/8gmgl950y3h7">experienced one of their biggest outages</a> in the past decade. Almost all websites behind Cloudflare's proxy/DDoS-protection service were offline for anywhere between 1–3 hours. That included our own <a href="https://ohdear.app">website &amp; application</a>, while our <a href="https://status.ohdear.app/">status pages</a> remained online the entire time.</p>
<p>This post examines some of our lessons learned, and we'll share our insights into what we saw during the outage.</p>
<h2 id="cloudflare-a-net-positive-for-our-industry">Cloudflare, a net positive for our industry <a href="#cloudflare-a-net-positive-for-our-industry" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#cloudflare-a-net-positive-for-our-industry">#</a></h2>
<p>Let me start by chiming in on the public debate: has the internet decided to centralize too much around a few key players (Cloudflare, AWS, Google, ...)? Probably.</p>
<p>Should we now all move away from these big players to <em>decentralize</em> the web again? Probably not.</p>
<p>Despite Cloudflare's outage yesterday, I firmly believe they're a net positive for our industry, advancing the protection, speed, and availability of websites globally. <a href="/uptime-statistics">Any major provider will experience downtime</a>. I'm confident there will be a solid and transparent post-mortem of this incident with many lessons learned to prevent this from happening again. (<strong>Update</strong>: Cloudflare's <a href="https://blog.cloudflare.com/18-november-2025-outage/">post-mortem on the incident is now available</a>)</p>
<p>So no, we won't suddenly move everything away from Cloudflare in a knee-jerk reaction, but we will be conscious to have procedures and workflows readily available to <em>temporarily</em> move away from Cloudflare when there is prolonged downtime.</p>
<p>Now, on to the real stuff!</p>
<h2 id="our-web-ui-was-offline-our-status-pages-amp-monitoring-werent">Our Web UI was offline, our status pages &amp; monitoring weren't <a href="#our-web-ui-was-offline-our-status-pages-amp-monitoring-werent" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#our-web-ui-was-offline-our-status-pages-amp-monitoring-werent">#</a></h2>
<p>Oh Dear itself sits behind Cloudflare. We've had the bad luck of experiencing some DDoS attacks a few years ago and have since opted into Cloudflare's Pro plan for better protection against this. As a result, during the outage, this was the availability of our services:</p>
<ul>
<li>Website: ❌</li>
<li>Dashboard: ❌</li>
<li>API: ❌</li>
<li>Monitoring engine: ✅</li>
<li>Notification engine: ✅</li>
<li>Status pages: ✅</li>
</ul>
<p>The problem we (and our clients) were facing: while status pages were online, our users couldn't post any updates to them because they couldn't log in to the main application that has the interface to manage those status pages.</p>
<p>This essentially turned status pages—unless users opted into the <a href="/docs/status-pages/create-your-first-oh-dear-status-page#auto-updates-based-on-uptime-monitoring">auto-updates based on our uptime monitors</a>—into a stale and outdated communication channel. Only when our dashboard services were restored could our users start posting updates.</p>
<p>All our actual monitoring &amp; notification engines remained online and performant at all times.</p>
<h2 id="temporarily-disabling-cloudflare">Temporarily disabling Cloudflare <a href="#temporarily-disabling-cloudflare" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#temporarily-disabling-cloudflare">#</a></h2>
<p>Because of this, we temporarily disabled Cloudflare for our website so that our users could access our service again.</p>
<p>This turned out to take slightly more time because the Cloudflare Dashboard itself was offline during their outage (because <code>challenges.cloudflare.com</code>, their &quot;I'm not a Robot&quot; service, was offline and prevented logins to their dashboard). Only once the CF dashboard was available again could we disable proxying through Cloudflare.</p>
<p><strong>Lesson learned</strong>: we need a readily available DNS backup at our domain registry, allowing us to more easily switch nameservers away from Cloudflare to a third party in the case of an emergency. We have backups of our DNS records, but there are <em>many</em> of them, and the process to restore those was still manual and too time-consuming.</p>
<h2 id="our-ping-endpoint-stays-behind-cloudflare">Our ping endpoint stays behind Cloudflare <a href="#our-ping-endpoint-stays-behind-cloudflare" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#our-ping-endpoint-stays-behind-cloudflare">#</a></h2>
<p>Users of our <a href="/docs/features/cron-job-monitoring">cron job/scheduled task monitoring</a> might have noticed notifications about missed scheduled tasks because the callback URL <code>ping.ohdear.app</code> is also routed through Cloudflare.</p>
<p>This was also unavailable during the outage. We do, however, have internal systems that prevented ~99% of the false alerts related to those cron job monitors from being sent.</p>
<p>Every 4 seconds, we ping our own endpoint and expect the result to be processed in the following seconds. This system caught the unavailability of the ping endpoint early on and muted most of the alerts for our clients. Only during the recovery/early downtime—when our system hadn't caught this yet—have we sent some notifications to users.</p>
<p><strong>Lessons learned</strong>: our internal system worked well for this particular use case, but we'll investigate options around service recovery times to ensure we don't send notifications that were <em>just</em> on the edge of the Cloudflare downtime.</p>
<h2 id="increased-system-load-for-downtime-verifications">Increased system load for downtime verifications <a href="#increased-system-load-for-downtime-verifications" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#increased-system-load-for-downtime-verifications">#</a></h2>
<p>The <em>happy path</em> of uptime monitoring is fairly straightforward: make an <a href="/features/website-uptime-monitoring">HTTP</a> (or <a href="/features/ping-monitoring">ping</a> or <a href="/features/tcp-port-monitoring">TCP</a>) request; if the service responds as expected, the service is considered up. All is well.</p>
<p>The <em>unhappy path</em>, where we detect downtime, is more resource-intensive: we'll do that <em>exact same</em> monitor request but route it via a different set of servers (technically: a different datacenter provider but within the same geographical continent) and expect that to <em>also</em> confirm the downtime.</p>
<p>This means that downtime detection <em>doubles our system load</em> because we can no longer just do a single check to confirm something is up; we need a second verification.</p>
<p>We have around 50+ servers worldwide, here's a screenshot of the network-throughput of one of our <em>satellite</em> servers:</p>
<p><img src="/media/blog/zM3c9x5439OjYdPKTneM3wUXikrRA1QTFcj1KUYA.png" alt="Network traffic graph for interface enp1s0 with incoming traffic in green spiking up to about 2.8 Mbps" /></p>
<p>In our capacity planning, we take this into account: our systems aren't running at their limits and always have the capacity to double their throughput at any moment in time, especially for these circumstances.</p>
<p><strong>Lessons learned</strong>: there's a reason we keep 50% spare capacity at all times—this incident shows why. We'll keep doing so.</p>
<h2 id="a-spike-in-notifications-sent">A spike in notifications sent <a href="#a-spike-in-notifications-sent" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#a-spike-in-notifications-sent">#</a></h2>
<p>It makes sense that when Cloudflare goes down, we'll start notifying <em>a lot</em> of our users that their website is down. Yesterday can be considered a stress test on our own systems as well, and I'm glad to see our choices in architecture have ensured that this was a smooth process.</p>
<p>At any given time, some of the websites we monitor are down, and we'll be notifying those users. Yesterday was a bit different, though.</p>
<p>For any of the following data, timestamps are shown <strong>in UTC</strong>.</p>
<p><img src="/media/blog/FmuAgKQxxPjrj0yyPPMq6A5Uwo9XQTXzuT5YqMAQ.png" alt="Downtime notifications sent per second graph spiking above 1500 between 11:30 and 14:30 during the outage" /></p>
<p>At the peak, we were sending ~1,500 notifications <em>per second</em>. This is a mix of channels—some via email, some Slack, some Discord, etc.—depending on the settings of our users.</p>
<p>The downtime wasn't consistent either, so during the same period we also sent out a lot of recovery notifications (only to send new downtime notifications a few minutes later when the service was disrupted again):</p>
<p><img src="/media/blog/lJrIwNoUMGJAyKXu2klO2YuHRghB8ovERz1IaWBx.png" alt="Recovery notifications sent per second graph with repeated spikes near 1700 between 11:30 and 14:30" /></p>
<p>During our peak, we observed at most a 1- to 2-minute delay between <em>detecting</em> the downtime and <em>sending</em> the notification because our queues were being hit much harder than ever before. We quickly scaled up capacity for processing outbound notifications and swiftly processed all notifications in &lt; 1 minute again.</p>
<p>We also monitor our own service with some of our competitor tooling, and we observed some of their email notifications only arriving <em>more than an hour later</em> than the actual incident. We're proud to say that wasn't the case for us.</p>
<p><strong>Lessons learned</strong>: our architecture allows for sending out many notifications in a short period of time with minimal disruption.</p>
<h2 id="where-did-our-users-get-notified-the-most">Where did our users get notified the most? <a href="#where-did-our-users-get-notified-the-most" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#where-did-our-users-get-notified-the-most">#</a></h2>
<p>We support a myriad of notification options, so where did we send most notifications during this outage?</p>
<p><img src="/media/blog/Wd0JUD9VbG3fVXdftExXBc8F21dCxOWiwnN5Olc9.png" alt="Downtime notifications per type stacked graph showing microsoftTeams in purple dominating the volume during the outage" /></p>
<p>In order of popularity, we sent out most notifications over:</p>
<ul>
<li>mail: 54.0%</li>
<li>slackApi: 18.8%</li>
<li>sms: 5.1%</li>
<li>telegram: 4.4%</li>
<li>discord: 3.8%</li>
</ul>
<p>The others are in single digits. Email being the most popular makes sense, as it's the most accessible <em>and</em> it's the one we enable by default if someone signs up to try Oh Dear. The bump in email delivery is also clear from our email delivery stats:</p>
<p><img src="/media/blog/6PaoHwyAOGqHzLyagAxUw3RXRoJBx4KbwokUNdTH.png" alt="Daily metric area chart staying flat from Oct 22 to Nov 17 then spiking sharply around Nov 18, 2025" /></p>
<h2 id="in-closing">In closing <a href="#in-closing" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#in-closing">#</a></h2>
<p>There are a few lessons learned from this incident, but we're proud to see how our service performed during a real-life stress test. We can implement some improvements over the coming weeks and prepare even better for a major outage. With the centralization of more of the web, we should prepare for downtime events to potentially have <em>much bigger ripple effects</em> that could affect more services at the same time.</p>
<p>Through our support channels, we heard some of our users request new features, such as:</p>
<ul>
<li>Mass-muting of all notifications during an outage like this (initiated by the user)</li>
<li>Out-of-band access to Status Pages for managing updates/incidents</li>
<li>An in-app banner highlighting a worldwide issue of downtime</li>
</ul>
<p>All of these are under consideration. If you missed crucial functionality during this outage, we'd love to hear from you!</p>
]]>
            </summary>
                                    <updated>2026-07-06T10:17:01+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[SQL performance: analysing & fixing slow queries (part 2)]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/sql-performance-improvements-analysing-fixing-the-slow-queries-part-2" />
            <id>https://ohdear.app/167</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p><em>This is <a href="/news-and-updates/sql-performance-improvements-finding-the-right-queries-to-fix-part-1">part 2 of a 3-part series on SQL performance improvements</a>.</em></p>
<p>A few weeks ago, we <a href="/news-and-updates/speed-improvements-to-the-dashboard-website-job-processing">massively improved the performance</a> of the dashboard &amp; website by optimizing some of our SQL queries. In this post, we'll dive deeper into the optimisations of queries with indexes.</p>
<p>Table of contents:</p>
<ul>
<li><a href="#ways-to-improve-database-performance">Ways to improve database performance</a></li>
<li><a href="#how-database-indexes-work">How database indexes work</a></li>
<li><a href="#let-mysql-explain-why-the-query-is-slow">Let MySQL explain why a query is slow</a></li>
<li><a href="#different-explain-output">Different EXPLAIN output</a></li>
<li><a href="#adding-indexes">Adding indexes</a></li>
<li><a href="#multiple-indexes-vs-composite-indexes">Multiple indexes vs. composite indexes</a></li>
<li><a href="#the-order-of-the-index-vs-the-order-of-the-where-statement">The order of the index vs. the order of the WHERE statement</a></li>
<li><a href="#null-values-in-indexes">NULL values in indexes</a></li>
<li><a href="#what-performance-gains-to-expect">What performance gains to expect</a></li>
<li><a href="#random-trivia-on-indexes">Random trivia on indexes</a></li>
<li><a href="#the-ai-way-how-llms-can-help">The AI way: how LLMs can help</a></li>
</ul>
<p>Let's go!</p>
<h2 id="what-these-results-look-like">What these results look like <a href="#what-these-results-look-like" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#what-these-results-look-like">#</a></h2>
<p>As a reminder, this is the resulting performance gain for the dashboard &amp; some of our internal APIs:</p>
<p><img src="/media/blog/RF9LvvgCQU1skDedrUkm0l6CEfuG7qTJ8Bu0XklO.png" alt="Oh Dear performance history graph over several days with 726ms highest, 184ms lowest and 413ms average response times" /></p>
<p><img src="/media/blog/1HEzbWpII2U7qyCcl9f708IdWFgFDeGUVcYhqNZ1.png" alt="Oh Dear performance history graph dropping from around 280ms average to a flat low baseline after a spike to 896ms" /></p>
<p>These graphs come from the <a href="/features/website-uptime-monitoring">Oh Dear uptime monitoring we perform</a>. Behind the scenes, we also use <a href="https://www.zabbix.com/">Zabbix</a> for low-level server monitoring, where we get extra insights into the performance of our servers.</p>
<p>The optimisations have lead to an increase in network bandwidth between our SQL server &amp; the rest of our infrastructure. Normally an increase like this would be cause for concern, but in our scenario it's because we're now able to perform all checks faster and complete our queues at a much faster pace, so that we're able to utilize the network bandwidth more.</p>
<p><img src="/media/blog/jMUegmXocf8CMVHYV05ewTJFqtGJeS6Wxsj4ZFrI.png" alt="Network traffic graph on bond-wan with outgoing traffic jumping from about 100 Mbps to over 200 Mbps" /></p>
<p>As a result of our improvements, we were able to almost double the SELECT's (green line) we're able to do concurrently.</p>
<p><img src="/media/blog/5ojyRfstZmp4602ROM5xog1EjCuvMm8gGzIwcSS8.png" alt="MySQL operations graph showing the green query rate rising from about 4 Kops to 6 Kops after a spike" /></p>
<p>This also gives us a bit of context: at a constant load of ~6,000 SELECT's per second, around 1,000 INSERT's, another 1,000 UPDATE's and ~600 DELETE's per second, we've got ourselves a busy system.</p>
<p>So then, what did we do to get these numbers?</p>
<h2 id="ways-to-improve-database-performance">Ways to improve database performance <a href="#ways-to-improve-database-performance" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#ways-to-improve-database-performance">#</a></h2>
<p>In general, speeding up SQL queries can be done in 4 main ways;</p>
<ol>
<li>Avoiding the query altogether (resolving N+1 loops, introducing caching, etc.)</li>
<li>Rewriting the query to be more performant (different joins, fewer subqueries, etc)</li>
<li>Get faster hardware</li>
<li>Adding indexes to the table to speed up data lookups</li>
</ol>
<p>This post focusses mostly on the last point: the indexes, the hidden weapon of any database system.</p>
<h2 id="how-database-indexes-work">How database indexes work <a href="#how-database-indexes-work" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#how-database-indexes-work">#</a></h2>
<p>For clarity, we run on MySQL 8.0, what we're explaining in this post is tested and applied on our systems, and <em>should</em> work on PostgreSQL and even SQLite - although the output of the <code>EXPLAIN</code> queries lower in this article will vary.</p>
<p>Imagine a table with 100,000,000 rows of data and multiple columns. If you ask your database to perform the following query:</p>
<pre data-lang="sql" class="notranslate">mysql&gt; <span class="hl-keyword">SELECT</span> * <span class="hl-keyword">FROM</span> <span class="hl-type">big_table</span>
       <span class="hl-keyword">WHERE</span> user_id = 1 <span class="hl-keyword">AND</span> deleted_at <span class="hl-keyword">IS NOT NULL</span>;
</pre>
<p>Without indexes, the database has to scan the entire table for rows that match your criteria. <em>With</em> indexes, it has a general idea of where the records you'll be asking might be. It's the difference between walking across a country with or without a map. You can get there, but it'll be a lot more efficient <em>with</em> that map.</p>
<p>When it comes to using indexes, you need to have a good idea on <em>what</em> data you want to filter on. Generally, that's everything in your <code>WHERE</code> clause. That's where the indexes come in.</p>
<p>In our example above, we're filtering data on both <code>user_id</code> and <code>deleted_at</code>, ideally we have an index that combines both of those data points for faster lookups, although even one index (say on the <code>user_id</code> column) would already make big differences.</p>
<p>There's a few gotcha's with that query straight-away (hint: <em>nullable</em> values can be tricky) which we'll cover deeper down.</p>
<h2 id="let-mysql-explain-why-the-query-is-slow">Let MySQL explain why the query is slow <a href="#let-mysql-explain-why-the-query-is-slow" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#let-mysql-explain-why-the-query-is-slow">#</a></h2>
<p>In our previous post, we explained the <a href="/news-and-updates/sql-performance-improvements-finding-the-right-queries-to-fix-part-1">techniques to identify which queries need improving</a>. If you've followed along, you might now have a list of queries that have been identified as <em>slow</em>. Now, it's time to analyse them. That's where MySQL's <code>EXPLAIN</code> keyword comes in.</p>
<p>Take your slow query, add in the keyword <code>EXPLAIN</code> just before your own query, and let MySQL tell you what it did during execution.</p>
<pre data-lang="sql" class="notranslate">mysql&gt; EXPLAIN <span class="hl-keyword">SELECT</span> * <span class="hl-keyword">FROM</span> ...
</pre>
<p>The output can be something like this:</p>
<pre data-lang="txt" class="notranslate">           id: 1
  select_type: SIMPLE
        table: &lt;your table-name&gt;
   partitions: NULL
         type: ref
possible_keys: &lt;table-name&gt;_created_at_index
          key: &lt;table-name&gt;_created_at_index
      key_len: 8
          ref: const
         rows: 1
     filtered: 100.00
        Extra: Using filesort
</pre>
<p>To start, the most important fields are:</p>
<ul>
<li><strong>possible_keys</strong>: if indexes already exist in your table, these are the ones that are under consideration for executing this query</li>
<li><strong>key</strong>: if there are multiple indexes, this is the one MySQL ended up chosing</li>
<li><strong>rows</strong>: MySQL estimates it will need to examine 1 row to satisfy the WHERE clause</li>
<li><strong>filtered</strong>: 100% of the examined rows are expected to pass the WHERE condition.</li>
<li><strong>extra</strong>: the biggest clue is here</li>
</ul>
<p>The <code>extra</code> column can contain a lot of value, typically you'll see values like these, sorted by the best possible output (aka the fastest queries) to the slowest.</p>
<ul>
<li>The best performance:
<ul>
<li><strong>(no extra info)</strong> - Optimal: using index efficiently with no additional operations</li>
<li><strong>Using index</strong> - Very fast: query satisfied entirely from index without accessing table data</li>
<li><strong>Using where; Using index</strong> - Fast: filtering done on index data only</li>
<li><strong>Using index condition</strong> - Good: index condition pushdown reduces data transfer</li>
</ul>
</li>
<li>Moderate performance:
<ul>
<li><strong>Using where</strong> - Acceptable: additional filtering applied after index lookup</li>
<li><strong>Distinct</strong> - Moderate: removing duplicates, but can use index</li>
<li><strong>Using index for group-by</strong> - Good for GROUP BY: grouping operations done via index</li>
</ul>
</li>
<li>Performance concerns:
<ul>
<li><strong>Using filesort</strong> - Slow: sorting requires additional memory/disk operations</li>
<li><strong>Using temporary</strong> - Slow: temporary table needed for complex operations</li>
<li><strong>Using temporary; Using filesort</strong> - Slower: both temporary table creation and sorting required</li>
</ul>
</li>
<li>The worst performance:
<ul>
<li><strong>Using join buffer</strong> - Slow: nested loop join without efficient index access</li>
<li><strong>Using where; Using join buffer</strong> - Very slow: inefficient joins with additional filtering</li>
<li><strong>Range checked for each record</strong> - Very slow: MySQL recalculates optimal index for each row</li>
<li><strong>Full scan on NULL key</strong> - Extremely slow: cannot use index due to NULL handling in subqueries</li>
</ul>
</li>
</ul>
<p>The key principle here is: anything involving filesorts, temporary tables, or full scans without indexes will significantly impact query performance and should be solved.</p>
<h2 id="different-explain-output">Different EXPLAIN output <a href="#different-explain-output" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#different-explain-output">#</a></h2>
<p>MySQL has several outputs for an <code>EXPLAIN</code> query, depending on your preferred tooling, these can be useful to you:</p>
<pre data-lang="sql" class="notranslate"><span class="hl-comment">-- output in tabular format, the default</span>
mysql&gt; EXPLAIN <span class="hl-keyword">SELECT</span> * <span class="hl-keyword">FROM</span> ...

<span class="hl-comment">-- output in JSON, also contains cost calculations</span>
<span class="hl-comment">-- from the query optimizer</span>
mysql&gt; EXPLAIN FORMAT=JSON <span class="hl-keyword">SELECT</span> * <span class="hl-keyword">FROM</span> ...

<span class="hl-comment">-- outputs a visual hierarchy of operations,</span>
<span class="hl-comment">-- useful with many joins</span>
mysql&gt; EXPLAIN FORMAT=TREE <span class="hl-keyword">SELECT</span> * <span class="hl-keyword">FROM</span> ...

<span class="hl-comment">-- performs the actual query and compares</span>
<span class="hl-comment">-- estimated vs. actual rows</span>
mysql&gt; EXPLAIN ANALYSE <span class="hl-keyword">SELECT</span> * <span class="hl-keyword">FROM</span> ...
</pre>
<p>I usually default to just the standard <code>EXPLAIN</code> output.</p>
<h2 id="adding-indexes">Adding indexes <a href="#adding-indexes" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#adding-indexes">#</a></h2>
<p>Most slow queries can be solved by adding indexes. This ensures MySQL can find the data efficiently, without having to scan the entire table.</p>
<p>Take the following query for instance:</p>
<pre data-lang="sql" class="notranslate"><span class="hl-keyword">SELECT</span>
	*
<span class="hl-keyword">FROM</span>
	`snooze_history_items`
<span class="hl-keyword">WHERE</span>
	`snooze_history_items`.`check_id` = 1
	<span class="hl-keyword">AND</span> `ends_at` &gt; '<span class="hl-value">2025-09-13 20:00:00</span>'
<span class="hl-keyword">ORDER BY</span>
	`ends_at` <span class="hl-keyword">DESC</span>
<span class="hl-keyword">LIMIT</span>
	1
</pre>
<p>We're querying a pretty big table to get some results. The two <code>WHERE</code> conditions are meant to limit the result, but without indexes, MySQL needs to scan and read the entire table, filter out the requested results, then return them.</p>
<p>To make matters worse, it also needs to then <em>sort</em> the result set in memory, before returning the result.</p>
<p>Both actions - reading the entire table to filter the results and then sorting them - are <em>incredibly</em> CPU intensive. If the query can't be simplified further, it's time to add an index so MySQL can consult its index for the results, efficiently gather <em>only</em> the data it needs, and instantly return it.</p>
<pre data-lang="sql" class="notranslate">mysql&gt; <span class="hl-keyword">CREATE INDEX</span> idx_check_ends_optimized
       <span class="hl-keyword">ON</span> snooze_history_items (check_id, <span class="hl-property">ends_at</span> <span class="hl-keyword">DESC</span>);
</pre>
<p>Or in a Laravel migration:</p>
<pre data-lang="php" class="notranslate"><span class="hl-type">Schema</span>::<span class="hl-property">table</span>(<span class="hl-value">'snooze_history_items'</span>, <span class="hl-keyword">function</span> (<span class="hl-injection"><span class="hl-type">Blueprint</span> $table</span>) {
    <span class="hl-variable">$table</span>-&gt;<span class="hl-property">index</span>([<span class="hl-value">'check_id'</span>, <span class="hl-value">'ends_at'</span>], <span class="hl-value">'idx_check_ends_optimized'</span>);
});
</pre>
<p>Now when we ask the <code>EXPLAIN</code> output, we'll see:</p>
<pre data-lang="sql" class="notranslate">mysql&gt; EXPLAIN <span class="hl-keyword">SELECT</span> ... 

possible_keys: idx_check_ends_optimized
          key: idx_check_ends_optimized
        Extra: Using <span class="hl-keyword">index</span> condition; Backward <span class="hl-keyword">index</span> scan
</pre>
<p>This saves a tremendous amount of CPU cycles in MySQL reading all data &amp; sorting the results. At our scale of close to 10k queries/s, this <em>seriously</em> adds up.</p>
<h2 id="multiple-indexes-vs-composite-indexes">Multiple indexes vs. composite indexes <a href="#multiple-indexes-vs-composite-indexes" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#multiple-indexes-vs-composite-indexes">#</a></h2>
<p>After a while, you might have multiple indexes on any given table, like these:</p>
<pre data-lang="sql" class="notranslate">KEY `table_user_id` (`user_id`) USING BTREE,
<span class="hl-property">KEY</span> `table_user_id_deleted_at` (`user_id`,`deleted_at`) USING BTREE,
<span class="hl-property">KEY</span> `table_user_id_deleted_at_client_type` (`user_id`,`deleted_at`,`client_type`) USING BTREE
</pre>
<p>Written differently, this table has 3 indexes:</p>
<ul>
<li>user_id (&quot;user_id_index&quot;)</li>
<li>user_id, deleted_at (&quot;user_id_deleted_at_index&quot;)</li>
<li>user_id, deleted_at, client_type (&quot;user_id_deleted_at_client_type_index&quot;)</li>
</ul>
<p>Indexes are used/read from left-to-right: an index that consists of 3 columns, like our <strong>user_id_deleted_at_client_type_index</strong> in the example above, can also be used by queries that only search for the left-most column of that index.</p>
<p>If you're performing a query like:</p>
<pre data-lang="sql" class="notranslate"><span class="hl-keyword">SELECT</span> * <span class="hl-keyword">FROM</span> <span class="hl-type">users</span> <span class="hl-keyword">WHERE</span> user_id = 1;
</pre>
<p>Any of the 3 indexes is capable of supporting this query, because the user_id is the left-most index. This actually makes the first index, <strong>user_id_index</strong>, obsolete and it can be dropped.</p>
<h2 id="the-order-of-the-index-vs-the-order-of-the-where-statement">The order of the index vs. the order of the WHERE statement <a href="#the-order-of-the-index-vs-the-order-of-the-where-statement" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#the-order-of-the-index-vs-the-order-of-the-where-statement">#</a></h2>
<p>Both of the following queries will use the same available index on the <strong>user_id, deleted_at</strong> fields. The order of the <code>WHERE</code> statements does not matter:</p>
<pre data-lang="sql" class="notranslate"><span class="hl-keyword">SELECT</span> * <span class="hl-keyword">FROM</span> <span class="hl-type">big_table</span>
<span class="hl-keyword">WHERE</span>
    user_id = 1
    <span class="hl-keyword">AND</span> deleted_at <span class="hl-keyword">IS NOT NULL</span>
</pre>
<p>and:</p>
<pre data-lang="sql" class="notranslate"><span class="hl-keyword">SELECT</span> * <span class="hl-keyword">FROM</span> <span class="hl-type">big_table</span>
<span class="hl-keyword">WHERE</span>
    deleted_at <span class="hl-keyword">IS NOT NULL</span>
    <span class="hl-keyword">AND</span> user_id = 1
</pre>
<p><strong>The order of the index, on the other hand, does matter!</strong></p>
<p>When creating indexes, it helps to visualize which data-filter would reduce the result set the most. In our query, if we can filter based on <code>user_id</code> first, we might already limit the scope of our query to 1% of the total table. This filter has the biggest impact. It's also the most likely field to be used in <code>WHERE</code> statements, so can probably be re-used by other queries, it makes sense for both of these reasons to keep <code>user_id</code> the first column in the index.</p>
<p>If we turned the indexes around, like so:</p>
<pre data-lang="sql" class="notranslate">KEY `table_deleted_at_user_id` (`deleted_at`, `user_id`) USING BTREE,
</pre>
<p>We'd now have an index that <em>starts</em> with the <code>deleted_at</code> column, followed by the <code>user_id</code> column. Now, any query that filters <em>only</em> on the <code>user_id</code> can no longer use this index and will have to fall back to a full table scan, even though it's part of another index.</p>
<h2 id="null-values-in-indexes">NULL values in indexes <a href="#null-values-in-indexes" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#null-values-in-indexes">#</a></h2>
<p>Some fields in your table might be nullable. A good example is usually a <code>deleted_at</code> soft-delete timestamp. It's only filled in with a date if a record is actually soft-deleted.</p>
<p>The main gotcha with nullable columns in MySQL indexes is that <code>NULL</code> values <em>are</em> indexed, but they can cause performance issues in your queries:</p>
<pre data-lang="sql" class="notranslate"><span class="hl-comment">-- This CAN use the index</span>
<span class="hl-keyword">WHERE</span> deleted_at <span class="hl-keyword">IS NULL</span>
<span class="hl-keyword">WHERE</span> deleted_at <span class="hl-keyword">IS NOT NULL</span>

<span class="hl-comment">-- These too, for either range or equality checks</span>
<span class="hl-keyword">WHERE</span> deleted_at = '<span class="hl-value">2025-10-06</span>'
<span class="hl-keyword">WHERE</span> deleted_at &gt;= '<span class="hl-value">2025-10-06</span>'
<span class="hl-keyword">WHERE</span> deleted_at &lt; '<span class="hl-value">2025-10-06</span>'
<span class="hl-keyword">WHERE</span> deleted_at <span class="hl-keyword">BETWEEN</span> '<span class="hl-value">2025-01-01</span>' <span class="hl-keyword">AND</span> '<span class="hl-value">2025-12-31</span>'

<span class="hl-comment">-- These CANNOT efficiently use the index</span>
<span class="hl-keyword">WHERE</span> deleted_at != '<span class="hl-value">2025-10-06</span>'  <span class="hl-comment">-- Inequality (requires scanning most of the index, inefficient range scan)</span>
<span class="hl-keyword">WHERE</span> deleted_at &lt;&gt; '<span class="hl-value">2025-10-06</span>'  <span class="hl-comment">-- Same, requires scanning most of the index</span>
<span class="hl-keyword">WHERE</span> <span class="hl-keyword">NOT</span> (deleted_at = '<span class="hl-value">2025-10-06</span>')  <span class="hl-comment">-- NOT conditions also require scanning most of the index here</span>
</pre>
<p>If you need consistent indexes on datetime fields, you could opt for a default timestamp (ie <code>1970-01-01</code>) on that column instead and removing the nullable property.</p>
<p>As a rule of thumb, where possible, put <em>nullable</em> columns as the last column in your composite indexes.</p>
<h2 id="what-performance-gains-to-expect">What performance gains to expect <a href="#what-performance-gains-to-expect" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#what-performance-gains-to-expect">#</a></h2>
<p>I define a fast query as one that completes under 15-20ms, depending on the size of the output. For single-row queries, it's reasonable to expect a response around 1ms to 2ms. Larger responses (ie many rows) will just take longer to travel over the network as well, let's set that upper bound to somewhere around 20ms.</p>
<p>Take the following example for instance:</p>
<pre data-lang="sql" class="notranslate"><span class="hl-keyword">SELECT</span> * <span class="hl-keyword">FROM</span> <span class="hl-type">big_table</span>
<span class="hl-keyword">WHERE</span> user_id = 1
<span class="hl-keyword">LIMIT</span> 10
</pre>
<p>The performance results are:</p>
<ul>
<li>Without an index on <code>user_id</code>: 32ms</li>
<li>With an index on <code>user_id</code>: 3ms</li>
</ul>
<p>That's a straight up 10x faster query, just with an index.</p>
<p>The results are a little more extreme with bigger tables or more complex queries, in our environment where we might have big queries with joins, subqueries &amp; lots of <code>WHERE</code> conditions, we've seen improvements from &gt; 5s down to &lt; 50ms, that's an almost <strong>100x faster query</strong>.</p>
<h2 id="random-trivia-on-indexes">Random trivia on indexes <a href="#random-trivia-on-indexes" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#random-trivia-on-indexes">#</a></h2>
<p>A few notes &amp; thoughts on indexes in general;</p>
<ul>
<li>They usually only matter for bigger tables, on very small tables, full scans <em>might</em> actually be faster than index lookups.</li>
<li>More indexes = slower write performance, so slower <code>INSERT</code>, <code>UPDATE</code> and <code>DELETE</code> queries. This can significantly slowdown the write performance, a table with 3+ indexes can see slower write performance by up to 20% and more. The larger (more columns) the index, the slower writes will become. Another reason to use a single, good, composite index vs. overlapping smaller ones.</li>
<li>Most applications benefit from more indexes, they often have a 95% SELECT vs. 5% WRITE ratio on database load anyway.</li>
<li>Primary keys &amp; foreign keys are automatically indexes as well, but are added as single-column indexes, and may be redundant with composite indexes.</li>
<li>Indexes consume disk space, but I've never realistically seen this have any noticeable impact.</li>
<li>Indexes also use RAM, but I've also not seen this become an issue.</li>
<li>You can always get a bigger and faster database server, often times, this is cheaper than spending weeks optimizing code or queries.</li>
<li>Using functions in queries means the index won't be used, ie <code>WHERE YEAR(created_at) = 2025</code> won't use an index on created_at, but <code>WHERE created_at &gt;= '2025-01-01' AND created_at &lt; '2026-01-01'</code> will</li>
<li>Leading wildcard searches break indexes: <code>WHERE name LIKE '%smith'</code> can't use an index, but <code>WHERE name LIKE 'smith%'</code> can.</li>
<li>In the past 20yrs of managing servers, I've had one weird instance where indexes became corrupt (queries were always slow, regardless of the indexes), where it needed an <code>OPTIMIZE TABLE &lt;table&gt;</code> to rebuild indexes. Since then, I run <code>OPTIMIZE TABLE</code> quite often when troubleshooting.</li>
</ul>
<p>Curious to hear trivia you might have on SQL performance that we don't yet know about!</p>
<h2 id="the-ai-way-how-llms-can-help">The AI way: how LLMs can help <a href="#the-ai-way-how-llms-can-help" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#the-ai-way-how-llms-can-help">#</a></h2>
<p>When you give your AI of choice enough context, the suggestions can be extremely powerful. You'll need to be diligent in reviewing the response, but if what you're looking for is a good first review of a slow query, this helps:</p>
<blockquote>
<p>Below is the EXPLAIN output of a slow query, followed by the result of the query analysis. The table structure is below that. Analyze the query and suggest which indexes to add, clarify why, and show the index creations in the Laravel migration format.</p>
</blockquote>
<p>Follow that with;</p>
<ul>
<li>The exact <code>EXPLAIN SELECT ...</code> query</li>
<li>The output of that EXPLAIN</li>
<li>The output of <code>SHOW CREATE TABLE &lt;tablename&gt;</code></li>
</ul>
<p>This gives the LLM the query that's slow, what the query planner did to execute it as well as the full table structure - including current indexes - to work with.</p>
<p>You should, however, review the suggestions and see if any of the indexes the LLM might suggest already exist or could be part of a composite index you added in earlier, to avoid duplicating indexes.</p>
<p>Next up, <a href="/news-and-updates/sql-performance-improvements-automatic-detection-regression-testing-part-3">automatically detecting performance regressions</a> so they never come back.</p>
<p>Once the slow queries are fixed you want to know if they ever creep back, that is what continuous <a href="/features/performance-monitoring">performance monitoring</a> is for.</p>
<h2 id="feedback">Feedback? <a href="#feedback" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#feedback">#</a></h2>
<p>If this article contains any errors or if it should clarify certain sections more, do reach out via either <em>mattias@ohdear.app</em> or ping me via <a href="https://x.com/mattiasgeniar">@mattiasgeniar</a> or our <a href="https://x.com/ohdearapp">@OhDearApp</a> account. Any feedback is appreciated!</p>
]]>
            </summary>
                                    <updated>2026-06-12T08:58:01+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Request Mirror: a free HTTP request reflector]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/introducing-request-mirror-a-free-micro-service-to-reflect-http-requests" />
            <id>https://ohdear.app/169</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>We have launched <a href="https://request-mirror.ohdear.app">Request Mirror</a>, a little free service to reflect HTTP requests. We've also open-sourced it: you can read the code in the <a href="https://github.com/ohdearapp/request-mirror.ohdear.app">ohdearapp/request-mirror.ohdear.app</a> repo on GitHub.</p>
<p>In this blog post I'd like to explain why we built it and how you can use it.</p>
<h2 id="why-we-needed-to-reflect-http-requests">Why we needed to reflect HTTP requests <a href="#why-we-needed-to-reflect-http-requests" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#why-we-needed-to-reflect-http-requests">#</a></h2>
<p>One of the many checks we offer is our uptime check. Like all our checks, we have an extensive test suite for our uptime monitoring functionality. We have tests that prove that when a site is down, we actually do warn our users.</p>
<p>So, in our end-to-end tests we need to have a site available that is considered down. Instead of bringing an actually service down, wouldn't it be nice if we could just tell a website what status code it should return?</p>
<p>That's exactly the sort of thing that Request Mirror can do. It has a route <code>/status/</code> that you can append with any status code you want. So a request to <code>https://request-mirror.ohdear.app/status/500</code> will respond with status code 500, mimicking a website that is down. Try it by visiting that URL in the browser and use your developer tools to see the status code.</p>
<p>In our tests we can use this:</p>
<pre data-lang="php" class="notranslate"><span class="hl-comment">// I've simplified the test for brevity</span>
<span class="hl-property">it</span>(<span class="hl-value">'can send a notification when the site is down'</span>, <span class="hl-keyword">function</span> () {
   <span class="hl-property">performUptimeCheck</span>(<span class="hl-value">'https://request-mirror.ohdear.app/status/500'</span>);
    
   <span class="hl-type">Notification</span>::<span class="hl-property">assertSent</span>(<span class="hl-type">UptimeCheckFailedNotification</span>::<span class="hl-keyword">class</span>);
});
</pre>
<h2 id="other-endpoints">Other endpoints <a href="#other-endpoints" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#other-endpoints">#</a></h2>
<p>Our uptime check has <a href="https://ohdear.app/docs/features/uptime-and-performance-monitoring#uptime-options">many options</a> to tailor it to your specific monitoring needs. To be able to test all these options, we added extra endpoints to Request Mirror.</p>
<p>Let's take a look at the ability to reflect headers:</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$response</span> = <span class="hl-type">Http</span>::<span class="hl-property">withHeaders</span>([
    <span class="hl-value">'extra-header'</span> =&gt; <span class="hl-value">'extra-value'</span>,
])-&gt;<span class="hl-property">get</span>(<span class="hl-value">'https://request-mirror.ohdear.app/get'</span>);

<span class="hl-comment">// the json response will have a headers property that contains</span>
<span class="hl-comment">// the `extra-header` header.</span>
<span class="hl-variable">$response</span>-&gt;<span class="hl-property">json</span>(<span class="hl-value">'headers'</span>);
</pre>
<p>These are also available:</p>
<ul>
<li><a href="https://request-mirror.ohdear.app/ip">https://request-mirror.ohdear.app/ip</a>: returns your IP</li>
<li><a href="https://request-mirror.ohdear.app/user-agent">https://request-mirror.ohdear.app/user-agent</a>: returns your user agent</li>
<li><a href="https://request-mirror.ohdear.app/json">https://request-mirror.ohdear.app/json</a>: returns a bit of random JSON</li>
<li><a href="https://request-mirror.ohdear.app/xml">https://request-mirror.ohdear.app/xml</a>: returns a bit of random XML</li>
</ul>
<p>And there are a few more listed on <a href="https://request-mirror.ohdear.app">Request Mirror</a>.</p>
<h2 id="an-alternative-httpbin">An alternative: HTTPBin <a href="#an-alternative-httpbin" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#an-alternative-httpbin">#</a></h2>
<p>There's also an excellent alternative that's been around for a long time: <a href="https://httpbin.org">HTTP Bin</a>. Request Mirror was heavily inspired by HTTP Bin.</p>
<p>Even though HTTP Bin works well, I still took the time to build Request Mirror, because the uptime of HTTP Bin was a bit flaky in the past period, causing our tests to fail sometimes. And with Request Mirror being in my control, I can add any endpoint Oh Dear needs for its test suite.</p>
<h2 id="in-closing">In closing <a href="#in-closing" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#in-closing">#</a></h2>
<p>Granted, <a href="https://request-mirror.ohdear.app">Request Mirror</a> is not the most exciting project, but if you need this kind of functionality it can come quite handy.</p>
<p>Should you need another endpoint for your testing, I'm open for pull requests to <a href="https://github.com/ohdearapp/request-mirror.ohdear.app">the request mirror repo on GitHub</a>.</p>
]]>
            </summary>
                                    <updated>2025-09-22T16:47:53+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[You can now connect your AI to Oh Dear]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/you-can-now-connect-your-ai-to-oh-dear" />
            <id>https://ohdear.app/168</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>Today, we have launched <a href="https://ohdear.app/docs/tools-and-sdks/mcp-server">our MCP server</a>. MCP (Model Context Protocol) is a standardized way for AI models to connect with external data sources and tools.</p>
<p>If you use a tool like <a href="https://claude.com/product/claude-code">Claude Code</a>, then this is how you can connect Oh Dear to it (you can <a href="https://ohdear.app/user/api-tokens">create an API token</a> in your account settings)</p>
<pre data-lang="bash" class="notranslate">claude mcp add <span class="hl-attribute">--transport</span> http ohdear https://ohdear.app/mcp \
--header <span class="hl-value">&quot;Authorization: Bearer YOUR-API-TOKEN-HERE&quot;</span>
</pre>
<h2 id="some-examples">Some examples <a href="#some-examples" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#some-examples">#</a></h2>
<p>Here are a couple of example prompts</p>
<p>“Analyse the performance of spatie.be of the last week”</p>
<p><img src="/media/blog/40imjDgf0CR5P71mpr0NeE6mRwg0jOOjNaDFwyie.jpg" alt="AI assistant terminal printing an uptime report for spatie.be with a per-day response time and status table" /></p>
<p>“Which DNS records changed recently at spatie.be”</p>
<p><img src="/media/blog/JzIQoHOy6L2WWs1tJMucQM3d7UBBQ42lAEGsoaU9.jpg" alt="AI assistant terminal summarizing recent DNS changes for spatie.be including added TXT and A records" /></p>
<p>“Show me the trends of the lighthouse reports of spatie.be”</p>
<p><img src="/media/blog/GUzjpSSb0jWwNNW6pcKgteVuk7FL8aqg92Liv6OS.jpg" alt="AI assistant terminal showing a Lighthouse performance score timeline and key metrics with a score of 88" /></p>
<p>This is very powerful stuff. I’m curious which prompts you will try.</p>
<h2 id="in-closing">In closing <a href="#in-closing" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#in-closing">#</a></h2>
<p>Our MCP server can access all checks and their results, so you can ask questions about anything that Oh Dear is monitoring for you.</p>
<p>You learn how to get started an view a few more examples in on <a href="https://ohdear.app/docs/tools-and-sdks/mcp-server">the dedicted MCP server page in our docs</a>.</p>
<p>We’re still improving our MCP server. If you have some unexpected results, or think a result can be better, do reach out to <a href="mailto:ohdeear.">support@ohdear.app</a></p>
]]>
            </summary>
                                    <updated>2026-06-12T08:58:01+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[SQL performance: finding the queries to fix (part 1)]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/sql-performance-improvements-finding-the-right-queries-to-fix-part-1" />
            <id>https://ohdear.app/165</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>A few weeks ago, we <a href="/news-and-updates/speed-improvements-to-the-dashboard-website-job-processing">massively improved the performance</a> of the dashboard &amp; website by optimizing some of our SQL queries. In this post, we'll share how we identified the queries that needed work. In the next post, we'll explore <em>how</em> we fixed each of them.</p>
<p>This is part of <a href="/news-and-updates/our-3-part-series-on-sql-performance-optimisations">our 3-part series on SQL performance</a>, start at the overview.</p>
<p>We'll cover the basics and gradually work our way up to the more advanced/complex ways of identifying slow queries.</p>
<p>In this post, you'll see:</p>
<ul>
<li><a href="#enable-the-debug-bar-in-your-local-environment">Using a local debug-bar to identify queries</a></li>
<li><a href="#let-mysql-tell-you-which-queries-are-slow">Using MySQL slow query log</a></li>
<li><a href="#let-mysql-tell-you-which-queries-lack-indexes">Logging queries that don't use indexes</a></li>
<li><a href="#let-mysql-tell-you-which-queries-are-executed-right-now">Evaluating currently running queries live</a></li>
<li><a href="#let-mysql-tell-you-which-queries-get-executed-the-most">Analysing the global query log</a></li>
<li>2 MySQL bonusses: <a href="#mysql-bonus-1-sort-output-vertically-not-horizontally">better CLI output</a> &amp; <a href="#mysql-bonus-2-write-query-output-to-a-file">redirecting output to files</a></li>
<li><a href="#let-laravel-warn-you-for-n1-queries">Enforce eager loading in local Laravel environment</a></li>
</ul>
<p>Let's go!</p>
<h2 id="what-these-results-look-like">What these results look like <a href="#what-these-results-look-like" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#what-these-results-look-like">#</a></h2>
<p>As a reminder, this is the resulting performance gain for the dashboard &amp; some of our internal APIs:</p>
<p><img src="/media/blog/RF9LvvgCQU1skDedrUkm0l6CEfuG7qTJ8Bu0XklO.png" alt="Oh Dear performance history chart with highest 726ms, lowest 184ms and average 413ms response times over a custom date range" /></p>
<p><img src="/media/blog/1HEzbWpII2U7qyCcl9f708IdWFgFDeGUVcYhqNZ1.png" alt="Oh Dear performance history chart averaging 200ms with a single spike to 896ms then a drop to a much flatter, lower response time" /></p>
<p>These graphs come from the <a href="/features/website-uptime-monitoring">Oh Dear uptime &amp; performance monitoring we perform</a>.</p>
<p>Slow queries like these quietly drag down response times in production, the exact kind of regression our <a href="/features/performance-monitoring">performance monitoring</a> is built to surface.</p>
<p>Now let's get started identifying which queries need optimising.</p>
<h2 id="enable-the-debug-bar-in-your-local-environment">Enable the debug-bar in your local environment <a href="#enable-the-debug-bar-in-your-local-environment" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#enable-the-debug-bar-in-your-local-environment">#</a></h2>
<p>The easiest place to start investigating queries is locally, in your development environment. Most frameworks have the concept of a &quot;debug bar&quot; - in the case of Laravel applications, the most widely used is the <a href="https://github.com/barryvdh/laravel-debugbar">barryvdh/laravel-debugbar</a> package that offers excellent insights.</p>
<p>Once enabled, you can see output similar to this:</p>
<p><img src="/media/blog/lpZ1GLA6rNmNyfkkmsq6CHq0uDNKV4RZJWNdTbKf.png" alt="Debugbar queries panel listing the SQL statements run for a request, including duplicate selects on users, monitors, teams and checks" />
It contains, at a glance:</p>
<ul>
<li>The total time spent executing SQL queries (top-right)</li>
<li>The total amount of queries executed (top-left)</li>
<li>How many of those were duplicates (indicating potential N+1 loop issues)</li>
</ul>
<p>Before optimising any query, it makes sense to ask the question: can the query be avoided in the first place? Duplicate queries are worth investigating, as well as queries that don't add meaning to the page you're seeing (ie a Class being lazy-loaded whose data isn't needed on this page).</p>
<p>In our case, if we're looking at the data being loaded on an <a href="https://ohdear.app/features/website-uptime-monitoring">uptime monitor</a>, we wouldn't expect a SQL query to load data for a <a href="https://ohdear.app/features/broken-page-and-mixed-content-detection">broken link monitor</a>. For this, you need application-awareness to know what data makes sense to load on that page.</p>
<h2 id="let-mysql-tell-you-which-queries-are-slow">Let MySQL tell you which queries are slow <a href="#let-mysql-tell-you-which-queries-are-slow" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#let-mysql-tell-you-which-queries-are-slow">#</a></h2>
<p>MySQL has the ability to enable a &quot;slow query log&quot;, where you get to decide what qualifies as a slow query. This is the easiest step to get started, as MySQL will log to disk the SQL queries that exceeded your threshold.</p>
<p>First, create the file to be used as the log (as the <code>root</code> user):</p>
<pre data-lang="txt" class="notranslate">$ touch /var/log/mysql-slow-query.log
$ chown mysql:mysql /var/log/mysql-slow-query.log
</pre>
<p>This creates an empty file and allows MySQL to read &amp; write to it. If the file doesn't exist, MySQL won't create it for you, it just won't log anything.</p>
<p>Next, hop in your MySQL command-line and activate the <a href="https://dev.mysql.com/doc/refman/8.4/en/slow-query-log.html">Slow Query Log</a>.</p>
<pre data-lang="txt" class="notranslate">$ mysql
mysql&gt; SET GLOBAL slow_query_log_file = '/var/log/mysql-slow-query.log';
mysql&gt; SET GLOBAL long_query_time = 1;
mysql&gt; SET GLOBAL slow_query_log = 'ON';
</pre>
<p>From this point, MySQL will log every query that exceeded the 1s threshold in your log file. Tweak the &quot;long query time&quot; as you see fit.</p>
<pre data-lang="txt" class="notranslate">$ tail -f /var/log/mysql-slow-query.log

# Query_time: 2.547717  Lock_time: 0.000003 Rows_sent: 0  Rows_examined: 0
SET timestamp=1757095277;
select * from `runs` where `runs`.`check_id` = ...;
</pre>
<p>This will give you a list of your slow queries, ready to be optimized.</p>
<p>The example above will modify your currently running MySQL instance to log queries, but if you restart your MySQL server, the settings won't be persisted. If you want to have this enabled all the time, it needs to be added to your <code>my.cnf</code> config file:</p>
<pre data-lang="txt" class="notranslate">[mysqld]
slow_query_log = ON
slow_query_log_file = /var/log/mysql-slow-query.log
long_query_time = 1
</pre>
<p>You can gradually increase the slow query threshold, MySQL allows decimal values to log queries that are faster than 500ms, 300ms, etc.</p>
<pre data-lang="txt" class="notranslate">mysql&gt; SET GLOBAL long_query_time = 0.3;
</pre>
<p>This would let MySQL log all queries that are slower than 300ms.</p>
<p>If the results become too verbose, you can tweak how many queries get logged a bit more by setting a minimal amount of rows that a query should return, before it's logged.</p>
<pre data-lang="txt" class="notranslate">mysql&gt; SET GLOBAL min_examined_row_limit = 1000;
</pre>
<p>Queries that examine fewer than this number of rows will not be logged to the slow query log.</p>
<h2 id="let-mysql-tell-you-which-queries-lack-indexes">Let MySQL tell you which queries lack indexes <a href="#let-mysql-tell-you-which-queries-lack-indexes" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#let-mysql-tell-you-which-queries-lack-indexes">#</a></h2>
<p>Spoiler alert: a fast query <em>usually</em> has indexes on them that make retrieving the data blazing fast. We'll explore how to set those &amp; decide <em>which</em> ones to set in future posts.</p>
<p>MySQL can log all queries that are being executed that <em>don't</em> use an index for lookups. This can get a little noisy, especially if you haven't added indexes before, so this is a setting to enable once you've done the 2 tips shared above first, to trim down on the log-noise this might generate.</p>
<pre data-lang="txt" class="notranslate">mysql&gt; SET GLOBAL log_queries_not_using_indexes = ON;
</pre>
<p>This will log all sorts of queries, including queries like:</p>
<ul>
<li><code>SELECT * FROM users</code>: a full table scan (without a <code>WHERE</code> clause)</li>
<li><code>SELECT * FROM users WHERE email = 'mattias@ohdear.app'</code>: a <code>WHERE</code> clause on a non-index column</li>
<li><code>SELECT * FROM users WHERE UPPER(email) = 'MATTIAS@OHDEAR.APP</code>: a function call on an indexed column (this prevents index usage)</li>
<li><code>SELECT * FROM users WHERE name LIKE '%mattias%'</code>: using a leading wildcard in a LIKE statement</li>
</ul>
<p>I wouldn't recommend running <code>log_queries_not_using_indexes</code> all the time, just for debugging &amp; analysis purposes.</p>
<p>The <code>log_queries_not_using_indexes</code> is also compatible with the same <code>min_examined_row_limit</code> option we shared in the previous tip, so you can limit the logging of non-indexed queries by adding:</p>
<pre data-lang="txt" class="notranslate">mysql&gt; SET GLOBAL min_examined_row_limit = 1000;
</pre>
<p>If you have a high amount of queries without indexes <em>and</em> a busy MySQL server, the log activity can also become a bottleneck. Keep this in mind and disable it again once you're done with your analysis.</p>
<pre data-lang="txt" class="notranslate">mysql&gt; SET GLOBAL log_queries_not_using_indexes = OFF;
</pre>
<p>Ps; feels weird writing queries that set a value to what seems to be a string, right? The values for <code>OFF</code> and <code>ON</code> are reserved keywords, similar to <code>TRUE</code> or <code>FALSE</code>, so they don't need quotes.</p>
<h2 id="let-mysql-tell-you-which-queries-are-executed-right-now">Let MySQL tell you which queries are executed right now <a href="#let-mysql-tell-you-which-queries-are-executed-right-now" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#let-mysql-tell-you-which-queries-are-executed-right-now">#</a></h2>
<p>As a rule of thumb, I like to use: whenever I can see a query being executed <em>right now</em>, it probably can use some optimisations.</p>
<p>Think about that logic for a second: ideally, queries are finished under 10ms or faster. What are the odds that when I request the <em>current</em> processlist, a query will show up? It becomes such a narrow window that when you look at the current processlist a few times, and you see the same queries showing up over and over again, they're worth investigating.</p>
<p>Let's start with the basics:</p>
<pre data-lang="txt" class="notranslate">mysql&gt; SHOW FULL PROCESSLIST;
</pre>
<p>If this returns a lot of results, you can use the raw SQL query to be able to filter the results based on your own criteria. The <code>SHOW FULL PROCESSLIST</code> is essentially a shortcut for the following SQL query:</p>
<pre data-lang="txt" class="notranslate">mysql&gt; SELECT 
		ID,
		USER,
		HOST,
		DB,
		COMMAND,
		TIME,
		STATE,
		INFO
FROM performance_schema.processlist
ORDER BY ID;
</pre>
<p>So you're able to trim down the output a little by avoiding Sleeping connections or from databases or users you don't need (if you're hosting multiple databases on this system);</p>
<pre data-lang="txt" class="notranslate">mysql&gt; SELECT 
		ID,
		USER,
		HOST,
		DB,
		COMMAND,
		TIME,
		STATE,
		INFO
FROM performance_schema.processlist
WHERE DB = 'ohdear' AND COMMAND != 'Sleep';
</pre>
<p>If you run this command a few times in a row and you see the same types of queries showing up, even if they have indexes, they're worth noting for followup.</p>
<p>The output of <code>PROCESSLIST</code> and the content of the <code>performance_schema.processlist</code> have a limitation that the <code>TIME</code> only has seconds granularity, so it's hard to sort on. As a workaround, once you need more insights into &lt; 1 second queries, you can run the following query:</p>
<pre data-lang="txt" class="notranslate">mysql&gt; SELECT 
	t.processlist_id,
	t.processlist_user,
	t.processlist_host,
	t.processlist_db,
	t.processlist_command,
	t.processlist_state,
	t.processlist_info,
	ROUND(s.timer_wait/1000000, 2) as execution_time_ms
FROM performance_schema.threads t
JOIN performance_schema.events_statements_current s ON t.thread_id = s.thread_id
WHERE t.processlist_command != 'Sleep' 
  AND s.timer_wait IS NOT NULL
ORDER BY s.timer_wait DESC;
</pre>
<p>This will show the time spent in miliseconds in the <code>execution_time_ms</code> column.</p>
<h2 id="let-mysql-tell-you-which-queries-get-executed-the-most">Let MySQL tell you which queries get executed the most <a href="#let-mysql-tell-you-which-queries-get-executed-the-most" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#let-mysql-tell-you-which-queries-get-executed-the-most">#</a></h2>
<p><strong>Caution: on high-traffic MySQL servers, this will become a major bottleneck! Be careful.</strong></p>
<p>This one is best to enable locally, in your development environment. Sure, you can try to enable this on staging or production, but there's a very big chance your disk I/O won't survive that setting. Be careful when doing this on anything <em>but</em> your local development environment.</p>
<p>I know some will #YOLO this in production, don't @ me when your system breaks. :-)</p>
<p>MySQL can log <em>all</em> queries that are being executed, with the ability to then fire off some CLI tools on that big query log to identify the most recurring queries.</p>
<p>First, similar to the Slow Query Log, create the file MySQL can write to:</p>
<pre data-lang="txt" class="notranslate">$ touch /var/log/mysql-general-query.log
$ chown mysql:mysql /var/log/mysql-general-query.log
</pre>
<p>Next, instruct MySQL to log all queries to that file:</p>
<pre data-lang="txt" class="notranslate">mysql&gt; SET GLOBAL general_log_file = '/var/log/mysql-general-query.log';
mysql&gt; SET GLOBAL general_log = ON;
</pre>
<p>To disable the general log again:</p>
<pre data-lang="txt" class="notranslate">mysql&gt; SET GLOBAL general_log = OFF;
</pre>
<p>Now you can - again, locally - open your application, run your jobs &amp; scheduled tasks, run your CI tests, ... and every query will be logged to the <code>/var/log/mysql-general-query.log</code> file. The output will be something like this:</p>
<pre data-lang="txt" class="notranslate">$ tail -f /var/log/mysql-general-query.log
2025-09-16T11:40:04.736814Z	   94 Prepare	select * from `plan_prices` where `archived` = ? and `is_yearly` = ? and `currency` = ? order by `amount` asc limit 1
2025-09-16T11:40:04.736987Z	   94 Execute	select * from `plan_prices` where `archived` = 0 and `is_yearly` = 0 and `currency` = 'eur' order by `amount` asc limit 1
2025-09-16T11:40:04.737575Z	   94 Close stmt
2025-09-16T11:40:04.745635Z	   94 Prepare	select * from `transformation_results` where `url` = ? and `type` = ? limit 1
2025-09-16T11:40:04.745773Z	   94 Execute	select * from `transformation_results` where `url` = 'https://ohdear.app.test/pricing' and `type` = 'ldJson' limit 1
2025-09-16T11:40:04.746173Z	   94 Close stmt
2025-09-16T11:40:04.768509Z	   94 Prepare	select * from `users` where `id` = ? limit 1
2025-09-16T11:40:04.768763Z	   94 Execute	select * from `users` where `id` = 27765 limit 1
2025-09-16T11:40:04.769149Z	   94 Close stmt
</pre>
<p>This quickly becomes overwhelming &amp; you won't be able to parse this manually anymore. That's where the CLI comes in. Percona offers some amazing scripts for us to use, let's install those first:</p>
<pre data-lang="txt" class="notranslate">$ apt install percona-toolkit # for debian/ubuntu
$ brew install percona-toolkit # for Mac
</pre>
<p>The <code>pt-query-digest</code> tool allows us to analyse this general log and sort the queries by either count or time-spent in the database. We just need a quick fix in our log, because <code>pt-query-digest</code> only looks at the keyword <code>Query</code> in the log, and we're using Prepared Statements so we have a lot of entries with <code>Prepare</code> and <code>Execute</code> that the tool will happily ignore. Since each <code>Execute</code> is just a <code>Query</code> that was executed, we can simply rename them in our logfile.</p>
<pre data-lang="txt" class="notranslate">$ sed 's/Execute\t/Query\t/' /var/log/mysql-general-query.log &gt; /var/log/mysql-general-query-editted.log
</pre>
<p>Then, let's fire off the analysis:</p>
<pre data-lang="txt" class="notranslate">$ pt-query-digest \
	--type=genlog \
	--group-by fingerprint \
	--order-by Query_time:cnt,Query_time:sum \
	--filter '$event-&gt;{cmd} =~ /^(Query|Execute)$/' \
	--limit 25 \
	/var/log/mysql-general-query-editted.log
</pre>
<p>The output is a bit noisy, but tells you which queries were executed most (<code>Count</code> attribute) and which spent most time (<code>Exec time</code> attribute). Any performance gain that can be made to the most-occuring queries will yield better results.</p>
<p>Improving a query from 25ms to 20ms may sound negligible, but if it's performed millions of times a day, it adds up.</p>
<h2 id="mysql-bonus-1-sort-output-vertically-not-horizontally">MySQL bonus 1: sort output vertically, not horizontally <a href="#mysql-bonus-1-sort-output-vertically-not-horizontally" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#mysql-bonus-1-sort-output-vertically-not-horizontally">#</a></h2>
<p>If you're working via the CLI, some of the queries I shared above can have quite lengthy output that's not always easy to see on small screens. You can change some of the outputs with <em>modifiers</em> in MySQL.</p>
<p>One very useful modifier is the <code>\G</code> control character - that's backslash + G.</p>
<p>This is a normal query output via the CLI:</p>
<pre data-lang="txt" class="notranslate">mysql&gt; SELECT id, name FROM teams LIMIT 2;
+----+--------------+
| id | name         |
+----+--------------+
|  1 | Team Mattias |
|  2 | Spatie       |
+----+--------------+
2 rows in set (0.00 sec)
</pre>
<p>If you add in the <code>\G</code> suffix to a query, the results are shown vertically instead:</p>
<pre data-lang="txt" class="notranslate">mysql&gt; SELECT id, name FROM teams LIMIT 2 \G;
*************************  * 1. row ***************************
  id: 1
name: Team Mattias

*************************  * 2. row ***************************
  id: 2
name: Spatie
2 rows in set (0.00 sec)
</pre>
<p>That's a litteral <code>\G</code> (backslash + G) at the end of the query.</p>
<h2 id="mysql-bonus-2-write-query-output-to-a-file">MySQL bonus 2: write query output to a file <a href="#mysql-bonus-2-write-query-output-to-a-file" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#mysql-bonus-2-write-query-output-to-a-file">#</a></h2>
<p>If you miss using <code>grep</code>, <code>awk</code>, <code>sort</code>, ... in a MySQL shell, you're probably a greybeard Linux sysadmin and we should have some beers together. But good news, MySQLs' output can be redirected to a file for easier parsing!</p>
<pre data-lang="txt" class="notranslate">mysql&gt; \T /tmp/query-output.log
Logging to file '/tmp/query-output.log'

mysql&gt; SELECT * FROM teams;

mysql&gt; \t
Outfile disabled.
</pre>
<p>With <code>\T</code> you can specify which file this MySQL session should write all its output to. And with <code>\t</code> you can stop writing to that file. Afterwards, your file contains all the output of the queries you ran in between.</p>
<p><em>You can also use <code>INTO OUTFILE</code> but I find it a little cumbersome to tweak queries to achieve that, I'd rather mark a terminal session as &quot;log to file&quot;, do the queries I want, and then stop that logging.</em></p>
<h2 id="let-laravel-warn-you-for-n1-queries">Let Laravel warn you for N+1 queries <a href="#let-laravel-warn-you-for-n1-queries" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#let-laravel-warn-you-for-n1-queries">#</a></h2>
<p>The Laravel framework can warn you when you're (potentially) causing excessive queries by throwing an exception when you're lazy loading relationships.</p>
<p>You can enable this warning in your non-production environment by adding the following into your <code>boot()</code> method in your <code>AppServiceProvider</code>:</p>
<pre data-lang="txt" class="notranslate">public function boot(): void
{
    Model::preventLazyLoading(! $this-&gt;app-&gt;isProduction());
}
</pre>
<p>Now, every time you run a piece of code like this:</p>
<pre data-lang="txt" class="notranslate">$posts = Post::all();

foreach ($posts as $post) {
    echo $post-&gt;comments-&gt;count();
}
</pre>
<p>It'll throw an exception, because for every iteration through the loop, Laravel would perform an extra query to count the comments <em>of that specific $post</em> instead of loading them at once. The fix, in this example, would be to eager load the counts only - not all the comments.</p>
<pre data-lang="txt" class="notranslate">// Eager load the comment counts
$posts = Post::withCount('comments')-&gt;get();

foreach ($posts as $post) {
    echo $post-&gt;comments_count; // Note the _count suffix
}
</pre>
<p>This is an example that will optimise your query count with just minimal code changes.</p>
<h2 id="next-up-fixing-the-queries">Next up: fixing the queries <a href="#next-up-fixing-the-queries" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#next-up-fixing-the-queries">#</a></h2>
<p>In this post, we shared several ways of <em>identifying</em> which queries either get executed the most or which are the slowest. There's many ways to get this data, and you're able to pick whichever method you're most comfortable with or have access to.</p>
<p>Query tuning is half of it; on the framework side, make sure you are <a href="/news-and-updates/making-sure-routes-and-config-files-are-cached-in-a-laravel-app">caching routes and config in your Laravel app</a>.</p>
<p>In our next post, we'll deep-dive into the different ways of identifying what the bottleneck of a query is and how to resolve it.</p>
]]>
            </summary>
                                    <updated>2026-06-12T08:58:01+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Speed improvements to the dashboard, website & job processing]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/speed-improvements-to-the-dashboard-website-job-processing" />
            <id>https://ohdear.app/166</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>The past month we dedicated time and resources into optimising the speed and experience of our public website, our dashboard and our behind-the-scenes uptime checks that we perform. Overall, our website and dashboard feels about <strong>2x to 3x faster</strong>.</p>
<p>The biggest gains are for our users that have &gt; 100 sites on their dashboard, they'll get a noticeably faster loading time. For those biggest users, the dashboard is quite litterally <em>10x</em> faster.</p>
<h2 id="what-these-results-look-like">What these results look like <a href="#what-these-results-look-like" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#what-these-results-look-like">#</a></h2>
<p>This is the historical performance of our homepage for instance:</p>
<p><img src="/media/blog/RF9LvvgCQU1skDedrUkm0l6CEfuG7qTJ8Bu0XklO.png" alt="Oh Dear performance history chart with response times averaging 413ms then dropping sharply at the end of the range" /></p>
<p>And one of our uptime location API endpoints:</p>
<p><img src="/media/blog/1HEzbWpII2U7qyCcl9f708IdWFgFDeGUVcYhqNZ1.png" alt="Oh Dear performance history chart with a spike to 896ms followed by response times flattening near zero" /></p>
<p>These graphs come from the <a href="/features/website-uptime-monitoring">Oh Dear uptime monitoring we perform</a>.</p>
<p>Over the next few blogposts, we'll share the details on how we identified which areas to focus on and how we implemented the performance improvements across the board.</p>
]]>
            </summary>
                                    <updated>2026-06-12T08:58:01+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Introducing ping and TCP port monitoring]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/introducing-ping-and-tcp-port-monitoring-and-lots-of-other-improvements" />
            <id>https://ohdear.app/164</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>A couple months ago, we sent out <a href="https://tally.so/r/nPLODP">a survey</a> to all our users asking what they like about Oh Dear, how they use it, and how we could improve our service.</p>
<p>One of the things that was asked a lot was ping and TCP port monitoring. The past few months we worked hard to add this kind of monitoring to our service. And while building it, we touched upon other parts of our service and improved lots of little things.</p>
<p>And I'm proud to share that we now have shipped it all! Let's go through it!</p>
<h2 id="goodbye-sites-welcome-monitors">Goodbye sites, welcome monitors <a href="#goodbye-sites-welcome-monitors" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#goodbye-sites-welcome-monitors">#</a></h2>
<p>Let's take a look at the dashboard at Oh Dear.</p>
<p><img src="/media/blog/TfLyEMIT7PLEN0XzV9UBthhXiwsjvCbcbrefkElH.jpg" alt="Oh Dear monitors overview listing a website, a TCP and a ping monitor with issues and active check counts" /></p>
<p>Long time Oh Dear users will probably notice that &quot;sites&quot; isn't in the main navigation anymore. It has been replaced by &quot;monitors&quot;.</p>
<p>Since Oh Dear started, we always had the concept of a &quot;Site&quot; you're monitoring. But with the introduction of Ping and TCP checks, we can now monitor more than just a site.</p>
<p>That's why we renamed the &quot;Site&quot; concept in Oh Dear to &quot;Monitor&quot;. Not only the main navigation has changed, but we updated all copy throughout our entire app and docs.</p>
<p>A monitor will have a type:</p>
<ul>
<li>HTTP: for checking websites</li>
<li>Ping: for checking servers via ICMP ping</li>
<li>TCP: for checking the connectivity of a port over TCP</li>
</ul>
<p>When you now create a monitor in Oh Dear, this new dialog pops up, which lets you choose the type of monitor.</p>
<p><img src="/media/blog/LXuYnULvwj4t8e2hRQoUI9zm4WYb3njBKi1UtcNt.jpg" alt="Oh Dear add monitor dialog with Website, Ping and TCP Port types and the Website option selected" /></p>
<h2 id="monitor-your-servers-via-ping">Monitor your servers via ping <a href="#monitor-your-servers-via-ping" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#monitor-your-servers-via-ping">#</a></h2>
<p>Let's explore the new ping monitoring type. With HTTP monitoring we're sending an HTTP request to your app to see if it works. With ping monitoring, we're sending an ICMP(v6) ping packet to your server, so this kind of monitoring is more low-level. Ping checks if your server is responding at the most basic network level. If ping fails, you know there's a fundamental connectivity issue before users even try to access your site.</p>
<p>Adding a ping monitor is easy, just select the ping type, enter the address (either a FQDN hostname or the IPv4 or IPv6 address) of your server and you're good to go.</p>
<p><img src="/media/blog/cUNwQbOsWXuiSeB9qD6mFaVC0ti1wkwrvlcF6fku.jpg" alt="Oh Dear add monitor dialog with the Ping type selected and a hostname or IP input field" /></p>
<p>On the uptime report of a ping check, you can see the average response time, together with the raw ping output.</p>
<p><img src="/media/blog/4wSFwpTyLhhGj0VXYf7vKIPlyAY7QIqLT1v3xILO.jpg" alt="Oh Dear ping uptime report for spatie.be showing online status, response times and raw ping output" /></p>
<p>When you scroll down a bit, you'll see a full graph of how the ping check performed over time.</p>
<p><img src="/media/blog/TlZiheQ1utkM2h1AJ7Ka05GwrcdpS0a7kekwambs.jpg" alt="Oh Dear ping monitor performance history chart with 100% uptime tiles across the past 7 days" /></p>
<p>Needless to say, whenever the ping fails, we'll send you notifications, via one of our <a href="https://ohdear.app/docs/notifications/notifications">many available notification channels</a>.</p>
<p>The ping check can also be customized to your heart's content. You can choose how many packets should be sent, what their size should be, define the acceptable packet loss, and much more.</p>
<p><img src="/media/blog/99hVayy6C8XnXaa5L15n18JEMKQiSgryUIwrd4Z3.jpg" alt="Oh Dear ping uptime settings with packet count, interval, timeout, packet size and acceptable loss fields" /></p>
<h2 id="monitor-tcp-ports">Monitor TCP ports <a href="#monitor-tcp-ports" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#monitor-tcp-ports">#</a></h2>
<p>Let's take a look at the second new monitoring type: TCP ports.</p>
<p>TCP port monitoring checks if specific services are running and accepting connections on their designated ports, giving you more targeted insight than basic ping monitoring: While ping only tells you the server is reachable, TCP port monitoring verifies that specific services (web server on port 80/443, database on 3306, SSH on 22, etc.) are actually listening and responding.</p>
<p>When creating a monitor, you can now choose the TCP type.</p>
<p><img src="/media/blog/4N9yjryvtywF3lKbxVUWfX1UB3AHsvgj5Yu5I1Fu.jpg" alt="Oh Dear add monitor dialog with the TCP Port type selected and a hostname:port input field" /></p>
<p>For this example we're going to monitor <code>smtp.google.com:587</code>, Gmail's mail server.</p>
<p>Let's maybe look at the settings of a TCP monitor first.</p>
<p><img src="/media/blog/15gWAwI8IaFn3LSdmwpK7b9hQKIVJS6LFt9CMjNx.jpg" alt="Oh Dear TCP port uptime settings for smtp.gmail.com:587 with timeout, welcome string and send string fields" /></p>
<p>On the image above you see that we can check the welcome message the server will send back. If the welcome message doesn't contain the string given, we'll regard the server as down. Optionally, you can also send a message to the server, and also check if the response we get for the message contains a substring. This way you can really test particular behavior of your server.</p>
<p>On the TCP result page, we'll show the welcome message (and also the response of the optional message when it has been set)</p>
<p><img src="/media/blog/UVIINETR1YUugPoVB659QcxZ82Ed3V8lOViQUJsL.jpg" alt="Oh Dear TCP port uptime report for smtp.gmail.com:587 showing online status, connection time and welcome message" /></p>
<h2 id="our-api-has-been-updated-as-well">Our API has been updated as well <a href="#our-api-has-been-updated-as-well" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#our-api-has-been-updated-as-well">#</a></h2>
<p>Across our UI, notifications and docs, we have changed the language so we'll talk about &quot;Monitors&quot;, instead of &quot;Sites&quot;. We've also updated our entire codebase. Internally our <code>Site</code> model is renamed to <code>Monitor</code>, together with all relationships, foreign keys, views, controllers... Together with the technical rename, we'll also pay off other technical debt that was accrued over the years.</p>
<p>This has also affected our API. At Oh Dear, we value backwards compatibility. In the eight years that we've been in operation, we've never made a breaking change to our API. We also considered keeping a v1 of our API, but we decided against it because the maintenance burden would be too big.</p>
<p>On <a href="https://ohdear.app/docs/faq/how-do-i-update-to-the-latest-version-of-the-oh-dear-api">this FAQ page</a>, you can read more info on which exact breaking changes we've made, and how you can upgrade.</p>
<h2 id="a-new-homepage">A new homepage <a href="#a-new-homepage" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#a-new-homepage">#</a></h2>
<p>To promote ping and TCP monitoring, we added two dedicated new pages, <a href="https://ohdear.app/features/ping-monitoring">one for ping</a>, and <a href="https://ohdear.app/features/tcp-port-monitoring">one for TCP</a>. These pages both have beautiful illustrations, made by our designer <a href="https://github.com/nckrtl">Nick</a>.</p>
<p>We also took the time to update our homepage to mention the new feature, and also to show some nice screenshots from our app.</p>
<p><img src="/media/blog/mhBCrdw8AZUY3AWuRIsGrVSswmhEGc9VGqZbko4G.jpg" alt="Oh Dear marketing homepage headline 'the all-in-one monitoring tool for your entire website' with ping and TCP feature links" /></p>
<p>The features and docs menu were revamped as well.</p>
<p><img src="/media/blog/aRxzMUOQPXXvh7H9XdY9c2hVz9Vzy9tN3xzp1wsV.jpg" alt="Oh Dear Features mega-menu listing all monitoring types including new Ping and TCP Port entries" /></p>
<p><img src="/media/blog/k898lJJtYDcqgvBBU89yHrIjTDOALCdUt9Vadkcx.jpg" alt="Oh Dear Docs mega-menu listing resources like checks, API, notifications, status pages and bounty program" /></p>
<h2 id="bonus-feature-customize-the-frequency-of-the-checks">Bonus feature: customize the frequency of the checks <a href="#bonus-feature-customize-the-frequency-of-the-checks" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#bonus-feature-customize-the-frequency-of-the-checks">#</a></h2>
<p>We've also shipped another small, but often requested feature: you can now choose the frequency of the check we run.</p>
<p>By default, we check your website for uptime every minute. The Lighthouse check runs daily. Using our new feature, you can now for instance choose that the uptime check should run every 2 minutes, and the Lighthouse check every 5 days.</p>
<p>You can choose the frequency at the settings of the check.</p>
<p><img src="/media/blog/zss3rg0i90NWkWsuNdBdgfsNzp4wk0El017pD1dJ.jpg" alt="Oh Dear Lighthouse settings for freek.dev with an arrow pointing to the 'Run check every 3 days' frequency option" /></p>
<h2 id="in-closing">In closing <a href="#in-closing" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#in-closing">#</a></h2>
<p>We hope you'll like these updates we've made to Oh Dear.</p>
<p>We aren't done and are building more exciting features, but it's a bit too early to talk about those. Should you have any suggestions on how to improve Oh Dear, do let us know by <a href="https://tally.so/r/nPLODP">filling in our survey</a> or just emailing <a href="mailto:support@ohdear.app">support</a>.</p>
<p>We'll close off this post by highlighting some of the features that were requested through our survey:</p>
<ul>
<li>We now <a href="/features/notifications">log sent notifications</a></li>
<li><a href="https://ohdear.app/news-and-updates/exploring-our-new-php-sdk-built-using-saloon">Our new SDK has been rebuilt from the ground up</a></li>
<li>People with a big number of sites in their team reported that the site list screen is slow. We made it faster, and are still optimizing it.</li>
<li>There's <a href="https://ohdear.app/team-settings/usage">a new usage screen</a> that shows your usage against your current plan</li>
<li>Maintenance periods can now have a name</li>
<li>We can now <a href="https://ohdear.app/news-and-updates/we-now-support-google-chat">send notifications via Google Chat</a></li>
<li>The certificate health check has a new &quot;Detected Certificates&quot; tab showing the history of certificates we detected for a site.</li>
<li>A graph was added to the Lighthouse result history</li>
<li><a href="https://ohdear.app/news-and-updates">Our blog</a> layout was simplified.</li>
<li>We made it more clear across our UI which things are clickable, by adding the cursor-pointer CSS class to those elements</li>
<li>You can now <a href="https://ohdear.app/news-and-updates/managing-monthly-reports-with-the-api">manage monthly reports with our API</a></li>
<li><a href="/features/notifications">Alert snoozing</a> now has full history</li>
<li>A new login method was added: <a href="https://ohdear.app/news-and-updates/you-can-now-log-in-using-passkeys">passkeys</a></li>
<li>Our broken links check has <a href="https://ohdear.app/news-and-updates/broken-links-troubleshooting-and-a-couple-of-extras">a new &quot;Troubleshooting&quot; tab</a>, showing you tips to fix broken links.</li>
<li>We <a href="https://ohdear.app/news-and-updates/introducing-our-improved-uptime-check">merged our uptime and performance check</a></li>
</ul>
]]>
            </summary>
                                    <updated>2026-06-12T08:58:01+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Exploring our new PHP SDK, built using Saloon]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/exploring-our-new-php-sdk-built-using-saloon" />
            <id>https://ohdear.app/163</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>Today, next to <a href="https://ohdear.app/news-and-updates/introducing-ping-and-tcp-port-monitoring-and-lots-of-other-improvements">Ping and TCP monitoring</a>, we've also launched a <a href="https://github.com/ohdearapp/ohdear-php-sdk">new PHP SDK package</a>, which has been rebuilt from scratch using the wonderful <a href="https://docs.saloon.dev">Saloon</a> library. Using our new SDK, you can easily use the entire Oh Dear API.</p>
<p>In this blog post, I'd like to show you how you can use the new SDK and how it works under the hood.</p>
<h2 id="using-the-sdk">Using the SDK <a href="#using-the-sdk" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#using-the-sdk">#</a></h2>
<p>With the SDK package installed (you'll only have to require it using <code>composer require ohdearapp/ohdear-php-sdk</code>), you'll be able to instantiate the Oh Dear class like this:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">use</span> <span class="hl-type">OhDear\PhpSdk\OhDear</span>;

<span class="hl-variable">$ohDear</span> = <span class="hl-keyword">new</span> <span class="hl-type">OhDear</span>(<span class="hl-value">'your-api-token'</span>);
</pre>
<p>That API token can be created on <a href="https://ohdear.app/user/api-tokens">the API tokens screen</a>.</p>
<p>On that <code>OhDear</code> class you can use one of the <a href="https://github.com/ohdearapp/ohdear-php-sdk/blob/monitors/README.md#usage">many available API methods</a>.</p>
<p>Here's how you would create a simple monitor.</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$monitor</span> = <span class="hl-variable">$ohDear</span>-&gt;<span class="hl-property">createMonitor</span>([
    <span class="hl-value">'url'</span> =&gt; <span class="hl-value">'https://example.com'</span>,
    <span class="hl-value">'type'</span> =&gt; <span class="hl-value">'http'</span>,
    <span class="hl-value">'team_id'</span> =&gt; 1,
]);

<span class="hl-keyword">echo</span> <span class="hl-variable">$monitor</span>-&gt;<span class="hl-property">url</span>; <span class="hl-comment">// returns https://example.com</span>
</pre>
<p>Of course, we'll start performing all of our checks on a new monitor right away.</p>
<p>Here's how you would list all broken links we detect on a site.</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$brokenLinks</span> = <span class="hl-variable">$ohDear</span>-&gt;<span class="hl-property">brokenLinks</span>(<span class="hl-variable">$monitorId</span>);

<span class="hl-keyword">foreach</span> (<span class="hl-variable">$brokenLinks</span> <span class="hl-keyword">as</span> <span class="hl-variable">$brokenLink</span>) {
    <span class="hl-keyword">echo</span> <span class="hl-value">&quot;Broken link: {$brokenLink-&gt;crawledUrl}&quot;</span>;
    <span class="hl-keyword">echo</span> <span class="hl-value">&quot;Status: {$brokenLink-&gt;statusCode}&quot;</span>;
    <span class="hl-keyword">echo</span> <span class="hl-value">&quot;Found on: {$brokenLink-&gt;foundOnUrl}&quot;</span>;
    <span class="hl-keyword">echo</span> <span class="hl-value">&quot;Link text: {$brokenLink-&gt;linkText}&quot;</span>;
    <span class="hl-keyword">echo</span> <span class="hl-value">&quot;Internal link: &quot;</span> . (<span class="hl-variable">$brokenLink</span>-&gt;<span class="hl-property">internal</span> <span class="hl-operator">?</span> <span class="hl-value">'Yes'</span> : <span class="hl-value">'No'</span>) . <span class="hl-value">&quot;&quot;</span>;
}
</pre>
<p>Here's a cool tidbit, you can't know from looking at the code above: you don't need to care about pagination. The <code>brokenLinks</code> method (and all other methods in our SDK that return multiple results) doesn't return a regular array. Instead it returns an iterator.</p>
<p>When you loop over the iterator, we will automatically fetch more pages of results from our API. It's completely transparent. So no matter how many broken links we detect on your site, looping over the iterator returned by <code>brokenLinks</code> will handle them all.</p>
<h2 id="built-on-top-of-saloon">Built on top of Saloon <a href="#built-on-top-of-saloon" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#built-on-top-of-saloon">#</a></h2>
<p>This way of handling pagination (with an iterator) is powered by <a href="https://docs.saloon.dev">Saloon</a>, which is a wonderful package to build modern PHP SDKs. I really had a fun time developing our SDK because Saloon streamlines everything so well.</p>
<p>At the heart of a Saloon powered SDK is <code>Connector</code>. This is the class that has all of the basic information of how to connect to an API. Here's the <code>OhDear</code> connector from our package (redacted for brevity).</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">&lt;?php</span>

<span class="hl-keyword">namespace</span> <span class="hl-type">OhDear\PhpSdk</span>;

<span class="hl-keyword">use</span> <span class="hl-type">Saloon\Http\Connector</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Saloon\Http\Request</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Saloon\Http\Response</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Throwable</span>;

<span class="hl-keyword">class</span> <span class="hl-type">OhDear</span> <span class="hl-keyword">extends</span> <span class="hl-type">Connector</span> <span class="hl-keyword">implements</span><span class="hl-type"> HasPagination
</span>{
    <span class="hl-keyword">use</span> <span class="hl-type">AcceptsJson</span>;
    <span class="hl-keyword">use</span> <span class="hl-type">AlwaysThrowOnErrors</span>;
   
    <span class="hl-keyword">protected</span> <span class="hl-type">string</span> <span class="hl-property">$apiToken</span>;

    <span class="hl-keyword">protected</span> <span class="hl-type">string</span> <span class="hl-property">$baseUrl</span>;

    <span class="hl-keyword">protected</span> <span class="hl-type">int</span> <span class="hl-property">$timeoutInSeconds</span>;

    <span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">__construct</span>(<span class="hl-injection">
        <span class="hl-type">string</span> $apiToken,
        <span class="hl-type">string</span> $baseUrl = 'https:<span class="hl-comment">//ohdear.app/api/',</span>
        <span class="hl-type">int</span> $timeoutInSeconds = 10,
    </span>) {
        <span class="hl-variable">$this</span>-&gt;<span class="hl-property">apiToken</span> = <span class="hl-variable">$apiToken</span>;
        <span class="hl-variable">$this</span>-&gt;<span class="hl-property">baseUrl</span> = <span class="hl-property">rtrim</span>(<span class="hl-variable">$baseUrl</span>, <span class="hl-value">'/'</span>);
        <span class="hl-variable">$this</span>-&gt;<span class="hl-property">timeoutInSeconds</span> = <span class="hl-variable">$timeoutInSeconds</span>;
    }

    <span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">resolveBaseUrl</span>(): <span class="hl-type">string</span>
    {
        <span class="hl-keyword">return</span> <span class="hl-variable">$this</span>-&gt;<span class="hl-property">baseUrl</span>;
    }

    <span class="hl-keyword">protected</span> <span class="hl-keyword">function</span> <span class="hl-property">defaultAuth</span>(): <span class="hl-type">TokenAuthenticator</span>
    {
        <span class="hl-keyword">return</span> <span class="hl-keyword">new</span> <span class="hl-type">TokenAuthenticator</span>(<span class="hl-variable">$this</span>-&gt;<span class="hl-property">apiToken</span>);
    }

    <span class="hl-keyword">protected</span> <span class="hl-keyword">function</span> <span class="hl-property">defaultHeaders</span>(): <span class="hl-type">array</span>
    {
        <span class="hl-keyword">return</span> [
            <span class="hl-value">'Accept'</span> =&gt; <span class="hl-value">'application/json'</span>,
            <span class="hl-value">'Content-Type'</span> =&gt; <span class="hl-value">'application/json'</span>,
        ];
    }

    <span class="hl-keyword">protected</span> <span class="hl-keyword">function</span> <span class="hl-property">defaultConfig</span>(): <span class="hl-type">array</span>
    {
        <span class="hl-keyword">return</span> [
            <span class="hl-value">'timeout'</span> =&gt; <span class="hl-variable">$this</span>-&gt;<span class="hl-property">timeoutInSeconds</span>,
        ];
    }

    <span class="hl-comment">// other methods</span>
}
</pre>
<p>For each different API request, you can create a class that extends <code>Saloon\Http\Request</code>. In the class, you can define the URL that should be called, and the parameters it needs. Here's the <code>GetMonitorRequest</code> that is used to retrieve a single monitor.</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">&lt;?php</span>

<span class="hl-keyword">namespace</span> <span class="hl-type">OhDear\PhpSdk\Requests\Monitors</span>;

<span class="hl-keyword">use</span> <span class="hl-type">OhDear\PhpSdk\Dto\Monitor</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Saloon\Enums\Method</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Saloon\Http\Request</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Saloon\Http\Response</span>;

<span class="hl-keyword">class</span> <span class="hl-type">GetMonitorRequest</span> <span class="hl-keyword">extends</span> <span class="hl-type">Request</span>
{
    <span class="hl-keyword">protected</span> <span class="hl-type">Method</span> <span class="hl-property">$method</span> = <span class="hl-type">Method</span>::<span class="hl-property">GET</span>;

    <span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">__construct</span>(<span class="hl-injection">
        <span class="hl-keyword">protected</span> <span class="hl-type">int</span> <span class="hl-property">$monitorId</span>
    </span>) {}

    <span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">resolveEndpoint</span>(): <span class="hl-type">string</span>
    {
        <span class="hl-keyword">return</span> <span class="hl-value">&quot;/monitors/{$this-&gt;monitorId}&quot;</span>;
    }

    <span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">createDtoFromResponse</span>(<span class="hl-injection"><span class="hl-type">Response</span> $response</span>): <span class="hl-type">Monitor</span>
    {
        <span class="hl-keyword">return</span> <span class="hl-type">Monitor</span>::<span class="hl-property">fromResponse</span>(<span class="hl-variable">$response</span>-&gt;<span class="hl-property">json</span>());
    }
}
</pre>
<p>That <code>Monitor</code> object is a DTO class that will transform the array response from our API to a real PHP object, which is nicer to handle.</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">&lt;?php</span>

<span class="hl-keyword">namespace</span> <span class="hl-type">OhDear\PhpSdk\Dto</span>;

<span class="hl-keyword">class</span> <span class="hl-type">Monitor</span>
{
    <span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">__construct</span>(<span class="hl-injection">
        <span class="hl-keyword">public</span> <span class="hl-type">int</span> <span class="hl-property">$id</span>,
        <span class="hl-keyword">public</span> <span class="hl-type">?int</span> <span class="hl-property">$teamId</span>,
        <span class="hl-keyword">public</span> <span class="hl-type">string</span> <span class="hl-property">$url</span>,
        
        <span class="hl-comment">// other properties omitted for brevity</span>
        
    </span>) {}

    <span class="hl-keyword">public</span> <span class="hl-keyword">static</span> <span class="hl-keyword">function</span> <span class="hl-property">fromResponse</span>(<span class="hl-injection"><span class="hl-type">array</span> $data</span>): <span class="hl-type">self</span>
    {
        <span class="hl-keyword">return</span> <span class="hl-keyword">new</span> <span class="hl-type">self</span>(
            <span class="hl-property">id</span>: <span class="hl-variable">$data</span>[<span class="hl-value">'id'</span>],
            <span class="hl-property">teamId</span>: <span class="hl-variable">$data</span>[<span class="hl-value">'team_id'</span>],
            <span class="hl-property">url</span>: <span class="hl-variable">$data</span>[<span class="hl-value">'url'</span>],
            
            <span class="hl-comment">// other properties omitted for brevity</span>
        );
    }
}
</pre>
<p>Using the connector and the request, you can get results from the API like this.</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">use</span> <span class="hl-type">OhDear\PhpSdk\Requests\Monitors\GetMonitorsRequest</span>;

<span class="hl-variable">$request</span> = <span class="hl-keyword">new</span> <span class="hl-type">GetMonitorsRequest</span>();

<span class="hl-comment">// raw response from the Oh Dear API</span>
<span class="hl-variable">$response</span> = <span class="hl-variable">$ohDear</span>-&gt;<span class="hl-property">send</span>(<span class="hl-variable">$request</span>);

<span class="hl-variable">$monitor</span> = <span class="hl-variable">$response</span>-&gt;<span class="hl-property">dto</span>();
</pre>
<p>By using the connector/request this way, you have full control to customize requests as you see fit (you could for example use <a href="https://docs.saloon.dev/digging-deeper/concurrency-and-pools">Saloon's concurrency functionality</a> this way.)</p>
<p>Now, to make it easier for the package user, I've added methods to the connector class, that wrap up those requests, so it becomes easier to use.</p>
<pre data-lang="php" class="notranslate"><span class="hl-comment">// on the OhDear connector class</span>

<span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">monitor</span>(<span class="hl-injection"><span class="hl-type">int</span> $monitorId</span>): <span class="hl-type">Monitor</span>
{
    <span class="hl-variable">$request</span> = <span class="hl-keyword">new</span> <span class="hl-type">GetMonitorRequest</span>(<span class="hl-variable">$monitorId</span>);

    <span class="hl-keyword">return</span> <span class="hl-variable">$this</span>-&gt;<span class="hl-property">send</span>(<span class="hl-variable">$request</span>)-&gt;<span class="hl-property">dto</span>();
}
</pre>
<p>With this in place, users can just get a monitor like this.</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$monitor</span> = <span class="hl-variable">$ohDear</span>-&gt;<span class="hl-property">monitor</span>(1);

<span class="hl-keyword">echo</span> <span class="hl-variable">$monitor</span>-&gt;<span class="hl-property">url</span>;
</pre>
<p>Easy peasy!</p>
<p>One thing that I'd like to highlight about Saloon, is its <a href="https://docs.saloon.dev/the-basics/testing">amazing testing facilities</a>. Using the fixture recorder, you can easily test endpoints of the real SDK.</p>
<p>Here is the test of that endpoint to get a single monitor.</p>
<pre data-lang="php" class="notranslate"><span class="hl-property">it</span>(<span class="hl-value">'can get a single monitor'</span>, <span class="hl-keyword">function</span> () {
    <span class="hl-type">MockClient</span>::<span class="hl-keyword">global</span>([
        <span class="hl-type">GetMonitorRequest</span>::<span class="hl-keyword">class</span> =&gt; <span class="hl-type">MockResponse</span>::<span class="hl-property">fixture</span>(<span class="hl-value">'monitor'</span>),
    ]);

    <span class="hl-variable">$monitor</span> = <span class="hl-variable">$this</span>-&gt;<span class="hl-property">ohDear</span>-&gt;<span class="hl-property">monitor</span>(82063);

    <span class="hl-property">expect</span>(<span class="hl-variable">$monitor</span>-&gt;<span class="hl-property">url</span>)-&gt;<span class="hl-property">toBe</span>(<span class="hl-value">'https://laravel.com'</span>);
});
</pre>
<p>In the code above you can see that we use Saloon's <code>MockResponse::fixture</code> function. This works a bit like snapshot testing. The first time you run this test, it will call the actual API and store its response in a file in the <code>Fixtures</code> directory of the test suite. The second time this test is run, it will not call the actual API anymore, but use the response saved in the <code>Fixtures</code> directory. And of course, this is a much faster test.</p>
<p>Notice that the test above mentioned the actual id of the created monitor. I don't mind that, because this is from our staging environment, which will be cleaned up by the time you read this. If you have sensitive values returned by your API, Saloon has got your back too as it can <a href="https://docs.saloon.dev/the-basics/testing#redacting-recorded-responses-fixtures">redact fixtures</a>.</p>
<h2 id="in-closing">In closing <a href="#in-closing" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#in-closing">#</a></h2>
<p>When I decided to rebuild our SDK in a modern way, I dreaded it a bit, as it's always a bit of tedious work. But because of <a href="https://docs.saloon.dev">Saloon</a>, I really enjoyed the process. After creating the base setup and a couple of requests, I also used AI to complete most of the endpoints. Because Saloon divides all concerns of an SDK so nicely, the AI didn't have any problems discovering the structure. Be sure to check out <a href="https://github.com/ohdearapp/ohdear-php-sdk">the source code our package</a>, to learn more about Saloon can be used.</p>
<p>We build tools like this because Oh Dear is made <a href="/for/php-developers">for Laravel and PHP developers</a>.</p>
<p>As a user of Oh Dear, I hope you'll enjoy using <a href="https://github.com/ohdearapp/ohdear-php-sdk">our new SDK</a>. All the most important API methods are covered by our SDK. Missing a method? Feel free to open a PR, or just reach out to support and we'll add it!</p>
]]>
            </summary>
                                    <updated>2025-08-27T08:29:59+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Here's how you can monitor your site's SEO performance]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/heres-how-you-can-monitor-your-sites-seo-performance" />
            <id>https://ohdear.app/149</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>SEO is in a weird place right now. About one in five LinkedIn posts in my feed currently claims that SEO is dead, or has been assimilated by LLMs. Do not be remiss, dearest reader, because even an LLM still uses search engines like Google and Bing for web crawling.</p>
<p>In other words, SEO still matters, a lot. Additionally, it's never a bad idea to keep tabs your website's SEO performance. SEO monitoring helps you identify exactly what needs improvement so that your website can get the attention it deserves, without directly emptying your pockets and bidding your life's savings on a couple of keywords.</p>
<h2 id="what-is-seo-monitoring">What is SEO monitoring? <a href="#what-is-seo-monitoring" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#what-is-seo-monitoring">#</a></h2>
<p>SEO monitoring refers to the continuous process of tracking website performance metrics that impact your SEO rankings, simple. It shows you the aspects of your website that could use improvement and what aspects are currently working well. You can gain further insights by comparing your metrics to competitor sites that are currently ranking for keywords you target.</p>
<p>Typically, SEO monitoring involves:</p>
<ul>
<li>Tracking keyword rankings</li>
<li>Analyzing organic traffic patterns</li>
<li>Evaluating technical SEO elements</li>
<li>Monitoring backlink profiles</li>
<li>Assessing page performance metrics</li>
<li>Checking for crawlability and indexation issues</li>
</ul>
<h2 id="why-does-seo-monitoring-matter">Why does SEO monitoring matter? <a href="#why-does-seo-monitoring-matter" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#why-does-seo-monitoring-matter">#</a></h2>
<p>Using SEO monitoring allows you to assign metrics to changes in your website’s rankings. That way, you can make continuous improvements backed by data as opposed to hoping something sticks. Identifying the most impactful improvements you can make allows you to zero in on what matters most. What gets measured, gets managed—you know what it is.</p>
<p>You can also use SEO monitoring to catch technical issues before they impact your rankings. Whether it’s <a href="https://developers.google.com/search/updates/core-updates">an update to Google’s algorithm</a> or a change in your CMS, monitoring SEO metrics allows you to make pre-emptive changes to keep your numbers up.</p>
<h2 id="what-are-some-key-seo-metrics-to-keep-an-eye-on">What are some key SEO metrics to keep an eye on? <a href="#what-are-some-key-seo-metrics-to-keep-an-eye-on" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#what-are-some-key-seo-metrics-to-keep-an-eye-on">#</a></h2>
<p>As with most things in marketing, there’s no shortage of metrics you can monitor. However, there are a few crucial metrics that you need to monitor. We’re going to group the most important metrics into categories so that you can more easily put your metrics to use based on the results you’re looking for.</p>
<h3 id="traffic-metrics">Traffic Metrics</h3>
<p>One of the main reasons to invest in SEO is to drive more traffic to your site. These are the three main metrics you should monitor when trying to increase your traffic:</p>
<ul>
<li><strong>Organic Sessions:</strong> These are the number of people who visit your site through results on search engines.</li>
<li><strong>New versus Returning Visitors:</strong> Higher levels of return visitors signal to search engines that your site consistently provides visitors with value.</li>
<li><strong>Bounce Rate:</strong> This refers to how many people leave shortly after being directed to your site and signals to search engines that your site is not delivering the content users seek.</li>
</ul>
<h3 id="engagement-metrics">Engagement Metrics</h3>
<p>You also want users to engage with your site, not just visit it. Here are likely the most important engagement metrics for SEO monitoring:</p>
<ul>
<li><strong>Pages Per Session:</strong> You want users that find your site through organic searches to explore your other pages. The number of pages per session tells you how many they visit on average.</li>
<li><strong>Scroll Depth:</strong> Users who read an entire page provide you with more value making it an important engagement metric.</li>
<li><strong>Click-through Rate:</strong> Every page comes with an intention for an action from the user. The click-through rate tells you the percentage of people who take that action.</li>
</ul>
<h3 id="technical-seo-metrics">Technical SEO Metrics</h3>
<p>For some sites, certain technical aspects can hold back your performance and therefore ranking, including:</p>
<ul>
<li><strong>Page Load Speed:</strong> If your site suffers from <a href="https://ohdear.app/docs/faq/why-do-you-need-website-performance-monitoring">performance issues</a> and takes too long to load, users bounce from your site before they see your content.</li>
<li><strong>Mobile-friendliness:</strong> Nowadays, most people browse the internet on their phone or tablet, so your site needs to work just as well for them as desktop users. Crazy how we still need to talk about this.</li>
<li><strong>Crawl Errors:</strong> Google can’t rank your site if its bots can’t crawl through it. Look out for crawl errors, such as <a href="https://ohdear.app/news-and-updates/detecting-and-resolving-broken-links-using-website-monitoring-software">broken links</a>, and fix them immediately. Getting on top of issues like these as quickly as possible is the name of the game here.</li>
</ul>
<h3 id="content-performance-metrics">Content Performance Metrics</h3>
<p>The most in-your-face SEO metrics are your content performance metrics, such as:</p>
<ul>
<li><strong>Keyword rankings:</strong> Most people investing in SEO aim to improve their keyword ranking, but that comes from improving other SEO metrics.</li>
<li><strong>Featured snippets:</strong> Oftentimes overlooked, featured snippets can drive more traffic than a high-ranking page.</li>
<li><strong>Backlink quality and quantity:</strong> The number and quality of sites linking to your website help search engine’s assess the trustworthiness and authority of your site.</li>
</ul>
<h2 id="cant-have-an-article-without-a-top-5-of-something">Can't have an article without a top 5 of something! <a href="#cant-have-an-article-without-a-top-5-of-something" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#cant-have-an-article-without-a-top-5-of-something">#</a></h2>
<p>We've talked at length about metrics, let's dive into some practicalities next. Martech vendors are kind of like a plague these days, so there's obviously no shortage of SEO tools around. These are the five most popular ones:</p>
<ol>
<li><strong>Google Search Console:</strong> Showing which queries bring users to your site, identifying indexing issues, and highlighting mobile usability problems makes <a href="https://search.google.com/search-console/about">Google Search Console</a> the core of most monitoring strategies.</li>
<li><strong>Google Analytics:</strong> It’s not specific for SEO, but <a href="https://developers.google.com/analytics">Google Analytics</a> provides tons of data that can help you monitor SEO metrics such as which channels drive traffic and the behavior flow of users.</li>
<li><strong>Ahrefs:</strong> As one of the most expensive SEO monitoring tools, <a href="https://ahrefs.com/">Ahrefs</a> provides a premium monitoring service for just about any metric you could want.</li>
<li><strong>SEMRush:</strong> For a budget-friendly alternative to Ahrefs, consider <a href="https://www.semrush.com/">SEMRush</a>. It’s another all-in-one tool, but it primarily focuses on content.</li>
<li><strong>Screaming Frog:</strong> To identify technical SEO errors, use <a href="https://www.screamingfrog.co.uk/seo-spider/">Screaming Frog</a>. It crawls your site the same way Google does to find issues.</li>
</ol>
<h2 id="dont-forget-about-google-lighthouse">Don't forget about Google Lighthouse <a href="#dont-forget-about-google-lighthouse" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#dont-forget-about-google-lighthouse">#</a></h2>
<p>Now, one can’t discuss SEO monitoring without mentioning <a href="https://ohdear.app/features/lighthouse-seo-monitoring">Google Lighthouse</a>. They provide a pretty complete overview of your site’s performance and then assign it a grade for different categories. It covers everything from mobile-friendliness and content quality to performance and crawlability.</p>
<p>After completing the test, Google Lighthouse gives you actionable tips on how to improve your site. However, their recommendations are fairly technical, so you'll need at least a modicum of SEO expertise to implement their suggestions. It’s a free tool, though, which makes it accessible to anyone and is an overall pretty valuable asset to have.</p>
<h2 id="seo-monitoring-made-even-simpler-with-oh-dear">SEO monitoring made even simpler with Oh Dear <a href="#seo-monitoring-made-even-simpler-with-oh-dear" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#seo-monitoring-made-even-simpler-with-oh-dear">#</a></h2>
<p>Running SEO tests across your site and recording your metrics takes a lot of time. <a href="https://ohdear.app/">Oh Dear</a> simplifies the entire process by integrating Google Lighthouse into our full-fledged website monitoring suite. It automatically tracks key SEO metrics so you can monitor the impact of your SEO efforts as you build or expand your site.</p>
<p>Automatically running daily Google Lighthouse scans also notifies you of issues before they impact your rankings. Whether you accidentally loaded a full-scale image instead of a compressed one or a plugin you used suddenly updated, causing a hit to your site’s performance, you’ll be notified immediately thanks to Oh Dear’s daily scans. Neat!</p>
<p>Lighthouse monitoring is natively integrated with Oh Dear’s broader website monitoring suite so you can truly keep track of every little aspect of your website’s health and performance.</p>
]]>
            </summary>
                                    <updated>2025-08-13T13:37:52+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Why we will be making breaking changes in our API soon]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/why-we-will-be-making-breaking-changes-in-our-api-soon" />
            <id>https://ohdear.app/155</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>In a few weeks, on Tuesday 26st August, we will be deploying breaking changes to our API. In this blog post we want to share why and how we'll do this.</p>
<p>If you're using our API, here are <a href="https://ohdear.app/docs/faq/how-do-i-update-to-the-latest-version-of-the-oh-dear-api">the upgrade instructions</a>.</p>
<h2 id="introducing-ping-and-tcp-port-monitoring">Introducing Ping and TCP Port monitoring <a href="#introducing-ping-and-tcp-port-monitoring" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#introducing-ping-and-tcp-port-monitoring">#</a></h2>
<p>A few weeks ago, we sent a survey out to all our users asking what they like about Oh Dear, how they use it, and how we could improve our service. If you didn't get it, you can still <a href="https://tally.so/r/nPLODP">fill out our survey</a>. It only takes a couple of minutes.</p>
<p>Currently, we can monitor the uptime of any HTTP(s) site. In our survey, our users mentioned that they'd like to use Oh Dear to also monitor MySQL, email, Redis, or any other kind of server.</p>
<p>That's why we're working on adding ping and TCP monitoring. Instead of performing a simple GET request, Oh Dear will be able to send a Unix ping command to a server, and to open up a TCP port to any server you'd like, and send a command through it.</p>
<p>When adding a site to monitor to Oh Dear you will be able to select the type (we'll make this choice much prettier than a simple dropdown when we ship this feature).</p>
<p><img src="/media/blog/y2A3jytKKZ1LWUmjGmWBU1fhTcSnj3O1PPSoiIUz.png" alt="Add monitor dialog with the Monitor Type dropdown open showing HTTP/HTTPS Website, ICMP Ping and TCP Port" /></p>
<p>We already have ping/TCP checks running in our staging environment. Here's how ping results look like.</p>
<p><img src="/media/blog/F7K1VKgUfdwiur0PoN7opXlHIpClH2R4HBWEVRne.png" alt="Uptime report for spatie.be confirming the site is online with a successful ICMP ping check" /></p>
<p>Of course, we'll also record performance data, which will be shown on that page as well when scrolling down.</p>
<p><img src="/media/blog/OVZSeOMji8jQR2u8wk9ZHfECp8cpmgEKwVq1OZtf.png" alt="Performance history chart for spatie.be with a green uptime timeline and one response time spike" /></p>
<p>And here's an example of TCP results.</p>
<p><img src="/media/blog/AHqlZtgo65dlbcS1PeRIGTFiyKvntDBxKY5KMnJ7.png" alt="Uptime report for smtp.gmail.com:587 showing a successful TCP connection and SMTP welcome message" /></p>
<p>For TCP checks, we'll also record connection time and dislay a graph for those times.</p>
<p><img src="/media/blog/KEezvvPq1B0MldgxBOUlZJQuEjUSw18zZmvQYOeW.png" alt="Performance history chart for the smtp.gmail.com:587 TCP port check with fluctuating response times" /></p>
<h2 id="naming-things-is-hard-why-we-are-breaking-our-api">Naming things is hard (why we are breaking our API) <a href="#naming-things-is-hard-why-we-are-breaking-our-api" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#naming-things-is-hard-why-we-are-breaking-our-api">#</a></h2>
<p>Since Oh Dear started, we always had the concept of a &quot;Site&quot; you're monitoring. But the name &quot;Site&quot; is really weird when you change the checking type to ping or TCP. For example, when checking the uptime of a port of a server, you're not really checking a &quot;Site&quot;.</p>
<p>That's why we made the hard choice of renaming the &quot;Site&quot; concept in Oh Dear to &quot;Monitor&quot;. A monitor will have a type:</p>
<ul>
<li>HTTP: for checking websites</li>
<li>Ping: for checking websites and servers via Ping</li>
<li>TCP: for checking the connectivity of a port</li>
</ul>
<p>Here's a screenshot from our staging environment, where we run a &quot;Monitor&quot; for each type. Notice that in the main navigation, we don't have &quot;Sites&quot; anymore, but &quot;Monitors&quot;.</p>
<p><img src="/media/blog/K7savCYDbQxJTookmo45MN2DUlR9PNfeSD2etwPH.png" alt="Oh Dear monitors list with freek.dev, smtp.gmail.com:587 and spatie.be all reporting no issues detected" /></p>
<p>Across our UI, notifications and docs, we'll change the language so we'll talk about &quot;Monitors&quot;, instead of &quot;Sites&quot;. We're also busy with updating our entire codebase. Internally our <code>Site</code> model is renamed to <code>Monitor</code>, together with all relationships, foreign keys, views, controllers... Together with the technical rename, we'll also pay off other technical debt that was accrued over the years.</p>
<p>This will also affect our API. At Oh Dear, we value backwards compatibility. In the eight years that we've been in operation, we've never made a breaking change to our API. We also considered keeping a v1 of our API, but we decided against it because the maintenance burden would be too big.</p>
<h2 id="well-help-prepare-you">We'll help prepare you <a href="#well-help-prepare-you" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#well-help-prepare-you">#</a></h2>
<p>On Tuesday 26st August, we'll be making breaking changes to our API. The &quot;sites&quot; endpoints will be renamed to the &quot;monitors&quot; endpoint. We also rename and regroup some of the attributes.</p>
<p>To help you through these changes, we'll publish a page with instructions on how to update to the new API. We will also update our PHP SDK package and related packages. We'll also send you a dedicated mail with a reminder an upgrade instructions.</p>
<p>To adapt your scripts, see our <a href="/news-and-updates/recipes-for-automating-oh-dear">recipes for automating Oh Dear</a>.</p>
<h2 id="more-features-are-coming">More features are coming <a href="#more-features-are-coming" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#more-features-are-coming">#</a></h2>
<p>Ping/TCP isn't the only new feature we have planned. Our uptime check runs once per minute by default. For most people this is fine, but a lot of users have asked for the ability to specify the frequency of checking. So we'll be adding that.</p>
<p>We also have other exciting things on the table, but it's a bit too early to talk about those. Should you have any suggestions on how to improve Oh Dear, do let us know by <a href="https://tally.so/r/nPLODP">filling in our survey</a>.</p>
<p>We'll close off this post by highlighting some of the features that were requested through our survey:</p>
<ul>
<li>People with a big number of sites in their team reported that the site list screen is slow. We made it faster, and are still optimizing stuff.</li>
<li>There's <a href="https://ohdear.app/team-settings/usage">a new usage screen</a> that shows your usage against your current plan</li>
<li>Maintenance periods can now have a name</li>
<li>We can now <a href="https://ohdear.app/news-and-updates/we-now-support-google-chat">send notifications via Google Chat</a></li>
<li>The certificate health check has a new &quot;Detected Certificates&quot; tab showing the history of certificates we detected for a site.</li>
<li>A graph was added to the lighthouse result history</li>
<li><a href="https://ohdear.app/news-and-updates">Our blog</a> layout was simplified.</li>
<li>We made it more clear across our UI which things are clickable, by adding the cursor-pointer CSS class to those elements</li>
<li>You can now <a href="https://ohdear.app/news-and-updates/managing-monthly-reports-with-the-api">manage monthly reports with our API</a></li>
<li><a href="/features/notifications">Alert snoozing</a> now has full history</li>
<li>A new login method was added: <a href="https://ohdear.app/news-and-updates/you-can-now-log-in-using-passkeys">passkeys</a></li>
<li>Our broken links check has <a href="https://ohdear.app/news-and-updates/broken-links-troubleshooting-and-a-couple-of-extras">a new &quot;Troubleshooting&quot; tab</a>, showing you tips to fix broken links.</li>
<li>We <a href="https://ohdear.app/news-and-updates/introducing-our-improved-uptime-check">merged our uptime and performance check</a></li>
</ul>
]]>
            </summary>
                                    <updated>2026-06-12T08:58:01+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Monitor your Laravel app for vulnerabilities with Oh Dear]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/how-to-monitor-your-laravel-app-for-critical-vulnerabilities-using-oh-dear" />
            <id>https://ohdear.app/154</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>A few months ago, a critical RCE vulnerability landed in Livewire v3 (CVE-2025-54068). Stephen Rees-Carter <a href="https://securinglaravel.com/security-notice-livewire-v3-rce">walked through the impact on Securing Laravel</a>: unauthenticated attackers could trigger remote code execution on apps that mounted a vulnerable component in a particular way. The patch shipped quickly. The problem, as always, was the gap between the patch shipping and you actually deploying it.</p>
<p>That gap is where production apps get owned. You don't get owned because the maintainers were slow. You get owned because nobody on your team noticed the advisory, or someone merged the bump but the deploy silently failed, or the dependency is pinned three layers deep and you didn't realise you were on a vulnerable version.</p>
<p>This post is the practical version of <a href="https://freek.dev/2892-how-to-protect-your-laravel-app-against-critical-vulnerabilities-like-livewires-recent-remote-code-execution-issue">Freek's writeup</a>: a complete walkthrough of wiring <code>spatie/laravel-health</code> into your app, exposing a health endpoint, and pointing Oh Dear at it so you actually find out when one of your dependencies turns into a problem.</p>
<h2 id="the-setup-in-one-sentence">The setup, in one sentence <a href="#the-setup-in-one-sentence" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#the-setup-in-one-sentence">#</a></h2>
<p><code>spatie/laravel-health</code> runs checks inside your app, including a check that compares your installed packages against the FriendsOfPHP security advisories database. Oh Dear pulls the result on a schedule and alerts you the moment something goes red. That's it. The rest is plumbing.</p>
<h2 id="install-the-packages">Install the packages <a href="#install-the-packages" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#install-the-packages">#</a></h2>
<pre data-lang="bash" class="notranslate"><span class="hl-keyword">composer</span> require spatie/laravel-health
<span class="hl-keyword">composer</span> require spatie/security-advisories-health-check
</pre>
<p>The first package is the framework. The second is the addon that does the actual vulnerability scan. It uses the same data source as <code>composer audit</code>, so if <code>composer audit</code> would flag a package, this check will too. Difference is, this one runs in production on a schedule, not just on your laptop when you remember.</p>
<p>Publish the config:</p>
<pre data-lang="bash" class="notranslate"><span class="hl-keyword">php</span> <span class="hl-keyword">artisan</span> vendor:publish <span class="hl-attribute">--tag</span>=<span class="hl-value">&quot;health-config&quot;</span>
</pre>
<h2 id="register-your-checks">Register your checks <a href="#register-your-checks" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#register-your-checks">#</a></h2>
<p>Create a service provider that registers the checks you care about. Security advisories first, the rest because if you've already gone to the trouble of wiring up health checks, you might as well catch a few other things while you're here.</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">namespace</span> <span class="hl-type">App\Providers</span>;

<span class="hl-keyword">use</span> <span class="hl-type">Illuminate\Support\ServiceProvider</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Spatie\Health\Checks\Checks\DatabaseCheck</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Spatie\Health\Checks\Checks\DebugModeCheck</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Spatie\Health\Checks\Checks\EnvironmentCheck</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Spatie\Health\Checks\Checks\HorizonCheck</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Spatie\Health\Checks\Checks\OptimizedAppCheck</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Spatie\Health\Checks\Checks\UsedDiskSpaceCheck</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Spatie\Health\Facades\Health</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Spatie\SecurityAdvisoriesHealthCheck\SecurityAdvisoriesCheck</span>;

<span class="hl-keyword">class</span> <span class="hl-type">HealthCheckServiceProvider</span> <span class="hl-keyword">extends</span> <span class="hl-type">ServiceProvider</span>
{
    <span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">boot</span>(): <span class="hl-type">void</span>
    {
        <span class="hl-type">Health</span>::<span class="hl-property">checks</span>([
            <span class="hl-type">SecurityAdvisoriesCheck</span>::<span class="hl-keyword">new</span>(),
            <span class="hl-type">UsedDiskSpaceCheck</span>::<span class="hl-keyword">new</span>(),
            <span class="hl-type">DebugModeCheck</span>::<span class="hl-keyword">new</span>(),
            <span class="hl-type">EnvironmentCheck</span>::<span class="hl-keyword">new</span>(),
            <span class="hl-type">DatabaseCheck</span>::<span class="hl-keyword">new</span>(),
            <span class="hl-type">HorizonCheck</span>::<span class="hl-keyword">new</span>(),
            <span class="hl-type">OptimizedAppCheck</span>::<span class="hl-keyword">new</span>(),
        ]);
    }
}
</pre>
<p>Register it in <code>bootstrap/app.php</code> inside <code>withProviders()</code>:</p>
<pre data-lang="php" class="notranslate">-&gt;<span class="hl-property">withProviders</span>([
    <span class="hl-type">App\Providers\HealthCheckServiceProvider</span>::<span class="hl-keyword">class</span>,
])
</pre>
<p>At this point, <code>php artisan health:check</code> already works locally. Run it. You'll see a table per check with a status. If you're on a vulnerable Livewire (or anything else FriendsOfPHP knows about), the security check will be red.</p>
<h2 id="run-the-checks-on-a-schedule">Run the checks on a schedule <a href="#run-the-checks-on-a-schedule" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#run-the-checks-on-a-schedule">#</a></h2>
<p>Health checks that don't run aren't health checks, they're decorative code. Schedule the runner so results are always fresh by the time Oh Dear comes looking:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">use</span> <span class="hl-type">Spatie\Health\Commands\RunHealthChecksCommand</span>;

<span class="hl-keyword">protected</span> <span class="hl-keyword">function</span> <span class="hl-property">schedule</span>(<span class="hl-injection"><span class="hl-type">Schedule</span> $schedule</span>): <span class="hl-type">void</span>
{
    <span class="hl-variable">$schedule</span>-&gt;<span class="hl-property">command</span>(<span class="hl-type">RunHealthChecksCommand</span>::<span class="hl-keyword">class</span>)-&gt;<span class="hl-property">everyMinute</span>();
}
</pre>
<p>Every minute is fine. The checks are cheap and the security advisory dataset is cached locally.</p>
<h2 id="expose-the-endpoint-oh-dear-will-read">Expose the endpoint Oh Dear will read <a href="#expose-the-endpoint-oh-dear-will-read" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#expose-the-endpoint-oh-dear-will-read">#</a></h2>
<p>In <code>config/health.php</code>, enable the Oh Dear endpoint:</p>
<pre data-lang="php" class="notranslate"><span class="hl-value">'oh_dear_endpoint'</span> =&gt; [
    <span class="hl-value">'enabled'</span> =&gt; <span class="hl-keyword">true</span>,
    <span class="hl-value">'always_send_fresh_results'</span> =&gt; <span class="hl-keyword">true</span>,
    <span class="hl-value">'secret'</span> =&gt; <span class="hl-property">env</span>(<span class="hl-value">'OH_DEAR_HEALTH_CHECK_SECRET'</span>),
    <span class="hl-value">'url'</span> =&gt; <span class="hl-value">'/oh-dear-health-check-results'</span>,
],
</pre>
<p>Generate a random secret and add it to <code>.env</code>:</p>
<pre data-lang="txt" class="notranslate">OH_DEAR_HEALTH_CHECK_SECRET=replace-with-a-long-random-string
</pre>
<p><code>spatie/laravel-health</code> registers the route for you. Oh Dear hits it, sends the secret, and gets back a JSON document describing the state of every check. That JSON looks roughly like this when something has gone wrong:</p>
<pre data-lang="json" class="notranslate"><span class="hl-property">{</span>
  <span class="hl-keyword">&quot;finishedAt&quot;</span>: 1738879833,
  <span class="hl-keyword">&quot;checkResults&quot;</span>: <span class="hl-property">[</span>
    <span class="hl-property">{</span>
      <span class="hl-keyword">&quot;name&quot;</span>: <span class="hl-value">&quot;SecurityAdvisories&quot;</span>,
      <span class="hl-keyword">&quot;label&quot;</span>: <span class="hl-value">&quot;PHP Package Security&quot;</span>,
      <span class="hl-keyword">&quot;status&quot;</span>: <span class="hl-value">&quot;failed&quot;</span>,
      <span class="hl-keyword">&quot;notificationMessage&quot;</span>: <span class="hl-value">&quot;Found 1 security vulnerability in livewire/livewire&quot;</span>,
      <span class="hl-keyword">&quot;shortSummary&quot;</span>: <span class="hl-value">&quot;1 vulnerability&quot;</span>,
      <span class="hl-keyword">&quot;meta&quot;</span>: <span class="hl-property">{</span>
        <span class="hl-keyword">&quot;vulnerabilities&quot;</span>: <span class="hl-property">[</span>
          <span class="hl-property">{</span>
            <span class="hl-keyword">&quot;package&quot;</span>: <span class="hl-value">&quot;livewire/livewire&quot;</span>,
            <span class="hl-keyword">&quot;version&quot;</span>: <span class="hl-value">&quot;3.6.2&quot;</span>,
            <span class="hl-keyword">&quot;advisories&quot;</span>: <span class="hl-property">[</span><span class="hl-value">&quot;CVE-2025-54068&quot;</span><span class="hl-property">]</span>
          <span class="hl-property">}</span>
        <span class="hl-property">]</span>
      <span class="hl-property">}</span>
    <span class="hl-property">}</span>
  <span class="hl-property">]</span>
<span class="hl-property">}</span>
</pre>
<p>Anything with <code>&quot;status&quot;: &quot;failed&quot;</code> becomes a notification.</p>
<h2 id="point-oh-dear-at-the-endpoint">Point Oh Dear at the endpoint <a href="#point-oh-dear-at-the-endpoint" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#point-oh-dear-at-the-endpoint">#</a></h2>
<p>In Oh Dear, open your site, go to <strong>Application health</strong>, and add the URL:</p>
<pre data-lang="txt" class="notranslate">https://your-app.com/oh-dear-health-check-results
</pre>
<p>Paste the same secret you put in <code>.env</code>. Hit save. Oh Dear will start polling, and the next time <code>RunHealthChecksCommand</code> writes a failed result for the security advisories check, you'll get an alert through whatever channel you've configured: email, Slack, Discord, Teams, webhook, your phone buzzing at 2am, your call.</p>
<p>If you've never set up a notification destination, <a href="/docs/notifications/notifications">the docs walk through it</a>. Slack is the path of least resistance if you don't already have one.</p>
<h2 id="why-this-beats-dependabot-alone">Why this beats Dependabot alone <a href="#why-this-beats-dependabot-alone" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#why-this-beats-dependabot-alone">#</a></h2>
<p>Dependabot is great. We use it on this codebase. But Dependabot scans the repository. Laravel Health scans what's actually running on your servers. Those are not always the same thing:</p>
<ul>
<li>A security PR was merged but the deploy failed and nobody noticed.</li>
<li>The fix is on <code>main</code> but production is pinned to a tag.</li>
<li>You forgot you had an old staging server still running an unpatched version.</li>
<li>A vendored copy of a library is masking the real version Composer thinks is installed.</li>
</ul>
<p>Dependabot catches the first category. Laravel Health plus Oh Dear catches the deployed state. Run both. They cost roughly nothing and they answer different questions.</p>
<p>You can keep an eye on all of this continuously with Oh Dear's <a href="/features/application-health-monitoring">application health monitoring</a>, not just at deploy time.</p>
<h2 id="bonus-catch-it-earlier-with-composer-audit-in-ci">Bonus: catch it earlier with <code>composer audit</code> in CI <a href="#bonus-catch-it-earlier-with-composer-audit-in-ci" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#bonus-catch-it-earlier-with-composer-audit-in-ci">#</a></h2>
<p>Health monitoring tells you about production. CI tells you before a vulnerable dependency ever reaches main. Add <code>composer audit</code> to your GitHub Actions workflow:</p>
<pre data-lang="yaml" class="notranslate"><span class="hl-keyword">name</span><span class="hl-property">:</span> Security Audit

<span class="hl-keyword">on</span><span class="hl-property">:</span>
  <span class="hl-keyword">push</span><span class="hl-property">:</span>
  <span class="hl-keyword">pull_request</span><span class="hl-property">:</span>
  <span class="hl-keyword">schedule</span><span class="hl-property">:</span>
    <span class="hl-property">-</span> <span class="hl-keyword">cron</span><span class="hl-property">:</span> '<span class="hl-value">0 */6 * * *</span>'

<span class="hl-keyword">jobs</span><span class="hl-property">:</span>
  <span class="hl-keyword">security</span><span class="hl-property">:</span>
    <span class="hl-keyword">runs-on</span><span class="hl-property">:</span> ubuntu-latest
    <span class="hl-keyword">steps</span><span class="hl-property">:</span>
      <span class="hl-property">-</span> <span class="hl-keyword">uses</span><span class="hl-property">:</span> actions/checkout@v4
      <span class="hl-property">-</span> <span class="hl-keyword">uses</span><span class="hl-property">:</span> shivammathur/setup-php@v2
        <span class="hl-keyword">with</span><span class="hl-property">:</span>
          <span class="hl-keyword">php-version</span><span class="hl-property">:</span> '<span class="hl-value">8.4</span>'
      <span class="hl-property">-</span> <span class="hl-keyword">run</span><span class="hl-property">:</span> composer install --no-progress
      <span class="hl-property">-</span> <span class="hl-keyword">run</span><span class="hl-property">:</span> composer audit
</pre>
<p>The scheduled cron is the important bit. New advisories get published against existing versions all the time. A <code>push</code> trigger only runs when you commit. The cron run gives you a daily(ish) sanity check against the lockfile that's already on <code>main</code>.</p>
<h2 id="what-youve-got-now">What you've got now <a href="#what-youve-got-now" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#what-youve-got-now">#</a></h2>
<p>After 15 minutes of work, you have:</p>
<ol>
<li>A health check running every minute on every server.</li>
<li>A JSON endpoint Oh Dear can read.</li>
<li>Notifications hitting your inbox or Slack the moment a known CVE shows up against any package in your <code>composer.lock</code>.</li>
<li>CI failing PRs that try to introduce a vulnerable dependency.</li>
<li>A scheduled CI run catching newly disclosed CVEs against your existing lockfile.</li>
</ol>
<p>The Livewire RCE was the prompt for this post, but the setup pays off for every future advisory you don't have to find out about on Twitter at midnight. The packages are free. The CI minutes are free. Oh Dear's Application Health is included in every plan. Set it up once and forget about it until it saves you.</p>
<p>If you live in the terminal, there is <a href="/news-and-updates/an-oh-dear-skill-for-use-in-claude-code-or-codex">an Oh Dear skill for Claude Code and Codex</a>.</p>
<p>If you want to read more on Laravel security in general, <a href="https://securinglaravel.com">Stephen Rees-Carter's Securing Laravel</a> is the best newsletter on the subject. Subscribe to that, set up the health check, and sleep slightly better.</p>
]]>
            </summary>
                                    <updated>2026-04-27T10:42:07+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Here's how you can build site templates for Oh Dear]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/heres-how-you-can-build-site-templates-for-oh-dear" />
            <id>https://ohdear.app/143</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>When you're managing a handful of client sites, setting things up manually is fine. Though if you're managing dozens of them, you're going to think twice about your approach.</p>
<p>For agencies, development teams and platforms who are responsible for loads of websites, having to repeat the same configuration over and over is not only inefficient but also more prone to errors. That’s where this blog post comes in handy.</p>
<p>While Oh Dear doesn’t offer templates via the UI (yet) you can achieve pretty powerful templating patterns using tags, notification channels and the API.</p>
<p>Here’s how you can achieve this:</p>
<h2 id="1-build-a-template-site">1. Build a Template Site <a href="#1-build-a-template-site" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#1-build-a-template-site">#</a></h2>
<p>Start by creating a site in Oh Dear that contains all your default settings. You can do this via the API but it's probably worth doing this via the UI and seeing what's available. Everything you can do in the UI you can do via the API.</p>
<p>For example, by default you might want to:</p>
<ul>
<li>Uptime and performance checks enabled</li>
<li>SSL certificate check enabled with a 30-day expiry threshold</li>
<li>Broken link crawler active</li>
</ul>
<p>Save the site and tag it with <code>template</code> and anything else you might want to identify it with if you have multiple types of sites to share config.</p>
<p>For example, you might have <code>template:marketing</code>, <code>template:app</code> and <code>template:internal</code>.</p>
<h2 id="2-create-notification-channels">2. Create Notification Channels <a href="#2-create-notification-channels" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#2-create-notification-channels">#</a></h2>
<p>Before we get too far into the API, let's consider how we want notification channels to work. We have multiple blog posts on this topic, so we won't go into too much detail here but tag-level notifications are great, extendible way to route client alerts and team-level tags are great for keeping your dev team informed across every site.</p>
<p>This decouples notification logic from individual sites and makes future updates super easy!</p>
<h2 id="3-use-the-api-to-clone-from-template">3. Use the API to Clone from Template <a href="#3-use-the-api-to-clone-from-template" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#3-use-the-api-to-clone-from-template">#</a></h2>
<p>When onboarding a new client site, skip the UI. Use the API to:</p>
<ul>
<li>Fetch your template site</li>
<li>Copy over settings (checks, thresholds, tags, notifications)</li>
<li>Apply any client-specific overrides (tags, server location, client report delivery address, etc.)</li>
</ul>
<p>This ensures consistency without requiring manual duplication. Let's get stuck in:</p>
<pre data-lang="js" class="notranslate"><span class="hl-keyword">const</span> response = <span class="hl-keyword">await</span> <span class="hl-property">fetch</span>(<span class="hl-value">'https://ohdear.app/api/sites'</span>, {
	<span class="hl-property">headers</span>: {
		<span class="hl-value">'Authorization'</span>: <span class="hl-value">'Bearer '</span> + process.<span class="hl-property">env</span>.<span class="hl-property">OH_DEAR_API_KEY</span>,
		<span class="hl-value">'Content-Type'</span>: <span class="hl-value">'application/json'</span>
	}
});

<span class="hl-keyword">const</span> sites = <span class="hl-keyword">await</span> response.<span class="hl-property">json</span>();

<span class="hl-keyword">const</span> templateSite = sites.<span class="hl-property">data</span>.<span class="hl-property">find</span>(site =&gt; site.<span class="hl-property">tags</span>.<span class="hl-property">includes</span>(<span class="hl-value">'template:marketing'</span>));


<span class="hl-keyword">const</span> keysToRemove = [
	<span class="hl-value">'id'</span>, <span class="hl-value">'team_id'</span>, <span class="hl-value">'url'</span>, <span class="hl-value">'label'</span>, <span class="hl-value">'checks'</span>,
];

<span class="hl-comment">// Remove keys that we don't want to copy over</span>
<span class="hl-keyword">const</span> rawData = <span class="hl-type">Object</span>.<span class="hl-property">fromEntries</span>(

  
<span class="hl-type">Object</span>.<span class="hl-property">entries</span>(templateSite).<span class="hl-property">filter</span>(([key]) =&gt; !keysToRemove.<span class="hl-property">includes</span>(key))
);

<span class="hl-comment">// Get the check types that are enabled for the template site</span>
<span class="hl-keyword">const</span> templateCheckTypes = templateSite.<span class="hl-property">checks</span>.<span class="hl-property">filter</span>(check =&gt; check.<span class="hl-property">enabled</span>).<span class="hl-property">map</span>(check =&gt; check.<span class="hl-property">type</span>);

<span class="hl-comment">// Check settings are found under each check instance in the response</span>
<span class="hl-comment">// Here's a way to go through each check and pluck the settings and merge them into a single object</span>
<span class="hl-keyword">const</span> checkSettings = templateSite.<span class="hl-property">checks</span>.<span class="hl-property">map</span>(check =&gt; check.<span class="hl-property">settings</span>).<span class="hl-property">reduce</span>((acc, obj) =&gt; ({ ...<span class="hl-property">acc</span>, ...<span class="hl-property">obj</span> }), {});

<span class="hl-comment">// Build the new site payload</span>
<span class="hl-keyword">const</span> urls = [
<span class="hl-value">&quot;https://example.com/from-template-1&quot;</span>,
<span class="hl-value">&quot;https://example.com/from-template-2&quot;</span>,
<span class="hl-value">&quot;https://example.com/from-template-3&quot;</span>,
]

  

<span class="hl-keyword">for</span> (<span class="hl-keyword">const</span> url <span class="hl-keyword">of</span> urls) {

<span class="hl-keyword">const</span> data = {
	<span class="hl-value">&quot;url&quot;</span>: url,
	<span class="hl-value">&quot;team_id&quot;</span>: templateSite.<span class="hl-property">team_id</span>,
	...<span class="hl-property">rawData</span>,
	...<span class="hl-property">checkSettings</span>,
	<span class="hl-property">checks</span>: templateCheckTypes
}

  

<span class="hl-keyword">const</span> created = <span class="hl-keyword">await</span> <span class="hl-property">fetch</span>(<span class="hl-value">'https://ohdear.app/api/sites'</span>, {
	<span class="hl-property">method</span>: <span class="hl-value">'POST'</span>,
	<span class="hl-property">headers</span>: {
		<span class="hl-value">'Authorization'</span>: <span class="hl-value">'Bearer '</span> + process.<span class="hl-property">env</span>.<span class="hl-property">OH_DEAR_API_KEY</span>,
		<span class="hl-value">'Content-Type'</span>: <span class="hl-value">'application/json'</span>
	},
	<span class="hl-property">body</span>: <span class="hl-type">JSON</span>.<span class="hl-property">stringify</span>(data)
})
</pre>
<p>Wrap this up into a function and you've got yourself a nice little script to clone a template site. Alternatively, you could store a local JSON object with all the default site settings and use the <a href="/docs/api/monitors">add-a-site-with-custom-settings</a> end point directly. On one hand this allow your non-tech team members to update the template site in Oh Dear but at the same time keeping the entire payload in a file means you can keep it in version control and share it with your team.</p>
<h2 id="4-mass-update-all-sites-via-the-api">4. Mass Update All Sites via the API <a href="#4-mass-update-all-sites-via-the-api" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#4-mass-update-all-sites-via-the-api">#</a></h2>
<p>Need to roll out a new policy across all sites (e.g. enable certificate checking with a new threshold)?</p>
<p>Use the API to:</p>
<ul>
<li>Fetch all sites (optionally filter by tag, URL, internal notes etc)</li>
<li>Update only the checks you care about</li>
<li>Apply the new certificate expiration threshold across the board</li>
</ul>
<p>This is especially helpful if you're enforcing compliance or want to roll out new monitoring best practices without point and click effort.</p>
<pre data-lang="js" class="notranslate"><span class="hl-keyword">const</span> response = <span class="hl-keyword">await</span> <span class="hl-property">fetch</span>(<span class="hl-value">'https://ohdear.app/api/sites'</span>, {
	<span class="hl-property">headers</span>: {
		<span class="hl-value">'Authorization'</span>: <span class="hl-value">'Bearer '</span> + process.<span class="hl-property">env</span>.<span class="hl-property">OH_DEAR_API_KEY</span>,
		<span class="hl-value">'Content-Type'</span>: <span class="hl-value">'application/json'</span>
	}
});

<span class="hl-keyword">const</span> sites = <span class="hl-keyword">await</span> response.<span class="hl-property">json</span>();

<span class="hl-keyword">for</span> (<span class="hl-keyword">const</span> site <span class="hl-keyword">of</span> sites.<span class="hl-property">data</span>){

<span class="hl-keyword">const</span> checks = site.<span class="hl-property">checks</span>
	.<span class="hl-property">filter</span>(check =&gt; check.<span class="hl-property">enabled</span>)
	.<span class="hl-property">map</span>(check =&gt; check.<span class="hl-property">type</span>)
	.<span class="hl-property">concat</span>([ 
		<span class="hl-value">&quot;certificate_health&quot;</span>, <span class="hl-comment">// enable certificate health check in all environments</span>
		site.<span class="hl-property">tags</span>.<span class="hl-property">includes</span>(<span class="hl-value">'env:production'</span>) ? <span class="hl-value">&quot;lighthouse&quot;</span> : <span class="hl-keyword">null</span> <span class="hl-comment">// enable lighthouse check for production sites</span>
])
.<span class="hl-property">filter</span>(Boolean);

<span class="hl-keyword">const</span> update = <span class="hl-keyword">await</span> <span class="hl-property">fetch</span>(<span class="hl-value">'https://ohdear.app/api/sites/'</span> + site.<span class="hl-property">id</span>, {
	<span class="hl-property">method</span>: <span class="hl-value">'PUT'</span>,
	<span class="hl-property">headers</span>: {
		<span class="hl-value">'Authorization'</span>: <span class="hl-value">'Bearer '</span> + process.<span class="hl-property">env</span>.<span class="hl-property">OH_DEAR_API_KEY</span>,
		<span class="hl-value">'Content-Type'</span>: <span class="hl-value">'application/json'</span>
	},
	
	<span class="hl-property">body</span>: <span class="hl-type">JSON</span>.<span class="hl-property">stringify</span>({
		<span class="hl-value">&quot;checks&quot;</span>: checks,
		<span class="hl-comment">// check settings</span>
		<span class="hl-value">&quot;certificate_health_check_expires_soon_threshold_in_days&quot;</span>: 10,
		<span class="hl-value">&quot;lighthouse_check_continent&quot;</span>: <span class="hl-value">&quot;europe&quot;</span>,
		<span class="hl-value">&quot;lighthouse_cpu_slowdown_modifier&quot;</span>: 0
	})
});
	
	console.<span class="hl-property">log</span>({
		<span class="hl-property">site</span>: site.<span class="hl-property">id</span>,
		<span class="hl-property">url</span>: site.<span class="hl-property">url</span>,
		<span class="hl-property">status</span>: update.<span class="hl-property">status</span>,
	});
}
</pre>
<p>Hopefully, this can help you get started with templating in Oh Dear. If you have any questions or feedback, please let us know.</p>
]]>
            </summary>
                                    <updated>2025-07-16T11:35:42+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[We now support Google Chat]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/we-now-support-google-chat" />
            <id>https://ohdear.app/147</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>I'm pleased to share that we've can now notify you via Google Chat.</p>
<p>Here's what that looks like:</p>
<p><img src="/media/blog/UGl3CnlGSwUs9nTGL9I668cNZwYZwB3YhHmE2Ub5.png" alt="Google Chat notifications from Oh Dear for immutable.be: site down with HTTP 500, certificate problems, and recovered after 8h 20m" /></p>
<p>Our Google Chat notifications include:</p>
<ul>
<li>Visual status indicators with emojis (✅ for success, ⚠️ for warnings, ❌ for errors)</li>
<li>Clear, concise messaging that tells you exactly what happened</li>
<li>Quick action buttons to view full reports and investigate issues</li>
<li>Consistent formatting that matches the severity of each alert type</li>
</ul>
<p>You can read more on how to set up Google Chat notifications <a href="https://ohdear.app/docs/notifications/google-chat">in our docs</a>.</p>
<p>Of course, we also offer <a href="https://ohdear.app/features/notifications">numerous other channels</a> to notify you when something is wrong with your site.</p>
]]>
            </summary>
                                    <updated>2026-06-12T08:58:01+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Recipes for automating Oh Dear]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/recipes-for-automating-oh-dear" />
            <id>https://ohdear.app/150</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>Today we are releasing <code>oh-dear-api-examples</code> a brand-new open-source repository on GitHub. It curates bite-size scripts, helpers and ideas for helping you bulk-manage your Oh Dear account via the API.</p>
<p><a href="https://github.com/ohdearapp/oh-dear-api-examples">github.com/ohdearapp/oh-dear-api-examples</a></p>
<h3 id="how-it-works">How it works</h3>
<p>Installation in under a minute just following the README.md or:</p>
<pre data-lang="bash" class="notranslate"><span class="hl-keyword">git</span> clone https://github.com/ohdearapp/oh-dear-api-examples.git
<span class="hl-keyword">cd</span> oh-dear-api-examples
<span class="hl-keyword">cp</span> .env.example .env
<span class="hl-keyword">npm</span> install
</pre>
<p>Just clone → npm install → start interacting with your account. This repository is a cookbook <em>not</em> a full SDK. It was created in JavaScript as it's easy to use, reference and port to another language. Copy a function, tweak it, add it to your workflows as needed. 💪</p>
<p>You will find a few different files</p>
<ul>
<li><code>./examples.js</code>: composable functions (create_site_from_existing, sync_status_pages_with_tagged_sites, generateReportSnapshot, …) you can import or run directly</li>
<li><code>report.js</code>: exposes a <code>generateReportSnapshot</code> function that returns a full site report for the month (up to the current day) - useful when recreating Oh Dear's monthly PDF reports</li>
<li><code>cache/</code>: responses are persisted locally so repeated runs are fast and you can inspect the raw JSON response when debugging</li>
<li><code>ohdear.js</code>: a thin wrapper around fetch to interact with the API</li>
<li><code>.env</code>: add you Oh Dear API key here (OH_DEAR_API_KEY=…)</li>
</ul>
<p>Let us know if you have any other use-cases, problems or automation suggestions. This could either be a product/service update or an addition to this repo. Just let us know!</p>
<h3 id="did-you-know">Did you know?</h3>
<p>We have several options to automate Oh Dear further;</p>
<ul>
<li>We have an <a href="https://ohdear.app/docs/tools-and-sdks/the-oh-dear-php-sdk">official Php SDK</a></li>
<li>There's an <a href="https://ohdear.app/docs/tools-and-sdks/our-cli-tool">Oh Dear CLI</a> command</li>
<li>And <a href="https://ohdear.app/docs/tools-and-sdks/3rd-party-integrations-of-oh-dear">more 3rd party integrations</a></li>
</ul>
<p>A handy next step: <a href="/news-and-updates/heres-how-you-can-build-site-templates-for-oh-dear">build site templates for Oh Dear</a>.</p>
<h3 id="need-more">Need more?</h3>
<p>Leave an issue in the repo or contact support for suggestions or more information. We look forward to helping you navigate real world use cases when using Oh Dear API to manage you team and client monitoring.</p>
]]>
            </summary>
                                    <updated>2025-07-20T19:57:58+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to create dynamic status pages on demand]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/how-to-create-dynamic-status-pages-on-demand" />
            <id>https://ohdear.app/142</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>Clients expect transparency, especially when things go wrong. But manually updating a status page during an incident or maintenance window slows you down when speed matters most.</p>
<p>Oh Dear’s status pages are more than just a pretty uptime dashboard. They’re fully API-driven and designed to scale with your workflow. Whether you manage five client sites or five hundred, you can create, update and sync status pages as needed.</p>
<p>Before we get into it, you can find everything there's to know about our status pages in the <a href="/docs/api/status-pages">documentation</a>.</p>
<h2 id="creating-status-pages-via-the-api">Creating status pages via the API <a href="#creating-status-pages-via-the-api" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#creating-status-pages-via-the-api">#</a></h2>
<p>You can programmatically spin up a status page using the Oh Dear API. This includes:</p>
<ul>
<li>Name and title</li>
<li>Sites components to show</li>
<li>Create a status update</li>
</ul>
<p>This is perfect for automating client onboarding from your CRM - provision their site, then create a branded status page as part of the same flow.</p>
<p>Let's create a status page for all of our client sites.</p>
<pre data-lang="js" class="notranslate"><span class="hl-keyword">const</span> response = <span class="hl-keyword">await</span> <span class="hl-property">fetch</span>(<span class="hl-value">'https://ohdear.app/api/status-pages'</span>, {
    <span class="hl-property">headers</span>: {
        <span class="hl-value">'Authorization'</span>: `Bearer ${process.<span class="hl-property">env</span>.<span class="hl-property">OHDEAR_API_KEY</span>}`,
        <span class="hl-value">'Content-Type'</span>: <span class="hl-value">'application/json'</span>
    },
    <span class="hl-property">method</span>: <span class="hl-value">'POST'</span>,
    <span class="hl-property">body</span>: <span class="hl-type">JSON</span>.<span class="hl-property">stringify</span>({
        <span class="hl-value">&quot;team_id&quot;</span>: 1,
        <span class="hl-value">&quot;title&quot;</span>: <span class="hl-value">&quot;Clients&quot;</span>,
        <span class="hl-value">&quot;sites&quot;</span>: []
    })
});

<span class="hl-keyword">const</span> data = <span class="hl-keyword">await</span> response.<span class="hl-property">json</span>();

<span class="hl-keyword">const</span> statusPageId = data.<span class="hl-property">id</span>;
</pre>
<h2 id="syncing-sites-according-to-their-tags">Syncing sites according to their tags <a href="#syncing-sites-according-to-their-tags" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#syncing-sites-according-to-their-tags">#</a></h2>
<p>Once the page is created, use the API to attach monitored sites to it. To keep status pages in sync with your infrastructure, you can pull all sites matching <strong>tag</strong> and attach them to the relevant status page.</p>
<p>In this example we will sync sites tagged with <code>env:production</code> and <code>client:*</code>. This way, new sites added to your system are reflected in public reporting without extra effort. Add this in as a hook to your CRM or on a schedule.</p>
<p>Tip: you can monitor the scheduled task using Oh Dear's cron job monitoring service.</p>
<p>Let's see how we can do this:</p>
<pre data-lang="js" class="notranslate"><span class="hl-comment">// get the status page using the id from the previous step or find it by your custom domain:</span>

<span class="hl-keyword">const</span> statusPages = <span class="hl-keyword">await</span> (<span class="hl-keyword">await</span> <span class="hl-keyword">get</span>(<span class="hl-value">'status-pages'</span>)).<span class="hl-property">json</span>();

<span class="hl-keyword">const</span> statusPage = statusPages.<span class="hl-property">data</span>.<span class="hl-property">find</span>(page =&gt; page.<span class="hl-property">domain</span> === <span class="hl-value">'status.your-site.com'</span>);

<span class="hl-keyword">const</span> sites = <span class="hl-keyword">await</span> (<span class="hl-keyword">await</span> <span class="hl-keyword">get</span>(<span class="hl-value">'sites'</span>)).<span class="hl-property">json</span>();

<span class="hl-keyword">const</span> sitesToSync = sites.<span class="hl-property">data</span>.<span class="hl-property">filter</span>(site =&gt; site.<span class="hl-property">tags</span>.<span class="hl-property">includes</span>(<span class="hl-value">'env:production'</span>) &amp;&amp; site.<span class="hl-property">tags</span>.<span class="hl-property">includes</span>(<span class="hl-value">'client:*'</span>));

<span class="hl-keyword">const</span> response = <span class="hl-keyword">await</span> <span class="hl-property">post</span>(`<span class="hl-property">https</span>:<span class="hl-comment">//ohdear.app/api/status-pages/${statusPage.id}/sites`, {</span>
        <span class="hl-value">&quot;sync&quot;</span>: <span class="hl-keyword">true</span>,
        <span class="hl-value">&quot;sites&quot;</span>: sitesToSync.<span class="hl-property">map</span>(site =&gt; ({
            <span class="hl-value">&quot;id&quot;</span>: site.<span class="hl-property">id</span>,
            <span class="hl-value">&quot;clickable&quot;</span>: <span class="hl-keyword">true</span>
        }))
    })

console.<span class="hl-property">log</span>(response.<span class="hl-property">status</span>);
</pre>
<p>You should see a 200 response and all sites synced to the status page. You can also switch off the sync by setting <code>&quot;sync&quot;: false</code> which turns this endpoint into a append-only operation.</p>
<p>Status page sites are automatically grouped by the 'group name' in your site settings. Note: tags are not visible to the public.</p>
<h2 id="creating-reusable-status-page-templates">Creating reusable status page templates <a href="#creating-reusable-status-page-templates" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#creating-reusable-status-page-templates">#</a></h2>
<p>If you manage multiple clients with similar needs, you can define a <strong>status page template</strong> from the UI or API. This gives you the option to set a default title, text and/or severity with a label for quick selection.</p>
<p>Even though we are using the API one of the biggest benefits of status page templates is the ability to give non-technical team members access to pre-prepared messages to the public. No more back and forth with the team to get a message approved.</p>
<p>Let's see how we can create a new template. You might use this to control the list of messages from your CRM.</p>
<pre data-lang="js" class="notranslate"><span class="hl-keyword">const</span> response = <span class="hl-keyword">await</span> <span class="hl-property">post</span>(<span class="hl-value">'https://ohdear.app/api/status-page-update-templates'</span>, {
    <span class="hl-value">&quot;team_id&quot;</span>: 1,
    <span class="hl-value">&quot;name&quot;</span>: <span class="hl-value">&quot;Major issue template&quot;</span>,
    <span class="hl-value">&quot;title&quot;</span>: <span class="hl-value">&quot;Major issue&quot;</span>,
    <span class="hl-value">&quot;text&quot;</span>: <span class="hl-value">&quot;We are currently experiencing a major issue. Please check back later.&quot;</span>,
    <span class="hl-value">&quot;severity&quot;</span>: <span class="hl-value">&quot;major&quot;</span>
})
</pre>
<p>You might want to make the template more useful by providing more details about the issue. You can view some example templates we have prepared here: https://ohdear.app/news-and-updates/new-feature-status-page-update-templates</p>
<h2 id="setting-up-recurring-maintenance-periods">Setting up recurring maintenance periods <a href="#setting-up-recurring-maintenance-periods" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#setting-up-recurring-maintenance-periods">#</a></h2>
<p>Using the API, you can schedule a <strong>maintenance window</strong> and set the status page’s current status at the same time. This is ideal when deploying updates or rolling out infrastructure changes.</p>
<p>We recommend:</p>
<ol>
<li>Triggering the maintenance state just before deployment starts (if you are zero downtime it might be worth doing this for larger code changes, migrations and performance-impacting work)</li>
<li>Updating the status to &quot;Maintenance scheduled&quot;</li>
<li>Redirecting traffic to the status page during the window if there's downtime involved</li>
</ol>
<p>This improves transparency and helps set expectations regarding performance while you focus on the work.</p>
<p>We frequently get asked about recurring maintenance periods. We don't support it directly in the application as it can be confusing to manage - sometimes it's not obvious why your notifications have been supressed. This is especially important when a maintenance window is cancelled internally but Oh Dear is not aware - potentially causing some alerts to be silenced.</p>
<p>However, the API allow you to set a time in advance and duration for the maintenance window., This way you can schedule as many maintenance windows as you want in preparation scheduled releases. Similarly, status page updates can also be set in advance so you can co-ordinate your alert maintenance windows and public status updates.</p>
<p>Let's see how we can do this:</p>
<pre data-lang="js" class="notranslate"><span class="hl-keyword">const</span> response = <span class="hl-keyword">await</span> <span class="hl-property">post</span>(<span class="hl-value">'https://ohdear.app/api/maintenance-periods'</span>, {
    <span class="hl-value">&quot;site_id&quot;</span>: 1,
    <span class="hl-value">&quot;start_at&quot;</span>: <span class="hl-value">&quot;2025-01-01 09:00&quot;</span>,
    <span class="hl-value">&quot;end_at&quot;</span>: <span class="hl-value">&quot;2025-01-01 10:00&quot;</span>,
});

<span class="hl-keyword">const</span> response = <span class="hl-keyword">await</span> <span class="hl-property">post</span>(<span class="hl-value">'https://ohdear.app/api/status-page-updates'</span>, {
    <span class="hl-value">&quot;status_page_id&quot;</span>: 1,
    <span class="hl-value">&quot;title&quot;</span>: <span class="hl-value">&quot;Scheduled maintenance&quot;</span>,
    <span class="hl-value">&quot;text&quot;</span>: <span class="hl-value">&quot;We are working on it. Please check back later.&quot;</span>,
    <span class="hl-value">&quot;severity&quot;</span>: <span class="hl-value">&quot;maintenance&quot;</span>,
    <span class="hl-value">&quot;time&quot;</span>: <span class="hl-value">&quot;2025-01-01 09:00&quot;</span>,
    <span class="hl-value">&quot;pinned&quot;</span>: <span class="hl-keyword">false</span>
});

</pre>
<p>Now everything is nicely syncronized. Set up as many as you need in advance, trigger it via a cron job or by deployment or via your internal tools - your status page and alerts is only an API call away.</p>
<h2 id="you-can-automate-everything">You can automate everything <a href="#you-can-automate-everything" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#you-can-automate-everything">#</a></h2>
<p>By integrating status page management into your deployment pipeline and client onboarding you give your customers a better experience without extra overhead. And with Oh Dear’s flexible API you're always in control of what gets shared, and when.</p>
<p>The API works across your account (assuming you have scoped the API key appropriately) so you can easily transfer sites, status pages, message templates and notifications between teams.</p>
<p>Let us know what you would like to see in the API to make managing sites as easy as using the UI.</p>
]]>
            </summary>
                                    <updated>2025-07-18T13:19:46+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Oh Dear now lets you verify redirect URLs during uptime checks]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/oh-dear-now-lets-you-verify-redirect-urls-during-uptime-checks" />
            <id>https://ohdear.app/145</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>We’ve just rolled out a helpful new setting to give you tighter control over how uptime is measured: Redirect URL Validation.</p>
<h3 id="why-url-validation-matters">Why URL validation matters</h3>
<p>By default, our uptime checks follow redirects and consider the site to be &quot;up&quot; as long as a 200 OK response is reached, regardless of what the final URL is.</p>
<p>But for some workflows, infrastructure setups or security models, ensuring the final destination matches a specific URL is critical. For example:</p>
<ul>
<li>You expect all traffic from https://example.com to land on https://www.example.com</li>
<li>You're routing through CDNs, reverse proxies or edge workers and want to verify the correct endpoint is reached</li>
<li>You're enforcing domain-level canonicalization</li>
</ul>
<h3 id="introducing-the-codeexpected-final-redirect-urlcode-setting">Introducing the <code>expected_final_redirect_url</code> setting</h3>
<p>Oh Dear has been able to verify the maximum number of redirects allowed before marking the site as &quot;down&quot;.</p>
<p>But with the new <code>expected_final_redirect_url</code> setting we will now inspect the full redirect chain during each uptime check. If the final URL doesn’t exactly match what you’ve configured, the site will be marked as down.</p>
<pre data-lang="json" class="notranslate"><span class="hl-keyword">&quot;expected_final_redirect_url&quot;</span>: <span class="hl-value">&quot;https://www.example.com&quot;</span>
</pre>
<p>If the final resolved URL is anything other than https://www.example.com the check fails.</p>
<h3 id="how-it-works">How it works</h3>
<p>You can configure this setting in the dashboard under your site's uptime check settings or via the API.</p>
<pre data-lang="txt" class="notranslate">PUT /api/sites/{site_id}

{
  &quot;uptime_check_redirect_url&quot;: &quot;https://www.example.com&quot;
}
</pre>
<p>What Happens If It Doesn't Match?</p>
<p>If the resolved URL doesn't match the configured redirect_url exactly:</p>
<ul>
<li>The site will be marked 'down'</li>
<li>The status page (if used) will reflect the downtime</li>
<li>Your configured notifications will be triggered just like with any other failure</li>
</ul>
<h3 id="whats-next">What's next?</h3>
<p>👉 Have questions or ideas for other advanced uptime rules? Reach out to us via support as we’d love to hear them.</p>
<p>Happy monitoring!</p>
]]>
            </summary>
                                    <updated>2025-07-18T13:14:41+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Oh Dear becomes the choice for Dutch education & research]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/oh-dear-becomes-the-preferred-website-monitoring-tool-for-dutch-education-and-research-institutions" />
            <id>https://ohdear.app/146</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p><a href="https://www.surf.nl/">SURF</a> is a Duth IT cooperative in service of educational and research institutions. In essence, they help out these organizations by vetting and building software and other tools that best suit their needs. For years they offered a website monitoring service of their own, SURFopzichter, to affiliated institutions. As of February 1, 2025, the application has been sunset. Since there are plenty more modern alternatives on the market that offer the same services, SURF now refers its members to Oh Dear, among others, as a user-friendly and comprehensive solution for website monitoring. Affiliated universities, colleges, and research institutions can now enjoy all-in-one monitoring with the help of a Belgium-made tool, awesome!</p>
<p>For context, SURF's primary goal is to strengthen the Dutch education and research sector by collaborating on digital services, innovation, and knowledge sharing in ICT. In practice, they are a member organization that provides solutions, knowledge, and expertise in areas such as security, public values, access management, storage, and connectivity. For its members, SURF functions as an association, service provider, and innovation hub, negotiating contracts with suppliers on behalf of its members. Basically, the aim is to provide members with good deals and proper expertise under clear conditions—both in line with (inter)national laws and regulations and shared public values.</p>
<p>“The fact that a large-scale member organization like SURF now offers Oh Dear to its members is a very nice confirmation for us that we have been able to build a reliable and quality product,” says our co-founder <a href="https://x.com/mattiasgeniar">Mattias</a>. “Even though we're small potatoes, we're proud that with Oh Dear, we're supporting the educational landscape and digital learning environments in the Netherlands.”</p>
<p>More than 100 institutions are affiliated with the SURF cooperative, including all Dutch universities, colleges, medical centers, vocational schools (MBO), as well as research institutions like the Dutch Research Council (NWO) and the Royal Library (KB).</p>
<p>“I also love how conscious and diligent people have become when they're looking for a specific tool,” adds our other co-founder, <a href="https://x.com/freekmurze">Freek</a>. “The biggest ballers are not always first-choice anymore. Some people even deliberately avoid the large tech companies from the US and instead opt for a fully EU-based solution. Buy local, as they say!”</p>
<p>Speaking of being EU-based, <a href="https://european-alternatives.eu/product/ohdear">Oh Dear was recently recommended on European Alternatives</a>, a database of alternative solutions to well-known software and tools. This didn't happen overnight, as Oh Dear underwent a pretty thorough assessment in terms of privacy and security.</p>
<p>With SURF’s trust, our boys Freek and Mattias are optimistic about the future. “Partnerships like these give Oh Dear a lot of credibility and confirm that we certainly don’t have to fall short compared to our biggest competitors,” they say.</p>
<p>If, by some miracle, you landed on this page without any context whatsoever, Oh Dear is an all-in-one website monitoring tool. It's a complete solution for uptime, domain, and performance monitoring, with built-in status pages and extensive notification settings. With a focus on transparency, ease of use, and reliability, Oh Dear serves customers worldwide—from sole proprietors to government agencies and large agencies. We can handle anything you throw at us, and we're still working on improving our services on the daily!</p>
]]>
            </summary>
                                    <updated>2025-06-24T21:00:03+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Typehinting Laravel validation rules using PHPStan's type aliases]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/typehinting-laravel-validation-rules-using-phpstans-type-aliases" />
            <id>https://ohdear.app/144</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>Static analysis is a powerful tool that examines your code without actually running it, catching bugs and type mismatches before they reach production. For PHP developers, <a href="https://phpstan.org/user-guide/getting-started">PHPStan</a> has become the go-to solution, and Laravel developers often use Larastan - a wrapper that adds Laravel-specific understanding to PHPStan. In this post, I'll share how I'm using PHPStan's type aliases feature to make validation rule typehints more readable and maintainable in our Laravel application.</p>
<h2 id="what-is-static-analysis-and-why-it-matters">What is static analysis and why it matters <a href="#what-is-static-analysis-and-why-it-matters" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#what-is-static-analysis-and-why-it-matters">#</a></h2>
<p>Static analysis tools like PHPStan analyze your codebase to find errors that might slip through testing. They check for type mismatches, undefined methods, incorrect return types, and more. While PHP has become more type-safe over the years, static analysis adds an extra layer of confidence, especially in large codebases where manual review becomes impractical.</p>
<h2 id="a-bit-of-history">A bit of history <a href="#a-bit-of-history" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#a-bit-of-history">#</a></h2>
<p>When we launched Oh Dear almost 8 years ago, we were using the cutting-edge tools of the time: PHP 7, Laravel 5, and the first version of Laravel Spark. Over the years, we've diligently kept our stack modern, upgrading annually to run on the latest versions. Our extensive test suite (over 2,000 tests) has been crucial in making these upgrades smooth and ensuring everything continues to work correctly.</p>
<p>Despite our commitment to code quality, there was one area I'd been putting off: static analysis.</p>
<p>At Spatie, where I also work, static analysis has been a standard practice for years. Starting new projects with PHPStan from day one is straightforward. But Oh Dear was different - it's a mature application with many many classes. The thought of retrofitting static analysis into such a large existing codebase felt overwhelming. I didn't want to just suppress all the errors in a baseline file; I wanted to fix them properly. But finding the time for such a massive undertaking seemed impossible.</p>
<p>Recently, I finally decided to tackle this technical debt. I started with PHPStan at level 1, methodically fixing every issue before moving to the next level. Now I'm working through level 6, where PHPStan becomes particularly strict about type definitions. The most common issues at this level involve missing or incomplete type hints - and that's where today's story begins.</p>
<h2 id="adding-typehints-to-laravel-form-requests">Adding typehints to Laravel form requests <a href="#adding-typehints-to-laravel-form-requests" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#adding-typehints-to-laravel-form-requests">#</a></h2>
<p>Before diving into the problem, let me briefly explain Laravel's FormRequest objects. In Laravel, FormRequests are classes that encapsulate validation logic for HTTP requests. They keep your controllers clean by moving validation rules to dedicated classes.</p>
<p>Oh Dear has grown into a substantial application with over 100 web and API endpoints, each with its own <code>FormRequest</code> object. Here's a typical example:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">namespace</span> <span class="hl-type">App\Http\Api\Requests</span>;

<span class="hl-keyword">use</span> <span class="hl-type">App\Domain\Site\Rules\SiteIdRule</span>;
<span class="hl-keyword">use</span> <span class="hl-type">App\Domain\Team\Rules\TeamMemberRule</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Illuminate\Foundation\Http\FormRequest</span>;

<span class="hl-keyword">class</span> <span class="hl-type">StoreStatusPageRequest</span> <span class="hl-keyword">extends</span> <span class="hl-type">FormRequest</span>
{
    <span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">rules</span>(): <span class="hl-type">array</span>
    {
        <span class="hl-keyword">return</span> [
            <span class="hl-value">'team_id'</span> =&gt; [<span class="hl-value">'required'</span>, <span class="hl-keyword">new</span> <span class="hl-type">TeamMemberRule</span>(<span class="hl-property">currentUser</span>())],
            <span class="hl-value">'title'</span> =&gt; [<span class="hl-value">'required'</span>, <span class="hl-value">'string'</span>, <span class="hl-value">'max:255'</span>],
            <span class="hl-value">'sites'</span> =&gt; [<span class="hl-value">'required'</span>, <span class="hl-value">'array'</span>],
            <span class="hl-value">'sites.*.id'</span> =&gt; [<span class="hl-value">'required'</span>, <span class="hl-keyword">new</span> <span class="hl-type">SiteIdRule</span>(<span class="hl-property">currentUser</span>())],
            <span class="hl-value">'sites.*.clickable'</span> =&gt; [<span class="hl-value">'bool'</span>],
        ];
    }
}
</pre>
<p>This looks clean and works perfectly. But when PHPStan examines it at level 6, it's not satisfied:</p>
<pre data-lang="txt" class="notranslate"> ------ --------------------------------------------------------------------------------------- 
  Line   StoreStatusPageRequest.php                                                             
 ------ --------------------------------------------------------------------------------------- 
  16     Method App\Http\Api\Requests\StoreStatusPageRequest::rules() return type has no value  
         type specified in iterable type array.                                                 
         🪪 missingType.iterableValue                                                           
         💡 See:                                                                                
            https://phpstan.org/blog/solving-phpstan-no-value-type-specified-in-iterable-type   
 ------ ---------------------------------------------------------------------------------------
</pre>
<p>PHPStan wants to know what's inside the array. Let's start with a simple fix:</p>
<pre data-lang="php" class="notranslate"><span class="hl-comment">// ...</span>

<span class="hl-comment">/** <span class="hl-value">@return</span> <span class="hl-type">array&lt;<span class="hl-generic">string</span>, array&lt;<span class="hl-generic">string</span>&gt;&gt; </span>*/</span>
<span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">rules</span>(): <span class="hl-type">array</span>
{
    <span class="hl-comment">// ...</span>
}
</pre>
<p>This tells PHPStan we're returning an array with string keys and arrays of strings as values. But now we get a different error:</p>
<pre data-lang="txt" class="notranslate"> ------ ------------------------------------------------------------------------------------------
  Line   StoreStatusPageRequest.php
 ------ ------------------------------------------------------------------------------------------
  19     Method App\Http\Api\Requests\StoreStatusPageRequest::rules() should return array&lt;string,
         array&lt;string&gt;&gt; but returns array&lt;string,
         list&lt;App\Domain\Site\Rules\SiteIdRule|App\Domain\Team\Rules\TeamMemberRule|string&gt;&gt;.
         🪪 return.type
 ------ ------------------------------------------------------------------------------------------
</pre>
<p>PHPStan has analyzed our actual return value and found that we're not just returning strings - we're also returning custom rule objects. Let's be more specific:</p>
<pre data-lang="php" class="notranslate"><span class="hl-comment">/** <span class="hl-value">@return</span> <span class="hl-type">array&lt;string, array&lt;\Illuminate\Contracts\Validation\ValidationRule|string&gt;&gt; </span>*/</span>
<span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">rules</span>(): <span class="hl-type">array</span>
{
    <span class="hl-comment">// ...</span>
}
</pre>
<p>Since both <code>SiteIdRule</code> and <code>TeamMemberRule</code> implement Laravel's <code>ValidationRule</code> interface, this type hint is more accurate and flexible. PHPStan is now satisfied:</p>
<pre data-lang="txt" class="notranslate"> [OK] No errors
</pre>
<p>But wait - Laravel's validation system is even more flexible than this. Rules don't always need to be wrapped in arrays, and there are multiple validation contracts. Here's the complete type hint that covers all possibilities:</p>
<pre data-lang="php" class="notranslate"><span class="hl-comment">/** <span class="hl-value">@return</span> <span class="hl-type">array&lt;string, array&lt;\Illuminate\Contracts\Validation\Rule|\Illuminate\Contracts\Validation\ValidationRule|string&gt;|string&gt; </span>*/</span>
<span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">rules</span>(): <span class="hl-type">array</span>
{
    <span class="hl-comment">// ...</span>
}
</pre>
<p>This type hint is accurate but... it's quite a mouthful, isn't it?</p>
<h2 id="type-aliases-to-the-rescue">Type aliases to the rescue <a href="#type-aliases-to-the-rescue" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#type-aliases-to-the-rescue">#</a></h2>
<p>Imagine copying that lengthy type hint to over 100 FormRequest classes. Not only would it be tedious, but it would also create a maintenance nightmare. What if Laravel adds a new validation contract? We'd need to update every single docblock.</p>
<p>Fortunately, PHPStan offers an elegant solution: <a href="https://phpstan.org/config-reference#type-aliases">type aliases</a>. These allow you to define complex types once and reference them by a simple name throughout your codebase.</p>
<p>In your <code>phpstan.neon</code> configuration file, you can define a type alias like this:</p>
<pre data-lang="txt" class="notranslate">parameters:
    level: 6
    
    typeAliases:
        ValidationRules: 'array&lt;string, array&lt;\Illuminate\Contracts\Validation\Rule|\Illuminate\Contracts\Validation\ValidationRule|string&gt;|string&gt;'
</pre>
<p>Now, instead of that complex type hint, you can simply use:</p>
<pre data-lang="php" class="notranslate"><span class="hl-comment">/** <span class="hl-value">@return</span> <span class="hl-type">ValidationRules </span>*/</span>
<span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">rules</span>(): <span class="hl-type">array</span>
{
    <span class="hl-comment">// ...</span>
}
</pre>
<h2 id="in-closing">In closing <a href="#in-closing" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#in-closing">#</a></h2>
<p>Adding static analysis to a large existing codebase can feel daunting, but it doesn't have to be an all-or-nothing endeavor. By starting at level 1 and gradually working your way up, you can improve your code quality incrementally. In our case, implementing PHPStan has already helped us catch several subtle bugs that our tests missed.</p>
<p>Type aliases, in particular, have made the process much more manageable. What could have been a tedious copy-paste exercise across 100+ files became a simple, maintainable solution. The readable type hints also serve as inline documentation, making our codebase more approachable for new team members.</p>
<p>This is the kind of detail we sweat because Oh Dear is made <a href="/for/php-developers">for Laravel and PHP developers</a>.</p>
]]>
            </summary>
                                    <updated>2025-06-18T09:41:32+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[5 tips for managing client sites with Oh Dear]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/5-tips-for-managing-client-sites-with-oh-dear" />
            <id>https://ohdear.app/139</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>Managing dozens (or hundreds) of client sites can quickly become chaotic without the right tools. Whether you're running an agency, internal platform team or dev shop, visibility and control are everything. That's where Oh Dear comes in.</p>
<p>Oh Dear is an all-in-one monitoring service that gives you a unified dashboard for uptime checks, performance monitoring, broken link detection, SSL and domain expiry alerts, scheduled task validation and more. But beyond just running checks Oh Dear also gives you powerful tools for organizing and automating how you manage your client sites at scale.</p>
<p>Here are 5 practical ways to make that happen.</p>
<hr />
<h2 id="1-use-tags-to-organize-and-notify">1. Use Tags to Organize and Notify <a href="#1-use-tags-to-organize-and-notify" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#1-use-tags-to-organize-and-notify">#</a></h2>
<p>When you're managing a fleet of sites, grouping them by client, environment, region or team is essential. Oh Dear lets you apply <strong>tags</strong> in any format but we suggest something like:</p>
<ul>
<li><code>client:spatie</code></li>
<li><code>env:production</code></li>
<li><code>infra:cloudflare</code></li>
<li><code>region:eu-west-1</code></li>
<li><code>project:rebrand</code></li>
</ul>
<p>You are probably already aware that you can route notifications to tags or groups of tags.  Once your tags are set up, you can configure <strong>tag group notifications</strong>. This means when something goes wrong on any site tagged with <code>env:production</code> + <code>client:*</code> for example, you can notify a VIP Slack channel or OpsGenie team automatically - no manual routing needed while <code>env:staging</code> + <code>internal</code> can go to the email inbox that no one reads.</p>
<p>See it in practice: <a href="/news-and-updates/why-full-service-agency-marbles-choose-oh-dear-for-its-uptime-monitoring">how full-service agency Marbles uses Oh Dear</a>.</p>
<p>Tags also make onboarding easy. New production application for a <code>client</code>? Just tag it accordingly <code>client:spatie</code> and all the right checks and notifications apply.</p>
<hr />
<h2 id="2-find-sites-by-tag-via-global-search">2. Find Sites by Tag via Global Search <a href="#2-find-sites-by-tag-via-global-search" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#2-find-sites-by-tag-via-global-search">#</a></h2>
<p>Tags aren't just for routing notifications - they’re fully searchable. Open the global search menu in Oh Dear and type any tag (like <code>region:eu-west-1</code> or <code>team:seo</code>) to instantly filter your sites.</p>
<p>This is especially handy for large accounts where you need to quickly check in on just the production sites in Europe or just the ones belonging to a specific customer.</p>
<hr />
<h2 id="3-trigger-health-checks-on-deployment-via-the-api">3. Trigger Health Checks on Deployment via the API <a href="#3-trigger-health-checks-on-deployment-via-the-api" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#3-trigger-health-checks-on-deployment-via-the-api">#</a></h2>
<p>Oh Dear provides an API endpoint to <strong>trigger checks manually</strong>, which is perfect for CI/CD workflows.</p>
<p>This ensures you catch issues <em>before</em> the client does, and keeps your deployment pipeline tightly integrated with live monitoring. Here's an article explaining how you can run a new broken links check on-demand: https://ohdear.app/news-and-updates/trigger-an-on-demand-uptime-broken-links-check-after-a-deploy</p>
<p>But you can also apply the same principles to all of our other checks. My favourites are:</p>
<ul>
<li>Lighthouse</li>
<li>Sitemap</li>
</ul>
<pre data-lang="js" class="notranslate"><span class="hl-keyword">const</span> OHDEAR_TOKEN = <span class="hl-value">'your API token'</span>;

<span class="hl-comment">// you can find this via the /sites/{id} endpoint</span>
<span class="hl-comment">// or from the check settings in the app</span>
<span class="hl-keyword">const</span> checkId = 1; 

<span class="hl-property">fetch</span>(<span class="hl-value">'https://ohdear.app/api/checks/1/request-run'</span>, {
  <span class="hl-property">method</span>: <span class="hl-value">'POST'</span>,
  <span class="hl-property">headers</span>: {
    <span class="hl-value">'Authorization'</span>: `Bearer ${OHDEAR_TOKEN}`,
    <span class="hl-value">'Accept'</span>: <span class="hl-value">'application/json'</span>,
    <span class="hl-value">'Content-Type'</span>: <span class="hl-value">'application/json'</span>
  }
})
  .<span class="hl-property">then</span>(res =&gt; {
    <span class="hl-keyword">if</span> (!res.<span class="hl-property">ok</span>) <span class="hl-keyword">throw</span> <span class="hl-keyword">new</span> <span class="hl-type">Error</span>(`HTTP ${res.<span class="hl-property">status</span>}: ${res.<span class="hl-property">statusText</span>}`);
    <span class="hl-keyword">return</span> res.<span class="hl-property">json</span>();
  })
  .<span class="hl-property">then</span>(data =&gt; console.<span class="hl-property">log</span>(<span class="hl-value">'Check run requested:'</span>, data))
  .<span class="hl-property">catch</span>(err =&gt; console.<span class="hl-property">error</span>(<span class="hl-value">'Error triggering check:'</span>, err));
</pre>
<p>Our uptime checks and application health checks run every minute - so that's two extra things we don't have to worry about.</p>
<p>You could also add a maintenance window if you don't have zero-downtime deployments or you are expecting some temporary latency after go-live. Read more here: https://ohdear.app/docs/features/how-to-configure-maintenance-windows</p>
<hr />
<h2 id="4-automate-site-creation-using-templates">4. Automate Site Creation Using Templates <a href="#4-automate-site-creation-using-templates" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#4-automate-site-creation-using-templates">#</a></h2>
<p>When spinning up new client sites speed and consistency is key. Rather than configuring everything from scratch, you can:</p>
<ul>
<li>Assign one of your sites as a <strong>template site</strong> using tag: <code>template</code> with your base checks, tags and settings.</li>
<li>Use the API to <strong>clone</strong> that site when onboarding a new client.</li>
<li>We support all site settings from our API so you can customize the new site as needed (e.g. domain name, team assignments) at the same time.</li>
<li>Add the site to an internal status page and client-facing status page both with custom domains. (Yes you can have as many status pages, custom domains and sites as you need. Included in every plan!)</li>
</ul>
<p>This approach saves time, ensures consistency across all clients and reduces configuration drift.</p>
<p>You are in good company. Oh Dear is <a href="/news-and-updates/oh-dear-becomes-the-preferred-website-monitoring-tool-for-dutch-education-and-research-institutions">the preferred tool for Dutch education and research</a>.</p>
<hr />
<h2 id="5-control-access-with-fine-grained-permissions">5. Control Access with Fine-Grained Permissions <a href="#5-control-access-with-fine-grained-permissions" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#5-control-access-with-fine-grained-permissions">#</a></h2>
<p>Not every stakeholder needs to see everything. Oh Dear lets you <strong>invite clients or teammates</strong> with scoped access:</p>
<ul>
<li>Give dev teams access to performance and scheduled task checks.</li>
<li>Let clients see uptime and SSL status only.</li>
<li>Prevent accidental edits to monitoring settings.</li>
</ul>
<p>This keeps your workspace clean and secure while still being transparent with stakeholders.</p>
<p>You can also add clients to the monthly site reports so they will automatically be up to date. We run reports on the first day of every month.</p>
<p>Tip: client email address can be added to the monthly site report via sites API.</p>
<hr />
<h2 id="bonus-add-site-status-badges">Bonus: Add Site Status Badges <a href="#bonus-add-site-status-badges" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#bonus-add-site-status-badges">#</a></h2>
<p>Want to show the latest status on your client sites? Oh Dear provides a <strong>site badge</strong> you can embed showing current uptime status.</p>
<pre data-lang="js" class="notranslate">
<span class="hl-comment">// https://ohdear.app/api/sites/{id}</span>

{
   ...
   <span class="hl-value">&quot;badge_id&quot;</span>: <span class="hl-value">&quot;01jeb77smcek1f4dxys08vect5&quot;</span>,
   ...
}

</pre>
<p>Then update your content with:</p>
<pre data-lang="html" class="notranslate">&lt;<span class="hl-keyword">img</span> <span class="hl-property">src</span>=&quot;https://ohdear.app/badges/site/{badge_id}/uptime/normal&quot; <span class="hl-property">alt</span>=&quot;uptime status badge&quot; /&gt;
</pre>
<p>It's a small touch that builds trust with clients and surfaces real-time updates to visitors.</p>
<hr />
<h2 id="whats-next">What's Next? <a href="#whats-next" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#whats-next">#</a></h2>
<p>In the next few blog posts, we'll dive deeper into each of these tips with hands-on code examples using the Oh Dear API, tips for setting up automation and ideas for streamlining your agency workflows even further.</p>
<p>Stay tuned!</p>
]]>
            </summary>
                                    <updated>2025-07-16T11:57:53+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Introducing our improved uptime check]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/introducing-our-improved-uptime-check" />
            <id>https://ohdear.app/140</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>The past few months, we’ve working on improving our uptime check. We proud to announce that this improved check is now available for all users. You don’t have to do anything to get it (unless you are not subscribed to Oh Dear, in that case your should subscribe to Oh Dear 😄), all our users now have it enabled by default.</p>
<p>In this blogpost, I’d like to give an overview of the changes and some background why we changed some things.</p>
<h2 id="a-bit-of-history">A bit of history <a href="#a-bit-of-history" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#a-bit-of-history">#</a></h2>
<p>When Oh Dear was initially built, we only had a handful of checks: uptime, certificate health, and broken links.</p>
<p>Over time we added more checks: DNS, application health, sitemap, <a href="https://ohdear.app/docs/features/what-checks-does-oh-dear-perform">and a couple more</a>. One of the checks we introduced after launch was the performance check. It displays a graph that shows the response times of your site. For each minute it has a data point.</p>
<p><img src="/media/blog/FsaJo0P4SMMKpEYEozKA8TfxbTuDopWLNelbQLGj.png" alt="Performance history area chart over the last hour with highest, lowest and average response time figures" /></p>
<p>Under the hood the performance check is powered by the uptime check. Each time we ping your site to verify it is online, we collect response time data for the performance check. That's why for the performance check to work, you had to have the uptime check enabled.</p>
<p>Because the vast majority of our users have both the uptime and the performance check enabled for their sites, and both of these checks are technically so intertwined, we decided to merge them together.</p>
<h2 id="lets-take-a-look-at-new-uptime-check">Let’s take a look at new uptime check <a href="#lets-take-a-look-at-new-uptime-check" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#lets-take-a-look-at-new-uptime-check">#</a></h2>
<p>In our UI, you'll only see the uptime check, but the new results page now also includes the performance graph. Here's how that new page looks like.</p>
<p><img src="/media/blog/rPuRmxulQMWgErDgRw9KCkde3gnJrBDczi94UZR9.jpg" alt="Oh Dear uptime report for spatie.be showing site is online, check details and a performance history chart" /></p>
<p>We also improved what you see when the site is down. The alert is now much more clear, and you can take certain actions right from the alert: request a new run of the check, view your site. You can even post an update directly to the status page that contains your site.</p>
<p><img src="/media/blog/mnhWcHQ6tDt45p6AnxXLz8aR8GGZXPeQAoqU1XvV.png" alt="Oh Dear uptime report for freek.dev showing the site is down for 12s with an HTTP 500 error description" /></p>
<p>Behind the scenes, we performed an additional technical upgrade. Instead of storing the performance data in our local MySQL database, it now gets saved in a ClickHouse database. This is a database specialized in working with time-based data. This unlocks future improvements we have in mind.</p>
<h2 id="in-closing">In closing <a href="#in-closing" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#in-closing">#</a></h2>
<p>We do hope that you like our new uptime check. Should you have any remarks, questions or feature requests, <a href="email:support@ohdear.app">let us know</a>!</p>
]]>
            </summary>
                                    <updated>2026-06-12T08:58:01+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Managing monthly reports with the API]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/managing-monthly-reports-with-the-api" />
            <id>https://ohdear.app/138</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>On the first of every month we generate an extensive PDF report for every site. This report contains a summary of all check results for the month and is a snapshot available to you and your team via email and the Oh Dear dashboard. We keep the report history so each month can be viewed in a browser or downloaded as a PDF.</p>
<p>This report can also be emailed to any email address - not just team members - perfect for keeping your customers informed.</p>
<h2 id="managing-reports-via-api">Managing reports via API <a href="#managing-reports-via-api" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#managing-reports-via-api">#</a></h2>
<p>Up until now you could enable this feature from the 'Monthly reports' menu within your site details dashboard where you can also find the history but you could not update these settings from the API.</p>
<p>We have been adding more and more support to our API recently including snoozing individual scheduled task and application health items, managing status pages, viewing sitemap reports as well as exposing all check settings.</p>
<p>We have now made it possible to create and update sites with <code>send_report_to_emails</code> and <code>include_check_types_in_report</code> parameters in the payload to control who receives an email and what appears on the report.</p>
<p>Here's an example where i'm updating <code>your_site_id</code> with just the new monthly report parameters</p>
<p>POST https://ohdear.app/api/sites/{your_site_id}</p>
<p>Payload</p>
<pre data-lang="json" class="notranslate"><span class="hl-property">{</span>
	<span class="hl-keyword">&quot;send_report_to_emails&quot;</span>: <span class="hl-property">[</span>
		<span class="hl-value">&quot;sean@ohdear.app&quot;</span>,
		<span class="hl-value">&quot;mattias@ohdear.app&quot;</span>,
		<span class="hl-value">&quot;freek@ohdear.app&quot;</span>
	<span class="hl-property">]</span>,
	<span class="hl-keyword">&quot;include_check_types_in_report&quot;</span>: <span class="hl-property">[</span>
	  <span class="hl-value">&quot;uptime&quot;</span>,
      <span class="hl-value">&quot;performance&quot;</span>,
      <span class="hl-value">&quot;broken_links&quot;</span>,
      <span class="hl-value">&quot;mixed_content&quot;</span>,
      <span class="hl-value">&quot;lighthouse&quot;</span>,
      <span class="hl-value">&quot;cron&quot;</span>,
      <span class="hl-value">&quot;application_health&quot;</span>,
      <span class="hl-value">&quot;sitemap&quot;</span>,
      <span class="hl-value">&quot;dns&quot;</span>,
      <span class="hl-value">&quot;domain&quot;</span>,
      <span class="hl-value">&quot;certificate_health&quot;</span>,
      <span class="hl-value">&quot;certificate_transparency&quot;</span>
	<span class="hl-property">]</span>
<span class="hl-property">}</span>
</pre>
<h2 id="whats-next">What's next? <a href="#whats-next" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#whats-next">#</a></h2>
<p>Have you got any feature requests or anything you would like to see in the API that we haven't covered yet?</p>
]]>
            </summary>
                                    <updated>2025-05-12T09:06:39+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Here are 10 ways to prevent website downtime]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/here-are-10-ways-to-prevent-website-downtime" />
            <id>https://ohdear.app/137</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>Every minute of website downtime cost large organizations <a href="https://www.forbes.com/councils/forbestechcouncil/2024/04/10/the-true-cost-of-downtime-and-how-to-avoid-it/">an average of $9,000</a>. That’s half a million dollars every hour, damn. And that’s just the average. If your organization heavily relies on your website to do business, that cost can increase even further. Needless to say, preventing website downtime is a top priority.</p>
<p>Before the how, the why: <a href="/news-and-updates/why-your-business-cant-afford-to-skip-website-monitoring">why your business can't afford to skip monitoring</a>.</p>
<h2 id="leading-causes-of-website-downtime">Leading causes of website downtime <a href="#leading-causes-of-website-downtime" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#leading-causes-of-website-downtime">#</a></h2>
<p>Before we jump into ways of preventing website downtime, let’s first cover the leading causes of website downtime. If you can diagnose the cause of the downtime, you’ll be in a much better spot to prevent it from happening again. You don't want the perpetual Whac-a-Mole. So, here are the eight leading causes of website downtime:</p>
<ul>
<li><strong>Server issues:</strong> Whether you run your own website servers or pay for third-party hosting, hardware or software issues with your website’s servers can cause your site to go down.</li>
<li><strong>Overwhelmed resources:</strong> A sudden spike in traffic to your website can cause your servers to become overwhelmed preventing additional traffic from accessing your site.</li>
<li><strong>Expired SSL certificates:</strong> Although this doesn’t cause your website to go down, an expired SSL certificate should be treated the same. Most users will navigate away from your site if they receive a warning saying your site is insecure which occurs with an expired SSL certificate.</li>
<li><strong>DNS configuration issues:</strong> Your DNS needs to be configured correctly so that users are directed to your site when they navigate to your site’s URL. A DNS issue prevents people from accessing your site through your domain name.</li>
<li><strong>Software conflicts:</strong> Most sites use software to handle the backend of their website. Any errors with the software, which can occur after an update or due to conflicts between multiple software running different parts of a site’s backend, can cause your site to go down.</li>
<li><strong>Cyberattacks:</strong> Malicious actors can bring your website down with cyberattacks such as <a href="https://www.sciencedirect.com/topics/computer-science/distributed-denial-of-service">DDoS attacks</a> which artificially overwhelm your hosting infrastructure.</li>
<li><strong>Human error:</strong> Mistakes happen more often than we think. Thankfully, if it was human error that took down your website, it usually means you can get it back up by reverting the action that caused the downtime.</li>
<li><strong>Maintenance gone awry:</strong> During scheduled maintenance, things can go wrong that cause the downtime to extend beyond the planned maintenance time.</li>
</ul>
<h2 id="10-ways-to-help-you-to-prevent-website-downtime">10 ways to help you to prevent website downtime <a href="#10-ways-to-help-you-to-prevent-website-downtime" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#10-ways-to-help-you-to-prevent-website-downtime">#</a></h2>
<p>So, how do you prevent those eight leading causes of website downtime from occurring? We’re going to cover the 10 most effective strategies to prevent website downtime and minimize its impact.</p>
<h3 id="implement-global-uptime-monitoring">Implement global uptime monitoring</h3>
<p>To prevent website downtime, you first need to <a href="/uptime-statistics">know when it happens</a>. That can help you identify issues causing your website to go down. It also allows you to take immediate action to bring your site back up when it goes down.</p>
<p>If you are new to this, start with <a href="/news-and-updates/how-does-website-monitoring-even-work">how website monitoring actually works</a>.</p>
<h3 id="monitor-your-ssl-certificates">Monitor your SSL certificates</h3>
<p>Like we mentioned, issues with your SSL certificate won’t cause website downtime, but it does divert traffic. When issues with your SSL certificate exist, anyone navigating to your website will be greeted with a big warning from their browser saying your site is not secure. Most people do not choose to proceed to your site after that warning. SSL certificate monitoring ensures you’re the first person to know about any issues with your certificate so you can fix it before they impact your users.</p>
<h3 id="set-up-performance-monitoring">Set up performance Monitoring</h3>
<p>Pages that load slowly can have just as big of an impact as a page being down. When a page takes over three seconds to load, nearly half of users will leave your site. Performance monitoring keeps an eye on how long it takes your site to load and tells you what’s bogging down your load times. That gives you the information you need to make your site faster.</p>
<h3 id="implement-dns-monitoring">Implement DNS monitoring</h3>
<p>Internal tests can easily miss DNS errors. Afterall, an issue with your DNS won’t prevent your server from sending data to a test environment. But, it does prevent your users from accessing your website. You need your DNS configured correctly to ensure your users can access your site through your domain name. DNS monitoring checks your configuration, ensures users can access your domain, and tells you where the error exists if there is an issue with your DNS records.</p>
<h3 id="schedule-regular-backups">Schedule regular backups</h3>
<p>Downtime is not 100% avoidable. There will be errors that happen whether they’re human errors, an issue with your hosting servers, or a software issue. Scheduling regular backups allows you to rollback your changes at any time while minimizing progress loss. This can immediately bring your website back up if you run into software issues or a human error that takes down your site.</p>
<h3 id="implement-load-balancing">Implement load balancing</h3>
<p>Without load balancing, a single server becoming overwhelmed can prevent some users from accessing your website. Load balancing spreads out the traffic to your website between multiple servers preventing any one server from becoming overwhelmed. This makes your website more scalable as you can add more servers to accommodate increases in traffic.</p>
<h3 id="monitor-scheduled-tasks">Monitor scheduled tasks</h3>
<p>Whether its database or server maintenance, keep track of scheduled tasks and monitor their progress. These tasks can fail and if not monitored, can cause cascading effects that create downtime for your website.</p>
<h3 id="check-for-mixed-content">Check for mixed content</h3>
<p>Mixed content doesn't cause downtime, but it does create a poor user experience. Browsers can identify mixed content and then send users a security warning just like with SSL certificate issues. Monitoring for this type of content tells you what pages it affects so that you can fix it.</p>
<h3 id="set-up-application-health-monitoring">Set Up application health monitoring</h3>
<p>Most modern websites use various applications to run all the aspects of their site. These applications need to work in tandem for every part of your site to work. Application health monitoring notifies you of issues within an application and with the integration of each application so that it can work cohesively.</p>
<h3 id="create-and-maintain-a-status-page">Create and maintain a status page</h3>
<p>When downtime occurs, communication is critical. You want your users to know that your website will be coming back up and, if you can, give them a timeframe of when they can expect your site to be back up. So, create and maintain a status page with all relevant information you can display during downtime.</p>
<h2 id="monitoring-your-website">Monitoring your website <a href="#monitoring-your-website" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#monitoring-your-website">#</a></h2>
<p>One more: do not forget your cron jobs, <a href="/news-and-updates/scheduled-task-monitoring-now-available-to-all-our-users">scheduled task monitoring</a> covers those.</p>
<p>It can sound overwhelming to manage all this stuff just to prevent downtime and fix issues before they impact your users. But guess what, modern website monitoring solutions—like <a href="https://ohdear.app/register">Oh Dear</a> of course—can manage all of the above with minimal effort from your end. They offer you a single dashboard to track all relevant aspects of your website from your performance and uptime to your SSL certificate and DNS records. You can try OhDear for free for 30 days to see how website monitoring can help you better manage your sites.</p>
<h2 id="faqs-about-website-downtime">FAQs about website downtime <a href="#faqs-about-website-downtime" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#faqs-about-website-downtime">#</a></h2>
<p><strong>How can I quickly respond to website downtime?</strong>
Website monitoring is the best way to get notified of website downtime immediately so that you can respond to it as quickly as possible.</p>
<p><strong>Should I notify users about downtime?</strong>
Yes, proactive communication is the best way to minimize the impacts of downtime to your end user. Be open about the cause of the downtime, how long you expect it to impact users, and the extent of the impact.</p>
<p><strong>How do I diagnose the cause of website downtime?</strong>
You can start your diagnosis with the monitoring tool that notified you of the downtime. It should tell you the general cause of the error based on the HTTP status code the monitoring service received to identify the downtime. From there, you need to systematically test ever aspect related to the general cause until you find the point of failure.</p>
<p><strong>What should I include in my website downtime status page?</strong>
When displaying a status page notifying users that your website is currently down, we recommend including information related to when they can expect your website to come back up as well as a sign-up form that allows users to receive a notification when your website is back up.</p>
]]>
            </summary>
                                    <updated>2026-07-06T10:18:18+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[You can now log in using passkeys]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/you-can-now-log-in-using-passkeys" />
            <id>https://ohdear.app/136</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>We’ve added a new option to log in to Oh Dear: passkeys!</p>
<p>When logging in using a passkey, you don’t have to type an email or password, and you won’t be redirect to a third party for logging in. It’s also super fast.</p>
<h2 id="how-to-use-passkeys-at-oh-dear">How to use passkeys at Oh Dear <a href="#how-to-use-passkeys-at-oh-dear" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#how-to-use-passkeys-at-oh-dear">#</a></h2>
<p>Here’s me logging in to Oh Dear using a passkey.</p>
<video width="640" height="360" controls>
  <source src="https://ohdear.app/media/videos/passkey-login.mp4" type="video/mp4">
  Your browser does not support the video tag.
</video>
<p>Cool right?</p>
<p>If you want to use a passkey to login, just head over to <a href="https://ohdear.app/user/profile">your profile page</a> and scroll down to the passkeys section. There you can create a new passkey.</p>
<p><img src="/media/blog/8ttfdBYlLRuNp2mawuhxCu2S0EsWIhLe9oUndlaS.png" alt="Oh Dear account settings Passkeys section with a new passkey name field and Create Passkey button" /></p>
<p>Please note that you need a modern browser and OS to use passkeys. For MacOS, the password app will be used to store your passkey. If you have 1Pass installed, it will offer to safe your passkey.</p>
<p>With the passkey stored in your password app of your choice, you can now use it. Just head over to the login page and click “Login with passkey” to login.</p>
<p>Easy peasy!</p>
<h2 id="learning-more">Learning more <a href="#learning-more" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#learning-more">#</a></h2>
<p>If you want to know more about passkeys, head over to the documentation of the <a href="https://spatie.be/docs/laravel-passkeys">spatie/laravel-passkeys</a> package, which we are using behind to scenes.</p>
]]>
            </summary>
                                    <updated>2026-06-12T08:58:01+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How does website monitoring even work?]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/how-does-website-monitoring-even-work" />
            <id>https://ohdear.app/134</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>Every website manager knows that feeling when you look at your inbox only to find a customer notifying you that a core page of your site is down. The worst part of it all, you don’t know how long that page has been down for. If you’ve yet to experience that, count your blessings. Well, unless you decide to opt for a website monitoring solution before it happens to you.</p>
<p>With website monitoring, you can ensure every page on your site is up and running at all times. And if any of them go down, you’ll be the first person to know. That way, you can fix the problem before it impacts any of your customers.</p>
<h2 id="the-technical-process-of-website-monitoring">The technical process of website monitoring <a href="#the-technical-process-of-website-monitoring" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#the-technical-process-of-website-monitoring">#</a></h2>
<p>Obviously, website monitoring monitors your website. But, how does that process actually work? It’s simpler than it sounds. Basically, the software uses an automated system to regularly check every page on your website and ensure an end-user could access them.</p>
<h3 id="the-request-response-cycle">The request-response cycle</h3>
<p>Let’s dig into the details of how that automated system works. It continuously sends your website server HTTP requests triggering the server to send an HTTP response back. That HTTP response includes a status code telling the monitoring service the current status of the page. Those HTTP status codes include:</p>
<ul>
<li><strong>HTTP 2xx:</strong> A response with a HTTP status code in the 200s is the expected response. It means the page is up and running.</li>
<li><strong>HTTP 3xx:</strong> This means the page is redirecting traffic to a different page. This could be expected, but not always.</li>
<li><strong>HTTP 4xx:</strong> When there’s a problem with the code in one of your pages that prevents it from being displayed.</li>
<li><strong>HTTP 5xx:</strong> When your server cannot fulfil an HTTP request, like an issue with your website host or servers.</li>
</ul>
<p>Receiving the status code for every page on your site is the basics of website monitoring. These services can do a lot more, though. For instance, they can also track how long it takes to fulfil a request so you can monitor the performance of your website.</p>
<p>That way, you can tell if a large uptick in traffic slows down your website which could mean you need to invest in faster server infrastructure.</p>
<p>You can even <a href="/news-and-updates/trigger-an-on-demand-uptime-broken-links-check-after-a-deploy">trigger an on-demand check right after a deploy</a>.</p>
<h2 id="types-of-website-monitoring">Types of website monitoring <a href="#types-of-website-monitoring" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#types-of-website-monitoring">#</a></h2>
<p>The specialty tasks website monitoring can perform, like the performance testing we just mentioned, depends on the type of website monitoring provided. Here are the main types of website monitoring you should look for when signing up for a website monitoring service:</p>
<h3 id="uptime-monitoring">Uptime monitoring</h3>
<p>This is the main type of website monitoring. It uses the process we discussed earlier with making an HTTP request and then analyzing the response code. However, more advanced uptime monitoring doesn’t just check that your website is up, it can also check for specific features in your website. Other services allow you to check that your website is up in specific regions or globally by making requests from localized servers.</p>
<p>It is not just GET, you can <a href="/news-and-updates/extending-uptime-monitoring-with-post-put-patch-methods">monitor POST, PUT and PATCH endpoints</a> too.</p>
<h3 id="ssl-certificate-monitoring">SSL certificate monitoring</h3>
<p>We’ve all gone to a site and saw the “your connection is not private” warning. That’s what happens when a site has an SSL certificate error. And just like you, that warning turns traffic away immediately. So, while receiving an HTTP response code, quality monitoring services also check the status of your SSL certificate and notify you of any errors. That way, you can fix any issues before your visitors notice them.</p>
<h3 id="broken-link-detection">Broken link detection</h3>
<p>Every website uses internal links and if you don’t, you should. But when you delete a page, it’s hard to find every single time you linked to that page on other pages. Or, if you change the URL of a page, finding every instance of a link to that URL can be a challenge. Broken link detection finds these issues for you while scouring your website. The service goes through each link on the page and makes sure it returns a valid HTTP response code as well.</p>
<h3 id="mixed-content-warnings">Mixed content warnings</h3>
<p>Just like an error with your SSL certificate can send your visitors a warning, mixed content on your pages does that too. That’s when your page has an SSL certificate and uses HTTPS, but content within your page uses insecure HTTP resources. This can cause your visitors to go running just like with an SSL certificate error. Monitoring this requires your website monitoring service to load all resources on the page and assess each one’s security status.</p>
<h3 id="performance-monitoring">Performance monitoring</h3>
<p>It’s easy to tell how quickly a website responds, but the information performance monitoring services can tell you a lot more than just how long it takes for a page to load from the time you enter the URL. It breaks down your page loading time into separate categories so you can identify the bottlenecks impeding your website’s performance.</p>
<p>Beyond up or down, we also watch speed, see <a href="/news-and-updates/introducing-new-performance-monitoring-for-your-websites">performance monitoring</a>.</p>
<h3 id="domain-monitoring">Domain monitoring</h3>
<p>There’s a lot that can go wrong with a domain. Whether it’s about to expire or someone fraudulently updated the WHOIS records, keeping tabs on your domain is just as important as keeping tabs on your website’s performance. That’s why most website monitoring services monitor your domain as well. They look for unexpected activity, warn you when your domain is about to expire, and verify all of your email-related records.</p>
<h2 id="what-kind-of-website-monitoring-do-you-need">What kind of website monitoring do you need? <a href="#what-kind-of-website-monitoring-do-you-need" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#what-kind-of-website-monitoring-do-you-need">#</a></h2>
<p>Clearly there's no shortage in different types of website monitoring, but in practice you usually don’t need to pick and choose. Tools like UptimeRobot, StatusCake, and <a href="https://ohdear.app/register">Oh Dear</a> are considered &quot;all-in-one&quot; monitoring solutions. All types of monitoring are included, coupled with a robust notification system. What's also cool and totally not self-promotional, is that Oh Dear offers unlimited status pages, Lighthouse SEO monitoring and broken links and content detection as well! I know what I'd choose!</p>
<p><img src="/media/blog/CPgVriz7TRT55j76AV8GyXLfqka5qIZtHWMkZmnD.png" alt="A banner promoting Oh Dear as an all-in-one website monitoring tool" /></p>
]]>
            </summary>
                                    <updated>2025-04-28T11:11:48+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Lifespan of TLS certificates is getting reduced to 47 days]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/lifespan-of-tls-certificates-is-getting-reduced-to-47-days" />
            <id>https://ohdear.app/133</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>In a pretty significant shift for internet security and subsequently certificate management, the CA/Browser Forum has officially voted to reduce the maximum validity period of TLS certificates to just 47 days by March 15, 2029.</p>
<p>This move aims to enhance digital security and trust across the web. But as these changes approach, it'll become increasingly crucial for organizations to understand their implications and prepare accordingly. Automation will likely become mandatory.</p>
<h2 id="whats-the-deal-with-tls-certificates">What's the deal with TLS certificates? <a href="#whats-the-deal-with-tls-certificates" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#whats-the-deal-with-tls-certificates">#</a></h2>
<h3 id="what-are-they">What are they?</h3>
<p>TLS (Transport Layer Security) certificates—more commonly known as SSL certificates—are digital certificates that authenticate the identity of a website and enable encrypted connections like HTTPS.</p>
<h3 id="why-do-i-need-one">Why do I need one?</h3>
<p>Well, they are essential for securing data transmitted between a user's browser and a web server, ensuring privacy and data integrity. This is pretty important, as SSL certificates are now a requirement for all websites, as mandated by Google and Mozilla. In a nutshell, they enable:</p>
<ul>
<li><strong>Data Encryption:</strong> Protecting sensitive information from eavesdroppers.</li>
<li><strong>Authentication:</strong> Verifying that a website is legitimate.</li>
<li><strong>Trust:</strong> Your browser will mark all HTTP pages as &quot;Not Secure&quot;.</li>
</ul>
<h2 id="what-are-the-upcoming-changes-to-certificate-lifetimes">What are the upcoming changes to certificate lifetimes? <a href="#what-are-the-upcoming-changes-to-certificate-lifetimes" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#what-are-the-upcoming-changes-to-certificate-lifetimes">#</a></h2>
<p>To keep a short story even shorter, the maximum certificate lifetime is going down pretty drastically. The idea is, by reducing the lifetimes, you need more frequent validation ergo potentially compromised certificates won't stick around for too long. Here's the rundown.</p>
<ul>
<li>Until March 15, 2026, the maximum lifetime for a TLS certificate is 398 days.</li>
<li>March 15, 2026, the maximum lifetime for a TLS certificate will be 200 days.</li>
<li>March 15, 2027, the maximum lifetime for a TLS certificate will be 100 days.</li>
<li>March 15, 2029, the maximum lifetime for a TLS certificate will be 47 days.</li>
</ul>
<p>Some additional clarification, though. As of March 15, 2029, the maximum lifetime for a TLS certificate will indeed be 47 days, but the maximum period during which domain validation information may be reused is <strong>only 10 days</strong>. That's pretty short. Manual revalidation will still be possible, but this kind of sets you up for trouble if you're not diligent.</p>
<h2 id="what-does-this-mean-for-my-business-and-websites">What does this mean for my business and website(s)? <a href="#what-does-this-mean-for-my-business-and-websites" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#what-does-this-mean-for-my-business-and-websites">#</a></h2>
<p>The proposed changes are put there to improve overall security on the web. However, that is assuming everyone handles their certificate renewals precisely on schedule. While shorter lifetimes do improve security, they also increase the risk of certificate expiration without you realized it. Failing to renew your TLS/SSL certificate means browsers will immediately block access to your site.</p>
<p>We probably don't have to paint this picture, but if your site becomes inaccessible because of an expired TLS certificate, this can lead to:</p>
<ul>
<li>Lost revenue</li>
<li>Damaged brand trust</li>
<li>Search engine ranking drops</li>
<li>Internal panic and/or angry clients</li>
</ul>
<p>As a side note, according to the 2023 EMA report, 25% of certificates pose a security threat because they're either expired or self-signed. As lifetimes shrink, that number will go up considerably if counter measures aren't taken.</p>
<p>We have navigated cert chaos before, like <a href="/news-and-updates/how-we-identified-clients-with-ssl-certificates-affected-by-lets-encrypt-mass-revocation">the Let's Encrypt mass revocation</a>, and <a href="/news-and-updates/how-oh-dear-identified-a-certificate-problem-at-a-large-cdn-provider">a certificate problem we caught at a large CDN provider</a>.</p>
<h2 id="how-do-i-prepare-for-these-changes">How do I prepare for these changes? <a href="#how-do-i-prepare-for-these-changes" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#how-do-i-prepare-for-these-changes">#</a></h2>
<p>The good news is that it's actually pretty easy to avoid certificates from expiring. There's plenty of time left and solutions are already readily available:</p>
<ul>
<li>The most obvious solution would be to <strong>automate TLS certificate renewals</strong>. Using services like Let’s Encrypt, cloud providers with automated certificate management, or something like CertBot you can deal with these shorter lifespans without breaking much of a sweat.</li>
<li>Another solution is to <strong>simply monitor your websites</strong>, which is just good practice either way. A website monitoring tool (like Oh Dear, obviously) can notify you in time of not only expired certificates, but of broken links, tasks, drops in performance, SEO...</li>
<li>Lastly, it's never a bad idea to <strong>keep your team informed</strong>. Make sure your developers, DevOps engineers, and sysadmins understand the timeline and implications of these changes.</li>
</ul>
<p><img src="/media/blog/HNNJsJujDfFDEcLQP8DeEnN1L0daDgdd5ilA85ZV.png" alt="Oh Dear certificate health report showing no problems detected and all certificate checks passing with green checkmarks" /></p>
<h2 id="closing-thoughts">Closing thoughts <a href="#closing-thoughts" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#closing-thoughts">#</a></h2>
<p>In a nutshell, the ballot argues that shorter lifetimes are necessary because the information contained in these certificates becomes less trustworthy over time, which can only be mitigated by revalidating said information more frequently.</p>
<p>DigiCert, the certificate authority where we got this information from, expects rapid adoption of automated certificate renewal well before the 2029 changes. This is the most likely course of events. But we wouldn't be Oh Dear if we didn't tell you to also use website monitoring :)</p>
]]>
            </summary>
                                    <updated>2026-06-12T08:58:01+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Running our test suite in parallel on GitHub actions]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/running-our-test-suite-in-parallel-on-github-actions" />
            <id>https://ohdear.app/132</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>A couple of years ago, Laravel introduced a great feature which allows to run PHPUnit / Pest tests in parallel. This results in a big boost in performance.</p>
<p>First we had to do the groundwork: <a href="/news-and-updates/making-our-laravel-test-suite-ready-for-parallel-testing">getting our Laravel suite ready for parallel testing</a>.</p>
<p>By default, it determines the concurrency level by taking a look at the number of CPU cores your machine has. So, if you're using a modern Mac that has 10 CPU cores, it will run 10 tests at the same time, greatly cutting down on the time your testsuite needs to run completely.</p>
<p>A default runner on GitHub doesn't have that many cores, so you can't leverage parallel testing as good as in your typical local environments.</p>
<p>In this blog post, I'd like to show you a way of running your tests on GitHub, by splitting them up in small chunks that can run concurrently.vWe use this technique to cut down the running time of our vast testsuite from 16 minutes to only just 4. In this blog post all examples will come from our base.</p>
<!--more-->
<h2 id="what-were-going-to-do">What we're going to do <a href="#what-were-going-to-do" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#what-were-going-to-do">#</a></h2>
<p>Like already mentioned in the introduction, a typical test runner on GitHub hasn't got a lot of cores, meaning the default way of running tests in parallel that Laravel offers (running a test per CPU core) doesn't work well.</p>
<p>What you can do at GitHub however is running a lot of GitHub actions jobs in parallel. So what we are going to do is splitting our testsuite in equal chunks, and create a test job on GitHub action per chunk. These chunks can run in parallel, which will immensely decrease the total running time.</p>
<p>I'll tell you how to achieve this technically in the remainder of this blog post, but here's already how the end result will look like on GitHub.</p>
<p><img src="/media/blog/ahsGwZzeKjPu9lOG4L2ubjF8xAVOrmfDBWekhzkF.png" alt="GitHub Actions run summary with twelve parallel Run Tests jobs all passing in 6m 12s total" /></p>
<p>In the screenshot above you can see that our test suite is split up in 12 parts which will all run concurrently. Composer / NPM will only run once to build up the dependencies and assets and they will be used in all 12 testing parts.</p>
<h2 id="splitting-up-the-testsuite-in-equal-parts">Splitting up the testsuite in equal parts <a href="#splitting-up-the-testsuite-in-equal-parts" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#splitting-up-the-testsuite-in-equal-parts">#</a></h2>
<p>Let's first take a look at how we can split the testsuite in equal parts. We use Pest as a test runner, which offers a <code>--list-tests</code> option to output all tests.</p>
<p><img src="/media/blog/fCyxyPAg8N2HABEnL3io7DTLTpmruuTs9gnMvxWR.png" alt="Terminal running 'vendor/bin/pest --list-tests' showing the available Oh Dear Pest test names" /></p>
<p>Here's a bit of code to get all test class names from that output.</p>
<pre data-lang="php" class="notranslate"> <span class="hl-variable">$process</span> = <span class="hl-keyword">new</span> <span class="hl-type">Process</span>([<span class="hl-property">__DIR__</span> . <span class="hl-value">'/../vendor/bin/pest'</span>, <span class="hl-value">'--list-tests'</span>]);

<span class="hl-variable">$process</span>-&gt;<span class="hl-property">mustRun</span>();

<span class="hl-variable">$index</span> = <span class="hl-variable">$shardNumber</span> - 1;

<span class="hl-variable">$allTestNames</span> = <span class="hl-type">Str</span>::<span class="hl-property">of</span>(<span class="hl-variable">$process</span>-&gt;<span class="hl-property">getOutput</span>())
    -&gt;<span class="hl-property">explode</span>(<span class="hl-value">&quot;\n&quot;</span>)
    -&gt;<span class="hl-property">filter</span>(<span class="hl-keyword">fn</span>(<span class="hl-injection"><span class="hl-type">string</span> $line</span>) =&gt; <span class="hl-property">str_contains</span>(<span class="hl-variable">$line</span>, <span class="hl-value">' - '</span>))
    -&gt;<span class="hl-property">map</span>(<span class="hl-keyword">function</span> (<span class="hl-injection"><span class="hl-type">string</span> $fullTestName</span>) {
        <span class="hl-variable">$testClassName</span> = <span class="hl-type">Str</span>::<span class="hl-property">of</span>(<span class="hl-variable">$fullTestName</span>)
            -&gt;<span class="hl-property">replace</span>(<span class="hl-value">'- '</span>, <span class="hl-value">''</span>)
            -&gt;<span class="hl-property">trim</span>()
            -&gt;<span class="hl-property">between</span>(<span class="hl-value">'\\\\', '</span>::')
            -&gt;<span class="hl-property">afterLast</span>(<span class="hl-value">'\\'</span>)
            -&gt;<span class="hl-property">toString</span>();

        <span class="hl-keyword">return</span> <span class="hl-variable">$testClassName</span>;
    })
    -&gt;<span class="hl-property">filter</span>()
    -&gt;<span class="hl-property">unique</span>();
</pre>
<p>In <code>$allTestNames</code> will be a collection containing all class names (= file names) that are inside the test suite.</p>
<p>To split the collection up in multiple parts, you can use the <code>split</code> function which accepts the number of parts you want. Here's how you would split up the tests in 12 parts, and get the first part</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$testNamesOfFirstPart</span> = <span class="hl-variable">$allTestNames</span>
   -&gt;<span class="hl-property">split</span>(12) <span class="hl-comment">// split the collection in 12 equal parts</span>
   -&gt;<span class="hl-property">get</span>(<span class="hl-property">key</span>: 0) <span class="hl-comment">// get the first part (the index is 0 based)</span>
</pre>
<p>PHPUnit / Pest also offers a <code>--filter</code> option to only run specific tests. If you only want to run the tests in from the <code>ArchTest</code> class (which is displayed in the screenshot above), you could execute this.</p>
<pre data-lang="bash" class="notranslate"><span class="hl-comment"># Will only run the tests from the ArchTest file</span>
vendor/bin/pest <span class="hl-attribute">--filter</span>=ArchTest
</pre>
<p>You can use <code>|</code> to specify multiple patterns. Here's how you could execute the tests from multiple files</p>
<pre data-lang="bash" class="notranslate"><span class="hl-comment"># Will only run the tests from the ArchTest file</span>
vendor/bin/pest <span class="hl-attribute">--filter</span>=ArchTest|CheckSitesBeingMonitoredTest
</pre>
<p>Here's how you could use the <code>$testNamesOfFirstPart</code> from the previous snippet to run the first part of the tests programmatically.</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$process</span> = <span class="hl-keyword">new</span> <span class="hl-type">Process</span>(
    <span class="hl-property">command</span>: [
       <span class="hl-value">'./vendor/bin/pest'</span>,
       <span class="hl-value">'--filter'</span>, 
       <span class="hl-variable">$testNamesOfFirstPart</span>-&gt;<span class="hl-property">join</span>(<span class="hl-value">'|'</span>)],
       <span class="hl-property">timeout</span>: <span class="hl-keyword">null</span> <span class="hl-comment">// take as much time as we need</span>
);

<span class="hl-variable">$process</span>-&gt;<span class="hl-property">start</span>();

<span class="hl-comment">/* pipe the Pest output to the console */</span>
<span class="hl-keyword">foreach</span> (<span class="hl-variable">$process</span> <span class="hl-keyword">as</span> <span class="hl-variable">$data</span>) {
    <span class="hl-keyword">echo</span> <span class="hl-variable">$data</span>;
}

<span class="hl-variable">$process</span>-&gt;<span class="hl-property">wait</span>();

<span class="hl-comment">// use the exit code of Pest as the exit code of the script</span>
<span class="hl-keyword">exit</span>(<span class="hl-variable">$process</span>-&gt;<span class="hl-property">getExitCode</span>());
</pre>
<h2 id="running-the-testsuite-parts-in-parallel-at-github">Running the testsuite parts in parallel at GitHub <a href="#running-the-testsuite-parts-in-parallel-at-github" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#running-the-testsuite-parts-in-parallel-at-github">#</a></h2>
<p>Now that you know how you could split up a test suite in equal parts, let's take a look at how we can run all these parts in parallel on GitHub actions.</p>
<p>GitHub actions support <a href="https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/running-variations-of-jobs-in-a-workflow">a <code>matrix</code> parameter</a>. Shortly said, this matrix parameter is used for testing variations of your test suite, and it will run those variations concurrently.</p>
<p>Here's the part of the Oh Dear GitHub workflow where the matrix is being set up. I've omitted several parts for brevity.</p>
<pre data-lang="yaml" class="notranslate"><span class="hl-comment"># .github/workflows/run-tests.yml</span>

<span class="hl-keyword">name</span><span class="hl-property">:</span> Run tests

<span class="hl-keyword">jobs</span><span class="hl-property">:</span>
    <span class="hl-keyword">run-tests</span><span class="hl-property">:</span>
        <span class="hl-keyword">name</span><span class="hl-property">:</span> Run Tests (Part $<span class="hl-value">{{</span><span class="hl-property"> matrix.shard_number </span><span class="hl-value">}}</span>/$<span class="hl-value">{{</span><span class="hl-property"> matrix.total_shard_count </span><span class="hl-value">}}</span>)
        <span class="hl-keyword">runs-on</span><span class="hl-property">:</span> ubuntu-latest
        <span class="hl-keyword">strategy</span><span class="hl-property">:</span>
            <span class="hl-keyword">matrix</span><span class="hl-property">:</span>
                <span class="hl-keyword">total_shard_count</span><span class="hl-property">:</span> <span class="hl-property">[</span>12<span class="hl-property">]</span>
                <span class="hl-keyword">shard_number</span><span class="hl-property">:</span> <span class="hl-property">[</span>'<span class="hl-value">01</span>', '<span class="hl-value">02</span>', '<span class="hl-value">03</span>', '<span class="hl-value">04</span>', '<span class="hl-value">05</span>', '<span class="hl-value">06</span>', '<span class="hl-value">07</span>', '<span class="hl-value">08</span>', '<span class="hl-value">09</span>', '<span class="hl-value">10</span>', '<span class="hl-value">11</span>', '<span class="hl-value">12</span>'<span class="hl-property">]</span>

        <span class="hl-keyword">steps</span><span class="hl-property">:</span>
           <span class="hl-comment">## </span>
           <span class="hl-comment">## multiple set up steps omitted for brevity</span>
           <span class="hl-comment">##</span>
            <span class="hl-property">-</span>   <span class="hl-keyword">name</span><span class="hl-property">:</span> Run tests
                <span class="hl-keyword">run</span><span class="hl-property">:</span> ./vendor/bin/pest
</pre>
<p>The matrix will create jobs per combination in the matrix. So it will run 1 (only one element in <code>total_shard_count</code>) * 12 (twelve elements in <code>shard_number</code>) = 12 times.</p>
<p>In the <code>Run tests</code> step, <code>pest is executed</code>. This will result in the whole testsuite being executed 12 times. Of course we don't want to execute the whole test suite 12 times, but only each separate 1/12 part of the testsuite.</p>
<p>We can achieve this by not running <code>/vendor/bin/pest</code> but a custom PHP script called <code>github_parallel_test_runner</code> that will receive the <code>total_shard_count</code> and the  <code>shard_number</code> as environment variables.</p>
<pre data-lang="yaml" class="notranslate"><span class="hl-comment"># .github/workflows/run-tests.yml</span>

<span class="hl-keyword">name</span><span class="hl-property">:</span> Run tests

<span class="hl-keyword">jobs</span><span class="hl-property">:</span>
    <span class="hl-keyword">run-tests</span><span class="hl-property">:</span>
        <span class="hl-keyword">name</span><span class="hl-property">:</span> Run Tests (Part $<span class="hl-value">{{</span><span class="hl-property"> matrix.shard_number </span><span class="hl-value">}}</span>/$<span class="hl-value">{{</span><span class="hl-property"> matrix.total_shard_count </span><span class="hl-value">}}</span>)
        <span class="hl-keyword">runs-on</span><span class="hl-property">:</span> ubuntu-latest
        <span class="hl-keyword">strategy</span><span class="hl-property">:</span>
            <span class="hl-keyword">matrix</span><span class="hl-property">:</span>
                <span class="hl-keyword">total_shard_count</span><span class="hl-property">:</span> <span class="hl-property">[</span>12<span class="hl-property">]</span>
                <span class="hl-keyword">shard_number</span><span class="hl-property">:</span> <span class="hl-property">[</span>'<span class="hl-value">01</span>', '<span class="hl-value">02</span>', '<span class="hl-value">03</span>', '<span class="hl-value">04</span>', '<span class="hl-value">05</span>', '<span class="hl-value">06</span>', '<span class="hl-value">07</span>', '<span class="hl-value">08</span>', '<span class="hl-value">09</span>', '<span class="hl-value">10</span>', '<span class="hl-value">11</span>', '<span class="hl-value">12</span>'<span class="hl-property">]</span>

        <span class="hl-keyword">steps</span><span class="hl-property">:</span>
           <span class="hl-comment">## </span>
           <span class="hl-comment">## multiple set up steps omitted for brevity</span>
           <span class="hl-comment">##</span>
            <span class="hl-property">-</span>   <span class="hl-keyword">name</span><span class="hl-property">:</span> Run tests
                <span class="hl-keyword">run</span><span class="hl-property">:</span> ./bin/github_parallel_test_runner
                <span class="hl-keyword">env</span><span class="hl-property">:</span>
                    <span class="hl-keyword">TOTAL_SHARD_COUNT</span><span class="hl-property">:</span> $<span class="hl-value">{{</span><span class="hl-property"> matrix.total_shard_count </span><span class="hl-value">}}</span>
                    <span class="hl-keyword">SHARD_NUMBER</span><span class="hl-property">:</span> $<span class="hl-value">{{</span><span class="hl-property"> matrix.shard_number </span><span class="hl-value">}}</span>
</pre>
<p>Here's the content of <code>./bin/github_parallel_test_runner</code> in our code base. It will read the environment variables, execute Pest using the <code>--list-files</code> and <code>--filter</code> flags to only run a part of the tests like explained in the previous section of this post.</p>
<pre data-lang="php" class="notranslate"><span class="hl-comment">#!/usr/bin/env php</span>
<span class="hl-keyword">&lt;?php</span>

<span class="hl-keyword">use</span> <span class="hl-type">Illuminate\Support\Collection</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Illuminate\Support\Str</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Symfony\Component\Process\Process</span>;

<span class="hl-keyword">require_once</span> <span class="hl-value">'vendor/autoload.php'</span>;

<span class="hl-variable">$shardNumber</span> = (<span class="hl-type">int</span>)<span class="hl-property">getenv</span>(<span class="hl-value">'SHARD_NUMBER'</span>);
<span class="hl-variable">$totalShardCount</span> = (<span class="hl-type">int</span>)<span class="hl-property">getenv</span>(<span class="hl-value">'TOTAL_SHARD_COUNT'</span>);

<span class="hl-keyword">if</span> (<span class="hl-variable">$shardNumber</span> === 0 <span class="hl-operator">||</span> <span class="hl-variable">$totalShardCount</span> === 0) {
    <span class="hl-keyword">echo</span> <span class="hl-value">&quot;SHARD_NUMBER and TOTAL_SHARD_COUNT must be set.&quot;</span> . <span class="hl-property">PHP_EOL</span>;
    <span class="hl-keyword">exit</span>(1);
}

<span class="hl-keyword">new</span> <span class="hl-type">ParallelTests</span>(<span class="hl-variable">$totalShardCount</span>)-&gt;<span class="hl-property">run</span>(<span class="hl-variable">$shardNumber</span>);

<span class="hl-keyword">class</span> <span class="hl-type">ParallelTests</span>
{
    <span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">__construct</span>(<span class="hl-injection">
        <span class="hl-keyword">protected</span> <span class="hl-type">int</span> <span class="hl-property">$totalShardCount</span>,
    </span>)
    {
    }

    <span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">run</span>(<span class="hl-injection"><span class="hl-type">int</span> $shardNumber</span>): <span class="hl-type">never</span>
    {
        <span class="hl-variable">$testNames</span> = <span class="hl-variable">$this</span>-&gt;<span class="hl-property">getTestNames</span>(<span class="hl-variable">$shardNumber</span>);

        <span class="hl-keyword">echo</span> <span class="hl-value">&quot;Running {$testNames-&gt;count()} tests on node {$shardNumber} of {$this-&gt;totalShardCount}...&quot;</span> . <span class="hl-property">PHP_EOL</span>;

        <span class="hl-variable">$exitCode</span> = <span class="hl-variable">$this</span>-&gt;<span class="hl-property">runPestTests</span>(<span class="hl-variable">$testNames</span>);

        <span class="hl-keyword">exit</span>(<span class="hl-variable">$exitCode</span>);
    }

    <span class="hl-comment">/** <span class="hl-value">@return</span> <span class="hl-type">Collection&lt;<span class="hl-generic">string</span>&gt; </span>*/</span>
    <span class="hl-keyword">protected</span> <span class="hl-keyword">function</span> <span class="hl-property">getTestNames</span>(<span class="hl-injection"><span class="hl-type">int</span> $shardNumber</span>): <span class="hl-type">Collection</span>
    {
        <span class="hl-variable">$process</span> = <span class="hl-keyword">new</span> <span class="hl-type">Process</span>([<span class="hl-property">__DIR__</span> . <span class="hl-value">'/../vendor/bin/pest'</span>, <span class="hl-value">'--list-tests'</span>]);

        <span class="hl-variable">$process</span>-&gt;<span class="hl-property">mustRun</span>();

        <span class="hl-variable">$index</span> = <span class="hl-variable">$shardNumber</span> - 1;

        <span class="hl-variable">$allTestNames</span> = <span class="hl-type">Str</span>::<span class="hl-property">of</span>(<span class="hl-variable">$process</span>-&gt;<span class="hl-property">getOutput</span>())
            -&gt;<span class="hl-property">explode</span>(<span class="hl-value">&quot;\n&quot;</span>)
            -&gt;<span class="hl-property">filter</span>(<span class="hl-keyword">fn</span>(<span class="hl-injection"><span class="hl-type">string</span> $line</span>) =&gt; <span class="hl-property">str_contains</span>(<span class="hl-variable">$line</span>, <span class="hl-value">' - '</span>))
            -&gt;<span class="hl-property">map</span>(<span class="hl-keyword">function</span> (<span class="hl-injection"><span class="hl-type">string</span> $fullTestName</span>) {
                <span class="hl-variable">$testClassName</span> = <span class="hl-type">Str</span>::<span class="hl-property">of</span>(<span class="hl-variable">$fullTestName</span>)
                    -&gt;<span class="hl-property">replace</span>(<span class="hl-value">'- '</span>, <span class="hl-value">''</span>)
                    -&gt;<span class="hl-property">trim</span>()
                    -&gt;<span class="hl-property">between</span>(<span class="hl-value">'\\\\', '</span>::')
                    -&gt;<span class="hl-property">afterLast</span>(<span class="hl-value">'\\'</span>)
                    -&gt;<span class="hl-property">toString</span>();

                <span class="hl-keyword">return</span> <span class="hl-variable">$testClassName</span>;
            })
            -&gt;<span class="hl-property">filter</span>()
            -&gt;<span class="hl-property">unique</span>();

        <span class="hl-keyword">echo</span> <span class="hl-value">&quot;Detected {$allTestNames-&gt;count()} tests:&quot;</span> . <span class="hl-property">PHP_EOL</span>;

        <span class="hl-keyword">return</span> <span class="hl-variable">$allTestNames</span>
            -&gt;<span class="hl-property">split</span>(<span class="hl-variable">$this</span>-&gt;<span class="hl-property">totalShardCount</span>)
            -&gt;<span class="hl-property">get</span>(<span class="hl-variable">$index</span>);
    }

    <span class="hl-keyword">protected</span> <span class="hl-keyword">function</span> <span class="hl-property">runPestTests</span>(<span class="hl-injection"><span class="hl-type">Collection</span> $testNames</span>): <span class="hl-type">?int</span>
    {
        <span class="hl-variable">$process</span> = <span class="hl-keyword">new</span> <span class="hl-type">Process</span>(
            <span class="hl-property">command</span>: [<span class="hl-value">'./vendor/bin/pest'</span>, <span class="hl-value">'--filter'</span>, <span class="hl-variable">$testNames</span>-&gt;<span class="hl-property">join</span>(<span class="hl-value">'|'</span>)],
            <span class="hl-property">timeout</span>: <span class="hl-keyword">null</span>
        );

        <span class="hl-variable">$process</span>-&gt;<span class="hl-property">start</span>();

        <span class="hl-comment">/* pipe the Pest output to the console */</span>
        <span class="hl-keyword">foreach</span> (<span class="hl-variable">$process</span> <span class="hl-keyword">as</span> <span class="hl-variable">$data</span>) {
            <span class="hl-keyword">echo</span> <span class="hl-variable">$data</span>;
        }

        <span class="hl-variable">$process</span>-&gt;<span class="hl-property">wait</span>();

        <span class="hl-keyword">return</span> <span class="hl-variable">$process</span>-&gt;<span class="hl-property">getExitCode</span>();
    }
}
</pre>
<p>To make this script executable you must execute this command and push the changes permissions...</p>
<pre data-lang="bash" class="notranslate"><span class="hl-keyword">chmod</span> +x ./bin/github_parallel_test_runner
</pre>
<h2 id="only-run-composer-and-npm-yarn-once">Only run composer and NPM / Yarn once <a href="#only-run-composer-and-npm-yarn-once" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#only-run-composer-and-npm-yarn-once">#</a></h2>
<p>In the screenshot above, you could see how there's a &quot;Composer and Yarn&quot; step that is executing only once before all the test parts run. Here's that screenshot again.</p>
<p><img src="/media/blog/CNKYnLy3HB8ynxEI90sHqHsKU1B0YpkaH4zV2LZy.png" alt="GitHub Actions run summary with twelve parallel Run Tests jobs all passing in 6m 12s total" /></p>
<p>A GitHub action workflow can contain multiple jobs, and you can define dependencies between them. In the snippet below you'll see the <code>setup-dependencies</code> job being defined (I've omitted all steps regarding to Yarn / NPM to keep things brief). We save the <code>vendor</code> directory as an artifact and use that saved directory in all of our test jobs. Finally, there's also a step to clean up any created artifacts.</p>
<p>You can see that in the <code>needs</code> key, you can define the steps that a job depends on.</p>
<pre data-lang="yaml" class="notranslate">
<span class="hl-comment">#</span>
<span class="hl-comment"># name, and concurrency setup omitted for brevity</span>
<span class="hl-comment">#</span>

<span class="hl-keyword">jobs</span><span class="hl-property">:</span>
    <span class="hl-keyword">setup-dependencies</span><span class="hl-property">:</span>
        <span class="hl-keyword">name</span><span class="hl-property">:</span> Composer and Yarn
        <span class="hl-keyword">runs-on</span><span class="hl-property">:</span> ubuntu-latest
        <span class="hl-keyword">steps</span><span class="hl-property">:</span>
            <span class="hl-property">-</span>   <span class="hl-keyword">uses</span><span class="hl-property">:</span> actions/checkout@v3
                <span class="hl-keyword">with</span><span class="hl-property">:</span>
                    <span class="hl-keyword">fetch-depth</span><span class="hl-property">:</span> 1

            <span class="hl-property">-</span>   <span class="hl-keyword">name</span><span class="hl-property">:</span> Setup PHP
                <span class="hl-keyword">uses</span><span class="hl-property">:</span> shivammathur/setup-php@v2
                <span class="hl-keyword">with</span><span class="hl-property">:</span>
                    <span class="hl-keyword">php-version</span><span class="hl-property">:</span> 8.4
                    <span class="hl-keyword">extensions</span><span class="hl-property">:</span> dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick
                    <span class="hl-keyword">coverage</span><span class="hl-property">:</span> none

            <span class="hl-property">-</span>   <span class="hl-keyword">name</span><span class="hl-property">:</span> Install composer dependencies
                <span class="hl-keyword">run</span><span class="hl-property">:</span> composer install --prefer-dist --no-scripts -q -o

            <span class="hl-property">-</span>   <span class="hl-keyword">name</span><span class="hl-property">:</span> Upload vendor directory
                <span class="hl-keyword">uses</span><span class="hl-property">:</span> actions/upload-artifact@v4
                <span class="hl-keyword">with</span><span class="hl-property">:</span>
                    <span class="hl-keyword">name</span><span class="hl-property">:</span> vendor-directory-$<span class="hl-value">{{</span><span class="hl-property"> github.run_id </span><span class="hl-value">}}</span>
                    <span class="hl-keyword">path</span><span class="hl-property">:</span> vendor
                    <span class="hl-keyword">retention-days</span><span class="hl-property">:</span> 1

    <span class="hl-keyword">run-tests</span><span class="hl-property">:</span>
        <span class="hl-keyword">needs</span><span class="hl-property">:</span> <span class="hl-property">[</span> setup-dependencies<span class="hl-property">]</span>
        <span class="hl-keyword">name</span><span class="hl-property">:</span> Run Tests (Part $<span class="hl-value">{{</span><span class="hl-property"> matrix.shard_number </span><span class="hl-value">}}</span>/$<span class="hl-value">{{</span><span class="hl-property"> matrix.total_shard_count </span><span class="hl-value">}}</span>)
        <span class="hl-keyword">runs-on</span><span class="hl-property">:</span> ubuntu-latest
        <span class="hl-keyword">strategy</span><span class="hl-property">:</span>
            <span class="hl-keyword">fail-fast</span><span class="hl-property">:</span> false
            <span class="hl-keyword">matrix</span><span class="hl-property">:</span>
                <span class="hl-keyword">total_shard_count</span><span class="hl-property">:</span> <span class="hl-property">[</span>12<span class="hl-property">]</span>
                <span class="hl-keyword">shard_number</span><span class="hl-property">:</span> <span class="hl-property">[</span>'<span class="hl-value">01</span>', '<span class="hl-value">02</span>', '<span class="hl-value">03</span>', '<span class="hl-value">04</span>', '<span class="hl-value">05</span>', '<span class="hl-value">06</span>', '<span class="hl-value">07</span>', '<span class="hl-value">08</span>', '<span class="hl-value">09</span>', '<span class="hl-value">10</span>', '<span class="hl-value">11</span>', '<span class="hl-value">12</span>'<span class="hl-property">]</span>
        <span class="hl-keyword">steps</span><span class="hl-property">:</span>
		        <span class="hl-comment">#</span>
		        <span class="hl-comment"># Some setup steps omitted for brevity</span>
		        <span class="hl-comment">#</span>
        
            <span class="hl-property">-</span>   <span class="hl-keyword">uses</span><span class="hl-property">:</span> actions/checkout@v3
                <span class="hl-keyword">with</span><span class="hl-property">:</span>
                    <span class="hl-keyword">fetch-depth</span><span class="hl-property">:</span> 1

            <span class="hl-property">-</span>   <span class="hl-keyword">name</span><span class="hl-property">:</span> Download vendor directory
                <span class="hl-keyword">uses</span><span class="hl-property">:</span> actions/download-artifact@v4
                <span class="hl-keyword">with</span><span class="hl-property">:</span>
                    <span class="hl-keyword">name</span><span class="hl-property">:</span> vendor-directory-$<span class="hl-value">{{</span><span class="hl-property"> github.run_id </span><span class="hl-value">}}</span>
                    <span class="hl-keyword">path</span><span class="hl-property">:</span> vendor

            <span class="hl-property">-</span>   <span class="hl-keyword">name</span><span class="hl-property">:</span> Run tests
                <span class="hl-keyword">run</span><span class="hl-property">:</span> ./bin/github-parallel-test-runner
                <span class="hl-keyword">env</span><span class="hl-property">:</span>
                    <span class="hl-keyword">TOTAL_SHARD_COUNT</span><span class="hl-property">:</span> $<span class="hl-value">{{</span><span class="hl-property"> matrix.total_shard_count </span><span class="hl-value">}}</span>
                    <span class="hl-keyword">SHARD_NUMBER</span><span class="hl-property">:</span> $<span class="hl-value">{{</span><span class="hl-property"> matrix.shard_number </span><span class="hl-value">}}</span>

    <span class="hl-keyword">cleanup-artifacts</span><span class="hl-property">:</span>
        <span class="hl-keyword">name</span><span class="hl-property">:</span> Clean up artifacts
        <span class="hl-keyword">needs</span><span class="hl-property">:</span> <span class="hl-property">[</span>setup-dependencies, run-tests<span class="hl-property">]</span>
        <span class="hl-keyword">runs-on</span><span class="hl-property">:</span> ubuntu-latest
        <span class="hl-keyword">if</span><span class="hl-property">:</span> always()
        <span class="hl-keyword">steps</span><span class="hl-property">:</span>
            <span class="hl-property">-</span>   <span class="hl-keyword">name</span><span class="hl-property">:</span> Delete artifacts
                <span class="hl-keyword">uses</span><span class="hl-property">:</span> geekyeggo/delete-artifact@v2
                <span class="hl-keyword">with</span><span class="hl-property">:</span>
                    <span class="hl-keyword">name</span><span class="hl-property">:</span> <span class="hl-property">|</span>
                        vendor-directory-$<span class="hl-value">{{</span><span class="hl-property"> github.run_id </span><span class="hl-value">}}</span>
                    <span class="hl-keyword">failOnError</span><span class="hl-property">:</span> false
</pre>
<h2 id="cancelling-stale-tests">Cancelling stale tests <a href="#cancelling-stale-tests" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#cancelling-stale-tests">#</a></h2>
<p>There's another neat little thing that we do in our GitHub action workflow to save some time. Whenever changes are pushed to a certain branch, we're not really interested in the results of the tests of any previous commits / pushes on that branch anymore.</p>
<p>Wouldn't it be nice if the test for any older commits on a branch were automatically cancelled, so the tests for the new commits / push would immediately start?</p>
<p>Well, with this snippet in your workflow, that's exactly what will happen.</p>
<pre data-lang="yaml" class="notranslate"><span class="hl-keyword">name</span><span class="hl-property">:</span> Run tests

<span class="hl-keyword">on</span><span class="hl-property">:</span>
    <span class="hl-keyword">push</span><span class="hl-property">:</span>
        <span class="hl-keyword">paths</span><span class="hl-property">:</span>
            <span class="hl-property">-</span> '<span class="hl-value">**.php</span>'
            <span class="hl-property">-</span> '<span class="hl-value">.github/workflows/run-tests.yml</span>'
            <span class="hl-property">-</span> '<span class="hl-value">phpunit.xml.dist</span>'
            <span class="hl-property">-</span> '<span class="hl-value">composer.json</span>'
            <span class="hl-property">-</span> '<span class="hl-value">composer.lock</span>'

<span class="hl-keyword">concurrency</span><span class="hl-property">:</span>
    <span class="hl-keyword">group</span><span class="hl-property">:</span> $<span class="hl-value">{{</span><span class="hl-property"> github.workflow </span><span class="hl-value">}}</span>-$<span class="hl-value">{{</span><span class="hl-property"> github.ref </span><span class="hl-value">}}</span>
    <span class="hl-keyword">cancel-in-progress</span><span class="hl-property">:</span> true
    
<span class="hl-keyword">jobs</span><span class="hl-property">:</span>
   <span class="hl-comment">#</span>
   <span class="hl-comment"># omitted for brevity</span>
   <span class="hl-comment">#</span>
</pre>
<h2 id="our-complete-test-workflow">Our complete test workflow <a href="#our-complete-test-workflow" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#our-complete-test-workflow">#</a></h2>
<p>To make things clear, here's our entire workflow file including the setup of all the services that our testsuite needs like MySQL, Redis, ClickHouse, Lighthouse and more.</p>
<p>This all runs inside <a href="/news-and-updates/our-gitlab-ci-pipeline-for-laravel-applications">our GitLab CI pipeline</a>.</p>
<pre data-lang="yaml" class="notranslate"><span class="hl-keyword">name</span><span class="hl-property">:</span> Run tests

<span class="hl-keyword">on</span><span class="hl-property">:</span>
    <span class="hl-keyword">push</span><span class="hl-property">:</span>
        <span class="hl-keyword">paths</span><span class="hl-property">:</span>
            <span class="hl-property">-</span> '<span class="hl-value">**.php</span>'
            <span class="hl-property">-</span> '<span class="hl-value">.github/workflows/run-tests.yml</span>'
            <span class="hl-property">-</span> '<span class="hl-value">phpunit.xml.dist</span>'
            <span class="hl-property">-</span> '<span class="hl-value">composer.json</span>'
            <span class="hl-property">-</span> '<span class="hl-value">composer.lock</span>'

<span class="hl-keyword">concurrency</span><span class="hl-property">:</span>
    <span class="hl-keyword">group</span><span class="hl-property">:</span> $<span class="hl-value">{{</span><span class="hl-property"> github.workflow </span><span class="hl-value">}}</span>-$<span class="hl-value">{{</span><span class="hl-property"> github.ref </span><span class="hl-value">}}</span>
    <span class="hl-keyword">cancel-in-progress</span><span class="hl-property">:</span> true

<span class="hl-keyword">jobs</span><span class="hl-property">:</span>
    <span class="hl-keyword">setup-dependencies</span><span class="hl-property">:</span>
        <span class="hl-keyword">name</span><span class="hl-property">:</span> Composer and Yarn
        <span class="hl-keyword">runs-on</span><span class="hl-property">:</span> ubuntu-latest
        <span class="hl-keyword">steps</span><span class="hl-property">:</span>
            <span class="hl-property">-</span>   <span class="hl-keyword">uses</span><span class="hl-property">:</span> actions/checkout@v3
                <span class="hl-keyword">with</span><span class="hl-property">:</span>
                    <span class="hl-keyword">fetch-depth</span><span class="hl-property">:</span> 1

            <span class="hl-property">-</span>   <span class="hl-keyword">name</span><span class="hl-property">:</span> Setup PHP
                <span class="hl-keyword">uses</span><span class="hl-property">:</span> shivammathur/setup-php@v2
                <span class="hl-keyword">with</span><span class="hl-property">:</span>
                    <span class="hl-keyword">php-version</span><span class="hl-property">:</span> 8.4
                    <span class="hl-keyword">extensions</span><span class="hl-property">:</span> dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick
                    <span class="hl-keyword">coverage</span><span class="hl-property">:</span> none

            <span class="hl-property">-</span>   <span class="hl-keyword">name</span><span class="hl-property">:</span> Get Composer Cache Directory
                <span class="hl-keyword">id</span><span class="hl-property">:</span> composer-cache
                <span class="hl-keyword">run</span><span class="hl-property">:</span> echo &quot;<span class="hl-value">dir=$(composer config cache-files-dir)</span>&quot; &gt;&gt; $GITHUB_OUTPUT

            <span class="hl-property">-</span>   <span class="hl-keyword">name</span><span class="hl-property">:</span> Cache Composer dependencies
                <span class="hl-keyword">uses</span><span class="hl-property">:</span> actions/cache@v3
                <span class="hl-keyword">with</span><span class="hl-property">:</span>
                    <span class="hl-keyword">path</span><span class="hl-property">:</span> $<span class="hl-value">{{</span> steps.composer-cache.outputs.dir <span class="hl-value">}}</span>
                    <span class="hl-keyword">key</span><span class="hl-property">:</span> $<span class="hl-value">{{</span><span class="hl-property"> runner.os </span><span class="hl-value">}}</span>-composer-$<span class="hl-value">{{</span> hashFiles('<span class="hl-value">**/composer.lock</span>') <span class="hl-value">}}</span>
                    <span class="hl-keyword">restore-keys</span><span class="hl-property">:</span> $<span class="hl-value">{{</span><span class="hl-property"> runner.os </span><span class="hl-value">}}</span>-composer-

            <span class="hl-property">-</span>   <span class="hl-keyword">name</span><span class="hl-property">:</span> Install composer dependencies
                <span class="hl-keyword">run</span><span class="hl-property">:</span> composer install --prefer-dist --no-scripts -q -o

            <span class="hl-property">-</span>   <span class="hl-keyword">name</span><span class="hl-property">:</span> Upload vendor directory
                <span class="hl-keyword">uses</span><span class="hl-property">:</span> actions/upload-artifact@v4
                <span class="hl-keyword">with</span><span class="hl-property">:</span>
                    <span class="hl-keyword">name</span><span class="hl-property">:</span> vendor-directory-$<span class="hl-value">{{</span><span class="hl-property"> github.run_id </span><span class="hl-value">}}</span>
                    <span class="hl-keyword">path</span><span class="hl-property">:</span> vendor
                    <span class="hl-keyword">retention-days</span><span class="hl-property">:</span> 1

            <span class="hl-property">-</span>   <span class="hl-keyword">name</span><span class="hl-property">:</span> Cache Node Modules
                <span class="hl-keyword">uses</span><span class="hl-property">:</span> actions/cache@v3
                <span class="hl-keyword">with</span><span class="hl-property">:</span>
                    <span class="hl-keyword">path</span><span class="hl-property">:</span> node_modules
                    <span class="hl-keyword">key</span><span class="hl-property">:</span> $<span class="hl-value">{{</span><span class="hl-property"> runner.os </span><span class="hl-value">}}</span>-node-$<span class="hl-value">{{</span> hashFiles('<span class="hl-value">**/yarn.lock</span>') <span class="hl-value">}}</span>
                    <span class="hl-keyword">restore-keys</span><span class="hl-property">:</span> $<span class="hl-value">{{</span><span class="hl-property"> runner.os </span><span class="hl-value">}}</span>-node-

            <span class="hl-property">-</span>   <span class="hl-keyword">name</span><span class="hl-property">:</span> Cache built assets
                <span class="hl-keyword">uses</span><span class="hl-property">:</span> actions/cache@v3
                <span class="hl-keyword">with</span><span class="hl-property">:</span>
                    <span class="hl-keyword">path</span><span class="hl-property">:</span> <span class="hl-property">|</span>
                        public/build
                        public/hot
                        public/css
                        public/js
                    <span class="hl-keyword">key</span><span class="hl-property">:</span> $<span class="hl-value">{{</span><span class="hl-property"> runner.os </span><span class="hl-value">}}</span>-assets-$<span class="hl-value">{{</span> hashFiles('<span class="hl-value">resources/**/*</span>') <span class="hl-value">}}</span>-$<span class="hl-value">{{</span> hashFiles('<span class="hl-value">**/yarn.lock</span>') <span class="hl-value">}}</span>
                    <span class="hl-keyword">restore-keys</span><span class="hl-property">:</span> $<span class="hl-value">{{</span><span class="hl-property"> runner.os </span><span class="hl-value">}}</span>-assets-

            <span class="hl-property">-</span>   <span class="hl-keyword">name</span><span class="hl-property">:</span> Compile assets
                <span class="hl-keyword">run</span><span class="hl-property">:</span> <span class="hl-property">|</span>
                    yarn install --pure-lockfile
                    yarn build
                <span class="hl-comment"># Only run build if node_modules or assets cache wasn't hit</span>
                <span class="hl-keyword">if</span><span class="hl-property">:</span> steps.node-cache.outputs.cache-hit != '<span class="hl-value">true</span>' || steps.assets-cache.outputs.cache-hit != '<span class="hl-value">true</span>'

            <span class="hl-property">-</span>   <span class="hl-keyword">name</span><span class="hl-property">:</span> Upload compiled assets
                <span class="hl-keyword">uses</span><span class="hl-property">:</span> actions/upload-artifact@v4
                <span class="hl-keyword">with</span><span class="hl-property">:</span>
                    <span class="hl-keyword">name</span><span class="hl-property">:</span> compiled-assets-$<span class="hl-value">{{</span><span class="hl-property"> github.run_id </span><span class="hl-value">}}</span>
                    <span class="hl-keyword">path</span><span class="hl-property">:</span> <span class="hl-property">|</span>
                        public/build
                        public/hot
                        public/css
                        public/js
                    <span class="hl-keyword">retention-days</span><span class="hl-property">:</span> 1

    <span class="hl-keyword">install-chrome-and-lighthouse</span><span class="hl-property">:</span>
        <span class="hl-keyword">name</span><span class="hl-property">:</span> Install Chrome and Lighthouse
        <span class="hl-keyword">runs-on</span><span class="hl-property">:</span> ubuntu-latest
        <span class="hl-keyword">steps</span><span class="hl-property">:</span>
            <span class="hl-property">-</span>   <span class="hl-keyword">uses</span><span class="hl-property">:</span> actions/checkout@v3
                <span class="hl-keyword">with</span><span class="hl-property">:</span>
                    <span class="hl-keyword">fetch-depth</span><span class="hl-property">:</span> 1

            <span class="hl-property">-</span>   <span class="hl-keyword">name</span><span class="hl-property">:</span> Setup PHP
                <span class="hl-keyword">uses</span><span class="hl-property">:</span> shivammathur/setup-php@v2
                <span class="hl-keyword">with</span><span class="hl-property">:</span>
                    <span class="hl-keyword">php-version</span><span class="hl-property">:</span> 8.4
                    <span class="hl-keyword">extensions</span><span class="hl-property">:</span> dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick
                    <span class="hl-keyword">coverage</span><span class="hl-property">:</span> none

            <span class="hl-property">-</span>   <span class="hl-keyword">name</span><span class="hl-property">:</span> Setup problem matchers
                <span class="hl-keyword">run</span><span class="hl-property">:</span> <span class="hl-property">|</span>
                    echo &quot;<span class="hl-value">::add-matcher::${{ runner.tool_cache }}/php.json</span>&quot;
                    echo &quot;<span class="hl-value">::add-matcher::${{ runner.tool_cache }}/phpunit.json</span>&quot;

            <span class="hl-property">-</span>   <span class="hl-keyword">name</span><span class="hl-property">:</span> Install Chrome Launcher
                <span class="hl-keyword">run</span><span class="hl-property">:</span> npm install chrome-launcher

            <span class="hl-property">-</span>   <span class="hl-keyword">name</span><span class="hl-property">:</span> Install Lighthouse
                <span class="hl-keyword">run</span><span class="hl-property">:</span> npm install lighthouse

            <span class="hl-property">-</span>   <span class="hl-keyword">name</span><span class="hl-property">:</span> Cache test environment
                <span class="hl-keyword">uses</span><span class="hl-property">:</span> actions/upload-artifact@v4
                <span class="hl-keyword">with</span><span class="hl-property">:</span>
                    <span class="hl-keyword">name</span><span class="hl-property">:</span> test-env-$<span class="hl-value">{{</span><span class="hl-property"> github.run_id </span><span class="hl-value">}}</span>
                    <span class="hl-keyword">path</span><span class="hl-property">:</span> <span class="hl-property">|</span>
                        node_modules
                    <span class="hl-keyword">retention-days</span><span class="hl-property">:</span> 1

    <span class="hl-keyword">run-tests</span><span class="hl-property">:</span>
        <span class="hl-keyword">needs</span><span class="hl-property">:</span> <span class="hl-property">[</span> setup-dependencies, install-chrome-and-lighthouse <span class="hl-property">]</span>
        <span class="hl-keyword">name</span><span class="hl-property">:</span> Run Tests (Part $<span class="hl-value">{{</span><span class="hl-property"> matrix.shard_number </span><span class="hl-value">}}</span>/$<span class="hl-value">{{</span><span class="hl-property"> matrix.total_shard_count </span><span class="hl-value">}}</span>)
        <span class="hl-keyword">runs-on</span><span class="hl-property">:</span> ubuntu-latest
        <span class="hl-keyword">strategy</span><span class="hl-property">:</span>
            <span class="hl-keyword">fail-fast</span><span class="hl-property">:</span> false
            <span class="hl-keyword">matrix</span><span class="hl-property">:</span>
                <span class="hl-keyword">total_shard_count</span><span class="hl-property">:</span> <span class="hl-property">[</span>12<span class="hl-property">]</span>
                <span class="hl-keyword">shard_number</span><span class="hl-property">:</span> <span class="hl-property">[</span>'<span class="hl-value">01</span>', '<span class="hl-value">02</span>', '<span class="hl-value">03</span>', '<span class="hl-value">04</span>', '<span class="hl-value">05</span>', '<span class="hl-value">06</span>', '<span class="hl-value">07</span>', '<span class="hl-value">08</span>', '<span class="hl-value">09</span>', '<span class="hl-value">10</span>', '<span class="hl-value">11</span>', '<span class="hl-value">12</span>'<span class="hl-property">]</span>

        <span class="hl-keyword">services</span><span class="hl-property">:</span>
            <span class="hl-keyword">mysql</span><span class="hl-property">:</span>
                <span class="hl-keyword">image</span><span class="hl-property">:</span> <span class="hl-keyword">mysql</span>:8.0
                <span class="hl-keyword">env</span><span class="hl-property">:</span>
                    <span class="hl-keyword">MYSQL_ALLOW_EMPTY_PASSWORD</span><span class="hl-property">:</span> yes
                    <span class="hl-keyword">MYSQL_DATABASE</span><span class="hl-property">:</span> ohdear_testing
                <span class="hl-keyword">ports</span><span class="hl-property">:</span>
                    <span class="hl-property">-</span> 3306
                <span class="hl-keyword">options</span><span class="hl-property">:</span> --health-cmd=&quot;<span class="hl-value">mysqladmin ping</span>&quot; --health-interval=10s --health-timeout=5s --health-retries=3
            <span class="hl-keyword">redis</span><span class="hl-property">:</span>
                <span class="hl-keyword">image</span><span class="hl-property">:</span> redis
                <span class="hl-keyword">ports</span><span class="hl-property">:</span>
                    <span class="hl-property">-</span> <span class="hl-keyword">6379</span><span class="hl-property">:</span>6379
                <span class="hl-keyword">options</span><span class="hl-property">:</span> --entrypoint redis-server
            <span class="hl-keyword">clickhouse</span><span class="hl-property">:</span>
                <span class="hl-keyword">image</span><span class="hl-property">:</span> clickhouse/clickhouse-server
                <span class="hl-keyword">options</span><span class="hl-property">:</span> &gt;-
                    <span class="hl-property">-</span>-health-cmd &quot;<span class="hl-value">clickhouse client -q 'SELECT 1'</span>&quot;
                    <span class="hl-property">-</span>-health-interval 10s
                    <span class="hl-property">-</span>-health-timeout 5s
                    <span class="hl-property">-</span>-health-retries 5
                <span class="hl-keyword">ports</span><span class="hl-property">:</span>
                    <span class="hl-property">-</span> <span class="hl-keyword">8123</span><span class="hl-property">:</span>8123
                    <span class="hl-property">-</span> <span class="hl-keyword">9000</span><span class="hl-property">:</span>9000
                    <span class="hl-property">-</span> <span class="hl-keyword">9009</span><span class="hl-property">:</span>9009
                <span class="hl-keyword">env</span><span class="hl-property">:</span>
                    <span class="hl-keyword">CLICKHOUSE_DB</span><span class="hl-property">:</span> ohdear
                    <span class="hl-keyword">CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT</span><span class="hl-property">:</span> 1

        <span class="hl-keyword">steps</span><span class="hl-property">:</span>
            <span class="hl-property">-</span>   <span class="hl-keyword">uses</span><span class="hl-property">:</span> actions/checkout@v3
                <span class="hl-keyword">with</span><span class="hl-property">:</span>
                    <span class="hl-keyword">fetch-depth</span><span class="hl-property">:</span> 1

            <span class="hl-property">-</span>   <span class="hl-keyword">name</span><span class="hl-property">:</span> create db
                <span class="hl-keyword">run</span><span class="hl-property">:</span> <span class="hl-property">|</span>
                    sudo /etc/init.d/mysql start
                    mysql  -u root -proot -e '<span class="hl-value">CREATE DATABASE IF NOT EXISTS ohdear_testing;</span>'

            <span class="hl-property">-</span>   <span class="hl-keyword">name</span><span class="hl-property">:</span> Setup PHP
                <span class="hl-keyword">uses</span><span class="hl-property">:</span> shivammathur/setup-php@v2
                <span class="hl-keyword">with</span><span class="hl-property">:</span>
                    <span class="hl-keyword">php-version</span><span class="hl-property">:</span> 8.4
                    <span class="hl-keyword">extensions</span><span class="hl-property">:</span> dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick
                    <span class="hl-keyword">coverage</span><span class="hl-property">:</span> none

            <span class="hl-property">-</span>   <span class="hl-keyword">name</span><span class="hl-property">:</span> Download test environment
                <span class="hl-keyword">uses</span><span class="hl-property">:</span> actions/download-artifact@v4
                <span class="hl-keyword">with</span><span class="hl-property">:</span>
                    <span class="hl-keyword">name</span><span class="hl-property">:</span> test-env-$<span class="hl-value">{{</span><span class="hl-property"> github.run_id </span><span class="hl-value">}}</span>
                    <span class="hl-keyword">path</span><span class="hl-property">:</span> .

            <span class="hl-property">-</span>   <span class="hl-keyword">name</span><span class="hl-property">:</span> Download vendor directory
                <span class="hl-keyword">uses</span><span class="hl-property">:</span> actions/download-artifact@v4
                <span class="hl-keyword">with</span><span class="hl-property">:</span>
                    <span class="hl-keyword">name</span><span class="hl-property">:</span> vendor-directory-$<span class="hl-value">{{</span><span class="hl-property"> github.run_id </span><span class="hl-value">}}</span>
                    <span class="hl-keyword">path</span><span class="hl-property">:</span> vendor

            <span class="hl-property">-</span>   <span class="hl-keyword">name</span><span class="hl-property">:</span> Download compiled assets
                <span class="hl-keyword">uses</span><span class="hl-property">:</span> actions/download-artifact@v4
                <span class="hl-keyword">with</span><span class="hl-property">:</span>
                    <span class="hl-keyword">name</span><span class="hl-property">:</span> compiled-assets-$<span class="hl-value">{{</span><span class="hl-property"> github.run_id </span><span class="hl-value">}}</span>
                    <span class="hl-keyword">path</span><span class="hl-property">:</span> public

            <span class="hl-property">-</span>   <span class="hl-keyword">name</span><span class="hl-property">:</span> Prepare Laravel Application
                <span class="hl-keyword">run</span><span class="hl-property">:</span> <span class="hl-property">|</span>
                    cp .env.example .env
                    php artisan <span class="hl-keyword">key</span><span class="hl-property">:</span>generate

            <span class="hl-property">-</span>   <span class="hl-keyword">name</span><span class="hl-property">:</span> Set permissions for vendor binaries
                <span class="hl-keyword">run</span><span class="hl-property">:</span> chmod -R +x vendor/bin/

            <span class="hl-property">-</span>   <span class="hl-keyword">name</span><span class="hl-property">:</span> Run tests
                <span class="hl-keyword">run</span><span class="hl-property">:</span> ./bin/github-parallel-test-runner
                <span class="hl-keyword">env</span><span class="hl-property">:</span>
                    <span class="hl-keyword">DB_PORT</span><span class="hl-property">:</span> $<span class="hl-value">{{</span> job.services.mysql.ports<span class="hl-property">[</span>3306<span class="hl-property">]</span> <span class="hl-value">}}</span>
                    <span class="hl-keyword">REDIS_PORT</span><span class="hl-property">:</span> $<span class="hl-value">{{</span> job.services.redis.ports<span class="hl-property">[</span>6379<span class="hl-property">]</span> <span class="hl-value">}}</span>
                    <span class="hl-keyword">CLICKHOUSE_HOST</span><span class="hl-property">:</span> localhost
                    <span class="hl-keyword">CLICKHOUSE_PORT</span><span class="hl-property">:</span> 8123
                    <span class="hl-keyword">CLICKHOUSE_DATABASE</span><span class="hl-property">:</span> ohdear
                    <span class="hl-keyword">CLICKHOUSE_USERNAME</span><span class="hl-property">:</span> default
                    <span class="hl-keyword">CLICKHOUSE_PASSWORD</span><span class="hl-property">:</span>
                    <span class="hl-keyword">CLICKHOUSE_HTTPS</span><span class="hl-property">:</span> false
                    <span class="hl-keyword">TOTAL_SHARD_COUNT</span><span class="hl-property">:</span> $<span class="hl-value">{{</span><span class="hl-property"> matrix.total_shard_count </span><span class="hl-value">}}</span>
                    <span class="hl-keyword">SHARD_NUMBER</span><span class="hl-property">:</span> $<span class="hl-value">{{</span><span class="hl-property"> matrix.shard_number </span><span class="hl-value">}}</span>

    <span class="hl-keyword">cleanup-artifacts</span><span class="hl-property">:</span>
        <span class="hl-keyword">name</span><span class="hl-property">:</span> Clean up artifacts
        <span class="hl-keyword">needs</span><span class="hl-property">:</span> <span class="hl-property">[</span>setup-dependencies, install-chrome-and-lighthouse, run-tests<span class="hl-property">]</span>
        <span class="hl-keyword">runs-on</span><span class="hl-property">:</span> ubuntu-latest
        <span class="hl-keyword">if</span><span class="hl-property">:</span> always()
        <span class="hl-keyword">steps</span><span class="hl-property">:</span>
            <span class="hl-property">-</span>   <span class="hl-keyword">name</span><span class="hl-property">:</span> Delete artifacts
                <span class="hl-keyword">uses</span><span class="hl-property">:</span> geekyeggo/delete-artifact@v2
                <span class="hl-keyword">with</span><span class="hl-property">:</span>
                    <span class="hl-keyword">name</span><span class="hl-property">:</span> <span class="hl-property">|</span>
                        vendor-directory-$<span class="hl-value">{{</span><span class="hl-property"> github.run_id </span><span class="hl-value">}}</span>
                        compiled-assets-$<span class="hl-value">{{</span><span class="hl-property"> github.run_id </span><span class="hl-value">}}</span>
                        test-env-$<span class="hl-value">{{</span><span class="hl-property"> github.run_id </span><span class="hl-value">}}</span>
                    <span class="hl-keyword">failOnError</span><span class="hl-property">:</span> false

</pre>
<h2 id="pros-and-cons">Pros and cons <a href="#pros-and-cons" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#pros-and-cons">#</a></h2>
<p>There are pros and cons for running tests in parallel on GitHub actions. Let's start with the most important pro first: your test suite will run significantly faster. It's also very easy to increase or decrease the level of parallelism. A faster testsuite means that your entire feedback cycle is faster, which is a big win.</p>
<p>On the cons side, there's certainly some more complexity involved: you need a script to split tests, the workflow becomes more complex.</p>
<p>There's also the risk of uneven test distribution. If your tests vary significantly in execution time, you might end up with some shards finishing much earlier than others, reducing the efficiency gain.</p>
<h2 id="in-closing">In closing <a href="#in-closing" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#in-closing">#</a></h2>
<p>The approach outlined in this post works exceptionally well for large test suites like we have, where we've seen significant reduction in test execution time. But even smaller projects can benefit from this technique, especially as they grow over time.</p>
<p>Fast CI matters to us because Oh Dear is built <a href="/for/php-developers">for Laravel and PHP developers</a> shipping every day.</p>
]]>
            </summary>
                                    <updated>2026-06-12T08:58:01+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Broken links troubleshooting and a couple of extras]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/broken-links-troubleshooting-and-a-couple-of-extras" />
            <id>https://ohdear.app/131</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>We are excited to announce a powerful new feature in our Broken Links monitor: <strong>Troubleshooting</strong>.</p>
<p>When we can’t crawl all your links, or you see unexpected gaps in the results, it can be tricky to know where to start. Our new Troubleshooting tab is here to help.</p>
<h3 id="quickly-identify-and-resolve-crawling-issues">Quickly identify and resolve crawling issues</h3>
<p>The Troubleshooting tab detects common issues that may affect link checking and gives you clear guidance to resolve them and in some instances allows you to resolve them with one click. Is <code>robots.txt</code>preventing us from accessing your site? Is your firewall rate-limiting us, or maybe you are linking to <code>www</code> when your site URL is different?</p>
<p><img src="/media/blog/Dp2uAtNN2lgnmatS9TybS7TH0csxCU6Klb8n9yk5.png" alt="Broken links Troubleshooting tab suggesting fixes for robots.txt blocking and firewall rate limiting" /></p>
<p>All broken link report pages will now have access to <code>suggested</code> and <code>all</code> troubleshooting filters where we either show you relevant actions based on your results or all possible issues and solutions. Think of it like interactive documentation and FAQs (which we have also linked to). There's even a short API snippet to trigger a new broken links - perfect for post-deployment hooks.</p>
<h3 id="why-it-matters">Why it matters</h3>
<p>Our goal is to make it easy for teams of all sizes to catch and fix broken links and quickly identify and resolve any issues. With this update, even the trickier edge cases become actionable and transparent. Take a look at what we have got so far - we will be keeping this up-to-date based on feedback so keep an eye out! And if you're still having trouble our support team is just a click away in the support bubble.</p>
<p>Related: <a href="/news-and-updates/chromes-next-steps-for-security-mixed-content-checking-more-important-than-ever">why mixed-content checking matters more than ever</a>.</p>
<p><img src="/media/blog/dIJbH3wTgbY59dWPWtLxiCYzmDeOERdgn1mFZgU8.png" alt="Broken links troubleshooting suggestions for crawl limit, site URL subdomain mismatch, and JavaScript-rendered pages" /></p>
<h3 id="new-robotstxt-controls">New robots.txt controls</h3>
<p>We have always allowed our users to 'ignore' broken links but sometimes you don't even want us to crawl your pages. Especially when you want us to respect your robots.txt file but where you may not have access to server config.</p>
<p>So we have added advanced settings for a couple of edge cases:</p>
<p>Force crawl specific URLs: If you can't or don’t want to modify your <code>robots.txt</code>, you can manually add specific pages to an allowlist that should always be crawled - even if disallowed.</p>
<p>Do not crawl specific URLs: Useful to exclude sensitive pages, admin areas, or known false positives.</p>
<p>These options give you just a little bit more control over how Oh Dear crawls your site.</p>
<p><img src="/media/blog/TRxIFlij1N1Gp7oIofllVBbB9lIyPscZd769JHfQ.png" alt="Crawler settings with respect robots toggle, do not crawl and force crawl URL fields, and a crawler speed dropdown" /></p>
<p>While you are at it, here is <a href="/news-and-updates/heres-how-you-can-monitor-your-sites-seo-performance">how to monitor your site's SEO performance</a>.</p>
<h3 id="coming-soon">Coming soon</h3>
<p>We have more broken links updates coming to your notification settings soon! Tired of receiving the same alert every day for the same broken links? You will now be able to only get a follow-up notification when new broken links are found. Giving you a bit more time to get them fixed without constantly snoozing the notification.</p>
<p>Keep an eye on the Oh Dear to find out when this is released :)</p>
]]>
            </summary>
                                    <updated>2026-06-12T08:58:01+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Why your business can’t afford to skip website monitoring]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/why-your-business-cant-afford-to-skip-website-monitoring" />
            <id>https://ohdear.app/129</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<h2 id="why-would-i-monitor-my-website-in-the-first-place">Why would I monitor my website in the first place? <a href="#why-would-i-monitor-my-website-in-the-first-place" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#why-would-i-monitor-my-website-in-the-first-place">#</a></h2>
<p>Your website is your business’ storefront, sales team, customer service department, and potentially even your primary revenue channel. Just like you’d protect the physical presence of these aspects of your business with a security system, you also need to protect the online aspects too. That means keeping an eye on your website with monitoring.</p>
<h2 id="how-much-does-website-downtime-cost">How much does website downtime cost? <a href="#how-much-does-website-downtime-cost" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#how-much-does-website-downtime-cost">#</a></h2>
<p>When figuring out the cost of website downtime, there are two factors at play - direct costs and indirect costs.</p>
<h3 id="direct-costs-of-website-downtime">Direct costs of website downtime</h3>
<p>You can calculate the direct costs. Figure out how much money your website makes you per hour or minute and then multiply that number by the amount of time your site was down.</p>
<p>Of course, the amount your website makes is a lot easier to calculate for e-commerce sites than other businesses such as consultants. But, you can still get a rough idea by considering the amount of inquiries you get on your website per hour or day and the rate that you turn those inquiries into sales.</p>
<h3 id="indirect-costs-of-website-downtime">Indirect costs of website downtime</h3>
<p>Then, there are the indirect costs. While they’re more difficult to account for, they cost your business all the same. Here are a few examples of the indirect costs to website downtime:</p>
<ul>
<li><strong>Brand reputation damage:</strong> Nowadays, people expect websites to always be working. When potential customers visit a website that’s down, they start off with a negative impression of the brand. They associate it with brands that cut corners and offer cheap services or products.</li>
<li><strong>SEO ranking penalties:</strong> Google constantly indexes websites so they have the most up-to-date information on your pages. If your site is down while Google is trying to crawl your site, it will negatively impact your rankings since it returns an <a href="https://developers.google.com/search/docs/essentials/technical">error code to the Googlebots</a>.</li>
<li><strong>Customers go elsewhere:</strong> According to a <a href="https://landing.adobe.com/en/na/products/creative-cloud/264699-state-of-content/index.html">study by Adobe</a>, if your site doesn’t load, 41 percent of people go elsewhere. This drives your potential customers to your competitors.</li>
<li><strong>Increased support costs:</strong> You likely have self-help articles on your website. When those go down, customers who need help must revert to calling your customer service line which increases your support costs.</li>
</ul>
<h3 id="real-world-examples-of-website-downtime-impacts">Real-world examples of website downtime impacts</h3>
<p>The costs of website downtime <a href="/uptime-statistics">aren’t just hypothetical</a>. They cause real-world impacts. Let’s go over a few of the many times website downtime has cost companies millions:</p>
<ul>
<li><strong>Amazon's Prime Day disaster:</strong> In 2018, Amazon went down for some users on the worst day for them, Prime Day. It’s one of their major shopping days and users couldn’t access the site. Amazon estimates this outage cost them <a href="https://www.businessinsider.com/amazon-prime-day-website-issues-cost-it-millions-in-lost-sales-2018-7">upwards of $99 million</a>.</li>
<li><strong>British Airways' internal chaos:</strong> Internal issues can arise from website downtime too. British Airways’ 2017 disaster is a perfect example. Their IT systems went down forcing them to cancel flights for <a href="https://www.theguardian.com/business/2017/may/31/ba-it-shutdown-caused-by-uncontrolled-return-of-power-after-outage">up to 75,000 passengers</a>.</li>
<li><strong>Facebook’s $90 million day:</strong> After just five hours of downtime, Facebook lost <a href="https://www.theguardian.com/technology/2021/oct/05/facebook-outage-what-went-wrong-and-why-did-it-take-so-long-to-fix">4.9 percent of its share value</a>. That resulted in Mark Zuckerberg losing roughly $6 billion in wealth.</li>
</ul>
<h2 id="why-startups-cant-afford-to-skip-website-monitoring">Why startups can't afford to skip website monitoring <a href="#why-startups-cant-afford-to-skip-website-monitoring" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#why-startups-cant-afford-to-skip-website-monitoring">#</a></h2>
<p>All too often, startups consider website monitoring a luxury to buy once they get bigger. The reality is that bigger companies have enough resources to survive through downtime issues whereas the same problems for startups can become fatal. Here are a few of the reasons why startups need website monitoring:</p>
<ul>
<li><strong>Myth of startup resilience:</strong> People think of startups as agile companies that can quickly adjust for any problems that arise. While it’s true that startups are agile, they also don’t have as much runway to adjust. That’s why startups need website monitoring that notifies them of downtime immediately so they can act right away. If it goes unnoticed, startups can run out of runway before they even get started recovering.</li>
<li><strong>First impressions are critical:</strong> We already touched on how website downtime can give customers a bad taste in their mouth. What we haven’t shared yet, though, is that according to <a href="https://in.nau.edu/experience-design/">Northern Arizona University</a>, 88 percent of users won’t return after a bad user experience. And your website being down is absolutely a bad first experience. Website monitoring helps identify downtime as the issue as opposed to a different issue such as a poorly designed website.</li>
<li><strong>Launch timing:</strong> There’s a cruel paradox where website downtime occurs when it’s least welcome, like the launch of your product or a feature. You’ve spent countless hours and thousands, if not millions, on your launch. If your website’s down, you can’t capitalize on it. Website monitoring can help you predict downtime to avoid these circumstances.</li>
<li><strong>Investor confidence:</strong> When pitching your startup to VCs, you usually need to present a demo and let them try your product. If your network goes down during these meetings, you’ll be hard-pressed to get a VC to come back another time, let alone invest in your startup. Your odds go up if you can cite your downtime statistics to show the VC that this is an outlier and not the norm.</li>
</ul>
<h2 id="what-else-should-you-monitor-on-your-website">What else should you monitor on your website? <a href="#what-else-should-you-monitor-on-your-website" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#what-else-should-you-monitor-on-your-website">#</a></h2>
<p>Monitoring the binary of whether your website is up or not leaves a lot of data off the table. That’s why alongside website downtime monitoring, you should also monitor:</p>
<h3 id="performance-metrics">Performance metrics</h3>
<p>Both users and Google care a lot about your performance metrics. Sure, your users don’t know how long it takes for your site to load, but they definitely know if it takes too long. And Google has figured out exactly what “too long” is. That’s why they use your website speed as a <a href="https://web.dev/articles/vitals">direct ranking factor</a>. You should apply this to all customer-facing online services. An overwhelmed server slowing down your online service or website is different from downtime, though, so it requires separate monitoring.</p>
<h3 id="functionality-testing">Functionality testing</h3>
<p>Website downtime monitoring won’t monitor for a broken user journey. That means when a user clicks a link or opens a menu, that link or menu doesn’t work. Services monitor for functionality by continuously running simulated user journeys identifying issues before they impact real users.</p>
<h3 id="third-party-service-integration">Third-party service integration</h3>
<p>Every third-party service you use introduces a potential point of failure for your website or online service. Now, that’s not to say you shouldn’t use third-party services. Every business depends on at least some third-party services. It only means you need to monitor their integration. APIs get updated all the time and those updates can break your integration. Third-party service monitoring ensures you know what’s working and what needs work at all times.</p>
<h3 id="security-monitoring">Security monitoring</h3>
<p>A <a href="https://newsroom.ibm.com/IBM-security?item=30567">study conducted by IBM</a> looked at how long it takes the average company to identify a security breach. The results are a shocking 197 days. That’s over half a year before a security breach gets identified. This is why security monitoring is crucial and why modern website monitoring services integrate security monitoring into their downtime monitoring. Any efforts to reduce that time to identify a breach help you minimize the damage it can cause.</p>
<h2 id="recap-why-you-need-website-monitoring">Recap: Why you need website monitoring <a href="#recap-why-you-need-website-monitoring" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#recap-why-you-need-website-monitoring">#</a></h2>
<p>Monitoring your website and online services is all about preventing you from jumping out of the frying pan and into the fire. That’s to say, without appropriately monitoring your website, you can incorrectly identify issues or not even realize an issue exists in the first place. Then, based on incorrect information, your attempts to fix the problem can make the problem worse.</p>
<p>For instance, if you notice a big uptick in your bounce rate after a marketing campaign, you might think the campaign flopped. But, it could be an issue with your site being overwhelmed causing it to slow down or go down completely for some users. With quality website monitoring, instead of scrapping an otherwise successful campaign, you can fix the core issue. That’s just one example of the value of website monitoring. Whether you’re a startup or an enterprise, website monitoring gives you the information you need to manage your website like the pros.</p>
]]>
            </summary>
                                    <updated>2026-07-06T10:17:44+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Here's what worked and what didn't when marketing SaaS like Oh Dear]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/heres-what-worked-and-what-didnt-when-marketing-saas-like-oh-dear" />
            <id>https://ohdear.app/128</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<h2 id="where-were-at-in-the-scene">Where we're at in the scene <a href="#where-were-at-in-the-scene" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#where-were-at-in-the-scene">#</a></h2>
<p>Oh Dear is the underdog in the website monitoring software space. We’re small fries in a market dominated by big, well-funded competitors. Yet somehow, Freek and Mattias managed to build a solid user base and created a profitable SaaS business. But where do you go from there? What was the eventual marketing strategy? We thought it would be cool to share with you guys our approach for Oh Dear, our lessons learned, and the strategies that helped us grow.</p>
<h2 id="started-from-the-bottom-now-were-here">Started from the bottom, now we're here <a href="#started-from-the-bottom-now-were-here" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#started-from-the-bottom-now-were-here">#</a></h2>
<p>After launching <a href="https://ohdear.app/">Oh Dear</a>, the boys were able to get some solid traction going thanks to their personal network. Both Freek and Mattias are well-known in the <a href="https://laravel.com/">Laravel</a> community, each running their own successful businesses—the <a href="https://ma.ttias.be/cronweekly/">cron.weekly newsletter</a> (Mattias) and <a href="https://spatie.be/">Spatie</a> (Freek). Their developer-focused blogs and community presence helped spread the word about Oh Dear organically.</p>
<p>Thanks to word-of-mouth and strong community engagement, we scaled to about 2,000 users without spending much on marketing.</p>
<p>But they knew they had to expand their audience beyond their own network. This is when <a href="https://brandimpact.be/">Brandimpact</a> entered the picture, a teeny tiny marketing agency from Ghent, Belgium, to help the boys start experimenting and build a robust, overarching marketing strategy. Below is an overview of what we did to better market Oh Dear throughout 2024.</p>
<h2 id="phase-1-getting-the-ball-rolling">Phase 1: Getting the ball rolling <a href="#phase-1-getting-the-ball-rolling" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#phase-1-getting-the-ball-rolling">#</a></h2>
<p>The Brandimpact gang got work doing the following:</p>
<ul>
<li>Customer interviews to better understand Oh Dear's ideal users</li>
<li>Competitor analysis &amp; keyword research to refine our positioning</li>
<li>A strategy workshop to set the priorities for the next six months</li>
</ul>
<p>The goals? Increasing awareness, improving conversion rates, and exploring less obvious acquisition channels.
Initially, we kept the personal networks of Freek and Mattias out of the picture, to see how independently the marketing efforts could run.</p>
<p>Since all of this was still very experimental, we limited our monthly media budget to approximately $5.000 per month for PPC (pay-per-click) advertising.</p>
<h3 id="what-worked">What worked</h3>
<h4 id="1-google-search-ads-with-limitations">1. Google Search Ads (With Limitations)</h4>
<p><a href="https://ads.google.com/">Google Ads</a> helped generate some free trial sign-ups, but at a pretty high acquisition cost. Since many <a href="https://ohdear.app/oh-dear-vs-competition">competitors</a> are obviously bidding on the same keywords (“website monitoring software”, “uptime monitoring software”), we were often outspent. While it brought in leads, it wasn’t sustainable for a bootstrapped SaaS like ours.</p>
<h4 id="2-cro-conversion-rate-optimization">2. CRO (Conversion Rate Optimization)</h4>
<p>We improved our website and trial experience by:</p>
<ul>
<li>Creating and adding a product video to the homepage</li>
<li>Making our copy more engaging and clear for our target audience</li>
<li>Extending the free trial from 7 to 30 days</li>
<li>Collecting and adding more customer testimonials</li>
</ul>
<p>Here's the video we ended up with, by the way. This was actually a lot of fun to do:</p>
<iframe width="100%" height="350px" src="https://www.youtube-nocookie.com/embed/IbCC06sHRtk?si=XCJlJO-rkTWsT1cj&amp;autoplay=1&amp;loop=1&amp;modestbranding=1&amp;playsinline=1" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen=""></iframe>
<h4 id="3-reddit-ads-better-than-expected">3. Reddit Ads — Better than expected</h4>
<p>We didn’t expect much from <a href="https://ads.reddit.com/">Reddit Ads</a> to be perfectly honest, but given our target audience we figured it was worth a shot. But as it turned out, Reddit ended up being one of the best-performing paid channels for us in terms of acquiring new trial sign-ups. We tested three different campaigns on Reddit:</p>
<ul>
<li>A feature-based carousel ad</li>
<li>A product video featuring Mattias &amp; Freek with a fun hook</li>
<li>A series of memes using popular formats</li>
</ul>
<p>Though we will say the quality of the traffic might be debatable—lots of clicks but not much else.</p>
<h3 id="what-didnt-work">What didn’t work</h3>
<h4 id="1-linkedin-ads-too-expensive">1. LinkedIn ads – Too expensive</h4>
<p>We targeted CTOs and developers, but the cost per click was too high. LinkedIn might work for enterprise SaaS, but for our niche, it wasn’t worth the investment.</p>
<h4 id="2-meta-amp-youtube-ads-no-significant-impact">2. Meta &amp; YouTube ads – No significant impact</h4>
<p>We briefly tested Facebook (Meta) Ads and YouTube Ads, but they didn’t generate enough quality leads. Developers don’t typically discover technical tools through these platforms.</p>
<h4 id="3-twitter-ads-garbage-clicks">3. Twitter ads – Garbage clicks</h4>
<p>Arguably the worst advertising services among social media platforms. Lots of reach and clicks but absolutely abysmal quality. We're suspecting many among them were just bots.</p>
<h2 id="phase-2-tuning-our-strategy">Phase 2: Tuning our strategy <a href="#phase-2-tuning-our-strategy" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#phase-2-tuning-our-strategy">#</a></h2>
<p>All in all, we were driving traffic but conversions weren't exactly growing in a similar fashion, so we turned our attention to re-activating our audience, leveraging SaaS aggregators and content marketing.</p>
<h3 id="1-affiliate-marketing-activating-our-user-base">1. Affiliate marketing – Activating our user base</h3>
<p>We already had an affiliate program offering 25% commission per sale, but few affiliates were actively promoting us. To improve this, we:</p>
<ul>
<li>Reached out to existing customers</li>
<li>Created a press kit with ready-to-use content</li>
<li>Updated the affiliate landing page on the website</li>
<li>Optimized our affiliate onboarding emails</li>
</ul>
<h3 id="2-saas-review-platforms-leveraging-g2-amp-capterra">2. SaaS review platforms – Leveraging G2 &amp; Capterra</h3>
<p>Like most people, developers tend to trust their peers, so we focused on building a strong presence on platforms like G2 and Capterra. It's kind of an SEO effort as well, since these platforms work similary to Google in terms of ranking search results.</p>
<ul>
<li>We added review badges to our site</li>
<li>We launched campaigns to encourage users to leave reviews</li>
<li>We paid for a Capterra CPC campaign (~€40 per free trial)</li>
</ul>
<p>Results? So far, Capterra (Gartner) has been our best-performing paid &quot;channel&quot; for free trial sign-ups.</p>
<h3 id="3-seo-amp-content-marketing-playing-the-long-game">3. SEO &amp; content marketing – Playing the long game</h3>
<p>We invested in SEO-driven content, but competing for high-intent keywords (like “uptime monitoring software”) remains tough. To improve our rankings, we're:</p>
<ul>
<li>Publishing SEO-optimized blog posts (see our blog)</li>
<li>Focusing on getting backlinks from SaaS review sites</li>
<li>Reaching out to tech blogs that review monitoring tools</li>
</ul>
<p>Example outreach targets:</p>
<ul>
<li><a href="https://middleware.io/blog/website-monitoring-tools/">Middleware.io's blog on website monitoring tools</a></li>
<li><a href="https://www.pcmag.com/picks/the-best-website-monitoring-services">PCMag’s Best Website Monitoring Services</a></li>
</ul>
<h3 id="4-direct-outreach-with-gifting-taking-things-offline">4. Direct outreach with gifting – Taking things offline</h3>
<p>We’re currently planning a gifting campaign targeting digital agencies in Belgium. The idea is to send over a little care package—as in, a couple of Belgian beers—to a series of target accounts. Custom packaging, a personalized message, nothing too sales-y either. Freek and Mattias were actually at a bar drinking said beer when the idea for Oh Dear first came up, so it's a fun little tie-in to the product.</p>
<h2 id="final-thoughts-what-we-learned-about-marketing-a-saas">Final Thoughts: What We Learned About Marketing a SaaS <a href="#final-thoughts-what-we-learned-about-marketing-a-saas" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#final-thoughts-what-we-learned-about-marketing-a-saas">#</a></h2>
<ol>
<li>Personal networks &amp; word-of-mouth are powerful, especially in the developer community.</li>
<li>Paid ads (safe for Reddit &amp; Capterra) weren’t particularly effective.</li>
<li>SEO &amp; review platforms can drive sustainable growth.</li>
<li>Don't be afraid to try something you're not familiar with. Trust is key, though.</li>
<li>There's no silver bullet, every audience is different.</li>
</ol>
<p>Our marketing strategy is always evolving, and we’ll keep refining our approach as we learn. Keep you posted!</p>
]]>
            </summary>
                                    <updated>2025-03-14T15:43:37+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Detecting and Resolving Broken Links Using Website Monitoring Software]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/detecting-and-resolving-broken-links-using-website-monitoring-software" />
            <id>https://ohdear.app/127</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>Broken links are more than minor annoyances—they can significantly harm your website’s user experience, SEO rankings, and overall credibility. Whether managing a small blog or a complex e-commerce platform, ensuring that your site functions flawlessly is essential to keeping visitors engaged and search engines satisfied. However, identifying and fixing broken links manually can be time-consuming and error-prone. That’s where website monitoring software comes into play.</p>
<p>These tools are especially relevant in today’s digital landscape, where user expectations and search engine algorithms demand seamless navigation and impeccable website health. Whether you're a website owner, SEO professional, or student learning about digital optimization, this guide will equip you with actionable insights and best practices for maintaining a robust online presence.</p>
<h2 id="how-do-broken-links-impact-website-seo-and-user-experience">How do broken links impact website SEO and user experience? <a href="#how-do-broken-links-impact-website-seo-and-user-experience" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#how-do-broken-links-impact-website-seo-and-user-experience">#</a></h2>
<p>Broken links negatively impact both website SEO and user experience. Firstly, they signal poor website maintenance to search engines, potentially lowering your search rankings. Secondly, they disrupt search engine crawlers, hindering their ability to index your content. Thirdly, broken links on pages with many inbound links diminish the value passed from other websites, reducing the page's authority.</p>
<p>Furthermore, broken links can frustrate users, leading to high bounce rates and short session durations, which send negative signals to search engines. From a user perspective, broken links create a frustrating experience, damaging your brand reputation and making your website appear unprofessional. This can disrupt user flow, prevent access to important information, and ultimately lead to missed opportunities for conversions and revenue.</p>
<h2 id="how-website-monitoring-software-helps-identify-broken-links">How website monitoring software helps identify broken links <a href="#how-website-monitoring-software-helps-identify-broken-links" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#how-website-monitoring-software-helps-identify-broken-links">#</a></h2>
<p><img src="/media/blog/h0IO8zxQmdMRFHmet2TBfHBgUPAuErRMK9RKPCEO.png" alt="Red-haired developer working at a desk with code on two monitors in a bright open office" />
Website monitoring software is an essential tool for efficiently identifying and managing broken links. These tools streamline the process by automating detection, providing actionable insights, and ensuring proactive website maintenance. Here's how they help:</p>
<h3 id="automated-crawling-of-your-website">Automated Crawling of Your Website</h3>
<p>Website monitoring software scans your entire site, following internal and external links to identify any that are broken. This eliminates the need for manual checking, which can be time-consuming and prone to human error.</p>
<h3 id="real-time-error-detection">Real-Time Error Detection</h3>
<p>Many website monitoring software continuously tracks your website and alerts you in real-time when a link becomes broken. This allows you to address the issue immediately before it affects SEO or user experience.</p>
<h3 id="comprehensive-reporting">Comprehensive Reporting</h3>
<p>These tools generate detailed reports that categorize broken links by type (404 errors, redirect loops, timeout issues, etc.) and highlight their location. This makes it easier to prioritize and fix the most critical problems first.</p>
<h3 id="integration-with-seo-and-analytics-tools">Integration with SEO and Analytics Tools</h3>
<p>Many monitoring platforms integrate with SEO tools like Google Analytics and Google Search Console. This allows you to see how broken links are impacting site traffic and rankings, helping prioritize fixes that matter most to your audience and search engines.</p>
<h3 id="scalability-for-large-websites">Scalability for Large Websites</h3>
<p>For websites with thousands of pages, manual link checking is impractical. Website monitoring software is built to handle large-scale link audits efficiently, ensuring no broken links are overlooked.</p>
<h3 id="user-friendly-dashboards">User-Friendly Dashboards</h3>
<p>Most website monitoring software offers intuitive dashboards that make it easy to manage broken links without technical expertise. Filters and sorting options allow quick access to the most urgent issues.</p>
<h2 id="what-features-should-you-look-for-in-software-designed-to-monitor-links">What features should you look for in software designed to monitor links? <a href="#what-features-should-you-look-for-in-software-designed-to-monitor-links" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#what-features-should-you-look-for-in-software-designed-to-monitor-links">#</a></h2>
<p>When selecting a website monitoring software, it's important to choose a tool with the right features to ensure effective and efficient link management. Here are the essential features to look for:</p>
<ul>
<li><strong>Real-time Monitoring</strong>: The ability to monitor link performance in real-time, is crucial for detecting issues as they occur. This allows for swift responses to potential problems, such as downtime or performance degradation.</li>
<li><strong>Automated Alerts</strong>: Automated alerts should notify users of any anomalies or threshold breaches in link performance. This feature ensures that administrators are promptly informed of issues that require attention, enabling quick action to mitigate risks.</li>
</ul>
<h2 id="how-accurate-are-automated-tools-compared-to-manual-link-checking">How accurate are automated tools compared to manual link-checking? <a href="#how-accurate-are-automated-tools-compared-to-manual-link-checking" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#how-accurate-are-automated-tools-compared-to-manual-link-checking">#</a></h2>
<p>Automated <a href="/news-and-updates/enhanced-broken-links-monitoring-images-css-and-js">link-checking tools</a> generally offer high accuracy in identifying broken links on a large scale, significantly outperforming manual checks in terms of speed and efficiency. However, they may occasionally miss certain types of links or misinterpret complex situations, such as dynamic content or JavaScript-based links. Manual checks, while time-consuming, can provide a more nuanced understanding of link functionality and context, ensuring that all critical links are properly evaluated.</p>
<p>For optimal results, a combination of both approaches is often recommended. Automated tools can quickly identify the majority of broken links, while manual checks can be used to review critical links, investigate false positives, and ensure the accuracy of the automated results.</p>
<h2 id="what-common-mistakes-do-businesses-make-when-addressing-broken-links">What common mistakes do businesses make when addressing broken links? <a href="#what-common-mistakes-do-businesses-make-when-addressing-broken-links" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#what-common-mistakes-do-businesses-make-when-addressing-broken-links">#</a></h2>
<p><img src="/media/blog/SemN7HPJ4n3gndzywX5I5pU4kuZGOQ2CHk0KyFay.png" alt="Person typing on a laptop showing terminal output, with a second laptop on the desk beside it" /></p>
<p>When businesses attempt to <a href="/features/broken-page-and-mixed-content-detection">address broken links</a>, they often make several common mistakes that can exacerbate the problem or lead to further issues. Here are the key mistakes to avoid:</p>
<ul>
<li><strong>Failure to Check Links Periodically</strong>: Many businesses do not regularly audit their websites for broken links. This oversight can lead to a poor user experience and negatively impact SEO, as broken links can diminish site credibility and authority.</li>
<li><strong>Ignoring Internal vs. External Links</strong>: Businesses sometimes treat internal and external links the same way. However, internal links can be controlled and fixed more easily, while external links may require different strategies, such as reaching out to the site owner for repairs.</li>
<li><strong>Failing to Revise Interlinking</strong>: When a website undergoes structural changes, such as URL updates, businesses often forget to update internal links accordingly. This can lead to numerous 404 errors if the old links are not redirected or corrected.</li>
</ul>
<h2 id="how-can-you-proactively-manage-internal-links-to-avoid-breaks">How can you proactively manage internal links to avoid breaks? <a href="#how-can-you-proactively-manage-internal-links-to-avoid-breaks" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#how-can-you-proactively-manage-internal-links-to-avoid-breaks">#</a></h2>
<p>Website monitoring software identifies and prevents time-consuming website issues, while <a href="https://research.com/software/best-time-tracking-software">time tracking software</a> measures the time spent resolving them. Both tools provide valuable data that can be used to optimize website performance and improve employee productivity. By combining data from both, businesses can make informed decisions about website investments and resource allocation.</p>
<p>Proactive management of internal links is crucial for maintaining a healthy website and avoiding broken links. This involves a multi-faceted approach. Firstly, establish and adhere to consistent naming conventions for all files and folders on your website, making it easier to track and update links. Secondly, a version control system should be implemented to track changes to website files and easily revert to previous versions if necessary.</p>
<p>You should also conduct regular website audits to identify and fix any broken internal links promptly. Fourthly, a content management system (CMS) with robust features for managing internal links, such as automated link checking and version history, should be used. Finally, all team members should be involved in the process by providing clear guidelines and training on best practices for creating and maintaining internal links. By implementing these strategies, you can significantly reduce the risk of broken internal links and ensure a seamless user experience across your entire website.</p>
<h2 id="future-proofing-your-website-against-broken-links">Future-proofing your website against broken links <a href="#future-proofing-your-website-against-broken-links" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#future-proofing-your-website-against-broken-links">#</a></h2>
<p>Future-proofing your website against broken links involves a proactive approach that anticipates and minimizes potential issues. This includes implementing robust internal linking structures with clear and consistent naming conventions. Utilizing a version control system and <a href="https://www.indeed.com/career-advice/career-development/web-traffic-monitoring">website monitoring software</a> allows for easy tracking and reversion of changes, minimizing the risk of unintended link breakage.</p>
<p>Regularly scheduled website audits and the use of a CMS with powerful link management features are essential for early detection and resolution of any broken links. Furthermore, fostering a company-wide understanding of the importance of proper link management through training and clear guidelines empowers all team members to contribute to a more resilient and user-friendly website.</p>
<h2 id="boost-your-websites-credibility">Boost Your Website’s Credibility <a href="#boost-your-websites-credibility" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#boost-your-websites-credibility">#</a></h2>
<p>Detecting and resolving broken links is not just about fixing errors—it’s about safeguarding your website’s credibility, user experience, and SEO performance. By leveraging website monitoring software, you can automate the detection process, gain valuable insights, and proactively address issues before they harm your site’s reputation or search rankings.</p>
<p>Coupled with best practices for link management and a future-proofing mindset, these tools enable you to maintain a seamless, reliable website that meets the demands of both users and search engines. By taking these steps, you can ensure that your site remains a trusted, high-performing resource in today’s competitive digital landscape.</p>
]]>
            </summary>
                                    <updated>2026-06-12T08:58:01+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Enhanced Broken Links Monitoring: Images, CSS and JS]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/enhanced-broken-links-monitoring-images-css-and-js" />
            <id>https://ohdear.app/123</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>We are excited to announce an enhancement to one of our most unique features at Oh Dear: Broken Links!</p>
<p>Broken links monitoring crawls your entire website for internal and external links but we now take this a step further by expanding our monitoring to include CSS, JavaScript, and image links.</p>
<h2 id="whats-new">What’s New? <a href="#whats-new" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#whats-new">#</a></h2>
<p>With this update, our broken links check now scans:</p>
<ul>
<li><strong>CSS Links</strong>: Verify that all your stylesheets are loading correctly and aren’t returning 404 errors or other issues.</li>
<li><strong>JavaScript Links</strong>: Ensure that all your JavaScript files are accessible helping to prevent issues that can break functionality or affect the user experience.</li>
<li><strong>Images</strong>: Confirm that all images on your site are loading properly, avoiding missing images that can make your site look unprofessional or incomplete.</li>
<li><strong>Open Graph Images</strong>: Your social previews are important - make sure they are loading correctly.</li>
</ul>
<p><img src="/media/blog/hu7tzP47Enq2iVMHqHlofqAiLpQ6e6vGSMUgTps7.png" alt="Broken links settings with resource checkboxes enabled for links, images, scripts, stylesheets and Open Graph images" /></p>
<h2 id="why">Why? <a href="#why" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#why">#</a></h2>
<p>Here’s why these new checks are important for your website:</p>
<ul>
<li><strong>Comprehensive Monitoring</strong>: By checking CSS, JavaScript, and image links, you can catch issues that might not be immediately visible but could still impact your site's performance, SEO, and user experience.</li>
<li><strong>Prevent Styling and Functionality Errors</strong>: Broken CSS and JavaScript files can cause your website to look broken or behave incorrectly. Early detection helps you fix these issues before your users even notice.</li>
<li><strong>Maintain a Professional Appearance</strong>: Broken image links can make your website look unprofessional. Our enhanced check ensures that all visual elements are loading correctly, preserving your brand’s appearance.</li>
<li><strong>Boost SEO</strong>: Search engines penalize sites with broken links, including CSS, JS, and images. Keeping all your assets intact helps maintain your SEO ranking and keeps your site in good standing.</li>
</ul>
<h2 id="how-it-works">How it works <a href="#how-it-works" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#how-it-works">#</a></h2>
<p>The enhanced broken links check doesn't require any additional setup other than just enabling the options from your broken links settings. Here’s how it benefits you:</p>
<ol>
<li><strong>Automatic Scanning</strong>: As part of your regular monitoring, Oh Dear will automatically check all your website’s links, including CSS, JavaScript, and images.</li>
<li><strong>Detailed Reporting</strong>: If we find any broken links, we’ll notify you immediately with detailed reports specifying which files are affected and the exact URLs that are broken.</li>
<li><strong>Proactive Maintenance</strong>: Use the insights from our enhanced checks to quickly fix broken assets and keep your website running smoothly.</li>
</ol>
<h2 id="ready-to-check-your-entire-website">Ready to check your entire website? <a href="#ready-to-check-your-entire-website" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#ready-to-check-your-entire-website">#</a></h2>
<p>This enhancement makes Oh Dear’s monitoring more comprehensive and reliable, ensuring that every element of your website is working as intended. By proactively checking all types of links, you can stay ahead of issues and address them before they become a problem.</p>
<p>When paired with our other features such as Lighthouse you can get deep insights into how your application is performing from the user's perspective.</p>
]]>
            </summary>
                                    <updated>2026-06-12T08:58:01+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[In & outside business hour notifications]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/in-outside-business-hour-notifications" />
            <id>https://ohdear.app/126</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>Developers, marketing teams and business owners rely on Oh Dear to monitor their entire website and applications. We offer a feature-packed, all-in-one service with simple pricing - designed specifically for your peace of mind.</p>
<p>Oh Dear already has a flexible and powerful notification system and to date has performed over 38 billion checks and sent over 19 million notifications across Email, Slack, MS Teams, PagerDuty, webhook and more.</p>
<p>To debug delivery, check the <a href="/news-and-updates/seeing-detailed-logs-for-webhook-events">detailed logs for webhook events</a>.</p>
<p>We respond to 30 different events across our services and all plans allow you to create an unlimited number of configurations (recipients) at the site, team and tag level!</p>
<h2 id="whats-new">What's new <a href="#whats-new" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#whats-new">#</a></h2>
<p>We are excited to introduce <strong>Business hours notifications</strong>! This feature allows teams to define specific business hours and tailor notification settings to ensure alerts are received at the right time by the right people. Whether you want to be notified during work hours, after-hours, or 24/7, you now have the flexibility to configure your notifications to fit your team's unique needs.</p>
<p><img src="/media/blog/a3PJS39gF8C5xDsLZDP6CxD3WOzxFuREHGE0rQcl.png" alt="Business Hours settings with start and end times per weekday, 09:00 to 17:00 Monday through Friday and weekends empty" /></p>
<p>In this post we will dive into how it works, why it's useful and how it can help streamline your team's workflow.</p>
<h2 id="why">Why? <a href="#why" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#why">#</a></h2>
<p>Not every alert needs immediate attention, especially if it occurs outside of your team’s active hours. By using Business Hours settings you can:</p>
<p><strong>Reduce alert fatigue</strong>: by only receiving relevant notifications when needed.</p>
<p><strong>Improve work-life balance</strong>: by reducing disruptions outside of scheduled hours.</p>
<p><strong>Streamline on-call responsibilities</strong>: by distinguishing between notifications during business hours and those meant for after-hours or on-call teams.</p>
<p><strong>Improve response times for critical issues</strong>: ensure the right notifications reach the right people as quickly as possible.</p>
<p>This new feature empowers your team to stay focused during working hours while keeping the after-hours alert volume in check. It also provides peace of mind by ensuring that critical issues are still flagged when necessary — whether inside or outside of business hours.</p>
<h2 id="how-it-works">How it works <a href="#how-it-works" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#how-it-works">#</a></h2>
<p>When creating a new notification configuration you will now have the option for 'Notification timing'. You will see the following three options:</p>
<ol>
<li>Any time</li>
<li>Within business hours only</li>
<li>Outisde business hours only</li>
</ol>
<p><img src="/media/blog/QPPW62inwA8GtL1rVX7y86uQLVEQuOFbhR5Vkiyf.png" alt="Add notification configuration dialog with a Notification timing dropdown set to Within business hours only and event toggles" /></p>
<p>To give you a better idea of how business hours can be used across your team alongside existing features here are a few practical scenarios:</p>
<p>Your support team might be comfortable handling problems during work hours while an on-call team is responsible for handling critical issues outside these hours.</p>
<ul>
<li>Support team: email to a shared inbox only during business hours for broken link events only</li>
<li>On-call team: SMS outside of business hours with negative events only (e.g. Site down, scheduled task not executed on time)</li>
<li>Development team: all events enabled on low-priority Slack channel at all times</li>
</ul>
<p>For on-call rotations, pair this with <a href="/news-and-updates/our-opsgenie-integration-is-now-available">our Opsgenie integration</a>.</p>
<h2 id="snooze">Snooze <a href="#snooze" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#snooze">#</a></h2>
<p>Are you receiving too many notifications? Are you working on fixing an issue and need to focus?</p>
<p>You can temporarily silence (or snooze) notifications. We will still perform all checks, but won't send any notifications when a snooze is active. A snooze is applied at the check-level (e.g. Lighthouse reports) for a particular site. It silences all notifications configured that would normally be sent.</p>
<p>If you decide a snooze isn't needed any more, you can end it from the 'snooze' tab on every check page.</p>
<h2 id="did-you-know">Did you know? <a href="#did-you-know" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#did-you-know">#</a></h2>
<p>We recently released support for <a href="/features/notifications">tag notifications</a>! This feature allows you to keep your notification settings under control by assigning tags to sites and configuring your notifications to tags (or groups of tags).</p>
<p>The new business hours notifications feature is all about control and customization for teams of all sizes. Together with tag notifications, 'business hours' gives your team a granular but maintainable level of control over your monitoring alerts.</p>
<p>We can’t wait for you to try it out and see how it improves your workflow. Let us know your feedback, and as always, happy monitoring!</p>
<p>Not registered? <a href="https://ohdear.app/register">Sign up for a free 30-day trial</a> (no payment card required)</p>
]]>
            </summary>
                                    <updated>2026-06-12T08:58:01+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[New feature: Status Page Update Templates]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/new-feature-status-page-update-templates" />
            <id>https://ohdear.app/120</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>We know how crucial it is to provide clear and timely communication during incidents or downtime. That’s why we’re excited to introduce Status Page Update Templates - a new feature that makes it even easier to keep your users informed while maintaining a consistent message.</p>
<h2 id="why-use-a-status-page">Why use a status page <a href="#why-use-a-status-page" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#why-use-a-status-page">#</a></h2>
<p>A status page is a dedicated, Oh Dear hosted site where you can communicate the current status of your website or service to your users by posting status updates. It's a simple yet powerful tool for transparency and trust-building. Here’s why you should consider having one.</p>
<ul>
<li><strong>Keep Users Informed</strong>: Communicate issues, such as downtime, slow performance, or scheduled maintenance, directly to your users. Instead of leaving them guessing or frustrated, they’ll know exactly what’s going on.</li>
<li><strong>Reduce Support Overhead</strong>: Proactively provide information and reduce the number of support tickets when issues arise.</li>
<li><strong>Build Trust</strong>: Transparency is key. Being open about issues builds trust with your users - a status page demonstrates that you are committed to keeping them in the loop.</li>
<li><strong>Improve User Experience</strong>: Users are more understanding of problems when they know what's happening and what to expect. By managing expectations you show that you’re on top of the situation.</li>
<li><strong>Centralised Communication</strong>: During an incident, a status page acts as a single source of truth, where all updates are published in real time. This ensures consistent messaging across all your communication channels.</li>
</ul>
<h2 id="why-use-status-page-update-templates">Why Use Status Page Update Templates? <a href="#why-use-status-page-update-templates" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#why-use-status-page-update-templates">#</a></h2>
<p>Things go wrong. While there is pressure to resolve the issue, there is also pressure to communicate quickly and accurately. With our new templates, you can:</p>
<ul>
<li><strong>Ensure Consistent Messaging</strong>: Keep your communications clear and consistent, reducing confusion for your users.</li>
<li><strong>Save Time During High-Pressure Situations</strong>: Apply a pre-made template, fill in the specifics, and publish your update in moments.</li>
<li><strong>Reduce Stress</strong>: No need to worry about drafting the perfect message; simply use a template and modify placeholders as needed.</li>
</ul>
<p><img src="/media/blog/O2KpXX9eDO07BRcEM8l9sh7o0naPqcZC1n9iVIyZ.png" alt="Status page templates tab listing a Maintenance - Started update template with scheduled maintenance severity" /></p>
<h2 id="key-features">Key Features <a href="#key-features" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#key-features">#</a></h2>
<p>Here’s why we think Status Page Update Templates are awesome.</p>
<ul>
<li><strong>Named Templates</strong>: Create templates that are easy to find and apply.</li>
<li><strong>Flexible Fields</strong>: Not all fields are required—you can save a template with just a description or any other combination that suits your needs.</li>
<li><strong>Quick Apply</strong>: Simply select a template when creating a new update then make any necessary adjustments before publishing.</li>
<li><strong>API Support</strong>: Manage your templates with new API endpoints for listing, creating, updating and deleting templates.</li>
</ul>
<h2 id="create-your-first-template">Create your first template <a href="#create-your-first-template" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#create-your-first-template">#</a></h2>
<p>Getting started with Status Page Update Templates is easy:</p>
<ol>
<li>Go to the 'Status Pages' section in your Oh Dear dashboard menu and select a status page to edit.</li>
<li>Select 'Status updates' in the sidebar.</li>
<li>Select 'Templates' tab.</li>
<li>Select 'Create Template'.</li>
<li>Fill in the name and any title, description, text, and severity level you need to use.</li>
<li>Click 'Save Template'.</li>
</ol>
<p>When an incident occurs, you can select a template from the list while creating a status update, and modify any placeholders as needed.</p>
<p><img src="/media/blog/QMGeshDqDwj0P7rKEaOfZZviXmJoXHrNl1m3FXst.png" alt="Create template dialog for a Major Service Outage status update with high severity, title and text fields" /></p>
<h2 id="api">API <a href="#api" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#api">#</a></h2>
<p>If you're looking to automate your status updates, we’ve got you covered! The Oh Dear API now supports managing status page templates with the following endpoints:</p>
<ul>
<li><strong>List Templates</strong>: Fetch all saved templates.</li>
<li><strong>Create Templates</strong>: Add a new template with the desired fields.</li>
<li><strong>Update Templates</strong>: Modify existing templates.</li>
<li><strong>Delete Templates</strong>: Remove templates you no longer need.</li>
</ul>
<p>Read the full <a href="/docs/api/introduction#status-pages">API documentation</a> to help you start managing your template integrations!</p>
<h2 id="examples">Examples <a href="#examples" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#examples">#</a></h2>
<p>View our <a href="https://ohdear.app/news-and-updates/incident-template-library">incident libray</a> to find example status updates to copy into your templates.</p>
<h2 id="a-more-user-friendly-approach">A More User-Friendly Approach <a href="#a-more-user-friendly-approach" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#a-more-user-friendly-approach">#</a></h2>
<p>We believe this new feature will make it easier for you to communicate effectively with your users. Whether you’re a small team managing one site or a large team overseeing multiple domains, having a set of templates ready can make all the difference during critical moments.</p>
]]>
            </summary>
                                    <updated>2026-06-12T08:58:01+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Incident Template Library]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/incident-template-library" />
            <id>https://ohdear.app/124</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>We recently announced a new feature to enhance how you communicate with your users during maintenance, incidents, and general service updates. <a href="https://ohdear.app/news-and-updates/new-feature-status-page-update-templates">Status Page Templates</a> allows you to save and re-use status updates - but how do you know what incidents might happen or what updates you need to keep users informed about until it's too late?</p>
<p>We have put together a library of ready-to-use templates designed to keep your users informed with clear, concise and consistent messaging.</p>
<ul>
<li><a href="#maintenance">Maintenance</a></li>
<li><a href="#incidents">Incidents</a></li>
<li><a href="#performance">Performance</a></li>
<li><a href="#informational">Informational</a></li>
</ul>
<h2 id="maintenance">Maintenance <a href="#maintenance" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#maintenance">#</a></h2>
<h3 id="maintenance-1-scheduled">Maintenance - 1. Scheduled</h3>
<p>Title: Scheduled maintenance</p>
<p>We will be performing scheduled maintenance on [date] from [start time] to [end time] UTC. During this period [services] may experience [brief outages/slower performance/limited access] in the following regions: [regions]. We apologise for any inconvenience.</p>
<h3 id="maintenance-2-started">Maintenance - 2. Started</h3>
<p>Title: Maintenance In Progress</p>
<p>Scheduled maintenance has started as planned. [Service] may be temporarily unavailable during this time. We are working to complete the maintenance as quickly as possible. Thank you for your patience.</p>
<h3 id="maintenance-3-completed">Maintenance - 3. Completed</h3>
<p>Title: Maintenance Completed</p>
<p>The scheduled maintenance has been successfully completed. All services are now fully operational. Thank you for your patience.</p>
<h3 id="maintenance-4-delayed">Maintenance - 4. Delayed</h3>
<p>Title: Maintenance Delayed</p>
<p>The scheduled maintenance has been delayed and will now occur at [time] UTC. We apologize for any inconvenience this delay may cause.</p>
<h3 id="maintenance-5-extended">Maintenance - 5. Extended</h3>
<p>Title: Maintenance Extended</p>
<p>The ongoing maintenance is taking longer than expected and will be extended until [time] UTC. We are working diligently to complete the process as soon as possible. Thank you for your continued patience.</p>
<h2 id="incidents">Incidents <a href="#incidents" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#incidents">#</a></h2>
<h3 id="incident-1-under-investigation">Incident - 1. Under Investigation</h3>
<p>Title: Investigating Issues</p>
<p>We are aware of an issue impacting [service] and are currently investigating. Updates will be provided as soon as more information is available.</p>
<h3 id="incident-2-issues-detected">Incident - 2. Issues Detected</h3>
<p>Title: Service Issues Detected</p>
<p>We have detected issues affecting [service] and some users may be experiencing slower response times. Our team is working to identify the cause and will provide updates as more information becomes available.</p>
<h3 id="incident-3-identified">Incident - 3. Identified</h3>
<p>Title: Issue Identified</p>
<p>We have identified the cause of the issue affecting [service] and are working on a fix. We will provide an update once we have more information.</p>
<h3 id="incident-4-partial-outage">Incident - 4. Partial Outage</h3>
<p>Title: Partial Service Outage</p>
<p>We are experiencing a partial outage affecting [service]. Our team is actively working to restore full service. Thank you for your patience.</p>
<h3 id="incident-5-major-outage">Incident - 5. Major Outage</h3>
<p>Title: Major Service Outage</p>
<p>We are currently experiencing a major outage affecting [service] in [region]. Our team is working urgently to restore service. We will provide updates as soon as we have more information.</p>
<h3 id="incident-6-monitoring">Incident - 6. Monitoring</h3>
<p>Title: Monitoring the Situation</p>
<p>A fix has been implemented, and we are currently monitoring the system to ensure stability. [services] should be operating normally, but we will continue to observe performance closely.</p>
<h3 id="incident-7-resolved">Incident - 7. Resolved</h3>
<p>Title: Incident Resolved</p>
<p>The issue affecting [service] has been resolved as of [time] (UTC). All services are now operational. We apologize for any inconvenience caused and thank you for your patience.</p>
<p>You can view the full incident report here.</p>
<h2 id="performance">Performance <a href="#performance" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#performance">#</a></h2>
<h3 id="performance-1-degradation-detected">Performance - 1. Degradation Detected</h3>
<p>Title: Performance Degradation Detected</p>
<p>We are currently investigating reports of degraded performance affecting [service]. Our team is working to identify the cause and mitigate the impact. We will provide updates as soon as possible.</p>
<h3 id="performance-2-identified">Performance - 2. Identified</h3>
<p>Title: Performance Issue Identified</p>
<p>We have identified the cause of the performance degradation affecting [service] and are working on a fix. Updates will be provided as we make progress.</p>
<h3 id="performance-3-monitoring-improvement">Performance - 3. Monitoring Improvement</h3>
<p>Title: Monitoring Performance Improvement</p>
<p>A fix has been implemented to address the performance issues. We are monitoring the system to ensure performance is fully restored. Thank you for your patience.</p>
<h3 id="performance-4-resolved">Performance - 4. Resolved</h3>
<p>Title: Performance Restored</p>
<p>The performance issues affecting [service] have been resolved. All services are now operating normally. We appreciate your patience during this time.</p>
<h3 id="informational">Informational</h3>
<h3 id="info-1-service-update">Info - 1. Service Update</h3>
<p>Title: Service Update</p>
<p>We have rolled out an update to [affected services]. This update includes [brief description of changes or improvements]. All services are operating normally.</p>
<h3 id="info-2-new-feature">Info - 2. New Feature</h3>
<p>Title: New Feature Available</p>
<p>We are excited to announce a new feature for [services]: [feature name/description]. This feature will [briefly describe benefit]. Thank you for using our services!</p>
<p>You can view the full release notes in our blog post.</p>
<h3 id="info-3-announcement">Info - 3. Announcement</h3>
<p>Title: General Announcement</p>
<p>[Service] announcement: [description]. This will not impact service availability. Thank you for your attention.</p>
<hr />
<p>We hope these new status page templates improve your communication processes and enhance your users' experience during service updates, incidents, and more.</p>
<p>Ready to try out the new templates? Head over to your status page settings and explore the options available. As always, we’re here to help - if you have any questions or need assistance, feel free to reach out to our support team.</p>
<p>Happy communicating!</p>
]]>
            </summary>
                                    <updated>2024-09-03T09:46:57+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Cutting PHP CPU usage 40% with an Ubuntu 22.04 upgrade]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/reducing-phps-cpu-usage-by-nearly-40-by-upgrading-from-ubuntu-2004-to-2204" />
            <id>https://ohdear.app/110</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>Over these past few weeks, we've updated <a href="/news-and-updates/how-to-upgrade-from-ubuntu-2004-lts-to-ubuntu-2204-lts">our Ubuntu 20.04 LTS uptime servers to Ubuntu 22.04 LTS</a>. The reason was mostly just staying up-to-date, but we had a surprising side-effect that was unexpected but very welcome.</p>
<h2 id="measuring-reduced-cpu-usage">Measuring reduced CPU usage <a href="#measuring-reduced-cpu-usage" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#measuring-reduced-cpu-usage">#</a></h2>
<p>An easy riddle: can you guess when we were running on Ubuntu 20.04 and when we were on 22.04? The spikes in the middle were during the upgrades (when this server was taken out of rotation).</p>
<p><img src="/media/blog/EAd6fAdGtiFZH7NliehPZUttW6lxgRkbeckl1Atp.png" alt="CPU utilization graph dropping after the upgrade window from Ubuntu 20.04 PHP 8.2 to Ubuntu 22.04 PHP 8.2" /></p>
<p>For the exact same workload, here's how the numbers work out:</p>
<ul>
<li>Avg CPU (user) on Ubuntu 20.04 LTS: <strong>22.9%</strong></li>
<li>Avg CPU (user) on Ubuntu 22.04 LTS: <strong>13.2%</strong></li>
</ul>
<p>That's a whopping 42% reduction in CPU usage just by upgrading the OS. 🥳</p>
<p>And this wasn't an isolated server, this was measured across multiple servers, across multiple (independent) cloud providers.</p>
<h2 id="purposefully-simple-workload">Purposefully simple workload <a href="#purposefully-simple-workload" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#purposefully-simple-workload">#</a></h2>
<p>Our satellite servers are spread worldwide and act as mere <em>proxies</em> for HTTP(s) requests. They are, on purpose, simple by design.</p>
<p>Each server has Nginx &amp; PHP-FPM running, nothing more. It just takes in requests from our dispatcher, processes them by calling out to an external service (aka: our client website to monitor) and then returns the response.</p>
<p>After the upgrade;, we observered a significant increase in <em>context switches</em> for each server.</p>
<p><img src="/media/blog/VEyGAFjHLeFvP3Eno5psi1Bjg1x4Vlih2IZM7914.png" alt="Graph of context switches and interrupts per second jumping higher after the Ubuntu upgrade" /></p>
<p>Because the workload has remained constant, I can only assume this dramatic speed up of context switches/interrupts is the direct cause of the performance improvements we're seeing. Happy to hear thoughts/theories on this!</p>
<h2 id="measured-across-dozens-of-servers">Measured across dozens of servers <a href="#measured-across-dozens-of-servers" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#measured-across-dozens-of-servers">#</a></h2>
<p>Just for dramatic effect, here's the CPU usage of a few other servers, but without the fixed y-axis scale. Sorry data scientists, I couldn't resist skewing the scale to our advantage here.</p>
<p><strong>Exhibit 1</strong>: a drop from ~16% CPU usage to ~10.3%.</p>
<p><img src="/media/blog/hjOde7cE7g2fkuRuj6nGUjASTKhdplbzpLp6LuO7.png" alt="Graph of CPU usage by the forge user falling from about 16% to 11% after the upgrade" />
<strong>Exhibit 2</strong>: a noticeable reduction in the <em>CPU load</em> as well:</p>
<p><img src="/media/blog/f4wsKVRzSrXmnVPAeLTIRdhrU9rOgUO06rHTSXls.png" alt="CPU load average graph dropping after the switch from Ubuntu 20.04 to Ubuntu 22.04" />
On average, the CPU load went from <strong>0.7</strong> to <strong>0.4</strong>.</p>
<h2 id="your-mileage-may-vary">Your mileage may vary <a href="#your-mileage-may-vary" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#your-mileage-may-vary">#</a></h2>
<p>Obviously, our setup isn't yours. We run <em>pure</em> PHP processes here, no databases/caches/file storage/... It may be a rather exceptional setup, as most environments would at least have some kind of persistence in the form of a database in the mix here.</p>
<p>But, the performance improvements are noticeable!</p>
<p>OS-level wins are one lever; on the app side, here is <a href="/news-and-updates/how-to-size-scale-your-laravel-queues">sizing and scaling your Laravel queues</a>.</p>
<p>We only caught this CPU regression because we track it. <a href="/features/performance-monitoring">performance monitoring</a> flags shifts like this automatically.</p>
<p>If you're still on Ubuntu 20.04 LTS - which gets support until 2025 - you might want to consider upgrading to the next LTS to get some noticeable speed improvements.</p>
]]>
            </summary>
                                    <updated>2026-06-12T08:58:01+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to upgrade from Ubuntu 20.04 LTS to Ubuntu 22.04 LTS]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/how-to-upgrade-from-ubuntu-2004-lts-to-ubuntu-2204-lts" />
            <id>https://ohdear.app/109</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>We're in the process of upgrading some of our older <em>uptime satellite</em> servers that power our <a href="https://ohdear.app/features/website-uptime-monitoring">Uptime Monitoring</a> from Ubuntu 20.04 to Ubuntu 22.04 LTS. This is mostly a smooth process, but in case anyone's ever in a similar position, these are the steps we're taking to complete the upgrades.</p>
<p>We should note: there's a lot of ways to upgrade servers, and the safest approach is to setup a brand new machine, move over your data, test things &amp; then migrate your DNS records to the new server. Our cloud providers however do not allow the existing public IP addresses to move from one server to another. Since a lot of clients have whitelisted <a href="https://ohdear.app/docs/faq/what-ips-does-oh-dear-monitor-from">our IPs</a> to get (unrestricted/un-ratelimited) access, we have to preserve them. For this reason, we're preferring <em>in-place upgrades</em>, where the IPs stay the same.</p>
<h2 id="backup-the-server-first">Backup the server first <a href="#backup-the-server-first" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#backup-the-server-first">#</a></h2>
<p>A lot of things can go wrong with in-place upgrades obviously, so it's best to be safe. You'll be installing a new Linux kernel, replace just about every package on the system (including <em>glibc</em> and <em>openssl</em>), and if anything goes wrong, your machine won't boot.</p>
<p>Ensure you have a back-up or a snapshot in place that you can restore from. This is also a good opportunity to test some of your disaster recovery practices, since you'll be taking snapshots &amp; rolling back to them.</p>
<h2 id="updates-first">Updates first <a href="#updates-first" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#updates-first">#</a></h2>
<p>I always like to ensure that the machine we'll be upgrading is in the best state possible, before the update. That's why I'll update &amp; reboot the machine first, to get a fresh system.</p>
<pre data-lang="txt" class="notranslate">$ apt update
$ apt upgrade
$ reboot
</pre>
<p>After that, you should be running the latest Kernel on your Ubuntu 20.04 LTS.</p>
<h2 id="perform-the-upgrade-to-ubuntu-2204-lts">Perform the upgrade to Ubuntu 22.04 LTS <a href="#perform-the-upgrade-to-ubuntu-2204-lts" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#perform-the-upgrade-to-ubuntu-2204-lts">#</a></h2>
<p>This is the type of step that is safest to perform <em>through a console screen or VNC display</em>. Ideally, you head over to your cloud provider of choice and open up the console there and login to the system as the local <em>root</em> user. This way, even if your SSH session(s) disconnect, you still have server access.</p>
<p>Because this process went incredibly smooth with our servers (and we have the fallbacks in place of snapshots/backups), we opted for the SSH approach instead.</p>
<h3 id="step-1-open-a-screentmux-via-ssh">Step 1: open a screen/tmux via SSH</h3>
<p>Your SSH session might disconnect throughout the upgrades, so it's safe to start the process in a separate shell that you can (try to) reconnect to later, should it disconnect.</p>
<pre data-lang="txt" class="notranslate">$ screen -S ubuntu_grade
</pre>
<p>You can detach yourself from the shell by hitting <code>CTRL+A</code> and then <code>D</code>.</p>
<h3 id="step-2-start-the-upgrade">Step 2: start the upgrade</h3>
<p>In your shell, type:</p>
<pre data-lang="txt" class="notranslate">$ cat /etc/issue
Ubuntu 20.04.6 LTS
</pre>
<p>This will confirm you're on Ubuntu 20.04 LTS.</p>
<p>Next, start the upgrade:</p>
<pre data-lang="txt" class="notranslate">$ do-release-upgrade
</pre>
<p>In this process, you'll be prompted to make several decisions. I'll mark some of these below with a little explainer to each.</p>
<pre data-lang="txt" class="notranslate">Third party sources disabled

Some third party entries in your sources.list were disabled. You can
re-enable them after the upgrade with the 'software-properties' tool
or your package manager.

To continue please press [ENTER]
</pre>
<p>You likely have 3rd party repos (ie: to get the latest PHP or Python versions). To avoid any interference of packages, those specific repos will be disabled during the upgrade. This is fine.</p>
<p>You might, as one of the first steps, encounter this message:</p>
<pre data-lang="txt" class="notranslate">Invalid package information

After updating your package information, the essential package
'ubuntu-minimal' could not be located. This may be because you have
no official mirrors listed in your software sources, or because of
excessive load on the mirror you are using. See /etc/apt/sources.list
for the current list of configured software sources.
In the case of an overloaded mirror, you may want to try the upgrade
again later.
</pre>
<p>This happened to us for the Digital Ocean VMs. I believe it's because Digital Ocean includes some of their own reposities with their mirrors in the distributions, and by default those get disabled during the upgrade. To avoid that, restart the command with:</p>
<pre data-lang="txt" class="notranslate">$ RELEASE_UPGRADER_ALLOW_THIRD_PARTY=1 do-release-upgrade
</pre>
<p>Once done, the upgrade will restart.</p>
<pre data-lang="txt" class="notranslate">14 packages are going to be removed. 145 new packages are going to be
installed. 893 packages are going to be upgraded.

You have to download a total of 826 M. This download will take about
2 minutes with your connection.

Installing the upgrade can take several hours. Once the download has
finished, the process cannot be canceled.

 Continue [yN]  Details [d]
</pre>
<p>Obviously, we'll install new packages, so hit <code>y</code> to continue.</p>
<pre data-lang="txt" class="notranslate"> Restart services during package upgrades without asking?                                                                                   
     &lt;Yes&gt;     &lt;no&gt;
</pre>
<p>In our case, each server we're upgrading gets taken out of rotation temporarily for us to perform the maintenance on. We're fine having the upgrader auto-restart services when needed, so we choose <code>&lt;Yes&gt;</code>.</p>
<p>Throughout the process you're going to get <em>a lot</em> of questions like these;</p>
<pre data-lang="txt" class="notranslate">Configuration file '/etc/crontab'
 ==&gt; Modified (by you or by a script) since installation.
 ==&gt; Package distributor has shipped an updated version.
   What would you like to do about it ?  Your options are:
    Y or I  : install the package maintainer's version
    N or O  : keep your currently-installed version
      D     : show the differences between the versions
      Z     : start a shell to examine the situation
 The default action is to keep your current version.
*** crontab (Y/I/N/O/D/Z) [default=N] ?
</pre>
<p>You made modifications to a file that the system upgrade would like to overwrite. These have to be decided upon on a case-by-case basis. After all, <em>you</em> know <em>your</em> system best. In the above example, we have custom cronjobs per server, so we'll choose <code>N</code> (the default value) to keep <em>our</em> version of the file.</p>
<p>Whenever possible, I'll opt for answer <code>I</code> where the default package version can get used, but you need to know the scenario's in which you can use that.</p>
<p>One of those scenario's for instance is <code>sysctl.conf</code>;</p>
<pre data-lang="txt" class="notranslate">Configuration file '/etc/sysctl.conf'
 ==&gt; Modified (by you or by a script) since installation.
 ==&gt; Package distributor has shipped an updated version.
   What would you like to do about it ?  Your options are:
    Y or I  : install the package maintainer's version
    N or O  : keep your currently-installed version
      D     : show the differences between the versions
      Z     : start a shell to examine the situation
 The default action is to keep your current version.
*** sysctl.conf (Y/I/N/O/D/Z) [default=N] ?
</pre>
<p>If you've not made any changes there (and it's unlikely you did), best to pick option <code>I</code> here for the package defaults. This is the file that is used to override default kernel parameter values. And since you'll be upgrading several kernel versions higher, I find it safest to take whatever the package maintainer opted to include in there. They probably know kernel tuning more than I do.</p>
<p><em>Note: some of these prompts will be pure text/cli, some will be in an ncurses-like UI. The UI might be different, but the questions/answers and logic to deal with them are the same in all scenario's.</em></p>
<pre data-lang="txt" class="notranslate">System upgrade is complete.

Restart required

To finish the upgrade, a restart is required.
If you select 'y' the system will be restarted.

Continue [yN]
</pre>
<p>Now's the moment of truth: hit <code>y</code> to reboot!</p>
<h3 id="step-3-verify-the-upgrade">Step 3: verify the upgrade</h3>
<p>Once the server has rebooted, log in and verify the upgrade is completed:</p>
<pre data-lang="txt" class="notranslate">$ cat /etc/issue
Ubuntu 22.04.4 LTS
</pre>
<h3 id="step-4-cleanup-obsolete-packages">Step 4: cleanup obsolete packages</h3>
<p>There might still be a few packages on the system that you can now cleanup.</p>
<pre data-lang="txt" class="notranslate">$ apt autoremove
</pre>
<p>This will prompt you to uninstall packages that <em>seem</em> obsolete, but the decision is ultimately yours.</p>
<p>Have a look at the installed packages and see if any of these are no longer needed (think: old PHP versions).</p>
<pre data-lang="txt" class="notranslate">$ apt list --installed
</pre>
<h2 id="youre-good">You're good! <a href="#youre-good" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#youre-good">#</a></h2>
<p>If all went well, you've now upgraded your machine successfully to the latest LTS version (at time of writing).</p>
<p>Now it's up to you to validate that your application(s) are still functional.</p>
]]>
            </summary>
                                    <updated>2024-02-20T21:03:27+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Making sure Laravel's debug mode is always disabled in production]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/making-sure-laravels-debug-mode-is-always-disabled-in-production" />
            <id>https://ohdear.app/107</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>Recently, people started talking about a malware called  “Androxgh0st” specifically targeting Laravel apps. In a recent edition of <a href="https://securinglaravel.com">Securing Laravel</a>,  Stephen Rees-Carter wrote <a href="https://securinglaravel.com/p/laravel-security-androxgh0st-malware">a good explanation</a> of how it works.</p>
<p>The malware targets apps with <code>APP_DEBUG</code> set to <code>true</code>. When enabled, Laravel will give detailed error messages, and some security features will be disabled. In production, you always want this value to be set to <code>false</code>.</p>
<p>You can make sure it's always set to' false' using Oh Dear’s <a href="https://ohdear.app/features/application-health-monitoring">application monitoring</a> feature. We can notify you whenever someone should set it to <code>true</code>. Let’s go through the steps required to set this up.</p>
<h3 id="installing-laravel-health-in-your-laravel-app">Installing Laravel Health in your Laravel app</h3>
<p>The <a href="https://spatie.be/docs/laravel-health">spatie/laravel-health package</a> can monitor the health of your application by registering one of <a href="https://spatie.be/docs/laravel-health/v1/available-checks/overview">the available checks</a>. Out of the box, it can monitor if your application is in debugging mode.</p>
<p>Using Laravel Health, you can check many other things, such as <a href="">used disk space</a>, whether or not <a href="">Horizon is running</a>, and [much more]!</p>
<p>You can install the package using composer.</p>
<pre data-lang="php" class="notranslate">composer <span class="hl-keyword">require</span> spatie/laravel-health
</pre>
<p>You’ll find full installation instructions <a href="https://spatie.be/docs/laravel-health/v1/installation-setup">here</a>.</p>
<p>To register the debug mode check, you can put this code in a service provider.</p>
<pre data-lang="php" class="notranslate"><span class="hl-comment">// typically, in a service provider</span>

<span class="hl-keyword">use</span> <span class="hl-type">Spatie\Health\Facades\Health</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Spatie\Health\Checks\Checks\UsedDiskSpaceCheck</span>;

<span class="hl-type">Health</span>::<span class="hl-property">checks</span>([
   <span class="hl-type">DebugModeCheck</span>::<span class="hl-keyword">new</span>(),
   
   <span class="hl-comment">// other checks can come here</span>
]);
</pre>
<h3 id="adding-a-health-check-endpoint-to-your-laravel-app">Adding a health check endpoint to your Laravel app</h3>
<p>Oh Dear’s application health check works by sending an HTTP request to your application to a specific endpoint to get health check results. Your application should respond with JSON containing the result of health checks.</p>
<p>The spatie/laravel-health package can add such an endpoint to your Laravel app. To do this, must configure the <code>ohdear_endpoint_key</code> in the <code>health</code> config file.</p>
<p>You can publish that <code>health</code> with this command:</p>
<pre data-lang="bash" class="notranslate"><span class="hl-keyword">php</span> <span class="hl-keyword">artisan</span> vendor:publish <span class="hl-attribute">--tag</span>=<span class="hl-value">&quot;health-config&quot;</span>
</pre>
<p>These are some of the default values in the published <code>health</code> config file.</p>
<pre data-lang="php" class="notranslate"><span class="hl-comment">// in app/config/health.php</span>

<span class="hl-comment">/*
 * You can let Oh Dear monitor the results of all health checks. This way, you'll
 * get notified of any problems even if your application goes totally down. Via
 * Oh Dear, you can also have access to more advanced notification options.
 */</span>
<span class="hl-value">'oh_dear_endpoint'</span> =&gt; [
    <span class="hl-value">'enabled'</span> =&gt; <span class="hl-keyword">false</span>,

    <span class="hl-comment">/*
     * When this option is enabled, the checks will run before sending a response.
     * Otherwise, we'll send the results from the last time the checks have run.
     */</span>
    <span class="hl-value">'always_send_fresh_results'</span> =&gt; <span class="hl-keyword">true</span>,

    <span class="hl-comment">/*
     * The secret that is displayed at the Application Health settings at Oh Dear.
     */</span>
    <span class="hl-value">'secret'</span> =&gt; <span class="hl-property">env</span>(<span class="hl-value">'OH_DEAR_HEALTH_CHECK_SECRET'</span>),

    <span class="hl-comment">/*
     * The URL that should be configured in the Application health settings at Oh Dear.
     */</span>
    <span class="hl-value">'url'</span> =&gt; <span class="hl-value">'/oh-dear-health-check-results'</span>,
],
</pre>
<p>To get started:</p>
<ul>
<li>set the <code>enabled</code> config option to <code>true</code></li>
<li>add a <code>secret</code> (we recommend putting it in the <code>.env</code> file, just like you would do for any application secret or password)</li>
<li>optionally customize the <code>url</code> where the health check endpoint will be registered.</li>
</ul>
<h3 id="configuring-the-health-check-at-oh-dear">Configuring the health check at Oh Dear</h3>
<p>At Oh Dear, you can create a new site to monitor and enable the application health check.</p>
<p>In the application health check settings screen at Oh Dear, you should fill in the URL and secret that you specified in the <code>health</code> config file.</p>
<p><img src="/media/blog/u87FnLilDHErJuldBRJuhH838GnpFMHAqVyZqgW0.jpg" alt="Oh Dear Application health settings with Health Report URL and Health Report Secret fields" /></p>
<p>And with this set up, Oh Dear will send you a notification whenever somebody should set <code>APP_DEBUG</code> to <code>true</code>.</p>
<h2 id="in-closing">In closing <a href="#in-closing" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#in-closing">#</a></h2>
<p>Oh Dear’s <a href="https://ohdear.app/docs/features/application-health-monitoring">application health check</a> can be used to warn you whenever somebody turns on debugging mode of your app, but also a lot more other things can be checked:</p>
<ul>
<li>disk space is running low</li>
<li>the database is down</li>
<li>Redis cannot be reached</li>
<li>mails cannot be sent</li>
<li>a reboot of your app is required</li>
<li>...</li>
</ul>
<p>Disabling debug mode is one step; you can also <a href="/news-and-updates/how-to-monitor-your-laravel-app-for-critical-vulnerabilities-using-oh-dear">monitor your Laravel app for critical vulnerabilities</a>.</p>
<p>Next to this application health check, we also offer <a href="https://ohdear.app/docs/features/cron-job-monitoring">a scheduled jobs check</a>. You can sync your application's schedule to Oh Dear using the <a href="https://github.com/spatie/laravel-schedule-monitor">spatie/laravel-schedule-monitor</a> package. We can notify you whenever a scheduled task is not running on time or not running at all.</p>
]]>
            </summary>
                                    <updated>2026-06-12T08:58:01+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Laravel Pulse cards for response times, jobs & links]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/laravel-pulse-cards-to-show-response-times-scheduled-jobs-broken-links" />
            <id>https://ohdear.app/106</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>Today, we released the <a href="https://github.com/ohdearapp/ohdear-pulse">ohdearapp/ohdear-pulse</a> package, which contains Laravel Pulse cards to show you the status of your scheduled jobs, any broken links you have in your Laravel app, and uptime / HTTP performance stats. All of these cards use the Oh Dear API to fetch their data.</p>
<p><a href="https://pulse.laravel.com">Laravel Pulse</a> is a first party package that can display a dashboard with information surrounding usage and performance of your Laravel app. Here’s how a default installation looks like.</p>
<h2 id="discovering-the-cards">Discovering the cards <a href="#discovering-the-cards" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#discovering-the-cards">#</a></h2>
<p>The first Pulse card one displays if you site is up, and recent response times.</p>
<p><img src="/media/blog/zOWPnJ060mFu93jkB8mkupKa7afqEkMIjZAKsTbc.jpg" alt="Oh Dear Laravel Pulse uptime card showing Online status, a 104.31ms response time and a 20-minute response time graph" /></p>
<p>This card, and also the other two, also support dark mode.</p>
<p><img src="/media/blog/rlkIKUV6fKTvYU1uEwydEkWUaPXroVVIPLwfZcNK.jpg" alt="Dark-mode Oh Dear Laravel Pulse uptime card showing Online status, 104.31ms response time and a response time graph" /></p>
<p>Oh Dear can monitor if the scheduled jobs of a Laravel app run on time. Using the <a href="https://github.com/spatie/laravel-schedule-monitor">spatie/laravel-schedule-monitor</a> package, you can <a href="https://github.com/spatie/laravel-schedule-monitor#getting-notified-when-a-scheduled-task-doesnt-finish-in-time">sync the schedule</a> of your app to Oh Dear.</p>
<p>The cron Pulse card displays when your scheduled jobs have run for the last time, and if they ran on time. Very powerful stuff if you ask me.</p>
<p><img src="/media/blog/GAssL7jgFce2aMU8S5KPnsAU0lm6sfC467CQZYUG.jpg" alt="Oh Dear Laravel Pulse Cron card listing scheduled tasks with last execution time, cron expression and ok results" /></p>
<p>The last card can display any broken links of your app. This card is powered by <a href="https://ohdear.app/docs/features/broken-links-detection">Oh Dear’s broken links check</a>, which crawls your entire site.</p>
<p><img src="/media/blog/5kqmKgkDbkMJLAL1Z7RSpmL4zXibq8MeOrpMiPWa.jpg" alt="Oh Dear Laravel Pulse Broken Links card listing 404 links with the page they were found on and link text" /></p>
<h2 id="installing-the-cards">Installing the cards <a href="#installing-the-cards" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#installing-the-cards">#</a></h2>
<p>To use these cards, you have to pull in the package in your app via Composer.</p>
<pre data-lang="bash" class="notranslate"><span class="hl-keyword">composer</span> require ohdearapp/ohdear-pulse
</pre>
<p>In your <code>config/services.php</code> file, add the following lines:</p>
<pre data-lang="php" class="notranslate"><span class="hl-value">'oh_dear'</span> =&gt; [
    <span class="hl-value">'pulse'</span> =&gt; [
        <span class="hl-value">'api_key'</span> =&gt; <span class="hl-property">env</span>(<span class="hl-value">'OH_DEAR_API_TOKEN'</span>),
        <span class="hl-value">'site_id'</span> =&gt; <span class="hl-property">env</span>(<span class="hl-value">'OH_DEAR_SITE_ID'</span>),
    ],
],
</pre>
<p>You can create an API token on the &quot;API Tokens&quot; page at Oh Dear. You'll find the site ID on the &quot;Settings&quot; page of a site on Oh Dear.</p>
<p>You can add the cards to your Pulse dashboard, by first publishing the Pulse's dashboard view:</p>
<pre data-lang="bash" class="notranslate"><span class="hl-keyword">php</span> <span class="hl-keyword">artisan</span> vendor:publish <span class="hl-attribute">--tag</span>=pulse-dashboard
</pre>
<p>Next, add the cards to the <code>resources/views/vendor/pulse/dashboard.blade.php</code> file:</p>
<pre data-lang="html" class="notranslate">&lt;<span class="hl-keyword">x-pulse</span>&gt;
    &lt;livewire:ohdear.pulse.uptime <span class="hl-property">cols</span>=&quot;4&quot; /&gt;
    
    &lt;livewire:ohdear.pulse.cron <span class="hl-property">cols</span>=&quot;8&quot; /&gt;

    &lt;livewire:ohdear.pulse.brokenLinks <span class="hl-property">cols</span>=&quot;8&quot; /&gt;
    
    {{-- Add more cards here --}}
&lt;/<span class="hl-keyword">x-pulse</span>&gt;
</pre>
<p>You’ll find the code in <a href="https://github.com/spatie/ohdear-pulse">this repo on GitHub</a>.</p>
]]>
            </summary>
                                    <updated>2026-06-12T08:58:01+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Our DNS check can now monitor hidden CNAME records]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/our-dns-check-can-now-monitor-hidden-cname-records" />
            <id>https://ohdear.app/103</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>Besides monitoring your site's uptime, Oh Dear offers <a href="https://ohdear.app/docs/features/what-checks-does-oh-dear-perform">many other checks</a> to monitor all kinds of aspects of your web app. One of those checks is <a href="https://ohdear.app/docs/features/dns-monitoring">our DNS check</a>.</p>
<p>Whenever we detect problems with your DNS records or when one of the DNS records changes, we can notify you. By default, we only monitor the DNS records of the domain you are monitoring. So when you're monitoring <code>example.com</code>, we'll only monitor the records of that hostname.</p>
<p>A CNAME record is a special kind of DNS record. It can be looked at as an alias or - in Linux terms - a symlink to another record. That record has a name, e.g., <code>mycname</code>, and a value it points to e.g. <code>someotherdomain.com</code>.</p>
<p>In the example above, the hostname of the record is not <code>example.com</code> but <code>mycname.example.com</code>. That is why we don't monitor it by default: it's not part of the main domain, and DNS query responses don't return these records by default. They are hard to discover.</p>
<p>We've now added the ability to specify the CNAMEs of your domain. You can do that in the DNS settings of your site. Here's an example where we want to monitor the <code>technical</code> CNAME record of <code>freek.dev</code></p>
<p><img src="/media/blog/0cNrLETR4UZLpxdxwAKNRBVXBmIGdfGhnCjP6ZVt.jpg" alt="Oh Dear DNS settings tab with an Extra CNAMEs field holding a 'technical' entry to add for monitoring" /></p>
<p>When you've added these CNAMEs, Oh Dear will monitor these records too, and notify you whenever they change or disappear.</p>
<p><img src="/media/blog/ay1zbdfaSQ79jSz93jsBXb0PunHZMgUBH1qdqGAr.jpg" alt="Oh Dear current DNS records list for freek.dev with an arrow pointing to the added technical.freek.dev CNAME" /></p>
<p>CNAME records are vital to most modern web apps, and we are glad we can now monitor these as part of our DNS check.</p>
]]>
            </summary>
                                    <updated>2026-06-12T08:58:01+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Make money by referring customers through our new affiliate program]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/make-money-by-referring-customers-through-our-new-affiliate-program" />
            <id>https://ohdear.app/101</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>We're proud to announce that we started <a href="https://ohdear.app/affiliate-program">our affiliate program</a>.</p>
<p>Using this program, you can generate a link (like https://ohdear.app?via=your-name) that you can include in your blog posts, tweets, or anywhere on the web. If somebody clicks that link and subscribes to Oh Dear in the next 30 days, you'll get 25% of the revenue of the first year of that subscription. The more people subscribe via your link, the more money you earn.</p>
<p>When you create your affiliate account, you'll get access to a nice dashboard showing you a clear overview of all your clicks, conversions, and money your links generated.</p>
<p><img src="/media/blog/T454bSiRbCjoZOijqKXmNKA2PbKoq6D0GjydNLiA.png" alt="Friends of Oh Dear affiliate dashboard showing a referral link, 25% commission terms, and visitor, lead and conversion counts" /></p>
<p>You'll find more info on all of this on our brand new (and beautiful) <a href="https://ohdear.app/affiliate-program">affiliate program page</a>.</p>
]]>
            </summary>
                                    <updated>2026-06-12T08:58:01+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Better handling of bounced emails]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/better-handling-of-bounced-emails" />
            <id>https://ohdear.app/100</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>Whenever we detects something wrong with your site it can send you a notification. We have multiple channels available: Slack, Telegram, webhooks, and many more. The most popular channel our users use is just a simple mail.</p>
<p>Behind the scenes, Oh Dear uses <a href="https://postmarkapp.com">Postmark</a> to send out mails. Postmark will inform us whenever a notification mail results in a hard bounce. A hard bounce means that the mail won't be delivered. The most common reason for this is that the mailbox doesn't exist (anymore). This can occur when somebody changed jobs and the work email address doesn't exist anymore.</p>
<p>Whenever Postmark informs us about a hard bounce, Oh Dear will determine which team that email belongs to. It wil send a mail to the owner of the Oh Dear team asking to correct the email address or to remove the member from the team.</p>
<p>For most cases this is fine, but what if it is the email address of the team owner itself that bounced? In this case we obviously can't mail the team owner anymore.</p>
<p>As of today, whenever an users logs in who's email has bounced (team owner and other members), we'll display a warning to update their email address. Here's how that looks.</p>
<p><img src="/media/blog/5BqUeqAqyLEixHr7Q9thLYV729MFeNTU2nNwJcCR.jpg" alt="Orange Please update your email banner warning that an email to a user bounced, with an Update email button" /></p>
<p>We hope that this will be let people understand faster why they didn't get an alert they were expecting.</p>
]]>
            </summary>
                                    <updated>2026-06-12T08:58:01+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Our redesigned status pages can now show uptime history]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/our-redesigned-status-pages-can-now-show-uptime-history" />
            <id>https://ohdear.app/95</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>Next to <a href="https://ohdear.app/docs/features/what-checks-does-oh-dear-perform">the many checks</a> we can perform, we can also render beautiful status pages to inform your audience about the health of your service.</p>
<p>Today, we've deployed a redesign of these status pages. In this iteration, everything is more polished. We picked a new font and colors and added some icons to make the status page a bit more visually interesting.</p>
<p>In addition to the cosmetic upgrade, we also added a significant new feature. We can now display 60 days of uptime history for your sites. Here's what that looks like (taken from <a href="https://status.flareapp.io">the Flare status page</a>).</p>
<p><img src="/media/blog/XI8Em4nKCosfPRT4M9624T8dAla868uTtuMEjJb0.jpg" alt="Flare status page reading No problems detected with 60-day uptime history bars showing 99.99% and 99.95% uptime" /></p>
<p>Every bar in the graph is a day. It's colored green when uptime is above 99% and orange when it's below that threshold. Of course, you can configure that threshold for your particular status page.</p>
<p>When you hover over a bar, you can see the exact amount of downtime.</p>
<p><img src="/media/blog/W3xW69iwdhkfn0UQHVlPQxU4dmzMMYIfJGUyvNak.jpg" alt="Status page uptime history bar with a hover tooltip showing 89.48% uptime and 2 hours 31 minutes downtime on April 12" /></p>
<p>We'll show the uptime history by default for a newly created status page. For the old status page, you'll need to manually enable it in your status page's settings.</p>
<p><img src="/media/blog/UYhcnVNrQmrj2dXcw96copbOHki3gJAVI96t33hN.jpg" alt="Oh Dear status page settings on the Uptime history tab with orange and red day percentage thresholds" /></p>
<p>We hope that you like this addition to Oh Dear! If you're not using Oh Dear to monitor your site, now's the perfect time to get started. We monitor uptime, SSL certificates, broken links, scheduled tasks, application health, DNS, domain expiry and more. We send notifications when something's wrong. All that paired with <a href="/docs/api/introduction" class="underline hover:no-underline">a developer-friendly API<a> and <a href="/docs/features/what-checks-does-oh-dear-perform" class="underline hover:no-underline">kick-ass documentation</a>.</p>
]]>
            </summary>
                                    <updated>2026-06-12T08:58:01+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Our Opsgenie integration is now available]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/our-opsgenie-integration-is-now-available" />
            <id>https://ohdear.app/92</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>When we detect a problem with your site we can notify you via mail, a slack message, a webhook, or any of <a href="https://ohdear.app/docs/notifications/notifications">our other notifications channels</a>. For most of our users this is enough, but those work in larger teams often need more flexibility.</p>
<p>Today, we are launching our <a href="https://www.atlassian.com/software/opsgenie">Opsgenie</a> integration, a modern incident management platform.</p>
<p>Opsgenie’s on-call scheduling allows you to set up escalation policies, so that if a notification isn’t handle within a certain timeframe, it will automatically be escalated to the next person in the on-call rotation. This ensures that critical notifications are always being addressed, even if the first person notified is unavailable or unable to resolve the issue.</p>
<p>Here's an example of such an escalation policy and on-call schedule.</p>
<p><img src="/media/blog/1DimlGmRM1T9Bi4g4WxIDKImf10CtneHVPSWbAuf.webp" alt="Opsgenie escalation policies and on-call schedules screen with a color-coded weekly rotation timeline" /></p>
<p>Oh Dear can now automatically open and close alerts at Opsgenie when we detect problems with your site. Opsgenie will then notify the right people that are on call as specified by the escalation policy.</p>
<p>Here's how an alert that was created for an incoming Oh Dear notification looks like in Opsgenie. As you can see we send extra information about the particular issue we detected. In this case we added some information on the expired notification.</p>
<p><img src="/media/blog/YVMwkDBozOhsYrbVWQnBzm9Ts1kIGTXkx0ROrmvr.jpg" alt="Opsgenie alert detail for an Oh Dear certificate-expiry P3 alert showing source, integration and description" /></p>
<p>Configuring Oh Dear to send notifications is pretty easy. All you need to do is to <a href="https://ohdear.app/docs/notifications/opsgenie">get an Opsgenie API key</a> on the team level and use that in the a newly created notification destination.</p>
<p><img src="/media/blog/vPffmfDqmI1pjjlNyLTKNIIG2N6xMpEpi81vS8Na.jpg" alt="Oh Dear edit notification configuration dialog for Opsgenie with API key, P3 priority and event toggles" /></p>
<p>If you're not using Oh Dear to monitor your site, now's the perfect time to get started. We monitor uptime, SSL certificates, broken links, scheduled tasks, application health, DNS, domain expiry and more. We send notifications when something's wrong. All that paired with <a href="/docs/api/introduction" class="underline hover:no-underline">a developer friendly API<a> and <a href="/docs/features/what-checks-does-oh-dear-perform" class="underline hover:no-underline">kick-ass documentation</a>.</p>
]]>
            </summary>
                                    <updated>2026-06-12T08:58:01+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Changing the owner of the team can now be done in our UI]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/changing-the-owner-of-the-team-can-now-be-done-in-our-ui" />
            <id>https://ohdear.app/87</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>Changing the owner of the team can now be done in our UI</p>
<p>In the past, we've seen users reach out to support to change the owner of a time for a variety of reasons.</p>
<p>You can now change the owner of the team without contacting support. Just head over to the team settings and scroll down. As the owner of a team, you can move ownership to another member of your team.</p>
<p><img src="/media/blog/qBhdXRYrXDaI44ZnuIbywwOzxZaoTHi0UFKCGXJT.jpg" alt="Oh Dear team settings Change team owner section with a Choose new owner dropdown and red Change team owner button" /></p>
<p>Because this action cannot be reversed, it needs to be confirmed.</p>
<p><img src="/media/blog/7NSb9qpsZYGaR8P162yygnATt7WzGrP1w0LBrUmW.jpg" alt="Are you sure confirmation dialog warning that changing the team owner to Freek cannot be reversed" /></p>
<p>If you're not using Oh Dear to monitor your site, now's the perfect time to get started. We monitor uptime, SSL certificates, broken links, scheduled tasks, application health, DNS, domain expiry and more. We send notifications when something's wrong. All that paired with <a href="/docs/api/introduction" class="underline hover:no-underline">a developer friendly API<a> and <a href="/docs/features/what-checks-does-oh-dear-perform" class="underline hover:no-underline">kick-ass documentation</a>.</p>
]]>
            </summary>
                                    <updated>2026-06-12T08:58:01+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Behind The Scenes Of Oh Dear]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/behind-the-scenes-of-oh-dear" />
            <id>https://ohdear.app/88</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>In this sponsored talk given at Laracon India 2023, I demo all major Oh Dear features. After that, I share how the Laravel app behind Oh Dear is structured using domains.</p>
<p>The audio quality is not perfect, but it should still be understandable.</p>
<iframe width="560" height="315" src="https://www.youtube.com/embed/Iq4L6m8SnFM" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>
]]>
            </summary>
                                    <updated>2023-03-10T13:29:50+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Our API tokens can now be scoped by site or status page]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/our-api-tokens-can-now-be-scoped-by-site-or-status-page" />
            <id>https://ohdear.app/84</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>Oh Dear has <a href="https://ohdear.app/docs/api/introduction">an extensive API</a> that powers <a href="https://ohdear.app/docs/tools-and-sdks/3rd-party-integrations-of-oh-dear">various powerful integrations</a>.</p>
<p>To use the API, you first need to create an API Token in the Oh Dear UI. Previously, such a token could be used to make API calls to any site or status page in your Oh Dear account.</p>
<p><img src="/media/blog/af7OVRCh1KHDMGVGXxMKlzBFjdj4dGHLvX9cecOU.jpg" alt="Oh Dear API Tokens settings with the toggle granting a token access to the entire account" /></p>
<p>We noticed that some of our users are agencies that use Oh Dear to monitor their clients' sites. When such an agency passes an Oh Dear API Token to one of their clients, then that client could potentially use the broadly scoped token to view the results and settings of other clients of that agency.</p>
<p>In general, it's best practice to scope down the abilities of the token to the bare minimum it needs to do in the integration where it will be used.</p>
<p>Today, we're launching the ability to scope an API Token by site or status page. When creating a token, you can pick the sites and status pages it should have access to.</p>
<p><img src="/media/blog/3JoJ7sxHXb0U7WY3VnOfuqM6l0BN8Yd42OKL2bBc.jpg" alt="Oh Dear API Tokens screen scoping a token per site and status page with allow and disallow controls" /></p>
<p>We hope that you like this nice addition.</p>
<p>If you're not using Oh Dear to monitor your site, now's the perfect time to get started. We monitor uptime, SSL certificates, broken links, scheduled tasks, application health, DNS, domain expiry and more. We send notifications when something's wrong. All that paired with <a href="/docs/api/introduction" class="underline hover:no-underline">a developer friendly API<a> and <a href="/docs/features/what-checks-does-oh-dear-perform" class="underline hover:no-underline">kick-ass documentation</a>.</p>
]]>
            </summary>
                                    <updated>2026-06-12T08:58:01+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Making sure routes and config files are cached in a Laravel app]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/making-sure-routes-and-config-files-are-cached-in-a-laravel-app" />
            <id>https://ohdear.app/80</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>In a typical Laravel application, you'll likely to have many routes, config files and possible some events. In your development environment these routes and config files will loaded and registered in each request. The performance penalty for this is not too big.</p>
<p>In a production environment, you want to cache these things. Laravel makes this easy by offering a couple of Artisan commands that you can use in your deployment procedure.</p>
<pre data-lang="bash" class="notranslate"><span class="hl-keyword">php</span> <span class="hl-keyword">artisan</span> optimize <span class="hl-comment"># will cache routes and config</span>
<span class="hl-keyword">php</span> <span class="hl-keyword">artisan</span> event:cache <span class="hl-comment"># will cache events</span>
</pre>
<p>By caching these things, you'll improve the performance of your Laravel app</p>
<p>Using our <a href="https://ohdear.app/features/application-health-monitoring">application health</a> check, you can get notified when things are not cached in production.</p>
<p>Oh Dear will not run any code inside your application or server. Instead, you should perform the checks yourself. Oh Dear will send an HTTP request to your application to a specific endpoint. Your application should respond with JSON containing the result of health checks.</p>
<p>Spatie's <a href="https://spatie.be/docs/laravel-health/v1/introduction">Laravel Health</a> package can build up the JSON the Oh Dear expects. Here are <a href="https://ohdear.app/docs/features/application-health-monitoring#laravel">the docs on how to do that</a>.</p>
<p><a href="https://spatie.be/docs/laravel-health/v1/introduction">Laravel Health</a> is a package that can detect various problems that are going on with your application and server. It can check disk space, cpu usage, if Horizon is running, and much more.</p>
<p>The package has <a href="https://spatie.be/docs/laravel-health/v1/available-checks/cached-config-routes-and-events">a new check</a> that makes sure wether routes, config and events are cached.</p>
<p>This is how you can use it. Check in the package can be registered via <code>Health::check()</code> function.</p>
<pre data-lang="php" class="notranslate"><span class="hl-comment">// typically in a service provider</span>

<span class="hl-keyword">use</span> <span class="hl-type">Spatie\Health\Facades\Health</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Spatie\Health\Checks\Checks\OptimizedAppCheck</span>;

<span class="hl-type">Health</span>::<span class="hl-property">checks</span>([
		<span class="hl-comment">// other checks...</span>

    <span class="hl-type">OptimizedAppCheck</span>::<span class="hl-keyword">new</span>(),
]);
</pre>
<p>This check will pass if the config, routes and events are cached.</p>
<p>If you only want to check certain caches, you can call the checkConfig, checkRoutes and checkEvents methods. In this example, we'll only check for cached config and routes.</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">use</span> <span class="hl-type">Spatie\Health\Facades\Health</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Spatie\Health\Checks\Checks\OptimizedAppCheck</span>;

<span class="hl-type">Health</span>::<span class="hl-property">checks</span>([
		<span class="hl-comment">// other checks,</span>

    <span class="hl-type">OptimizedAppCheck</span>::<span class="hl-keyword">new</span>()
       -&gt;<span class="hl-property">checkConfig</span>()
       -&gt;<span class="hl-property">checkRoutes</span>(),
]);
</pre>
<p>With that <code>OptimizedAppCheck</code> in place and Oh Dear app configured, this is what it looks like when something is not cached.</p>
<p><img src="/uploads/blogs/cache-config/slack.png" alt="image" /></p>
<p>This is a Slack notification, but it will look similar on all notification channels we offer.</p>
<p>Small wins like config caching add up, and <a href="/features/performance-monitoring">performance monitoring</a> tells you whether they actually moved the needle.</p>
]]>
            </summary>
                                    <updated>2022-07-16T15:16:39+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Building Oh Dear’s new design: Creating a color system, why and how]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/building-oh-dears-new-design-creating-a-color-system-why-and-how" />
            <id>https://ohdear.app/78</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>In the previous blog post I talked about how we are implementing the Oh Dear redesign. In this post I go into more detail on why and how we implemented a tailwind-like color system which supports all the colours used throughout the redesign.</p>
<h2 id="from-sketch-to-code">From Sketch to code <a href="#from-sketch-to-code" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#from-sketch-to-code">#</a></h2>
<p>One of the tasks that needs to be done to go from design to code is extracting the colours used in the design. In our case, the designer already put together a specific palette of colours and defined it within the Sketch file. We copied over those colours and renamed them a bit so they fit nicely in our tailwind.config.js.</p>
<pre data-lang="txt" class="notranslate">extend: {
	colors: {
		'bg-blue': '#...',
		'darkish-blue': '#...',
		divide: '#...',
		whiteish: '#...',
		green: '#...',
        ...
	},
	...
}
</pre>
<h3 id="not-all-colours-were-named">Not all colours were named</h3>
<p>While implementing the design, more and more colours kept popping up that were defined in the inspector as a plain hex code, instead of a named color.</p>
<p><img src="/uploads/blogs/redesign-2022-part-3/color-inspector.png" alt="image" /></p>
<p>As we already had to rename the tracked colours, I didn’t think it would be helpful to ask to designer to go through the document and give every specific color a name. We would have to know the desired naming pattern and we didn’t know what would be the best approach.</p>
<p>The design is also very complex with a lot of gradients and nuances created by use of different opacities. Going through all the layers and track all those colours would be very time consuming. We decided to leave the document as is and look for another alternative.</p>
<h3 id="tailwind-to-the-rescue-or-not">Tailwind to the rescue, or not?</h3>
<p>The first solution we ended up with was using tailwind classes with arbitrary values like <code>text-[#0C0515]</code> or <code>bg-[#0C0515]</code>, which is supported since [JIT mode]. This made it really easy to give an element a very specific color without the need to think of a name and having to define it in the tailwind config file. This felt like a real time-saver.</p>
<p>Although this worked well at the beginning we started to discover more and more colours that kind of looked the same but had a different hex code. As hex codes can vary a lot, while looking visually the same, and vice versa, it wasn’t easy to search occurrences of previously used hex codes that were close to a specific color.</p>
<p><img src="/uploads/blogs/redesign-2022-part-3/hex-codes.jpg" alt="image" /></p>
<p>This also resulted in code that was flooded with arbitrary values that didn’t make the use of color much consistent. Dark mode was also on the list of features to implement, and adding dark mode specific hex codes wouldn’t make it any cleaner.</p>
<p><img src="/uploads/blogs/redesign-2022-part-3/arbitrary-values-example.png" alt="image" /></p>
<h3 id="difficulty-of-going-a-shade-lighter-or-darker">Difficulty of going a shade lighter or darker</h3>
<p>Another difficulty we faced was the ability to play with a slightly lighter of darker shade. Sometimes a different colour would look better on the web compared to what was used in the design. To do that we adjusted the hex code, which then turned into yet another unique arbitrary value. We figured that having all those arbitrary values would lead to more inconsistencies and a bigger than necessary css bundle housing all those colours.</p>
<p>At this point we thought it would be great if we could use Tailwind’s colour system. Going a shade lighter or darker is as easy as lowering or increasing the number in the class. For example <code>text-blue-500</code> is lighter than <code>text-blue-600</code>. There was only one problem, the standard colours didn’t match the used colours in the design. So we had to extend the existing set of colours.</p>
<h2 id="creating-our-own-color-palettes">Creating our own color palettes <a href="#creating-our-own-color-palettes" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#creating-our-own-color-palettes">#</a></h2>
<p>We created our own color palettes through a series of easy to reproduce steps.</p>
<h3 id="1-index-all-colours">1. Index all colours</h3>
<p>The first step we took was index all colours used in the design. And put them in a separate art-board in Sketch. We ended up with something like this:</p>
<p><img src="/uploads/blogs/redesign-2022-part-3/collecting-colors.png" alt="image" /></p>
<h3 id="2-group-the-colours">2. Group the colours</h3>
<p>Next we grouped them by color, and we could already spot different colours that were almost similar and could be consolidated into a single color:</p>
<p><img src="/uploads/blogs/redesign-2022-part-3/group-colors.png" alt="image" /></p>
<h3 id="3-fill-in-the-blanks">3. Fill in the blanks</h3>
<p>After grouping them we started to order them from light to dark. As we didn’t have step-based shades for each colour yet we filled in the blanks where needed. We ended up with five base colours and a few specific colours with fewer shades:</p>
<p><img src="/uploads/blogs/redesign-2022-part-3/complete-color-system.jpg" alt="image" /></p>
<p>As you might notice we ended up with a higher resolution palette than the standard 9 shades per base color. This is because the design contained many colours that were slightly lighter or darker. We tried to replace a specific X50 for a X00 instead, but the difference in color was often too big. So we needed those subtle variations in shades and decided to create shades of the base colours in steps of 50 instead of 100.</p>
<h3 id="but-you-wont-be-using-all-those-colours">But you won’t be using all those colours!</h3>
<p>That’s true, but with Tailwind that’s not a problem. Thanks to the ability to purge all unused styles the final CSS bundle will be small no matter how much colours we define in the config.</p>
<p>The only downside is that your tailwind config can grow pretty large while having so many colours. If you would really dislike that you can always extract the colours to a separate file and then include that in the config file.</p>
<h3 id="4-import-and-replace">4. Import and replace</h3>
<p>After having all the hex codes imported in the tailwind config we replaced all the hex codes in the html with the newly available color classes. The code now looked cleaner as the color + a number was more familiar than a hex code. It’s now also easier to play with the shades as we only need to increase or lower the number in the color class at the end by 50.</p>
<h2 id="in-conclusion">In conclusion <a href="#in-conclusion" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#in-conclusion">#</a></h2>
<p>The best way of exporting colours from design to code is very dependant on the tools and frameworks you use. In our case we use Tailwind as a CSS framework and we were already familiar with its color system. If you don’t use Tailwind a different approach might be  better, although I could still see how the method described in this post can be applied on any project.</p>
<p>Creating the system was a bit tedious and time consuming. Most time was spent on ordering the colours and filling in the blanks which was done by hand. However, many weeks have been passed by now since implementing the color system and we are still very happy with it. It’s definitely an investment worth doing.</p>
<p>In the next post I will go into more detail on how we are implementing dark mode.</p>
<p>Do you have a question or feedback? Feel free to send a reply <a href="https://twitter.com/ohdearapp">via Twitter</a>.</p>
]]>
            </summary>
                                    <updated>2022-05-20T08:41:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Status pages can now be viewed as JSON or XML]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/status-pages-can-now-be-viewed-as-json-or-xml" />
            <id>https://ohdear.app/76</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>Next to a large collection of checks, Oh Dear offers the ability to easily create beautiful status pages. This way, you can communicate the status of your sites and services to your users. Take a look at the Oh Dear powered status pages for <a href="https://status.flareapp.io">Flare</a> and <a href="https://status.laravel.com">Laravel</a>.</p>
<p><img src="/uploads/blogs/status-json/statuspage.png" alt="image" /></p>
<p>Today, we added the ability to view the status page as JSON (or XML if that is your thing). You just have to append <code>/json</code> (or <code>/xml</code> to the status page URL. So for the Laravel status page, you'll find the JSON at <a href="https://status.laravel.com/json">status.laravel.com/json</a>.</p>
<p>Here's what the response looks like:</p>
<pre data-lang="json" class="notranslate"><span class="hl-property">{</span>
  <span class="hl-keyword">&quot;title&quot;</span>: <span class="hl-value">&quot;Laravel Service Health Dashboard&quot;</span>,
  <span class="hl-keyword">&quot;timezone&quot;</span>: <span class="hl-value">&quot;America\/Chicago&quot;</span>,
  <span class="hl-keyword">&quot;pinnedUpdate&quot;</span>: null,
  <span class="hl-keyword">&quot;sites&quot;</span>: <span class="hl-property">{</span>
    <span class="hl-keyword">&quot;Ungrouped&quot;</span>: <span class="hl-property">[</span>
      <span class="hl-property">{</span>
        <span class="hl-keyword">&quot;label&quot;</span>: <span class="hl-value">&quot;envoyer.io&quot;</span>,
        <span class="hl-keyword">&quot;url&quot;</span>: <span class="hl-value">&quot;https:\/\/envoyer.io&quot;</span>,
        <span class="hl-keyword">&quot;status&quot;</span>: <span class="hl-value">&quot;up&quot;</span>
      <span class="hl-property">}</span>,
      <span class="hl-property">{</span>
        <span class="hl-keyword">&quot;label&quot;</span>: <span class="hl-value">&quot;forge.laravel.com&quot;</span>,
        <span class="hl-keyword">&quot;url&quot;</span>: <span class="hl-value">&quot;https:\/\/forge.laravel.com&quot;</span>,
        <span class="hl-keyword">&quot;status&quot;</span>: <span class="hl-value">&quot;up&quot;</span>
      <span class="hl-property">}</span>,
      <span class="hl-property">{</span>
        <span class="hl-keyword">&quot;label&quot;</span>: <span class="hl-value">&quot;laravel.com&quot;</span>,
        <span class="hl-keyword">&quot;url&quot;</span>: <span class="hl-value">&quot;https:\/\/laravel.com&quot;</span>,
        <span class="hl-keyword">&quot;status&quot;</span>: <span class="hl-value">&quot;up&quot;</span>
      <span class="hl-property">}</span>,
      <span class="hl-property">{</span>
        <span class="hl-keyword">&quot;label&quot;</span>: <span class="hl-value">&quot;vapor.laravel.com&quot;</span>,
        <span class="hl-keyword">&quot;url&quot;</span>: <span class="hl-value">&quot;https:\/\/vapor.laravel.com&quot;</span>,
        <span class="hl-keyword">&quot;status&quot;</span>: <span class="hl-value">&quot;up&quot;</span>
      <span class="hl-property">}</span>,
      <span class="hl-property">{</span>
        <span class="hl-keyword">&quot;label&quot;</span>: <span class="hl-value">&quot;nova.laravel.com&quot;</span>,
        <span class="hl-keyword">&quot;url&quot;</span>: <span class="hl-value">&quot;https:\/\/nova.laravel.com&quot;</span>,
        <span class="hl-keyword">&quot;status&quot;</span>: <span class="hl-value">&quot;up&quot;</span>
      <span class="hl-property">}</span>
    <span class="hl-property">]</span>
  <span class="hl-property">}</span>,
  <span class="hl-keyword">&quot;updatesPerDay&quot;</span>: <span class="hl-property">{</span>
    <span class="hl-keyword">&quot;1652245200&quot;</span>: <span class="hl-property">[</span><span class="hl-property">]</span>,
    <span class="hl-keyword">&quot;1652158800&quot;</span>: <span class="hl-property">[</span><span class="hl-property">]</span>,
    <span class="hl-keyword">&quot;1652072400&quot;</span>: <span class="hl-property">[</span><span class="hl-property">]</span>,
    <span class="hl-keyword">&quot;1651986000&quot;</span>: <span class="hl-property">[</span><span class="hl-property">]</span>,
    <span class="hl-keyword">&quot;1651899600&quot;</span>: <span class="hl-property">[</span><span class="hl-property">]</span>,
    <span class="hl-keyword">&quot;1651813200&quot;</span>: <span class="hl-property">[</span><span class="hl-property">]</span>,
    <span class="hl-keyword">&quot;1651726800&quot;</span>: <span class="hl-property">[</span><span class="hl-property">]</span>
  <span class="hl-property">}</span>
<span class="hl-property">}</span>
</pre>
<p>By offering JSON (and XML), your users can integrate the status of your service better in their systems. If you forget the exact URL, you can access the JSON (or XML) version via &quot;Subscribe to updates&quot; dropdown in the header of a status page.</p>
<p><img src="/uploads/blogs/status-json/dropdown.png" alt="screenshot" /></p>
<p>Alternatively, your users can also subscribe to updates of your status page via Slack. You'll find more info about that on our <a href="/features/status-pages">status pages</a> page.</p>
]]>
            </summary>
                                    <updated>2022-05-11T07:01:11+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Introducing Nick and Sean to the Oh Dear team]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/introducing-nick-and-sean-to-the-oh-dear-team" />
            <id>https://ohdear.app/72</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>We're growing the team at Oh Dear with two very strong additions: Nick and Sean.</p>
<p>Ever since we started Oh Dear (almost 5 years ago 😱) we've been adding new features, scaling out our infrastructure and growing our user base.</p>
<p>That growth has lead us to two important realisations:</p>
<ol>
<li>Slowly but surely, Oh Dear is growing beyond our ability to manage it with just Freek &amp; Me (Mattias)</li>
<li>The growth has provided us the funding to look for strong hires to enhance our team</li>
</ol>
<p>As a result, we're very proud to introduce both Nick and Sean that are working on Oh Dear!</p>
<h2 id="sean">Sean <a href="#sean" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#sean">#</a></h2>
<p>Sean is working on our client support &amp; bugfixes. His background in both Finance and Laravel development gives him a unique perspective and skillset to tackle our client questions and implement long-lasting fixes for recurring problems in our support queue.</p>
<p>Where to find Sean online:</p>
<ul>
<li><a href="https://twitter.com/resohead">@resohead</a> on Twitter</li>
</ul>
<h2 id="nick">Nick <a href="#nick" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#nick">#</a></h2>
<p>Nick's implementing a brand new redesign for Oh Dear, something we started working on months ago, and is in the final stages of being converted to working HTML &amp; CSS.</p>
<p>That redesign contains <em>a lot</em> of frontend challenges, as we're really going <em>all out</em> in the new design. Nick's vast experience in frontend frameworks is being put to the test in order to implement every little detail.</p>
<p>The results are already looking slick! 💪</p>
<p><img src="/uploads/blogs/redesign-2022-tease-1/redesign-ohdear-teaser.png" alt="Redesign Oh Dear 2022 - Teaser" /></p>
<p>(But, more on that redesign in later blogposts. #tease)</p>
<p>Where to find Nick online:</p>
<ul>
<li><a href="https://twitter.com/nckrtl">@nckrtl</a> on Twitter</li>
<li><a href="https://nckrtl.com/">nckrtl.com</a></li>
</ul>
<p>Both were hired for their unique skillsets, not for their amount of Twitter followers. If you want to stay up-to-date with some behind the scenes leaks, make sure to give them a follow. 😁</p>
]]>
            </summary>
                                    <updated>2022-03-14T11:08:03+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[You can now monitor your DNS records using Oh Dear]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/you-can-now-monitor-your-dns-records-using-oh-dear" />
            <id>https://ohdear.app/69</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>We can now notify you of changing DNS records <em>and</em> let you know when we've detected a problem with your domain nameservers. Woohoo! 🥳</p>
<h2 id="what-is-dns-record-monitoring">What is DNS record monitoring? <a href="#what-is-dns-record-monitoring" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#what-is-dns-record-monitoring">#</a></h2>
<p>Behind just about every website is a translation service that converts what we humans can read (the &quot;domain name&quot;) to something a computer can interpret (the &quot;IP address&quot;). This conversion or translation is <em>crucial</em> to the functioning of your website.</p>
<p>If the Domain Name Service (DNS) does not work properly and <em>can't</em> translate the domain to an IP address, the web-browser cannot connect to your server and your site is offline.</p>
<p>Most of the time, DNS <em>just works ™️</em>. But sometimes, very subtle problems can occur. And, as webdevelopers ourselves, we've seen a fair few of those.</p>
<p>That's why we're launching DNS record monitoring to have the ability to:</p>
<ol>
<li>Notify you <strong>when DNS records change</strong></li>
<li>Alert you whenever <strong>nameservers stop responding</strong></li>
<li>Offer confirmation that all <strong>nameservers are returning the same records</strong></li>
</ol>
<p>All of this is the same convenient packaging you know as <em>Oh Dear</em>. 🤓</p>
<h2 id="receive-confirmation-of-dns-changes">Receive confirmation of DNS changes <a href="#receive-confirmation-of-dns-changes" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#receive-confirmation-of-dns-changes">#</a></h2>
<p>For many, changing DNS records is a scary action. What can go wrong? Are you sure you're changing the right value? What's the potential fall-out?</p>
<p>With our DNS change monitoring, we can notify you and show you the records that <em>have</em> changed, so you can confirm that those were <em>intended</em> to change.</p>
<p>Take this example for instance: we changed a DNS record and can show you the new (green) and old (red) value.</p>
<p><img src="/uploads/blogs/dns-monitoring-feature/dns-monitoring-changed-records.png" alt="DNS records changed" /></p>
<p>We can offer you the final confirmation you need to make DNS changes with peace of mind.</p>
<p>As always, <a href="/features/notifications">we can notify you in a lot of ways</a>. Most users opt for the Slack notifications, and every DNS change gets reported there nicely:</p>
<p><img src="/uploads/blogs/dns-monitoring-feature/dns-monitoring-slack-alert.png" alt="DNS records changed" /></p>
<p>Showing <em>which</em> records get updated, added or removed isn't very convenient in a small notification, so we can show you a full report more easily right in the Oh Dear website.</p>
<h2 id="a-back-up-of-all-your-necessary-dns-records">A back-up of all your necessary DNS records <a href="#a-back-up-of-all-your-necessary-dns-records" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#a-back-up-of-all-your-necessary-dns-records">#</a></h2>
<p>By default, we'll show you the records we found for your domain. This is a convenient back-up of all the necessary records you can use to rebuild your DNS setup, in case it ever fails.</p>
<p><img src="/uploads/blogs/dns-monitoring-feature/dns-monitoring-overview-records.png" alt="DNS records changed" /></p>
<p>You'll find a full history of all the changes and all records we've ever found, going back to the day you enabled the DNS monitoring. Handy if you ever need to revert to a former value!</p>
<p><img src="/uploads/blogs/dns-monitoring-feature/dns-monitoring-history.png" alt="DNS records history" /></p>
<p>Not quite sure what each record can mean? Click on the label in the front (A, AAAA, MX, ...) and you'll be taken to <a href="/docs/features/domain-name-monitoring">our extensive documentation on DNS records</a> to help explain it great detail.</p>
<h2 id="notifications-when-nameservers-are-out-of-sync">Notifications when nameservers are out-of-sync <a href="#notifications-when-nameservers-are-out-of-sync" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#notifications-when-nameservers-are-out-of-sync">#</a></h2>
<p>There are cases where not all nameservers are <em>in-sync</em>. Let's say you have 4 nameservers for your domain and you make a DNS record change. 3 out of those 4 pick up the changes instantly, but the 4th one happens to miss it.</p>
<p>Sure, it's a problem for your hosting- or nameserver-provider, but the trouble falls onto you. About a quarter of your visitors can't reach your site now, because they happen to (randomly) query that 4th nameserver.</p>
<p>How would you know, if your tests happen to hit one of the first 3 nameservers?</p>
<p>Well, we'll let you know when we detect that. That way, you can escalate to your hosting- or nameserver-provider quicker and have less downtime. Win!</p>
<h2 id="detecting-domain-name-hijacks">Detecting domain name hijacks <a href="#detecting-domain-name-hijacks" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#detecting-domain-name-hijacks">#</a></h2>
<p>Imagine the following, scary, example:</p>
<ol>
<li>Someone manages to take over your domain name and transfer it into their name</li>
<li>They put the very same site online as the one you have, they just change the payment details for incoming payments</li>
</ol>
<p>Would your current monitor be able to detect this? The site is still up and your <a href="https://ohdear.app/docs/features/uptime-and-performance-monitoring#uptime-check-options">checkstring</a> may still be valid. It's actually a case where Oh Dear would <em>also</em> think your site is still online!</p>
<p>But, there are 2 crucial changes that would happen in such a hostile takeover of your domain:</p>
<ol>
<li>The SSL certificate would change: the attacker would have to retrieve a new one (unless they managed to get hold of your private key)</li>
<li>The DNS has to change, to point to a server the attacker controls</li>
</ol>
<p>We can now proudly say we can detect and alert on both!</p>
<p>We could already offer <a href="/features/continuous-certificate-monitoring">SSL change alerts</a> but can now <em>also</em> notify you of <a href="/features/dns-monitoring">changing DNS records</a>.</p>
<p>Just another feature to offer you the certainty that your site both online and actually <em>your</em> site.</p>
<h2 id="useful-when-your-clients-manage-the-domain-name">Useful when your clients manage the domain name <a href="#useful-when-your-clients-manage-the-domain-name" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#useful-when-your-clients-manage-the-domain-name">#</a></h2>
<p>As a web agency or 3rd party supplier, you may not always be in control of the DNS of the websites you're building.</p>
<p>It's often the case the client has full control over the domain name <em>and</em> the DNS, but you're responsible for the website and the hosting.</p>
<p>With our DNS monitoring, you'll know <em>instantly</em> when your client made a change to the DNS records that <em>could</em> impact you.</p>
<p>And as always with DNS, the sooner you can catch those changes and react, the better. The longer it goes without being detected, the more chances you'll have of the wrong DNS record being cached on recursive resolvers worldwide.</p>
<p>And that means it'll take longer for the corrective DNS record to be found too!</p>
<h2 id="opt-in-for-all-clients">Opt-in for all clients <a href="#opt-in-for-all-clients" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#opt-in-for-all-clients">#</a></h2>
<p>We don't enable the DNS checks for everyone, because we do realize it's a little bit technical and not all our clients are interested in this.</p>
<p>Everyone can now enable it for their sites and be prompted to enable DNS monitoring whenever they add a new one. The feature is live for everyone!</p>
<p><img src="/uploads/blogs/dns-monitoring-feature/dns-monitoring-add-screen.png" alt="Add a new site - enable DNS" /></p>
<p>Whenever you add a new site, you can choose to enable DNS monitoring or not.</p>
<h2 id="granularity-on-which-dns-records-you-care-about">Granularity on which DNS records you care about <a href="#granularity-on-which-dns-records-you-care-about" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#granularity-on-which-dns-records-you-care-about">#</a></h2>
<p>You can toggle the alerts per <em>type</em> of DNS record you care about.</p>
<p><img src="/uploads/blogs/dns-monitoring-feature/dns-monitoring-settings.png" alt="DNS record settings" /></p>
<p>Maybe you're behind Cloudflare or another DDoS protection service and your A &amp; AAAA records change often? You can then disable the alerts <em>specifically</em> for those records. Leave the MX and TXT record alerts online and you'll receive notifications whenever those change.</p>
<p>If you're monitoring <code>awesomesite.tld</code>, we'll monitor the DNS records we can find on that domain.</p>
<p>If you care extra about <code>secretpage.awesomesite.tld</code>, you can add that to Oh Dear as well and simply enable the DNS monitoring for that domain, and we'll find &amp; alert on those records too. You decide which domains matter and which you want to receive alerts on.</p>
<p>We later extended this to <a href="/news-and-updates/our-dns-check-can-now-monitor-hidden-cname-records">monitor hidden CNAME records</a> too.</p>
<h2 id="give-it-a-try">Give it a try! <a href="#give-it-a-try" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#give-it-a-try">#</a></h2>
<p>As always, <a href="https://ohdear.app/register">we offer a 10-day trial</a> with no credit card requirements. Just an email address and you can test us out.</p>
<p>Don't like it? Simple ignore our emails and we'll delete your account after a few weeks. You don't even have to ask. 😇</p>
]]>
            </summary>
                                    <updated>2021-11-23T16:01:53+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[You can now customise how we handle redirects]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/you-can-now-customise-how-we-handle-redirects" />
            <id>https://ohdear.app/68</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>A good deal of sites redirect visitors to a specific more relevant page. Think, for instance, of a site that redirects you from <code>/</code> to a page in a relevant language, for instance <code>/en</code> or <code>/nl</code>.</p>
<p>A single redirect is often not a problem, but having multiple redirects in a chain can hinder your site's user experience. Modern browsers also limit the number of redirects.</p>
<p>At our uptime check settings screen, we've added a new option that allows you to specify how many redirects your site can have before we consider it a problem.</p>
<p><img src="/uploads/blogs/redirects/settings.png" alt="screenshot" /></p>
<p>Under the hood, we've always allowed five redirects before we considered the site as down. That's why we are now using five as the default value for this setting.</p>
<p>Should your website redirect more than the allowed limit, we'll send you a notification and consider your site down.</p>
<p>On the uptime check report screen, we list the redirects that we have detected while running the check.</p>
<p><img src="/uploads/blogs/redirects/redirects.png" alt="screenshot" /></p>
<p>We hope you like these improvements on how we handle redirects. We're <a href="/news-and-updates">constantly improving</a> our service. If you have any feature requests or feedback, let us know. You can use the support bubble in the bottom right corner of this page to send us a message. We're listening!</p>
]]>
            </summary>
                                    <updated>2021-10-07T09:29:22+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Introducing our new interactive Telegram notifications]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/introducing-our-new-interactive-telegram-notifications" />
            <id>https://ohdear.app/67</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>When something is wrong with your site, we can already notify you via a lot of different channels: mail, Slack, SMS, Webhooks, ...</p>
<p>Today, we're adding support for Telegram notifications. We've polished the entire connection flow and made the notifications themselves interactive.</p>
<p>To get started, click the <strong>Telegram</strong> notification channel at either the team or site level.</p>
<p>First, you need to invite <code>@OhDearAppBot</code> to your Telegram channel.</p>
<p>Next, you must copy the start command we generated and paste it into the Telegram channel.</p>
<img src="/img/docs/notifications/telegram/start-token.png" alt="Telegram channel where the Oh Dear start command with token is pasted and the bot confirms the connection" class="max-w-md" />
<p>At Oh Dear, the start command will be acknowledged too.</p>
<p>Finally, you must choose for which events you want to receive notifications and save the Telegram settings.</p>
<p><img src="/img/docs/notifications/telegram/saved.png" alt="screenshot" /></p>
<p>Whenever something is wrong with your site, we'll send you a notification. Let's take a look at what happens when the site is down.</p>
<img src="/img/docs/notifications/telegram/down.png" alt="Telegram down alert for freek.dev with a 503 error and Full report, Check again and Snooze buttons" class="max-w-md" />
<p>Notice that there are buttons underneath the notification to view the full report, snooze further notifications, and to rerun the check. Pretty handy.</p>
<p>When you press &quot;Snooze&quot;, you'll see the various snoozing options.</p>
<img src="/img/docs/notifications/telegram/snooze.png" alt="Telegram snooze options for the freek.dev uptime check ranging from 5 minutes to a week" class="max-w-md" />
<p>Let's give this post a happy ending by showing what the notification looks like when a site comes back online.</p>
<img src="/img/docs/notifications/telegram/up.png" alt="Telegram recovery notification that freek.dev is back up after 3 minutes, following the earlier down alert" class="max-w-md" />
<p>Combine them with <a href="/news-and-updates/in-outside-business-hour-notifications">in- and outside-business-hour notifications</a> to stay sane.</p>
<p>We hope you like this new notification channel.</p>
]]>
            </summary>
                                    <updated>2026-06-12T09:06:57+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Let's Encrypt DST Root CA X3 certificate set to expire]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/lets-encrypt-dst-root-ca-x3-certificate-set-to-expire" />
            <id>https://ohdear.app/65</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>If you've been using Let's Encrypt for a while, you may have noticed that their certificates are signed by a root certificate titled <em>DST Root CA X3</em>.</p>
<p>That root certificate is set to expire in a few hours. Any certificates still signed by that root will no longer be valid. But luckily, that shouldn't form a problem for most Let's Encrypt users.</p>
<p>For a while now, new SSL issuances by Let's Encrypt have issued certificates against DST Root CA X3 (the one that is about to expire) <em>and</em> ISRG Root X1. The former will expire, the latter remains valid for years to come.</p>
<p>If Oh Dear detects you're sending along the older, about to expire, intermediate certificate in your certificate chain, we'll notify you to take action:</p>
<p><img src="/uploads/blogs/letsencrypt-r3-expires/letsencrypt_r3_expire.png" alt="Lets Encrypt R3 expiration" /></p>
<p>The fix would be to re-issue certificates <em>or</em> manually change the certificate chain in your configuration that you're sending along via your webserver.</p>
<p>While this will only affect much older devices (ie: iPhone 4 or older), we feel it's important we notify our clients should this affect them.</p>
<p>For more details, we kindly refer to the <a href="https://letsencrypt.org/docs/dst-root-ca-x3-expiration-september-2021/">official Let's Encrypt explanation of their expiring root certificate</a>.</p>
]]>
            </summary>
                                    <updated>2021-09-29T19:17:28+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[We've increased our performance by decreasing the number of queries]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/weve-increased-our-performance-by-decreasing-the-number-of-queries" />
            <id>https://ohdear.app/64</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>Under the hood, Oh Dear is a large Laravel application that performs many queries all of the time. To power future features, we've recently changed our database structure and refactored some pieces in our code base. We increased performance by decreasing the number of queries.</p>
<p>As an Oh Dear user, you've probably not noticed any changes. Our UI has always been very responsive. Most of the changes impacted the work that we perform on a queue, which is where 99% of our workload happens.</p>
<p>To learn more about the specific changes we made, check out <a href="https://freek.dev/2075-strategies-for-decreasing-the-number-of-queries-in-a-laravel-app">this extensive blog published on freek.dev</a>. If you're a PHP or Laravel developer, you could leverage some of our techniques in your projects.</p>
]]>
            </summary>
                                    <updated>2021-09-18T16:52:09+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Monitoring password protected sites using Oh Dear]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/monitoring-password-protected-sites-using-oh-dear" />
            <id>https://ohdear.app/63</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>Keeping an eye on your site and sending you a notification when it goes down is one of the core features of Oh Dear. Under the hood, we'll send a request to your site and take a look if the response code is in the <code>200</code>-<code>299</code> range, which is the default response code range to indicate that everything is ok.</p>
<p>Some of our users are monitoring password protected sites. In such cases, the web server might reply with status code <code>401</code> (unauthorised). Sure, extra headers can be specified on Oh Dear to hint to your server it should respond <code>200</code> from our particular request, but that's rather cumbersome.</p>
<p>You can now easily monitor password protected sites using our new &quot;Expected response status codes&quot; setting. Using this setting, you can specify which status codes Oh Dear should consider ok.</p>
<p><img src="https://ohdear.app/uploads/blogs/response-status/status.jpg" alt="screenshot" /></p>
<p>By default, this setting is set to <code>2*</code>, which means everything in the <code>200</code> range is ok. For a password-protected site, you could set this to <code>4O1</code>. That's certainly easier than having to add some headers.</p>
<p>A nice side effect of setting the expected response code to <code>401</code> is that if you misconfigured your password protection, we have got your back.  When making your page accidentally make your page publicly visible, it will probably respond with <code>200</code>, which isn't expected, and we'll send a notification.</p>
<p>Like you can see in the screenshot above, you can tweak our uptime monitor in various ways. If there is any setting you feel is missing, let us know. We're listening.</p>
]]>
            </summary>
                                    <updated>2021-09-29T19:16:26+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[We now support Pushover's priority messages]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/we-now-support-pushovers-priority-messages" />
            <id>https://ohdear.app/61</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>When we detect something wrong with your site (it is down, a broken link is detected, the certificate is invalid, ...), we can notify you via one of the many notification channels we support.</p>
<p>One of those channels is <a href="https://pushover.net">Pushover</a>, an excellent service to send native notifications to mobile devices. We have supported Pushover since we launched a couple of years ago. Now, we've added a nice option that several of our users we're asking for: setting the priority.</p>
<p>When you define Pushover notifications in our UI, you'll notice a new priority field.</p>
<p><img src="/uploads/blogs/pushover-priority/priority.png" alt="screenshot" /></p>
<p>In the Pushover docs, you'll <a href="https://pushover.net/api#priority">find</a> a description of the behaviour of each priority. The lowest priority will not generate a notification on a mobile device but will increment the badge number. The &quot;emergency&quot; priority will keep on notifying you until you explicitly acknowledge the notification.</p>
<p>Adding support for Pushover's priorities is a minor feature, but a useful one for our customers using this service.</p>
]]>
            </summary>
                                    <updated>2021-08-14T20:29:09+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Introducing advanced user management for large teams]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/introducing-advanced-user-management-for-large-teams" />
            <id>https://ohdear.app/60</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>If we look at the number of sites that our users monitor, we can split our user base into two large groups. Teams in the first group only monitor one or a couple of sites. The second group monitors 30 or more sites.</p>
<p>We've just launched new features that make user management more flexible for large teams. In this blog post, we'd like to tell you all about it.</p>
<h2 id="managing-access-for-team-members">Managing access for team members <a href="#managing-access-for-team-members" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#managing-access-for-team-members">#</a></h2>
<p>We assume that the teams with more than 30 sites consist of web agencies that monitor websites for their clients.</p>
<p>Wouldn't it be nice if business owners of the monitored sites could also see their sites in Oh Dear? Using our new user management features that's now possible.</p>
<p>To give people access to your Oh Dear account, you can invite them to your team on the team settings screen.</p>
<p><img src="/uploads/blogs/user-management/add-team-member.png" alt="screenshot" /></p>
<p>That <code>guest</code> role is new. This type of user cannot add a site to your Oh Dear account.</p>
<p>In addition to picking a role, you can also choose which sites and status pages the invitee should have access to.</p>
<p><img src="/uploads/blogs/user-management/site-selection.png" alt="screenshot" /></p>
<p>Of course, the role and the selection of sites and status pages can also be changed for existing team members. Just click one of the links after the team member name.</p>
<p><img src="/uploads/blogs/user-management/members.png" alt="screenshot" /></p>
<h2 id="in-closing">In closing <a href="#in-closing" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#in-closing">#</a></h2>
<p>We think that these changes are a very nice addition for people who monitor many sites. We've implemented this in a simple way. For people with only a handful of site sites, everything in our UI stays simple.</p>
<p>We hope that you like these changes. If you got feature requests, <a href="mailto:support@ohdear.app">let us know</a>!</p>
]]>
            </summary>
                                    <updated>2021-05-04T18:39:30+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Introducing monthly site reports]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/introducing-monthly-site-reports" />
            <id>https://ohdear.app/54</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>Today, we're introducing a new major feature: monthly site reports. In such a report, you get a bird's eye summary of everything we know of a site in a particular month.</p>
<p>We've gone the extra mile and added the ability to <strong>mail these reports to people outside of your team</strong> automatically. If you're an agency and manage sites for your clients, you could use this feature to send a monthly report of all broken links to your client.</p>
<p>In this blog post, we'll tell you all about the feature.</p>
<h2 id="what-is-a-site-report">What is a site report <a href="#what-is-a-site-report" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#what-is-a-site-report">#</a></h2>
<p>Since Oh Dear was launched in 2017, we've been sending monthly uptime mails to all our users. In these emails, we list every site we monitor for a user and its uptime percentage for the past month.</p>
<p><img src="/uploads/blogs/site-report/uptime-mail.png" alt="screenshot" /></p>
<p>Our users seem to love the monthly uptime mails. They don't have to actively log into Oh Dear to see how their sites are doing. We sometimes get responses from clients who are so happy that we reported 100% uptime for all their sites.</p>
<p>But why only mention the uptime percentage? Wouldn't it be nice to have a summary of all checks we perform? Starting from next month, our monthly mail will also, for every site, include a link to a full report that summarises everything that happened at that site.</p>
<p><img src="/uploads/blogs/site-report/uptime-mail-with-report.png" alt="screenshot" /></p>
<p>Here's what such a report looks like.</p>
<p><img src="/uploads/blogs/site-report/report.png" alt="screenshot" /></p>
<p>In the screenshot, you only see the uptime statistics, but the report contains a performance report, a list of all broken links and mixed content and more, ...</p>
<p><strong>Here's <a href="https://ohdear.app/site-report/703?signature=5cbf8802090ccac99c1cf21f0809094a9c5e8ed630c2fdf358a02469b464df48">an actual report</a> so you can see what it looks like in the browser.</strong></p>
<p>You can also view a report and all past ones, in &quot;Past reports&quot; tab of a site.</p>
<p><img src="/uploads/blogs/site-report/past-reports.png" alt="screenshot" /></p>
<h2 id="automatically-mailing-site-reports">Automatically mailing site reports <a href="#automatically-mailing-site-reports" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#automatically-mailing-site-reports">#</a></h2>
<p>We figure that some of our clients want to send a report for a particular site to somebody that is not an Oh Dear user.</p>
<p>For example, if you run an agency, you probably handle websites for your clients. Wouldn't it be nice if you could send an uptime report, or a report mentioning all broken links to your client?</p>
<p>On the &quot;Settings&quot; tab of monthly reports, you can specify a couple of e-mail addresses to automatically send monthly reports to. You can even specify what information should go in the report.</p>
<p><img src="/uploads/blogs/site-report/mail-reports.png" alt="screenshot" /></p>
<h2 id="reports-without-logging-in">Reports without logging in <a href="#reports-without-logging-in" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#reports-without-logging-in">#</a></h2>
<p>These reports are available to anyone that received the e-mail or the direct link. You don't need an Oh Dear account to view them.</p>
<p>Behind the scenes, it's using signed URLs (much like our <a href="/features/notifications">action links in notification emails</a> that provide a secure and convenient way to view the report without having to log in.</p>
<h2 id="in-closing">In closing <a href="#in-closing" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#in-closing">#</a></h2>
<p>We think these new site reports will be a welcome addition for our users.
We regularly add new features to Oh Dear, but still keep the service easy to use. Here's a list of all the things we improved on Oh Dear in the past year.</p>
<p>If you have any questions or feedback, let us know!</p>
]]>
            </summary>
                                    <updated>2021-03-04T08:55:44+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Making our Laravel test suite ready for parallel testing]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/making-our-laravel-test-suite-ready-for-parallel-testing" />
            <id>https://ohdear.app/52</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>To make sure that our service is working correctly, we have a suite of automated tests. The test suite is executed when we make changes and deploy something to production.</p>
<p>Using the new <a href="https://laravel.com/docs/8.x/testing#running-tests-in-parallel">parallel testing</a> feature that recently landed in Laravel, we managed to run our testsuite <strong>about four times faster</strong>.</p>
<p>Here is the test output when running all tests sequentially. The time needed is <strong>4m28</strong>s.</p>
<p><img src="/uploads/blogs/parallel/single.jpg" alt="single" /></p>
<p>And here's the test output when using parallel testing. It only took <strong>1m05s</strong>.</p>
<p><img src="/uploads/blogs/parallel/parallel.jpg" alt="parallel" /></p>
<p>In this blogpost you'll learn what parallel testing is, and what changes we needed to make to our tests to make use of this awesome feature.</p>
<h2 id="what-is-parallel-testing">What is parallel testing? <a href="#what-is-parallel-testing" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#what-is-parallel-testing">#</a></h2>
<p>By default, PHPUnit runs each test in our suite sequentially. A test will only start if a previous one has finished. While this has the advantage of being a very simple strategy, it's not an optimal approach.</p>
<p>Most of our tests make use of the database. At the start of each test, the database is cleared. At the end of the test we verify if the expected results are written in the db. Now, if multiple tests were running at the same time, we could get unexpected results, as the a test would also see the changes in the db made by other tests.</p>
<p>In the latest version of Laravel, you can run tests in parallel by using <a href="https://laravel.com/docs/master/testing#running-tests-in-parallel">the <code>parallel</code> option</a> when running tests.</p>
<pre data-lang="txt" class="notranslate">php artisan test --parallel
</pre>
<p>Under the hood Laravel will leverage <a href="https://github.com/paratestphp/paratest"><code>ParaTest</code></a> by <a href="https://github.com/brianium">Brian Scaturro</a> to run multiple PHPUnit processes concurrently. Most of the heavy work for parallel testing is performed in <code>ParaTest</code>. By default, ParaTest will create one test process per core in your computer.</p>
<p>The parallel testing additions in Laravel also handle the creationg and migration of databases. Per PHPUnit process, Laravel will automatically <a href="https://laravel.com/docs/master/testing#parallel-testing-and-databases">create and configure a database</a>, so each test still can use an isolated database. If your computer has 4 cores, then 4 databases will be created, and Laravel will make sure that each database is only used by 1 test at a given time.</p>
<h2 id="additional-changes-to-our-test-suite">Additional changes to our test suite <a href="#additional-changes-to-our-test-suite" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#additional-changes-to-our-test-suite">#</a></h2>
<p>It's really nice that Laravel takes care of making sure each test has its own database to work with. However, the are also other resource that, when used concurrently, can mess up the assertions a test makes. Let's dive in two concrete examples in the Oh Dear test suite.</p>
<h2 id="checking-contents-of-mails-in-parallel-tests">Checking contents of mails in parallel tests <a href="#checking-contents-of-mails-in-parallel-tests" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#checking-contents-of-mails-in-parallel-tests">#</a></h2>
<p>Oh Dear sends several emails to all users. In our tests we make sure that those emails work correctly. One of those mails is the uptime report we send in the beginning of every month.</p>
<p>In Laravel you can <a href="https://laravel.com/docs/master/mocking#mail-fake">fake all mails</a>. This allows you to assert that it was sent, and who it was sent to. Mails won't actually <em>be</em> sent or rendered, so you can't make assertions on the content. To test the content of mails, Laravel provides <a href="https://laravel.com/docs/8.x/mail#testing-mailables">a few handy assertion methods</a> on a <code>Mailable</code>.</p>
<p>In Oh Dear we use both methods, but we also use a third approach. Using the <a href="https://github.com/spatie/laravel-mail-preview">spatie/laravel-mail-preview</a> package, each mail will not be sent, but be written to disk. This allows us to make assertions against those written mails. In our tests we can just call code that sends mails, there's no need for <code>Mail::fake()</code> or having to hold an instance of <code>Mailable</code> in the test.</p>
<p>Here's a concrete example. In this test we call an Artisan command that sends mails, and we assert that the right content is in the sent mail.</p>
<pre data-lang="php" class="notranslate"><span class="hl-comment">/** <span class="hl-value">@test</span> */</span>
<span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">it_can_report_the_uptime_statistics</span>()
{
    <span class="hl-type">Site</span>::<span class="hl-property">factory</span>()-&gt;<span class="hl-property">create</span>([<span class="hl-value">'url'</span> =&gt; <span class="hl-value">'https://spatie.be'</span>]);

    <span class="hl-comment">// will seed uptime history records with 99.98% uptime </span>
    <span class="hl-variable">$this</span>-&gt;<span class="hl-property">generateUpAndDownTime</span>();

    <span class="hl-type">TestTime</span>::<span class="hl-property">addMonth</span>();

    <span class="hl-variable">$this</span>-&gt;<span class="hl-property">artisan</span>(<span class="hl-value">'ohdear:email-monthly-uptime-reports'</span>);

    <span class="hl-variable">$this</span>
        -&gt;<span class="hl-property">assertLatestSentMailContains</span>(<span class="hl-value">&quot;spatie.be&quot;</span>)
        -&gt;<span class="hl-property">assertLatestSentMailContains</span>(<span class="hl-value">&quot;99.98%&quot;</span>);
}
</pre>
<p>Here's the implementation of that <code>assertLatestSentMailContains</code> method. It will find the latest sent mail, and make an assert if the passed <code>substring</code> is in the content of the mail.</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">protected</span> <span class="hl-keyword">function</span> <span class="hl-property">assertLatestSentMailContains</span>(<span class="hl-injection"><span class="hl-type">string</span> $substring</span>): <span class="hl-type">self</span>
{
    <span class="hl-variable">$mailStorageDirectory</span> = <span class="hl-property">config</span>(<span class="hl-value">'mail-preview.storage_path'</span>);

    <span class="hl-variable">$sentMails</span> = <span class="hl-property">collect</span>(<span class="hl-type">File</span>::<span class="hl-property">allFiles</span>(<span class="hl-variable">$mailStorageDirectory</span>));

    <span class="hl-variable">$latestMailPath</span> = <span class="hl-variable">$sentMails</span>
        -&gt;<span class="hl-property">sortByDesc</span>(<span class="hl-keyword">fn</span>(<span class="hl-injection"><span class="hl-type">SplFileInfo</span> $file</span>) =&gt; <span class="hl-variable">$file</span>-&gt;<span class="hl-property">getMTime</span>())
        -&gt;<span class="hl-property">first</span>()
        -&gt;<span class="hl-property">getPathName</span>();

    <span class="hl-variable">$latestMailContent</span> = <span class="hl-property">file_get_contents</span>(<span class="hl-variable">$latestMailPath</span>);

    <span class="hl-variable">$this</span>-&gt;<span class="hl-property">assertStringContainsString</span>(<span class="hl-variable">$substring</span>, <span class="hl-variable">$latestMailContent</span>);

    <span class="hl-keyword">return</span> <span class="hl-variable">$this</span>;
}
</pre>
<p>If all our test run sequentially our tests run just fine. Using parallel testing, this tests, and all others where <code>assertLatestSentMailContains</code> start to fail.</p>
<p>Why? Because all those tests write mails in the same directory, so the latest sent mail could have been sent in the code from another test.</p>
<p>This can be fixed by using a separate directory per test process. When using parallel testing, the <code>ParallelTesting::token()</code> method will return a token that is guaranteed to be unique for all concurrently running tests. In the <code>setUp</code> method in our base <code>TestCase</code> is performed before each test. In that <code>setUp</code> method we're calling this new method <code>configureMailPreviewDirectory()</code>.</p>
<pre data-lang="txt" class="notranslate">protected function configureMailPreviewDirectory(): void
{
    $previewMailDirectory = 'storage/email-previews-' . ParallelTesting::token();

    config()-&gt;set('mail-preview.storage_path', $previewMailDirectory);

    if (File::exists($previewMailDirectory)) {
        File::deleteDirectory($previewMailDirectory);
    }
    
    File::makeDirectory($previewMailDirectory);
}
</pre>
<p>In the <code>configureMailPreviewDirectory</code> we will ensure an empty directory exists per value that the testing token returns. We will also dynamically change the configuration of the <code>laravel-mail-preview</code> package so that directory is used to save sent mails of a particular test.</p>
<h2 id="preparing-the-internal-test-server-for-parallel-tests">Preparing the internal test server for parallel tests <a href="#preparing-the-internal-test-server-for-parallel-tests" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#preparing-the-internal-test-server-for-parallel-tests">#</a></h2>
<p>The second place where we had to make some changes to make parallel test work, is on test test server that is booted within our tests. This way of testing is not that common, but I wanted to mention it here for those of you interested how tests specific to Oh Dears core functionality look like.</p>
<p>Oh Dear can checks if a site is up, if there are broken links on a page of site, and much more. To speed up some of the tests, we're not using a real world site to check against.</p>
<p>Instead, our tests suite starts a <a href="https://lumen.laravel.com">Lumen</a> based web server. Here's the content of one of the tests where are Lumen server is used.</p>
<pre data-lang="php" class="notranslate"> <span class="hl-keyword">protected</span> <span class="hl-keyword">function</span> <span class="hl-property">setUp</span>(): <span class="hl-type">void</span>
{
    <span class="hl-keyword">parent</span>::<span class="hl-property">setUp</span>();

    <span class="hl-variable">$this</span>-&gt;<span class="hl-property">check</span> = <span class="hl-type">Check</span>::<span class="hl-property">factory</span>()-&gt;<span class="hl-property">create</span>([
        <span class="hl-value">'type'</span> =&gt; <span class="hl-type">CheckType</span>::<span class="hl-property">BROKEN_LINKS</span>,
        <span class="hl-value">'site_id'</span> =&gt; <span class="hl-type">Site</span>::<span class="hl-property">factory</span>()-&gt;<span class="hl-property">create</span>([
            <span class="hl-value">'url'</span> =&gt; <span class="hl-value">'http://localhost:8181'</span>,
            <span class="hl-value">'broken_links_check_include_external_links'</span> =&gt; <span class="hl-keyword">true</span>,
        ]),
    ]);

    <span class="hl-variable">$this</span>-&gt;<span class="hl-property">run</span> = <span class="hl-type">Run</span>::<span class="hl-property">createForCheck</span>(<span class="hl-variable">$this</span>-&gt;<span class="hl-property">check</span>);

    <span class="hl-type">Server</span>::<span class="hl-property">boot</span>();

    <span class="hl-type">Event</span>::<span class="hl-property">fake</span>();
}

 <span class="hl-comment">/** <span class="hl-value">@test</span> */</span>
<span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">it_can_perform_a_run_and_detect_broken_links</span>()
{
    <span class="hl-type">Server</span>::<span class="hl-property">activateRoutes</span>(<span class="hl-value">'brokenLinks'</span>);

    <span class="hl-property">app</span>(<span class="hl-type">CrawlerChecker</span>::<span class="hl-keyword">class</span>)-&gt;<span class="hl-property">perform</span>([<span class="hl-variable">$this</span>-&gt;<span class="hl-property">run</span>]);

    <span class="hl-variable">$this</span>-&gt;<span class="hl-property">run</span>-&gt;<span class="hl-property">refresh</span>();

    <span class="hl-variable">$this</span>-&gt;<span class="hl-property">assertEquals</span>(7, <span class="hl-variable">$this</span>-&gt;<span class="hl-property">run</span>-&gt;<span class="hl-property">crawledUrlsForBrokenLinks</span>()-&gt;<span class="hl-property">count</span>());

    <span class="hl-variable">$brokenLinks</span> = <span class="hl-variable">$this</span>-&gt;<span class="hl-property">run</span>-&gt;<span class="hl-property">brokenLinks</span>()-&gt;<span class="hl-property">toArray</span>();

    <span class="hl-variable">$this</span>-&gt;<span class="hl-property">assertEquals</span>([
        [
            <span class="hl-value">'crawled_url'</span> =&gt; <span class="hl-value">'https:::///invalidLink'</span>,
            <span class="hl-value">'status_code'</span> =&gt; <span class="hl-keyword">null</span>,
            <span class="hl-value">'found_on_url'</span> =&gt; <span class="hl-value">'http://localhost:8181/'</span>,
        ],
        [
            <span class="hl-value">'crawled_url'</span> =&gt; <span class="hl-value">'http://localhost:8181/broken'</span>,
            <span class="hl-value">'status_code'</span> =&gt; 404,
            <span class="hl-value">'found_on_url'</span> =&gt; <span class="hl-value">'http://localhost:8181/link3'</span>,
        ],
        [
            <span class="hl-value">'crawled_url'</span> =&gt; <span class="hl-value">'https://ohdearthisdomaincertainlydoesntexist'</span>,
            <span class="hl-value">'status_code'</span> =&gt; <span class="hl-keyword">null</span>,
            <span class="hl-value">'found_on_url'</span> =&gt; <span class="hl-value">'http://localhost:8181/'</span>,
        ],
    ], <span class="hl-variable">$brokenLinks</span>);

    <span class="hl-type">Event</span>::<span class="hl-property">assertDispatched</span>(<span class="hl-type">BrokenLinksFound</span>::<span class="hl-keyword">class</span>);
    <span class="hl-type">Event</span>::<span class="hl-property">assertNotDispatched</span>(<span class="hl-type">BrokenLinksFixed</span>::<span class="hl-keyword">class</span>);
}
</pre>
<p>There's a lot going on in the code above.</p>
<p>In short, this test will instruct our <code>Server</code> (this is our lumen test server) to load up the <code>brokenLinks</code> routes files. This route file contains routes whose responses contain a couple of broken links.</p>
<p>A check being performed is called a <code>run</code> in Oh Dear. In the test above we are going to let our <code>Crawler</code> checker perform a run of type <code>CheckType::BROKEN_LINKS</code>. After the check is done, we can assert that the expected number of pages was crawled, and that broken links were found.</p>
<p>Let's take a look at the code of the <code>Server</code> class. It's a lot of code, but don't be scared. This code will:</p>
<ul>
<li>run <code>composer install</code> to install the server's dependencies if they are not install yet.</li>
<li>start up the Lumen server in the background, it can also wait until the boot process is complete</li>
<li>shuts down the server after the test has run</li>
<li>provide a method to dynamically change the route file used by the Lumen app</li>
</ul>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">use</span> <span class="hl-type">ErrorException</span>;
<span class="hl-keyword">use</span> <span class="hl-type">GuzzleHttp\Client</span>;

<span class="hl-keyword">class</span> <span class="hl-type">Server</span>
{
    <span class="hl-keyword">protected</span> <span class="hl-type">Client</span> <span class="hl-property">$client</span>;

    <span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">__construct</span>(<span class="hl-injection"><span class="hl-type">Client</span> $client = <span class="hl-keyword">null</span></span>)
    {
        <span class="hl-keyword">static</span>::<span class="hl-property">boot</span>();

        <span class="hl-variable">$this</span>-&gt;<span class="hl-property">client</span> = <span class="hl-variable">$client</span> ?? <span class="hl-keyword">new</span> <span class="hl-type">Client</span>();
    }

    <span class="hl-keyword">public</span> <span class="hl-keyword">static</span> <span class="hl-keyword">function</span> <span class="hl-property">boot</span>()
    {
        <span class="hl-keyword">if</span> (! <span class="hl-property">file_exists</span>(<span class="hl-property">__DIR__</span>.<span class="hl-value">'/vendor'</span>)) {
            <span class="hl-property">exec</span>(<span class="hl-value">'cd &quot;'</span>.<span class="hl-property">__DIR__</span>.<span class="hl-value">'&quot;; composer install'</span>);
        }

        <span class="hl-keyword">if</span> (<span class="hl-keyword">static</span>::<span class="hl-property">serverHasBooted</span>()) {
            <span class="hl-keyword">return</span>;
        }

        <span class="hl-variable">$startServerCommand</span> = <span class="hl-value">'php -S '</span>.<span class="hl-property">rtrim</span>(<span class="hl-keyword">static</span>::<span class="hl-property">getServerUrl</span>(), <span class="hl-value">'/'</span>).<span class="hl-value">' -t ./tests/Server/public &gt; /dev/null 2&gt;&amp;1 &amp; echo $!'</span>;

        <span class="hl-variable">$pid</span> = <span class="hl-property">exec</span>(<span class="hl-variable">$startServerCommand</span>);

        <span class="hl-keyword">while</span> (! <span class="hl-keyword">static</span>::<span class="hl-property">serverHasBooted</span>()) {
            <span class="hl-property">sleep</span>(1);
        }

        <span class="hl-property">register_shutdown_function</span>(<span class="hl-injection">function () </span><span class="hl-keyword">use</span> (<span class="hl-variable">$pid</span>) {
            @<span class="hl-property">exec</span>(<span class="hl-value">&quot;kill {$pid} 2&gt;/dev/null&quot;</span>);
        });
    }

    <span class="hl-keyword">public</span> <span class="hl-keyword">static</span> <span class="hl-keyword">function</span> <span class="hl-property">getServerUrl</span>(<span class="hl-injection"><span class="hl-type">string</span> $endPoint = ''</span>): <span class="hl-type">string</span>
    {
        <span class="hl-keyword">return</span> <span class="hl-value">'localhost:'</span>.<span class="hl-property">getenv</span>(<span class="hl-value">'TEST_SERVER_PORT'</span>).<span class="hl-value">'/'</span>.<span class="hl-variable">$endPoint</span>;
    }

    <span class="hl-keyword">public</span> <span class="hl-keyword">static</span> <span class="hl-keyword">function</span> <span class="hl-property">serverHasBooted</span>(): <span class="hl-type">bool</span>
    {
        <span class="hl-variable">$context</span> = <span class="hl-property">stream_context_create</span>([<span class="hl-value">'http'</span> =&gt; [
            <span class="hl-value">'timeout'</span> =&gt; 1,
        ]]);

        <span class="hl-keyword">try</span> {
            <span class="hl-variable">$result</span> = <span class="hl-property">file_get_contents</span>(<span class="hl-value">'http://'</span>.<span class="hl-type">self</span>::<span class="hl-property">getServerUrl</span>(<span class="hl-value">'booted'</span>), <span class="hl-keyword">false</span>, <span class="hl-variable">$context</span>) != <span class="hl-keyword">false</span>;
        } <span class="hl-keyword">catch</span> (<span class="hl-type">ErrorException</span>) {
            <span class="hl-variable">$result</span> = <span class="hl-keyword">false</span>;
        }

        <span class="hl-keyword">return</span> <span class="hl-variable">$result</span>;
    }

    <span class="hl-keyword">public</span> <span class="hl-keyword">static</span> <span class="hl-keyword">function</span> <span class="hl-property">activateRoutes</span>(<span class="hl-injection"><span class="hl-type">string</span> $routeConfiguration</span>)
    {
        <span class="hl-variable">$token</span> = <span class="hl-type">ParallelTesting</span>::<span class="hl-property">token</span>();

        <span class="hl-property">file_put_contents</span>(<span class="hl-property">__DIR__</span> .<span class="hl-value">&quot;/public/config-{$token}.json&quot;</span>, <span class="hl-property">json_encode</span>([<span class="hl-value">'routes'</span> =&gt; <span class="hl-variable">$routeConfiguration</span>]));
    }
}
</pre>
<p>In the  <code>activateRoutes</code> you see that we now use the <code>ParallelTesting::token()</code>. The method will use that token to write a config file per concurrent test that contains the route configuration to use. Before we supported parallel testing we only had one config file.</p>
<p>Here's how the entire <code>Server</code> directory looks like in our tests looks like. Those grayed out files are not under version control (as they are generated). You can see that we have config files per test token (I have 8 cores on my development machine).</p>
<p><img src="/uploads/blogs/parallel/server-dir.png" alt="directory" /></p>
<p>The <code>index.php</code> file contains the entire Lumen server setup. Let's take a look.</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">use</span> <span class="hl-type">Laravel\Lumen\Http\Request</span>;

<span class="hl-keyword">require_once</span> <span class="hl-property">__DIR__</span>.<span class="hl-value">'/../vendor/autoload.php'</span>;

<span class="hl-property">putenv</span>(<span class="hl-value">'APP_DEBUG=true'</span>);

<span class="hl-variable">$app</span> = <span class="hl-keyword">new</span> <span class="hl-type">Laravel\Lumen\Application</span>(
    <span class="hl-property">realpath</span>(<span class="hl-property">__DIR__</span>.<span class="hl-value">'/../'</span>)
);

<span class="hl-variable">$app</span>-&gt;<span class="hl-property">configure</span>(<span class="hl-value">'app'</span>);

<span class="hl-variable">$app</span>-&gt;<span class="hl-property">router</span>-&gt;<span class="hl-property">group</span>([
    <span class="hl-value">'namespace'</span> =&gt; <span class="hl-value">'App\Http\Controllers'</span>,
], <span class="hl-keyword">function</span> (<span class="hl-injection">$router) </span><span class="hl-keyword">use</span> (<span class="hl-variable">$app</span>) {
    <span class="hl-variable">$router</span>-&gt;<span class="hl-property">get</span>(<span class="hl-value">'booted'</span>, <span class="hl-keyword">fn</span> () =&gt; <span class="hl-value">'app has booted'</span>);

    <span class="hl-keyword">if</span> (<span class="hl-variable">$token</span> = <span class="hl-type">Request</span>::<span class="hl-property">createFromGlobals</span>()-&gt;<span class="hl-property">header</span>(<span class="hl-value">'testtoken'</span>)) {
        <span class="hl-variable">$routesFile</span> = <span class="hl-property">routesFileForToken</span>(<span class="hl-variable">$token</span>);

        <span class="hl-keyword">require</span> <span class="hl-variable">$routesFile</span>;
    }
});

<span class="hl-variable">$app</span>-&gt;<span class="hl-property">run</span>();

<span class="hl-keyword">function</span> <span class="hl-property">routesFileForToken</span>(<span class="hl-injection"><span class="hl-type">string</span> $token</span>): <span class="hl-type">string</span>
{
    <span class="hl-variable">$configFile</span> = <span class="hl-property">__DIR__</span> . <span class="hl-value">&quot;/config-{$token}.json&quot;</span>;

    <span class="hl-variable">$config</span> = <span class="hl-property">json_decode</span>(<span class="hl-property">file_get_contents</span>(<span class="hl-variable">$configFile</span>), <span class="hl-keyword">true</span>);

    <span class="hl-variable">$routesFile</span> = <span class="hl-property">__DIR__</span> . <span class="hl-value">&quot;/routeFiles/{$config['routes']}.php&quot;</span>;

    <span class="hl-keyword">return</span> <span class="hl-variable">$routesFile</span>;
}
</pre>
<p>In the code above you see that we use the value in the <code>testtoken</code> header to determine which of the config files should be read.</p>
<p>In our <code>CrawlerChecker</code> class we added this piece of code in the function that determines the headers to be used.</p>
<pre data-lang="txt" class="notranslate">if (app()-&gt;environment('testing')) {
    $defaultHeaders['testtoken'] = ParallelTesting::token();
}
</pre>
<p>Now you've seen everything that makes this work.</p>
<p>Let's summarise: in our <code>it_can_perform_a_run_and_detect_broken_links</code> test we use <code>Server::activateRoutes('brokenLinks');</code> which configures the internal Lumen server responses with routes/responses that are in the <code>brokenLinks</code> routes files. This configuration is written to a file that contains the test token in its name. The <code>CrawlerChecker</code> adds the token to the headers of each request so the Lumen server knows which configuration to use.</p>
<h2 id="why-is-the-difference-so-big">Why is the difference so big <a href="#why-is-the-difference-so-big" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#why-is-the-difference-so-big">#</a></h2>
<p>Besides tests that use our Lumen server, the Oh Dear test suite contains a lot of tests that reach out to real world sites to test things. The tests that make sure that our certificate check is working correctly, will reach out to <a href="https://badssl.com">badsll.com</a>, a site that provides examples of broken certificates. Reaching out to this site takes time. Our test suite is mostly constrained by the network, and not by CPU / memory.</p>
<p>Without all tests running sequentially a lot of time was lost just waiting for network requests to finish. With parallel testing other tests are being performed instead of just waiting around.</p>
<h2 id="in-closing">In closing <a href="#in-closing" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#in-closing">#</a></h2>
<p>We think parallel testing is a very nice addition to Laravel. This feature was already available for quite some time <a href="https://bigbinary.com/blog/rails-6-adds-parallel-testing">in Rails</a>. I'm sure that this inspired the Laravel team to add the feature.</p>
<p>Even though people could already use ParaTest directly, having Laravel take care of databases so nicely in parallel test will surely help drive adoption. After the feature was released in Laravel, a lot of happy people tweeted out screenshots of dramatically improved testing times, and we were one of them.</p>
<blockquote class="twitter-tweet"><p lang="en" dir="ltr">I spent some time cleaning up the test suite of <a href="https://twitter.com/OhDearApp?ref_src=twsrc%5Etfw">@OhDearApp</a> <br><br>Here are the results when using sequential and parallel testing.<br><br>Sequential: 04m28s - 320 MB<br>Parallel: 01m05s - 44.5 MB<a href="https://t.co/stLO4y3AdI">https://t.co/stLO4y3AdI</a><a href="https://twitter.com/hashtag/php?src=hash&amp;ref_src=twsrc%5Etfw">#php</a> <a href="https://twitter.com/hashtag/laravel?src=hash&amp;ref_src=twsrc%5Etfw">#laravel</a> <a href="https://t.co/HlYFPkIrb9">pic.twitter.com/HlYFPkIrb9</a></p>&mdash; Freek Van der Herten 📯 (@freekmurze) <a href="https://twitter.com/freekmurze/status/1354217443418374152?ref_src=twsrc%5Etfw">January 26, 2021</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
<p>We sweat test-suite speed because Oh Dear is built <a href="/for/php-developers">for Laravel and PHP developers</a>.</p>
<p>To know more about Laravel testing check out <a href="https://laravel.com/docs/master/testing#running-tests-in-parallel">the Laravel docs</a>.</p>
]]>
            </summary>
                                    <updated>2021-02-01T12:32:05+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How we added a favicons to our site list]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/how-we-added-a-favicons-to-our-site-list" />
            <id>https://ohdear.app/49</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>Today we've added favicons to the site list you see when you log in to Oh Dear.</p>
<p><img src="/uploads/blogs/favicons-dashboard/ohdear-dashboard-favicons.png" alt="Site list with favicons" /></p>
<p>Using the favicon, you quickly recognize a particular site. It also just looks nice visually.</p>
<p>In this blog post, we'd like to share how we achieved this.</p>
<h2 id="watch-it-being-code-up">Watch it being code up <a href="#watch-it-being-code-up" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#watch-it-being-code-up">#</a></h2>
<p>In this stream, I live coded the favicon import. You can see our actual codebase, my thinking process, and the little mistakes I made along the way.</p>
<p>If you prefer reading how we go about import favicons, you can continue reading underneath the video.</p>
<iframe width="560" height="315" src="https://www.youtube.com/embed/-sMu9fYz1zA" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
<h2 id="using-a-third-party-api">Using a third-party API <a href="#using-a-third-party-api" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#using-a-third-party-api">#</a></h2>
<p>Oh Dear is a large Laravel application. To handle any files, we rely on <a href="https://spatie.be">Spatie</a>'s <a href="https://spatie.be/docs/laravel-medialibrary">Media Library package</a>.</p>
<p>Using this package, you can associate all sorts of files with Eloquent models. In this case, the file is a favicon, and the model is the <code>Site</code> model used in our codebase.</p>
<h2 id="grabbing-the-favicon">Grabbing the favicon <a href="#grabbing-the-favicon" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#grabbing-the-favicon">#</a></h2>
<p>But let's first take a look at how we grab the favicon. <a href="https://twitter.com/JamesIvings/status/1334050619481595904">This tweet</a>, mentioning third party APIs to grab a favicon, inspired us to add favicons to the list.</p>
<blockquote class="twitter-tweet"><p lang="en" dir="ltr">Google has a secret API that you can use to show *any* website&#39;s icon on your site 🐳<a href="https://t.co/pcHygeE6wd">https://t.co/pcHygeE6wd</a> <a href="https://t.co/Hv9vvSVZ4V">pic.twitter.com/Hv9vvSVZ4V</a></p>&mdash; James Ivings (@JamesIvings) <a href="https://twitter.com/JamesIvings/status/1334050619481595904?ref_src=twsrc%5Etfw">December 2, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
<p>We decided to go for Duck Duck Go's favicon service because that one is the only one that gives a 404 when grabbing a favicon for a nonexisting site. That 404 is important because we don't want to import a generic favicon.</p>
<p>Using Duck Duck Go's favicon service is easy. All you need to do is mention the domain inside the URL of the API. Here's an example where we <a href="https://icons.duckduckgo.com/ip3/ohdear.app.ico">grab the favicon for Oh Dear</a>.</p>
<pre data-lang="txt" class="notranslate">https://icons.duckduckgo.com/ip3/ohdear.app.ico
</pre>
<h2 id="configuring-spatielaravel-medialibary">Configuring spatie/laravel-medialibary <a href="#configuring-spatielaravel-medialibary" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#configuring-spatielaravel-medialibary">#</a></h2>
<p>We don't want to link to that URL in our views directly. If we would do that, then Duck Duck Go would receive traffic each time a site list is displayed, making us a bad internet citizen. We want to import that favicon when a site is being added to the account, and maybe once a week or month from then on.</p>
<p>Luckily, grabbing, storing, and displaying the favicon is very easy using <a href="https://spatie.be/docs/laravel-medialibrary">laravel-medialibrary</a>. We're not going over how you should install the package in your Laravel project; you can find instructions for that <a href="https://spatie.be/docs/laravel-medialibrary/v9/handling-uploads-with-media-library-pro/installation">in the media library docs</a>.</p>
<p>With the package installed in a project, you can prepare an Eloquent model to handle media. This can be done by adding an interface a trait to the model, in our case, the <code>Site</code> model.</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">namespace</span> <span class="hl-type">App\Domain\Site\Models</span>;

<span class="hl-keyword">use</span> <span class="hl-type">Illuminate\Database\Eloquent\Model</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Spatie\MediaLibrary\HasMedia</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Spatie\MediaLibrary\InteractsWithMedia</span>;

<span class="hl-keyword">class</span> <span class="hl-type">Site</span> <span class="hl-keyword">extends</span> <span class="hl-type">Model</span> <span class="hl-keyword">implements</span><span class="hl-type"> HasMedia
</span>{
   <span class="hl-keyword">use</span> <span class="hl-type">InteractsWithMedia</span>;
}
</pre>
<p>The media library allows a model to hold multiple collections of media. If you have a <code>BlogPost</code> model, you might have a collection <code>images</code> with all the images that need to be displayed, and a collect <code>downloads</code> that holds all the files that can be downloaded for a particular <code>BlogPost</code>.</p>
<p>In our case, we need a <code>favicon</code> collection. Though it is not strictly required, you can define a collection on the model.</p>
<pre data-lang="php" class="notranslate"><span class="hl-comment">// in the site model</span>

<span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">registerMediaCollections</span>(): <span class="hl-type">void</span>
{
   <span class="hl-variable">$this</span>
       -&gt;<span class="hl-property">addMediaCollection</span>(<span class="hl-value">'favicon'</span>)
       -&gt;<span class="hl-property">useDisk</span>(<span class="hl-value">'favicons'</span>)
       -&gt;<span class="hl-property">singleFile</span>();
	
}
</pre>
<p><a href="https://spatie.be/docs/laravel-medialibrary/v9/working-with-media-collections/defining-media-collections">Defining a collection</a> allows you to define some behavior on it. Using <code>useDisk</code> we specify that any file added to the collection should be stored with the disk with a given name. In case you're not familiar with disks, it's Laravel's way of <a href="https://laravel.com/docs/master/filesystem#introduction">abstracting the filesytem</a>. In our production environment, this disk points to an S3 bucket.</p>
<p>The <code>singleFile</code> collection call will <a href="https://spatie.be/docs/laravel-medialibrary/v9/working-with-media-collections/defining-media-collections#single-file-collections">guarantee</a> that there is, at any given time, only a maximum of one file present in the collection. When a second file is added to the collection, the first one will be deleted. This will be handy for updating the favicon. We can just add the latest one to the collection, and the older one will get deleted automatically.</p>
<p>A nice thing to know is that when a model gets deleted, the media library will automatically delete any associated files.</p>
<h2 id="importing-a-favicon-for-a-site">Importing a favicon for a site <a href="#importing-a-favicon-for-a-site" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#importing-a-favicon-for-a-site">#</a></h2>
<p>Fetching a favicon from an external service might take a second, so it's a perfect fit for a queued job. Here's the <code>AddFaviconToSiteJob</code> in our code base.</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">namespace</span> <span class="hl-type">App\Domain\Site\Jobs</span>;

<span class="hl-keyword">use</span> <span class="hl-type">App\Domain\Site\Models\Site</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Exception</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Illuminate\Bus\Queueable</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Illuminate\Contracts\Queue\ShouldQueue</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Illuminate\Foundation\Bus\Dispatchable</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Illuminate\Queue\InteractsWithQueue</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Illuminate\Queue\SerializesModels</span>;

<span class="hl-keyword">class</span> <span class="hl-type">AddFaviconToSiteJob</span> <span class="hl-keyword">implements</span><span class="hl-type"> ShouldQueue
</span>{
    <span class="hl-keyword">use</span> <span class="hl-type">Dispatchable</span>;
    <span class="hl-keyword">use</span> <span class="hl-type">InteractsWithQueue</span>;
    <span class="hl-keyword">use</span> <span class="hl-type">Queueable</span>;
    <span class="hl-keyword">use</span> <span class="hl-type">SerializesModels</span>;

    <span class="hl-keyword">public</span> <span class="hl-property">$deleteWhenMissingModels</span> = <span class="hl-keyword">true</span>;

    <span class="hl-keyword">public</span> <span class="hl-type">Site</span> <span class="hl-property">$site</span>;

    <span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">__construct</span>(<span class="hl-injection"><span class="hl-type">Site</span> $site</span>)
    {
        <span class="hl-variable">$this</span>-&gt;<span class="hl-property">site</span> = <span class="hl-variable">$site</span>;

        <span class="hl-variable">$this</span>-&gt;<span class="hl-property">queue</span> = <span class="hl-value">'import-favicons'</span>;
    }

    <span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">handle</span>()
    {
        <span class="hl-variable">$url</span> = <span class="hl-value">&quot;https://icons.duckduckgo.com/ip3/{$this-&gt;site-&gt;getDomainWithoutProtocol()}.ico&quot;</span>;

        <span class="hl-keyword">try</span> {
            <span class="hl-variable">$this</span>
                -&gt;<span class="hl-property">site</span>
                -&gt;<span class="hl-property">addMediaFromUrl</span>(<span class="hl-variable">$url</span>)
                -&gt;<span class="hl-property">toMediaCollection</span>(<span class="hl-value">'favicon'</span>);
        } <span class="hl-keyword">catch</span> (<span class="hl-type">Exception</span> <span class="hl-variable">$exception</span>) {
            <span class="hl-property">report</span>(<span class="hl-variable">$exception</span>);
        }
    }
}
</pre>
<p>In the snippet above, you can see that the media library offers a convenient <code>addMediaFromUrl</code> method that will download a file from a URL.</p>
<p>We wrap the logic in try/catch block to prevent that job from failing if Duck Duck Go is down for any reason. We don't report the error to our exception tracking service <a href="https://flareapp.io">Flare</a>, so we can still get a notification that something happened.</p>
<p>In our codebase, we like to put isolated pieces of logic into action classes. An action class is nothing more than a regular class, where, by convention, there is an <code>execute</code> method to execute it. If you want to know more about action classes, check out <a href="https://freek.dev/1371-refactoring-to-actions">this blog post</a>.</p>
<p>We have an action called <code>CreateSiteAction</code> to create a new site. It gets called from the controller that handles site creations for our web app and from our API endpoint to create a site. We can dispatch the job inside of that action.</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">namespace</span> <span class="hl-type">App\Domain\Site\Actions</span>;

<span class="hl-keyword">use</span> <span class="hl-type">App\Domain\Site\Jobs\AddFaviconToSiteJob</span>;
<span class="hl-keyword">use</span> <span class="hl-type">App\Domain\Site\Models\Site</span>;

<span class="hl-keyword">class</span> <span class="hl-type">CreateSiteAction</span>
{
    <span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">execute</span>(<span class="hl-injection"><span class="hl-type">array</span> $attributes</span>): <span class="hl-type">Site</span>
    {
        <span class="hl-comment">// ... other steps to create a site</span>

        <span class="hl-property">dispatch</span>(<span class="hl-keyword">new</span> <span class="hl-type">AddFaviconToSiteJob</span>(<span class="hl-variable">$site</span>));

        <span class="hl-keyword">return</span> <span class="hl-variable">$site</span>;
    }
}
</pre>
<h2 id="displaying-the-favicon">Displaying the favicon <a href="#displaying-the-favicon" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#displaying-the-favicon">#</a></h2>
<p>Using laravel-medialibrary, it's easy to display the favicon. The <code>getFirstMediaUrl($collectionName)</code> will return an URL to the first media item in the collection.</p>
<p>In our Blade view that renders the site list, we can just use it:</p>
<pre data-lang="txt" class="notranslate">    &lt;img class=&quot;inline mr-1 w-4 h-4&quot; src=&quot;{{ $site-&gt;getFirstMediaUrl($collectionName) }}&quot; alt=&quot;favicon&quot; /&gt;
@endif
</pre>
<p>And with that, we now display favicons for any sites that are added to Oh Dear.</p>
<h2 id="importing-favicons-for-existing-sites">Importing favicons for existing sites <a href="#importing-favicons-for-existing-sites" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#importing-favicons-for-existing-sites">#</a></h2>
<p>Of course, we want to import favicons for the thousands of sites already monitored by Oh Dear. This is done by the following Artisan command, which is <a href="https://laravel.com/docs/master/scheduling#defining-schedules">scheduled</a> to run every month.</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">namespace</span> <span class="hl-type">App\Domain\Site\Commands</span>;

<span class="hl-keyword">use</span> <span class="hl-type">App\Domain\Site\Jobs\AddFaviconToSiteJob</span>;
<span class="hl-keyword">use</span> <span class="hl-type">App\Domain\Site\Models\Site</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Illuminate\Console\Command</span>;

<span class="hl-keyword">class</span> <span class="hl-type">AddFaviconToSitesCommand</span> <span class="hl-keyword">extends</span> <span class="hl-type">Command</span>
{
    <span class="hl-keyword">protected</span> <span class="hl-property">$signature</span> = <span class="hl-value">'ohdear:sites:add-favicon-to-sites'</span>;

    <span class="hl-keyword">protected</span> <span class="hl-property">$description</span> = <span class="hl-value">'Fetch the favicon for all sites'</span>;

    <span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">handle</span>()
    {
        <span class="hl-variable">$this</span>-&gt;<span class="hl-property">info</span>(<span class="hl-value">'Fetching favicons...'</span>);

        <span class="hl-type">Site</span>::<span class="hl-property">each</span>(<span class="hl-keyword">function</span> (<span class="hl-injection"><span class="hl-type">Site</span> $site</span>) {
            <span class="hl-keyword">if</span> (! <span class="hl-variable">$site</span>-&gt;<span class="hl-property">team</span>-&gt;<span class="hl-property">hasActiveSubscriptionOrIsOnGenericTrial</span>()) {
                <span class="hl-keyword">return</span>;
            }

            <span class="hl-variable">$this</span>-&gt;<span class="hl-property">comment</span>(<span class="hl-value">&quot;Fetching favicon for `{$site-&gt;label}` ({$site-&gt;id})&quot;</span>);

            <span class="hl-property">dispatch</span>(<span class="hl-keyword">new</span> <span class="hl-type">AddFaviconToSiteJob</span>(<span class="hl-variable">$site</span>));
        });

        <span class="hl-variable">$this</span>-&gt;<span class="hl-property">info</span>(<span class="hl-value">'All done'</span>);
    }
}
</pre>
<h2 id="in-closing">In closing <a href="#in-closing" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#in-closing">#</a></h2>
<p><a href="https://duckduckgo.com">Duck Duck Go</a> and <a href="https://spatie.be/docs/laravel-medialibrary">laravel-medialibrary</a> made it painless to import and display favicons.</p>
<p>If you want to see this all in action, <a href="https://ohdear.app/register">start your free ten-day trial</a> now.</p>
]]>
            </summary>
                                    <updated>2020-12-03T14:06:40+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Our Black Friday special: free until we prove our worth]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/our-black-friday-special-oh-dear-is-free-until-weve-proven-our-worth" />
            <id>https://ohdear.app/46</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>As an uptime monitoring service, we usually see 2 kinds of customers. For the first type, we're an easy sell, but the other needs a lot of convincing.</p>
<ol>
<li>✅ &quot;My site went down, I need monitoring!&quot;</li>
<li>❌ &quot;Why do I need monitoring if my site never goes down?&quot;</li>
</ol>
<p>So our Black Friday promotion is dedicated to all those that aren't yet convinced you might need monitoring.</p>
<p><em>(Spoiler: you probably need monitoring. 😉 )</em></p>
<h2 id="when-do-you-need-monitoring">When do you need monitoring? <a href="#when-do-you-need-monitoring" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#when-do-you-need-monitoring">#</a></h2>
<p>We can argue that you <em>always</em> need monitoring, but really - if you've never been bitten by downtime before, would we be able to convince you?</p>
<p>To put it in the paraphrased words of <a href="https://www.youtube.com/watch?v=uznTHSEgx4U">Janet Jackson's &quot;Got Till Its Gone&quot;</a>:</p>
<blockquote>
<p>Don't it always seem to go</p>
<p>That you don't know you need monitoring</p>
<p>until it's too late</p>
</blockquote>
<p>We hear you Janet, we hear you.</p>
<h2 id="oh-dear-is-free-this-week-until-weve-proven-our-worth">Oh Dear is free this week, until we've proven our worth <a href="#oh-dear-is-free-this-week-until-weve-proven-our-worth" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#oh-dear-is-free-this-week-until-weve-proven-our-worth">#</a></h2>
<p>For any new user that signs up this week, Oh Dear is and will continue to be fully free of charge.</p>
<p>That is ... until we've proven our value to you. If we never have to send you a single alert for downtime or expiring certificates, you don't have to pay us. Ever.</p>
<p>Think of it like an insurance policy, except one with zero downsides or monthly payments.</p>
<p>Because we have a set of notifications that we consider critical and some we consider more like <em>FYI's</em>, we'll classify <em>proving our worth</em> as sending these kinds of notifications:</p>
<ul>
<li>Your site is down</li>
<li>Your certificate is about to expire</li>
<li>Your certificate has expired</li>
</ul>
<p>If you sign up today, you'll never have to pay for Oh Dear until we send you one of those notifications.</p>
<p>From that point on, we've proven our worth to you and you're free to either let the account expire or subscribe to one of our plans. If we didn't convince you then, we probably never will. 😉</p>
<p>We won't ask for any payment information upfront to sneakily charge you, either. Our signup is just as simple as before, requiring just an email address and a password of your chosing.</p>
<h2 id="offer-valid-between-now-amp-black-friday">Offer valid between now &amp; Black Friday <a href="#offer-valid-between-now-amp-black-friday" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#offer-valid-between-now-amp-black-friday">#</a></h2>
<p>This offer is valid for anyone that signs up <em>as of now</em> until Black Friday, November 27th, 2020.</p>
<p>If you're confident you don't need monitoring, you have nothing to lose with this deal. 🔥</p>
]]>
            </summary>
                                    <updated>2025-06-24T21:00:02+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Scheduled task monitoring now available to all our users]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/scheduled-task-monitoring-now-available-to-all-our-users" />
            <id>https://ohdear.app/45</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>After an intense testing period, we're excited to announce our scheduled task monitoring is now available to all our users!</p>
<p><img src="/img/features/cron_example_listing.png" alt="Scheduled task monitoring in Oh Dear" /></p>
<h2 id="why-monitor-scheduled-tasks">Why monitor scheduled tasks? <a href="#why-monitor-scheduled-tasks" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#why-monitor-scheduled-tasks">#</a></h2>
<p>Automation is at the heart of just about every company these days. We trust our automation to do the work for us. We trust that it will continue to run fine, from the day we wrote the code.</p>
<p>But <em>trust</em> isn't a strategy. It's <em>hope</em>. It's a <em>wish</em>.</p>
<p>With our newly launched scheduled task monitor, we can turn that <em>hope</em> into a <em>certainty</em>. 😎</p>
<p>You'll be able to monitor the frequency of every scheduled task you have and get actionable reports when they either A) don't run on time or B) don't report any status, indicating the task more than likely failed.</p>
<h2 id="free-for-all-oh-dear-users">Free for all Oh Dear users <a href="#free-for-all-oh-dear-users" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#free-for-all-oh-dear-users">#</a></h2>
<p>This feature is now live and available to all existing Oh Dear users, at no additional cost.</p>
<p>You'll find a new column on your dashboard called <em>Tasks</em>, with additional information available once you click through. Per site, you can add scheduled tasks and start monitoring straight away!</p>
<h2 id="how-does-scheduled-task-monitoring-work">How does scheduled task monitoring work? <a href="#how-does-scheduled-task-monitoring-work" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#how-does-scheduled-task-monitoring-work">#</a></h2>
<p>We use an industry-standard approach in the form of &quot;ping URLs&quot;.</p>
<p>The idea is quite simple: after every task gets executed, your application code can make a network request to a pre-defined URL we provide you. We monitor the requests on that URL.</p>
<p>If we don't receive a request (on time), we assume the scheduled task has failed, and we can notify you.</p>
<p>Making a network request doesn't have to be complex, for many languages it's a one-line addition to your scripts.</p>
<p>PHP:</p>
<pre data-lang="php" class="notranslate">@<span class="hl-property">file_get_contents</span>(<span class="hl-value">'https://ping.ohdear.app/e536e771-9ff6'</span>);
</pre>
<p>Python:</p>
<pre data-lang="python" class="notranslate">requests.get(<span class="hl-value">&quot;https://ping.ohdear.app/e536e771-9ff6&quot;</span>)
</pre>
<p>Ruby:</p>
<pre data-lang="txt" class="notranslate">Net::HTTP.get(URI.parse('https://ping.ohdear.app/e536e771-9ff6'))
</pre>
<p>Node:</p>
<pre data-lang="txt" class="notranslate">https.get(&quot;https://ping.ohdear.app/e536e771-9ff6&quot;);
</pre>
<p>We've got <a href="/docs/features/cron-job-monitoring">a lot of examples</a> available for just about any popular language.</p>
<h2 id="deep-integration-into-php">Deep integration into PHP <a href="#deep-integration-into-php" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#deep-integration-into-php">#</a></h2>
<p>Because we have deeply nested roots in the PHP community, there are more convenient integrations available for Laravel and Symfony users.</p>
<p>For <a href="https://ohdear.app/docs/features/cron-job-monitoring#php">Laravel</a>, you can integrate using <a href="https://github.com/spatie/laravel-schedule-monitor">an additional package that auto-syncs your tasks to Oh Dear</a>.</p>
<pre data-lang="php" class="notranslate">$ composer <span class="hl-keyword">require</span> spatie/laravel-schedule-monitor
$ composer <span class="hl-keyword">require</span> ohdearapp/ohdear-php-sdk 
$ php artisan vendor:publish --provider=<span class="hl-value">&quot;Spatie\ScheduleMonitor\ScheduleMonitorServiceProvider&quot;</span> --tag=<span class="hl-value">&quot;migrations&quot;</span>
$ php artisan vendor:publish --provider=<span class="hl-value">&quot;Spatie\ScheduleMonitor\ScheduleMonitorServiceProvider&quot;</span> --tag=<span class="hl-value">&quot;config&quot;</span>
$ php artisan migrate
</pre>
<p>As a final step, add your <a href="/docs/api/introduction">Oh Dear API key</a> to <code>config/schedule-monitor.php</code> and this package will automatically sync all schedules to Oh Dear and we'll take it from there.</p>
<p><img src="/uploads/blogs/scheduled-task-monitoring/sync-oh-dear.png" alt="Cron sync from Oh Dear" /></p>
<p>Freek made a more in-depth video of how this works under the hood, if you're interested:</p>
<iframe src="https://player.vimeo.com/video/460607656" width="640" height="480" frameborder="0" allow="autoplay; fullscreen" allowfullscreen></iframe>
<p>If you prefer not to use an extra package, you could add the <code>thenPing()</code> method to each of the task definitions in your <code>Console/Kernel.php</code> instead:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">class</span> <span class="hl-type">Kernel</span> <span class="hl-keyword">extends</span> <span class="hl-type">ConsoleKernel</span>
{
    <span class="hl-keyword">protected</span> <span class="hl-keyword">function</span> <span class="hl-property">schedule</span>(<span class="hl-injection"><span class="hl-type">Schedule</span> $schedule</span>)
    {
        <span class="hl-variable">$schedule</span>-&gt;<span class="hl-property">command</span>(<span class="hl-value">'emails:send'</span>)
            -&gt;<span class="hl-property">daily</span>()
            -&gt;<span class="hl-property">thenPing</span>(<span class="hl-value">'https://ping.ohdear.app/e536e771-9ff6'</span>);
    }
}
</pre>
<p>For Symfony users, there's <a href="https://ohdear.app/docs/features/cron-job-monitoring#php">a PingTask package</a> that allows you to define the ping URL per task you define:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">use</span> <span class="hl-type">Zenstruck\ScheduleBundle\Schedule\Task\PingTask</span>;

<span class="hl-variable">$schedule</span>-&gt;<span class="hl-property">add</span>(<span class="hl-keyword">new</span> <span class="hl-type">PingTask</span>(<span class="hl-value">'https://ping.ohdear.app/e536e771-9ff6'</span>));
</pre>
<p>Whatever your use case may be, you'll find a convenient integration for it.</p>
<h2 id="notifications-where-you-need-them">Notifications where you need them <a href="#notifications-where-you-need-them" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#notifications-where-you-need-them">#</a></h2>
<p>As with all our checks, this monitoring is coupled with our powerful notification system!</p>
<p>You can choose where you <a href="/features/notifications">receive your notifications</a> and what <em>type</em> of notifications you want. Only care about the failures? Just enable that.</p>
<p><img src="/img/features/cron_slack_notification.png" alt="Example Slack notification for a failed scheduled task" /></p>
<p>We've been using this ourselves for several months now and have included every improvement we wanted <em>ourselves</em> into this feature. Our goal is to put all the necessary information right at your fingertips.</p>
<h2 id="cron-jobs-amp-scheduled-task-monitoring">Cron jobs &amp; scheduled task monitoring <a href="#cron-jobs-amp-scheduled-task-monitoring" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#cron-jobs-amp-scheduled-task-monitoring">#</a></h2>
<p>We call the feature <em>&quot;scheduled task monitoring&quot;</em>, but it's more than just that. <em>Anything</em> can be a scheduled task.</p>
<p>From a cron job that calls a back-up script to a scheduled task on Windows, everything that <em>should</em> run on a fixed schedule can be monitored through this new feature.</p>
<p>For instance, you can monitor the successes of your back-up script that is triggered via cron:</p>
<pre data-lang="bash" class="notranslate">24 4 * * * /root/tarsnap-backup.sh &amp;&amp; <span class="hl-keyword">curl</span> <span class="hl-attribute">-fsS</span> <span class="hl-attribute">--retry</span> 3 <span class="hl-attribute">-o</span> /dev/null https://ping.ohdear.app/e536e771-9ff6
</pre>
<p>Because of the use of the <code>&amp;&amp;</code> operator, the <code>curl</code> example will <em>only</em> be executed if the first script exited with an exit code of <code>0</code>, to indicate it succeeded. If it reports anything else, the <code>curl</code> won't be called and we will know we missed a report for that task.</p>
<h2 id="monitoring-queues-and-jobs-health">Monitoring queues and jobs health <a href="#monitoring-queues-and-jobs-health" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#monitoring-queues-and-jobs-health">#</a></h2>
<p>Since the idea behind <em>scheduled task monitoring</em> is to check a URL for recurring hits, this can also be used to monitor the health of running jobs or queues.</p>
<p>Imagine the following flow:</p>
<ol>
<li>Add a new scheduled task in your code to put a new job on the queue every 5 minutes</li>
<li>This job has one purpose: to ping our endpoint</li>
</ol>
<p>This way, you've tested your application one step further: this ensures both the <em>schedule</em> runs and the <em>queues</em> are alive to pick up &amp; process jobs.</p>
<p>If we don't receive the hit to our endpoint on time, chances are something's wrong and we can alert you.</p>
<h2 id="want-to-give-it-a-try">Want to give it a try? <a href="#want-to-give-it-a-try" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#want-to-give-it-a-try">#</a></h2>
<p>Some things at Oh Dear don't change: we still have a free, 10-day trial, with no strings attached. Just an email and a password are needed, no credit card details.</p>
<p><a href="https://ohdear.app/register">Start a trial today and see how easy it is to integrate scheduled task monitoring!</a></p>
<p>If you decide to sign up, we're giving a 30% discount for the first 3 months for any new user. Use coupon code <code>MONITOR-ALL-THE-THINGS</code> during subscription to lock in your discount. This code is valid until October 1st, 2020!</p>
<p>We're immensely proud of this feature (<a href="https://freek.dev/1767-why-and-how-you-should-monitor-scheduled-tasks">so much goes on behind the scenes</a>) and can't wait to hear your feedback.</p>
<p>If we could ask a favour: share this post as far and wide as you can! 😁</p>
]]>
            </summary>
                                    <updated>2020-09-28T14:22:49+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to monitor websites behind HTTP basic authentication]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/how-to-monitor-websites-behind-http-basic-authentication" />
            <id>https://ohdear.app/44</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>Oh Dear allows you to monitor any kind of website, even those behind HTTP basic authentication. In this guide, we'll cover the steps on how to monitor a website behind an HTTP basic authentication prompt.</p>
<h2 id="what-is-http-basic-authentication">What is HTTP basic authentication? <a href="#what-is-http-basic-authentication" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#what-is-http-basic-authentication">#</a></h2>
<p>A quick recap: HTTP basic authentication, also known as HTTP basic auth, is a very simple way of authentication yourself to a website.</p>
<p>Any time you see a &quot;classic&quot; popup like this, it's most likely fueled by HTTP basic authentication:</p>
<p><img src="/uploads/blogs/http-basic-authentication-monitoring/http-basic-auth-prompt.png" alt="HTTP Basic Auth prompt" /></p>
<p>Once you've entered your username and password, you won't have to re-enter them in the same session. Your browser will remember them.</p>
<h2 id="monitoring-sites-with-http-basic-authentication">Monitoring sites with HTTP basic authentication <a href="#monitoring-sites-with-http-basic-authentication" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#monitoring-sites-with-http-basic-authentication">#</a></h2>
<p>The &quot;trick&quot; to monitoring sites behind a basic HTTP auth, is to look for the <code>Authorization</code> header and add that to the Oh Dear settings under <em>HTTP Headers</em>.</p>
<p>You see, we allow you <a href="/docs/features/configure-your-oh-dear-settings-per-monitor#custom-http-headers">to set custom HTTP headers</a> for things like Cookies, language headers and also Authorization headers.</p>
<p>First, we need to find the correct <em>Authorization</em> header. This header is a <em>hash</em> of your username &amp; password combination, so it doesn't change. That's good, because now we can re-use this hash in Oh Dear!</p>
<p>First, log in to the site you want to monitor with the correct credentials. Next, right click somewhere on the page and pick <em>Inspect</em>.</p>
<p><img src="/uploads/blogs/http-basic-authentication-monitoring/http-basic-auth-right-click-inspect.png" alt="Right Click: inspect" /></p>
<p>Now follow these steps:</p>
<p><img src="/uploads/blogs/http-basic-authentication-monitoring/http-basic-auth-see-header.png" alt="HTTP Basic Auth header" /></p>
<ol>
<li>Go to the <em>Network</em> tab and reload your current page</li>
<li>Click on the first resource that loaded, which is most likely your homepage</li>
<li>Scroll down on the right hand site until you see the <em>Request Headers</em></li>
<li>Look for the <em>authorization</em> header</li>
</ol>
<p>The header is called <em>authorization</em> and the value is <em>Basic bWF0...</em>.</p>
<h2 id="add-this-header-to-oh-dears-site-settings">Add this header to Oh Dear's site settings <a href="#add-this-header-to-oh-dears-site-settings" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#add-this-header-to-oh-dears-site-settings">#</a></h2>
<p>Now that we have the <em>Authorization</em> header, you can add it to your Oh Dear settings for the site.</p>
<p>Head over to the site, hit <em>Settings</em> in the left menu and find the section for <em>Headers</em>.</p>
<p>Add the <em>Authorization</em> header with the value of <em>Basic bWF0...</em> you found earlier, in the inspector.</p>
<p><img src="/uploads/blogs/http-basic-authentication-monitoring/ohdear-add-custom-http-header.png" alt="Add custom header to Oh Dear" /></p>
<p>Oh Dear will now add this header to every uptime check and broken links check we perform, automatically logging in via the HTTP Basic Authentication.</p>
]]>
            </summary>
                                    <updated>2020-09-15T08:02:39+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Deep integration with WordPress and Oh Dear]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/deep-integration-with-wordpress-and-oh-dear" />
            <id>https://ohdear.app/42</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>There is now a WordPress plugin that gives you deeper insights into broken links found, the performance of your site and the uptime statistics!</p>
<h2 id="a-wordpress-plugin-for-oh-dear">A WordPress plugin for Oh Dear <a href="#a-wordpress-plugin-for-oh-dear" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#a-wordpress-plugin-for-oh-dear">#</a></h2>
<p>Our friends at <a href="https://kryptonitewp.com/">KryptoniteWP</a> have developed a free WordPress plugin that seamlessly integrates with your Oh Dear account through our API.</p>
<p>Once installed, you'll be able to see the uptime statistics of your site, the performance (as measured, externally, by Oh Dear) and a list of broken links that were found.</p>
<h2 id="uptime-statistics-in-wordpress">Uptime statistics in WordPress <a href="#uptime-statistics-in-wordpress" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#uptime-statistics-in-wordpress">#</a></h2>
<p>We can only hope everyone's statistics look as good as these:</p>
<p><img src="/img/docs/3rd-party-integrations/wordpress-plugin/ohdear-wordpress-1-uptime.png" alt="Install the Oh Dear plugin" /></p>
<p>In the plugin dashboard, you'll find the uptime details of the last 30 days neatly presented.</p>
<h2 id="performance-statistics-in-wordpress">Performance statistics in WordPress <a href="#performance-statistics-in-wordpress" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#performance-statistics-in-wordpress">#</a></h2>
<p>Below the uptime stats, you can find the performance numbers we measured for your site.</p>
<p><img src="/img/docs/3rd-party-integrations/wordpress-plugin/ohdear-wordpress-2-performance.png" alt="Install the Oh Dear plugin" /></p>
<p>If you <em>mouse-over</em> the chart, you will see even more details of the performance characteristics of your site.</p>
<h2 id="listing-broken-links-in-wordpress">Listing broken links in WordPress <a href="#listing-broken-links-in-wordpress" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#listing-broken-links-in-wordpress">#</a></h2>
<p>At the very bottom, you can find a list of all the broken pages Oh Dear found when we checked your website.</p>
<p><img src="/img/docs/3rd-party-integrations/wordpress-plugin/ohdear-wordpress-3-broken-links.png" alt="Install the Oh Dear plugin" /></p>
<p>There's a super convenient <em>edit</em> button available that takes you directly to the offending page, so you can fix any broken links straight away.</p>
<p>There's a benefit of having an external, 3rd-party service, like us check this: we see exactly what your visitors see.</p>
<p>No accidental <em>only-available-for-logged-in-users</em> pages, no hidden typos in URLs, ... We find &amp; alert you on time so you can fix them before your visitors.</p>
<h2 id="installing-the-wordpress-plugin">Installing the WordPress plugin <a href="#installing-the-wordpress-plugin" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#installing-the-wordpress-plugin">#</a></h2>
<p>Installation couldn't be easier!</p>
<p>Head over to your Plugin section in WordPress, search for <em>&quot;Oh Dear&quot;</em> and hit Install.</p>
<p><img src="/img/docs/3rd-party-integrations/wordpress-plugin/ohdear-wordpress-install.png" alt="Install the Oh Dear plugin" /></p>
<p>Once installed, head over to the settings and copy/paste your <a href="/user/api-tokens">API key</a>, so the plugin has authenticated access to your Oh Dear account.</p>
<p>Everything else happens automatically! The uptime &amp; performance data is fetched and cached locally, as well as the broken links reporting.</p>
<h2 id="insights-for-your-clients">Insights for your clients <a href="#insights-for-your-clients" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#insights-for-your-clients">#</a></h2>
<p>This plugin is ideal to provide your clients, who are probably looking at the WordPress dashboard to create content and manage their site, insights into the health of their entire website.</p>
<p>It is an isolated way of providing important data to your client, that doesn't require any extra work from you.</p>
<p>On the contrary even, by giving your client direct access to performance data and broken links reports, they can do the initial corrections themselves before having to escalate them to you.</p>
<h2 id="thank-you-kryptonitewp">Thank you KryptoniteWP! <a href="#thank-you-kryptonitewp" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#thank-you-kryptonitewp">#</a></h2>
<p>We want to extend our thanks to <a href="https://kryptonitewp.com/">KryptoniteWP</a> and <a href="https://twitter.com/flowdee">Florian</a> in particular that built this WordPress plugin.</p>
<p>It's amazing to see the community build <a href="/docs/api/introduction">on top of our API</a> and build such incredibly high-quality plugins!</p>
]]>
            </summary>
                                    <updated>2021-11-22T08:17:23+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Introducing new performance monitoring for your websites]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/introducing-new-performance-monitoring-for-your-websites" />
            <id>https://ohdear.app/39</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>We're excited to announce all Oh Dear users now have access to detailed performance metrics for all of their websites!</p>
<p>Let us take you through a quick tour of the new performance monitoring.</p>
<h2 id="an-updated-dashboard">An updated dashboard <a href="#an-updated-dashboard" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#an-updated-dashboard">#</a></h2>
<p>All our users will see a new dashboard upon login. We'll show you a performance graph and the latest response times next to the existing checks of uptime, broken links, mixed content &amp; certificate health.</p>
<p><img src="/img/dashboard-screenshot-for-homepage.png" alt="Screenshot of the Oh Dear dashboard" /></p>
<p>The dashboard updates in real-time, as soon as we have new performance metrics. You'll be able to see the data come in and the graph will auto-update itself!</p>
<h2 id="detailed-performance-metrics">Detailed performance metrics <a href="#detailed-performance-metrics" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#detailed-performance-metrics">#</a></h2>
<p>You can click through on the graph on the dashboard or the left-hand menu on the overview page of a site. In there, you'll be able to see detailed metrics of the performance of your website.</p>
<p><img src="/img/features/performance-graphs.png" alt="Detailed performance metrics in Oh Dear" /></p>
<p>If you hover over the chart, we'll pop-up the breakdown of each area of your performance.</p>
<p>We show you the following data:</p>
<ul>
<li><strong>DNS lookup</strong>: The time it takes to resolve the domain name to an IP address via DNS.</li>
<li><strong>TCP Connection Time</strong>: The time it takes to connect to the remote host (TCP three-way handshake).</li>
<li><strong>TLS Connection Time</strong>: The total time it took for the TLS handshake to complete (cipher negotiation &amp; encryption).</li>
<li><strong>Remote Server Processing</strong>: The time it took the server to process the request and start sending the first byte of the page. This is also known as the <strong>Time To First Byte</strong> or <strong>TTFB</strong>.</li>
<li><strong>Content Download</strong>: The time, in seconds, it took for the page to be downloaded from the remote server.</li>
</ul>
<p>Each of those metrics will allow you to pinpoint particular problems related to your website performance.</p>
<h2 id="get-notified-on-performance-issues">Get notified on performance issues <a href="#get-notified-on-performance-issues" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#get-notified-on-performance-issues">#</a></h2>
<p>The feature was designed with two clear goals in mind:</p>
<ul>
<li>You want to define a hard threshold in terms of performance goals</li>
<li>You want to know when your performance characteristics suddenly change</li>
</ul>
<p>You're now able to define a threshold for what you consider to be <em>slow</em>. Our default is 3500ms, or 3.5 seconds.</p>
<p>Once a site consistently crosses the 3.5 second mark, we'll notify you that things are getting a bit slow. We hope our users will crank that up so the threshold is a lot <em>lower</em> than 3.5 seconds. 😁</p>
<p>Our other alert relates to a <em>change</em> in performance: imagine you deploy your site, but it gets about 50% slower. Or faster? Classic monitoring will report the site as <em>up</em> and your visitors can still see it, but it has <em>drastically</em> changed the speed of the site.</p>
<p><img src="/uploads/blogs/performance-feature/performance-change-detected-1m.png" alt="Change in performance detected" /></p>
<p>Our default alert is a sudden change of 50% in performance: if we detect your site is slowing down or getting a lot faster, we'll make sure you know!</p>
<p>It may be harmless, it may even be good news, but we reckon you'd like to know whenever there's a big change like this.</p>
<h2 id="history-data-amp-trending">History data &amp; trending <a href="#history-data-amp-trending" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#history-data-amp-trending">#</a></h2>
<p>You're able to see the most granular data for the last 14 days. We'll aggregate the data in hourly averages for long-term storage and trending.</p>
<p>Per site, you can see a quick view for the last 7 days, and a view with all historic datapoints.</p>
<p><img src="/uploads/blogs/performance-feature/performance-change-detected-7d.png" alt="Long-term trends in performance data" /></p>
<p>In one of our next releases, we'll add the ability to export this data as a PNG or PDF so you can use it in your client reports.</p>
<h2 id="data-available-through-our-api-amp-sdk">Data available through our API &amp; SDK <a href="#data-available-through-our-api-amp-sdk" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#data-available-through-our-api-amp-sdk">#</a></h2>
<p>We've added the necessary endpoints to our API to allow you to retrieve the data at your convenience.</p>
<pre data-lang="bash" class="notranslate">$ <span class="hl-keyword">curl</span> https://ohdear.app/api/sites/1/performance-records?filter[start]=20200607125435&amp;filter[end]=20200608125435&amp;filter[timeframe]=1m \
    <span class="hl-attribute">-H</span> <span class="hl-value">&quot;Authorization: Bearer $OHDEAR_TOKEN&quot;</span> \
    <span class="hl-attribute">-H</span> <span class="hl-value">'Accept: application/json'</span> \
    <span class="hl-attribute">-H</span> <span class="hl-value">'Content-Type: application/json'</span>

{
  <span class="hl-value">&quot;data&quot;</span>: [
    {
      <span class="hl-value">&quot;id&quot;</span>: 121,
      <span class="hl-value">&quot;site_id&quot;</span>: 1,
      <span class="hl-value">&quot;created_at&quot;</span>: <span class="hl-value">&quot;2020-06-04 11:18:15&quot;</span>,
      <span class="hl-value">&quot;time_namelookup&quot;</span>: 0.032941,
      <span class="hl-value">&quot;time_connect&quot;</span>: 0.029106,
      <span class="hl-value">&quot;time_appconnect&quot;</span>: 0.076993,
      <span class="hl-value">&quot;time_pretransfer&quot;</span>: 0.00015999999999999348,
      <span class="hl-value">&quot;time_remoteserver&quot;</span>: 0.03007500000000002,
      <span class="hl-value">&quot;time_redirect&quot;</span>: 0,
      <span class="hl-value">&quot;time_download&quot;</span>: 0.0007059999999999844,
      <span class="hl-value">&quot;time_total&quot;</span>: 0.169981
    },
    {
      <span class="hl-value">&quot;id&quot;</span>: 122,
      <span class="hl-value">&quot;site_id&quot;</span>: 1,
      <span class="hl-value">&quot;created_at&quot;</span>: <span class="hl-value">&quot;2020-06-04 11:19:15&quot;</span>,
      <span class="hl-value">&quot;time_namelookup&quot;</span>: 0.008363,
      <span class="hl-value">&quot;time_connect&quot;</span>: 0.020259,
      <span class="hl-value">&quot;time_appconnect&quot;</span>: 0.042906999999999994,
      <span class="hl-value">&quot;time_pretransfer&quot;</span>: 0.00010600000000000886,
      <span class="hl-value">&quot;time_remoteserver&quot;</span>: 0.020762000000000003,
      <span class="hl-value">&quot;time_redirect&quot;</span>: 0,
      <span class="hl-value">&quot;time_download&quot;</span>: 0.0005309999999999898,
      <span class="hl-value">&quot;time_total&quot;</span>: 0.092928
    },
    {
      ...
    }
  ],
}
</pre>
<p>For more information, <a href="/docs/api/uptime-metrics">please have a read of the performance metric API docs</a>.</p>
<p>If you use our <a href="/docs/tools-and-sdks/the-oh-dear-php-sdk">PHP-SDK</a>, a convenient PHP package that acts as a wrapper for our API, you can also query for performance records as of version <code>2.0.0</code>.</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">&lt;?php</span>
<span class="hl-variable">$sdk</span>-&gt;<span class="hl-property">performanceRecords</span>(1, <span class="hl-value">'2020-06-07'</span>, <span class="hl-value">'2020-06-08'</span>);

<span class="hl-variable">$sdk</span>-&gt;<span class="hl-property">performanceRecords</span>(1, <span class="hl-value">'2020-06-07 09:00:00'</span>, <span class="hl-value">'2020-06-08 17:00:00'</span>);
</pre>
<p>It accepts a site ID, the <code>start</code> and <code>end</code> date, and will return an array of all the detailed performance metrics we have available.</p>
<p>For more details, have a look at <a href="/docs/tools-and-sdks/the-oh-dear-php-sdk">the detailed documentation on the new <code>performanceRecords()</code> method</a>.</p>
<h2 id="want-more-information">Want more information? <a href="#want-more-information" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#want-more-information">#</a></h2>
<p>Looking for some more reading on the new performance monitoring? Well, we've got you covered!</p>
<ul>
<li>More high-level details are available <a href="/features/performance-monitoring">on the performance feature page</a></li>
<li>Our <a href="/docs/features/uptime-and-performance-monitoring">documentation has been updated</a> to cover the performance checks</li>
<li>We have <a href="/docs/api/uptime-metrics">API integrations available</a></li>
<li>To help consume the data, we have <a href="/docs/tools-and-sdks/the-oh-dear-php-sdk">updated our PHP-SDK</a></li>
</ul>
<h2 id="want-to-give-it-a-try">Want to give it a try? <a href="#want-to-give-it-a-try" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#want-to-give-it-a-try">#</a></h2>
<p>Want to know how fast your own website is?</p>
<p>Sign up to <a href="/register">create an account</a> and you'll see the detailed metrics appear!</p>
]]>
            </summary>
                                    <updated>2020-06-09T11:44:40+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Resolving the AddTrust External CA Root certificate expiration]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/resolving-the-addtrust-external-ca-root-certificate-expiration" />
            <id>https://ohdear.app/38</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>Some of our users have received reports about their <strong>AddTrust External CA Root</strong> or <strong>USERTrust RSA Certification Authority</strong> certificate. The problem occurs because <em>the remote server</em> sends a root certificate in the chain that will expire in less than 14 days.</p>
<p>Here are the steps to verify this and a few tips on how to resolve it.</p>
<h2 id="what-are-the-addtrust-external-ca-root-expiration-notifications">What are the AddTrust External CA Root expiration notifications? <a href="#what-are-the-addtrust-external-ca-root-expiration-notifications" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#what-are-the-addtrust-external-ca-root-expiration-notifications">#</a></h2>
<p>Oh Dear checks all the certificates your server sends back to us whenever we connect to it.</p>
<p>Sometimes we just get 1 certificate back, sometimes we receive an entire chain of certificates (this is usually the correct thing to do, minus the root certificate).</p>
<p>Sometimes, we receive certificates where - in the middle of the chain - an expired certificate is present. We alert on these, as clients might block connections when one certificate in the chain is expired.</p>
<p>Sometimes, and it's rare, a server sends a root certificate along that is close to expiry, but actually isn't <em>needed</em>.</p>
<p>For some of our users, they've received these reports for the <strong>AddTrust External CA Root</strong> and <strong>USERTrust RSA Certification Authority</strong> root certificates.</p>
<h2 id="verify-that-the-ssl-certificates-are-indeed-about-to-expire">Verify that the SSL certificates are indeed about to expire <a href="#verify-that-the-ssl-certificates-are-indeed-about-to-expire" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#verify-that-the-ssl-certificates-are-indeed-about-to-expire">#</a></h2>
<p>It's a bit technical, so if this doesn't make a whole lot of sense, we suggest you reach out to your hosting provider or your SSL Certificate provider - they'll be able to help out!</p>
<p>Forward them this post, and they'll be able to fix things for you.</p>
<p>In this example, we'll connect to a random Tumblr blog and request the certificates. Tumblr appears to be one of the larger providers worldwide that's sending a soon-to-expire root certificate along in their chain.</p>
<p><em>Update: they since removed the old expiring root from their chain.</em></p>
<pre data-lang="bash" class="notranslate">$ openssl s_client <span class="hl-attribute">-showcerts</span> <span class="hl-attribute">-connect</span> world-of-cats.tumblr.com:443

CONNECTED(00000006)
depth=3 C = SE, O = AddTrust AB, OU = AddTrust External TTP Network, CN = AddTrust External CA Root
verify return:1
depth=2 C = US, ST = New Jersey, L = Jersey City, O = The USERTRUST Network, CN = USERTrust RSA Certification Authority
verify return:1
depth=1 C = GB, ST = Greater Manchester, L = Salford, O = Sectigo Limited, CN = Sectigo RSA Domain Validation Secure Server CA
verify return:1
depth=0 CN = *.tumblr.com
verify return:1
---
Certificate chain
 0 s:/CN=*.tumblr.com
   i:/C=GB/ST=Greater Manchester/L=Salford/O=Sectigo Limited/CN=Sectigo RSA Domain Validation Secure Server CA
-----BEGIN CERTIFICATE-----
MIIGpzCCBY+gAwIBAgIRAOsw1/2DvyzYHRF0zq8c9xQwDQYJKoZIhvcNAQELBQAw
gY8xCzAJBgNVBAYTAkdCMRswGQYDVQQIExJHcmVhdGVyIE1hbmNoZXN0ZXIxEDAO
BgNVBAcTB1NhbGZvcmQxGDAWBgNVBAoTD1NlY3RpZ28gTGltaXRlZDE3MDUGA1UE
AxMuU2VjdGlnbyBSU0EgRG9tYWluIFZhbGlkYXRpb24gU2VjdXJlIFNlcnZlciBD
QTAeFw0yMDAzMjYwMDAwMDBaFw0yMjA2MjgwMDAwMDBaMBcxFTATBgNVBAMMDCou
dHVtYmxyLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAPe+MXhf
4lZYnrsY5Ch1L0MqHOc44hIHkmawVVyTA8CynfbbFJWW+3Uoy0tHTcRgXyaV9xU3
oFyaIyYsLEUzfQLLmCuUjs8zSYvDH0jioCAz2HnkBaSuRAeOL2Iuaa9RoUVPrm8H
TwMwNQEjTJXM9SdSDzK8fS78jglTpzy+1CvWXjo9ij4+hiz2UjRkntA1oKXtOgnm
7W63CwD+fs9cW4VJaehjIAXf8AM/vd/WTrsDmr2ne17D05Lg7UgIJJaAo7JkF7bt
v3Be/BEYQTo/Eo0Ao9mqbt0DotNRn3lyh1y6MMhU7Hbr/qQzq1+cyEr+d0yd/ugp
P4c3HKfxD21+6PUCAwEAAaOCA3MwggNvMB8GA1UdIwQYMBaAFI2MXsRUrYrhd+mb
+ZsF4bgBjWHhMB0GA1UdDgQWBBR7eUPnbT0TNZdZJ9sYGcIV+LV+zzAOBgNVHQ8B
Af8EBAMCBaAwDAYDVR0TAQH/BAIwADAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYB
BQUHAwIwSQYDVR0gBEIwQDA0BgsrBgEEAbIxAQICBzAlMCMGCCsGAQUFBwIBFhdo
dHRwczovL3NlY3RpZ28uY29tL0NQUzAIBgZngQwBAgEwgYQGCCsGAQUFBwEBBHgw
djBPBggrBgEFBQcwAoZDaHR0cDovL2NydC5zZWN0aWdvLmNvbS9TZWN0aWdvUlNB
RG9tYWluVmFsaWRhdGlvblNlY3VyZVNlcnZlckNBLmNydDAjBggrBgEFBQcwAYYX
aHR0cDovL29jc3Auc2VjdGlnby5jb20wIwYDVR0RBBwwGoIMKi50dW1ibHIuY29t
ggp0dW1ibHIuY29tMIIB9wYKKwYBBAHWeQIEAgSCAecEggHjAeEAdgBGpVXrdfqR
IDC1oolp9PN9ESxBdL79SbiFq/L8cP5tRwAAAXEYg7bxAAAEAwBHMEUCIC/2w8Js
j5l7v6HXiXF3xmZtlnP24wVQyCbuZog5CR4LAiEA94tol2Wv9CfY5+oZOZbguyby
+2GjsTF/Kt6VtkYfu7EAdgDfpV6raIJPH2yt7rhfTj5a6s2iEqRqXo47EsAgRFwq
cwAAAXEYg7a6AAAEAwBHMEUCIEz+QPJzXAcJ+DO/vY35zaJyFO79tb0YGqxIdMK8
QmUxAiEAuMC/Pb9ASdjVA+1V9XRze3+FOuzYgDIukcloJkFTBQIAdwBByMqx3yJG
ShDGoToJQodeTjGLGwPr60vHaPCQYpYG9gAAAXEYg7beAAAEAwBIMEYCIQCRQNow
KwOkT83uWKunFDLxYKvelZx3iDVN4XnT7QSyQAIhAPqkCy4vgInpPdm6vlIVU5w5
HhBlRa5yypT2soaqe3MHAHYAb1N2rDHwMRnYmQCkURX/dxUcEdkCwQApBo2yCJo3
2RMAAAFxGIO2jAAABAMARzBFAiEA02T++BnEL2AifEhiThLu+o9o1rL2gHVRu4qv
gtaVNTkCIEuIiz0vVU4rWmvm2qOqlMer4UzeUq0FDmn6L2ib4uMEMA0GCSqGSIb3
DQEBCwUAA4IBAQC1S8CwUg1o7Nek9AteJHycWe54Yk/kSRN8VFax8AqbWWFF9528
20VpJ/XFFacju1mj7cdEPGLHVJ80Ia3D+1YT7c2OaFa/SI3BVs05BRKmjvxHR7ZM
W7RNZK+8qmMzh2mEjj1LPWezioxec5KR90LjSGIaG3KnWHpQhGSwC3AFzkmrF6Pj
S8z3gfSbuWDicMwEWmiW6Gjy1Xe6jg/DJn8NKwLw5ju+17oyKR7BxQ3nfFltAWSL
qHzYRzzARUalZIQNvYEYNvqcA3rsBnpRLG5N4OCsV/VE/cDDQX4XrOGVdvj4fQlf
QWOOZkkAxvC6iU63He3vFdE1HrmuTqQYTLoS
-----END CERTIFICATE-----
 1 s:/C=GB/ST=Greater Manchester/L=Salford/O=Sectigo Limited/CN=Sectigo RSA Domain Validation Secure Server CA
   i:/C=US/ST=New Jersey/L=Jersey City/O=The USERTRUST Network/CN=USERTrust RSA Certification Authority
-----BEGIN CERTIFICATE-----
MIIGEzCCA/ugAwIBAgIQfVtRJrR2uhHbdBYLvFMNpzANBgkqhkiG9w0BAQwFADCB
iDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0pl
cnNleSBDaXR5MR4wHAYDVQQKExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNV
BAMTJVVTRVJUcnVzdCBSU0EgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMTgx
MTAyMDAwMDAwWhcNMzAxMjMxMjM1OTU5WjCBjzELMAkGA1UEBhMCR0IxGzAZBgNV
BAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEYMBYGA1UE
ChMPU2VjdGlnbyBMaW1pdGVkMTcwNQYDVQQDEy5TZWN0aWdvIFJTQSBEb21haW4g
VmFsaWRhdGlvbiBTZWN1cmUgU2VydmVyIENBMIIBIjANBgkqhkiG9w0BAQEFAAOC
AQ8AMIIBCgKCAQEA1nMz1tc8INAA0hdFuNY+B6I/x0HuMjDJsGz99J/LEpgPLT+N
TQEMgg8Xf2Iu6bhIefsWg06t1zIlk7cHv7lQP6lMw0Aq6Tn/2YHKHxYyQdqAJrkj
eocgHuP/IJo8lURvh3UGkEC0MpMWCRAIIz7S3YcPb11RFGoKacVPAXJpz9OTTG0E
oKMbgn6xmrntxZ7FN3ifmgg0+1YuWMQJDgZkW7w33PGfKGioVrCSo1yfu4iYCBsk
Haswha6vsC6eep3BwEIc4gLw6uBK0u+QDrTBQBbwb4VCSmT3pDCg/r8uoydajotY
uK3DGReEY+1vVv2Dy2A0xHS+5p3b4eTlygxfFQIDAQABo4IBbjCCAWowHwYDVR0j
BBgwFoAUU3m/WqorSs9UgOHYm8Cd8rIDZsswHQYDVR0OBBYEFI2MXsRUrYrhd+mb
+ZsF4bgBjWHhMA4GA1UdDwEB/wQEAwIBhjASBgNVHRMBAf8ECDAGAQH/AgEAMB0G
A1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAbBgNVHSAEFDASMAYGBFUdIAAw
CAYGZ4EMAQIBMFAGA1UdHwRJMEcwRaBDoEGGP2h0dHA6Ly9jcmwudXNlcnRydXN0
LmNvbS9VU0VSVHJ1c3RSU0FDZXJ0aWZpY2F0aW9uQXV0aG9yaXR5LmNybDB2Bggr
BgEFBQcBAQRqMGgwPwYIKwYBBQUHMAKGM2h0dHA6Ly9jcnQudXNlcnRydXN0LmNv
bS9VU0VSVHJ1c3RSU0FBZGRUcnVzdENBLmNydDAlBggrBgEFBQcwAYYZaHR0cDov
L29jc3AudXNlcnRydXN0LmNvbTANBgkqhkiG9w0BAQwFAAOCAgEAMr9hvQ5Iw0/H
ukdN+Jx4GQHcEx2Ab/zDcLRSmjEzmldS+zGea6TvVKqJjUAXaPgREHzSyrHxVYbH
7rM2kYb2OVG/Rr8PoLq0935JxCo2F57kaDl6r5ROVm+yezu/Coa9zcV3HAO4OLGi
H19+24rcRki2aArPsrW04jTkZ6k4Zgle0rj8nSg6F0AnwnJOKf0hPHzPE/uWLMUx
RP0T7dWbqWlod3zu4f+k+TY4CFM5ooQ0nBnzvg6s1SQ36yOoeNDT5++SR2RiOSLv
xvcRviKFxmZEJCaOEDKNyJOuB56DPi/Z+fVGjmO+wea03KbNIaiGCpXZLoUmGv38
sbZXQm2V0TP2ORQGgkE49Y9Y3IBbpNV9lXj9p5v//cWoaasm56ekBYdbqbe4oyAL
l6lFhd2zi+WJN44pDfwGF/Y4QA5C5BIG+3vzxhFoYt/jmPQT2BVPi7Fp2RBgvGQq
6jG35LWjOhSbJuMLe/0CjraZwTiXWTb2qHSihrZe68Zk6s+go/lunrotEbaGmAhY
LcmsJWTyXnW0OMGuf1pGg+pRyrbxmRE1a6Vqe8YAsOf4vmSyrcjC8azjUeqkk+B5
yOGBQMkKW+ESPMFgKuOXwIlCypTPRpgSabuY0MLTDXJLR27lk8QyKGOHQ+SwMj4K
00u/I5sUKUErmgQfky3xxzlIPK1aEn8=
-----END CERTIFICATE-----
 2 s:/C=US/ST=New Jersey/L=Jersey City/O=The USERTRUST Network/CN=USERTrust RSA Certification Authority
   i:/C=SE/O=AddTrust AB/OU=AddTrust External TTP Network/CN=AddTrust External CA Root
-----BEGIN CERTIFICATE-----
MIIFdzCCBF+gAwIBAgIQE+oocFv07O0MNmMJgGFDNjANBgkqhkiG9w0BAQwFADBv
MQswCQYDVQQGEwJTRTEUMBIGA1UEChMLQWRkVHJ1c3QgQUIxJjAkBgNVBAsTHUFk
ZFRydXN0IEV4dGVybmFsIFRUUCBOZXR3b3JrMSIwIAYDVQQDExlBZGRUcnVzdCBF
eHRlcm5hbCBDQSBSb290MB4XDTAwMDUzMDEwNDgzOFoXDTIwMDUzMDEwNDgzOFow
gYgxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpOZXcgSmVyc2V5MRQwEgYDVQQHEwtK
ZXJzZXkgQ2l0eTEeMBwGA1UEChMVVGhlIFVTRVJUUlVTVCBOZXR3b3JrMS4wLAYD
VQQDEyVVU0VSVHJ1c3QgUlNBIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MIICIjAN
BgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAgBJlFzYOw9sIs9CsVw127c0n00yt
UINh4qogTQktZAnczomfzD2p7PbPwdzx07HWezcoEStH2jnGvDoZtF+mvX2do2NC
tnbyqTsrkfjib9DsFiCQCT7i6HTJGLSR1GJk23+jBvGIGGqQIjy8/hPwhxR79uQf
jtTkUcYRZ0YIUcuGFFQ/vDP+fmyc/xadGL1RjjWmp2bIcmfbIWax1Jt4A8BQOujM
8Ny8nkz+rwWWNR9XWrf/zvk9tyy29lTdyOcSOk2uTIq3XJq0tyA9yn8iNK5+O2hm
AUTnAU5GU5szYPeUvlM3kHND8zLDU+/bqv50TmnHa4xgk97Exwzf4TKuzJM7UXiV
Z4vuPVb+DNBpDxsP8yUmazNt925H+nND5X4OpWaxKXwyhGNVicQNwZNUMBkTrNN9
N6frXTpsNVzbQdcS2qlJC9/YgIoJk2KOtWbPJYjNhLixP6Q5D9kCnusSTJV882sF
qV4Wg8y4Z+LoE53MW4LTTLPtW//e5XOsIzstAL81VXQJSdhJWBp/kjbmUZIO8yZ9
HE0XvMnsQybQv0FfQKlERPSZ51eHnlAfV1SoPv10Yy+xUGUJ5lhCLkMaTLTwJUdZ
+gQek9QmRkpQgbLevni3/GcV4clXhB4PY9bpYrrWX1Uu6lzGKAgEJTm4Diup8kyX
HAc/DVL17e8vgg8CAwEAAaOB9DCB8TAfBgNVHSMEGDAWgBStvZh6NLQm9/rEJlTv
A73gJMtUGjAdBgNVHQ4EFgQUU3m/WqorSs9UgOHYm8Cd8rIDZsswDgYDVR0PAQH/
BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wEQYDVR0gBAowCDAGBgRVHSAAMEQGA1Ud
HwQ9MDswOaA3oDWGM2h0dHA6Ly9jcmwudXNlcnRydXN0LmNvbS9BZGRUcnVzdEV4
dGVybmFsQ0FSb290LmNybDA1BggrBgEFBQcBAQQpMCcwJQYIKwYBBQUHMAGGGWh0
dHA6Ly9vY3NwLnVzZXJ0cnVzdC5jb20wDQYJKoZIhvcNAQEMBQADggEBAJNl9jeD
lQ9ew4IcH9Z35zyKwKoJ8OkLJvHgwmp1ocd5yblSYMgpEg7wrQPWCcR23+WmgZWn
RtqCV6mVksW2jwMibDN3wXsyF24HzloUQToFJBv2FAY7qCUkDrvMKnXduXBBP3zQ
YzYhBx9G/2CkkeFnvN4ffhkUyWNnkepnB2u0j4vAbkN9w6GAbLIevFOFfdyQoaS8
Le9Gclc1Bb+7RrtubTeZtv8jkpHGbkD4jylW6l/VXxRTrPBPYer3IsynVgviuDQf
Jtl7GQVoP7o81DgGotPmjw7jtHFtQELFhLRAlSv0ZaBIefYdgWOWnU914Ph85I6p
0fKtirOMxyHNwu8=
-----END CERTIFICATE-----
</pre>
<p>That's a lot of text right there!</p>
<p>The very last certificate is the <strong>AddTrust External CA Root</strong> certificate. This is the one that's causing a bit of problems at the moment. If we decode that blob of text, we can see why.</p>
<p>To decode a certificate, copy/paste the certificate between the <code>-----BEGIN CERTIFICATE-----</code> and <code>-----END CERTIFICATE-----</code> (including those lines) and save it to a text file. It should look a little something like this:</p>
<pre data-lang="bash" class="notranslate">-----BEGIN CERTIFICATE-----
MIIFdzCCBF+gAwIBAgIQE+oocFv07O0MNmMJgGFDNjANBgkqhkiG9w0BAQwFADBv
MQswCQYDVQQGEwJTRTEUMBIGA1UEChMLQWRkVHJ1c3QgQUIxJjAkBgNVBAsTHUFk
[...]
0fKtirOMxyHNwu8=
-----END CERTIFICATE-----
</pre>
<p>We named our text file <code>certificate.crt</code>.</p>
<pre data-lang="bash" class="notranslate">$ openssl x509 <span class="hl-attribute">-in</span> certificate.crt <span class="hl-attribute">-text</span>

Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            13:ea:28:70:5b:f4:ec:ed:0c:36:63:09:80:61:43:36
    Signature Algorithm: sha384WithRSAEncryption
        Issuer: C=SE, O=AddTrust AB, OU=AddTrust External TTP Network, CN=AddTrust External CA Root
        Validity
            Not Before: May 30 10:48:38 2000 GMT
            Not After : May 30 10:48:38 2020 GMT
        Subject: C=US, ST=New Jersey, L=Jersey City, O=The USERTRUST Network, CN=USERTrust RSA Certification Authority
        Subject Public Key Info:
            Public Key Algorithm: rsaEncryption
                Public-Key: (4096 bit)

[...]
</pre>
<p>That particular certificate expires on <strong>May 30 10:48:38 2020 GMT</strong>. In other words, in just about 14 days.</p>
<h2 id="validating-the-ssl-certificate-path">Validating the SSL Certificate Path <a href="#validating-the-ssl-certificate-path" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#validating-the-ssl-certificate-path">#</a></h2>
<p>There are several paths possible to validate the certificate of <strong>.tumblr.com</strong>. One of them doesn't even require the <em>AddTrust External CA Root</em> certificate:</p>
<pre data-lang="bash" class="notranslate">1	Sent by server	*.tumblr.com
Fingerprint SHA256: 3b46c48112e902c99d6f6ece3dd4877b190936e51289c90c874e219cf0494cd2
Pin SHA256: uSU/pyBXHivUNGcwZD+1TTSBYu6Q4n3GlvZTctoDmdQ=
RSA 2048 bits (e 65537) / SHA256withRSA

2	Sent by server	Sectigo RSA Domain Validation Secure Server CA
Fingerprint SHA256: 7fa4ff68ec04a99d7528d5085f94907f4d1dd1c5381bacdc832ed5c960214676
Pin SHA256: 4a6cPehI7OG6cuDZka5NDZ7FR8a60d3auda+sKfg4Ng=
RSA 2048 bits (e 65537) / SHA384withRSA

3	In trust store	USERTrust RSA Certification Authority   Self-signed
Fingerprint SHA256: e793c9b02fd8aa13e21c31228accb08119643b749c898964b1746d46c3d4cbd2
Pin SHA256: x4QzPSC810K5/cMjb05Qm4k3Bw5zBn4lTdO/nEW/Td4=
RSA 4096 bits (e 65537) / SHA384withRSA
</pre>
<p>Since that soon-to-expire root certificate that is being sent along isn't actually needed, it should be safe to remove it from your intermediate certificate list.</p>
<p>Or, perhaps even better, replace it with an up-to-date one that <em>is</em> valid for your certificate chain.</p>
<h2 id="replace-or-remove-the-old-root-certificate-in-your-chain">Replace or remove the old root-certificate in your chain <a href="#replace-or-remove-the-old-root-certificate-in-your-chain" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#replace-or-remove-the-old-root-certificate-in-your-chain">#</a></h2>
<p>It's best to doublecheck this with your SSL Provider, to verify the best course of action here.</p>
<p>If you are in control of your own webserver/proxy/SSL setups, you should be able to find the following certificate somewhere in your intermediate certificate list, and remove it.</p>
<pre data-lang="bash" class="notranslate">-----BEGIN CERTIFICATE-----
MIIFdzCCBF+gAwIBAgIQE+oocFv07O0MNmMJgGFDNjANBgkqhkiG9w0BAQwFADBv
MQswCQYDVQQGEwJTRTEUMBIGA1UEChMLQWRkVHJ1c3QgQUIxJjAkBgNVBAsTHUFk
ZFRydXN0IEV4dGVybmFsIFRUUCBOZXR3b3JrMSIwIAYDVQQDExlBZGRUcnVzdCBF
eHRlcm5hbCBDQSBSb290MB4XDTAwMDUzMDEwNDgzOFoXDTIwMDUzMDEwNDgzOFow
gYgxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpOZXcgSmVyc2V5MRQwEgYDVQQHEwtK
ZXJzZXkgQ2l0eTEeMBwGA1UEChMVVGhlIFVTRVJUUlVTVCBOZXR3b3JrMS4wLAYD
VQQDEyVVU0VSVHJ1c3QgUlNBIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MIICIjAN
BgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAgBJlFzYOw9sIs9CsVw127c0n00yt
UINh4qogTQktZAnczomfzD2p7PbPwdzx07HWezcoEStH2jnGvDoZtF+mvX2do2NC
tnbyqTsrkfjib9DsFiCQCT7i6HTJGLSR1GJk23+jBvGIGGqQIjy8/hPwhxR79uQf
jtTkUcYRZ0YIUcuGFFQ/vDP+fmyc/xadGL1RjjWmp2bIcmfbIWax1Jt4A8BQOujM
8Ny8nkz+rwWWNR9XWrf/zvk9tyy29lTdyOcSOk2uTIq3XJq0tyA9yn8iNK5+O2hm
AUTnAU5GU5szYPeUvlM3kHND8zLDU+/bqv50TmnHa4xgk97Exwzf4TKuzJM7UXiV
Z4vuPVb+DNBpDxsP8yUmazNt925H+nND5X4OpWaxKXwyhGNVicQNwZNUMBkTrNN9
N6frXTpsNVzbQdcS2qlJC9/YgIoJk2KOtWbPJYjNhLixP6Q5D9kCnusSTJV882sF
qV4Wg8y4Z+LoE53MW4LTTLPtW//e5XOsIzstAL81VXQJSdhJWBp/kjbmUZIO8yZ9
HE0XvMnsQybQv0FfQKlERPSZ51eHnlAfV1SoPv10Yy+xUGUJ5lhCLkMaTLTwJUdZ
+gQek9QmRkpQgbLevni3/GcV4clXhB4PY9bpYrrWX1Uu6lzGKAgEJTm4Diup8kyX
HAc/DVL17e8vgg8CAwEAAaOB9DCB8TAfBgNVHSMEGDAWgBStvZh6NLQm9/rEJlTv
A73gJMtUGjAdBgNVHQ4EFgQUU3m/WqorSs9UgOHYm8Cd8rIDZsswDgYDVR0PAQH/
BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wEQYDVR0gBAowCDAGBgRVHSAAMEQGA1Ud
HwQ9MDswOaA3oDWGM2h0dHA6Ly9jcmwudXNlcnRydXN0LmNvbS9BZGRUcnVzdEV4
dGVybmFsQ0FSb290LmNybDA1BggrBgEFBQcBAQQpMCcwJQYIKwYBBQUHMAGGGWh0
dHA6Ly9vY3NwLnVzZXJ0cnVzdC5jb20wDQYJKoZIhvcNAQEMBQADggEBAJNl9jeD
lQ9ew4IcH9Z35zyKwKoJ8OkLJvHgwmp1ocd5yblSYMgpEg7wrQPWCcR23+WmgZWn
RtqCV6mVksW2jwMibDN3wXsyF24HzloUQToFJBv2FAY7qCUkDrvMKnXduXBBP3zQ
YzYhBx9G/2CkkeFnvN4ffhkUyWNnkepnB2u0j4vAbkN9w6GAbLIevFOFfdyQoaS8
Le9Gclc1Bb+7RrtubTeZtv8jkpHGbkD4jylW6l/VXxRTrPBPYer3IsynVgviuDQf
Jtl7GQVoP7o81DgGotPmjw7jtHFtQELFhLRAlSv0ZaBIefYdgWOWnU914Ph85I6p
0fKtirOMxyHNwu8=
-----END CERTIFICATE-----
</pre>
<p>This file is usually referenced in your webserver configs, it might look like this:</p>
<p>In Nginx:</p>
<pre data-lang="bash" class="notranslate">	[...]
	 ssl_certificate             /path/to/fullchain.pem;
</pre>
<p>In Apache:</p>
<pre data-lang="bash" class="notranslate">	[...]
	SSLCertificateChainFile      /path/to/fullchain.pem;
</pre>
<p>Open that file, take a back-up, and remove the certificate referenced above here.</p>
<p>Restart your webserver to load the new certificate configurations, and doublecheck if everything still works properly.</p>
<h2 id="why-does-oh-dear-report-on-these-certificates">Why does Oh Dear report on these certificates? <a href="#why-does-oh-dear-report-on-these-certificates" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#why-does-oh-dear-report-on-these-certificates">#</a></h2>
<p>We verify every certificate that gets sent by the server. In this case, the final root certificate that was being sent isn't <em>technically</em> needed to validate the certificate chain, as there's a local root certificate present (on your own device) that perfectly does that already.</p>
<p>It's unclear how every device in the wild would react if a server sends along an expired, but ultimately unneeded, root certificate.</p>
<p>How would an old Android phone react? Or an embedded device, running old firmware? We can't know, so we prefer to err on the side of caution and alert you that the server is sending along expiring certificates.</p>
<p>Hopefully this post can help you identify the problem and roll out a solution!</p>
<h2 id="update-we-will-modify-our-alerting-settings">Update: we will modify our alerting settings <a href="#update-we-will-modify-our-alerting-settings" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#update-we-will-modify-our-alerting-settings">#</a></h2>
<p>After internal debates, we've decided to make the behaviour of these alerts configurable.</p>
<p>To be clear: the server should not send an expired root certificate back to the client. It's impossible to predict how old devices might respond, and it'll surely break some embedded devices or devices with older SSL validation logic.</p>
<p>However, modern  browsers treat this as a non-issue, since they can find a different path to validate the certificate and tie it to a valid root certificate.</p>
<p>In one of our next releases, you will be able to select if we should validate <em>all</em> certificates a server sends, or just the <em>domain</em> certificate. The default will be to validate <em>all</em> certificates, as we've always done.</p>
<p>In some scenario's, it's difficult or near impossible to change the certificate chain (ie: shared hosting setups that offer little to no control of the certificates). For those scenario's, you might want to disable the validation of <em>all</em> certificates (even though it might cause issues for some clients).</p>
]]>
            </summary>
                                    <updated>2020-05-31T07:56:36+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How Oh Dear identified a certificate problem at a large CDN provider]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/how-oh-dear-identified-a-certificate-problem-at-a-large-cdn-provider" />
            <id>https://ohdear.app/37</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>As part of our service, we perform <a href="/features/continuous-certificate-monitoring">SSL certificate monitoring</a>. We do this slightly different than other providers, which is why were able to detect a problem with the SSL certificates of a large, commercial, CDN provider.</p>
<p>In this post, we'll do a technical deep-dive into how we found this problem!</p>
<h2 id="why-we-found-it-in-the-first-place">Why we found it in the first place <a href="#why-we-found-it-in-the-first-place" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#why-we-found-it-in-the-first-place">#</a></h2>
<p>While we're a traditional website monitoring service in some aspects, we do a couple of things differently from our competition (and I'm not just talking about our crawler that <a href="/features/broken-page-and-mixed-content-detection">looks for broken links &amp; mixed content</a>).</p>
<p>Our SSL monitoring is fairly <em>intense</em>. We fetch and validate the <strikethrough>SSL</strikethrough> <strikethrough>TLS</strikethrough> x.509 certificates <strong>every 5 minutes</strong>, for each site, to report on changes and problems.</p>
<p>With over 250 checks a day for the SSL certificate alone, it means we have a lot of opportunities to catch errors related to your site certificates.</p>
<p>This allows us to quickly identify situations where a certificate has changed (use case: did you renew <em>all</em> the SANs on the certificate?) or one that has incorrectly been replaced.</p>
<h2 id="what-happened-here">What happened here? <a href="#what-happened-here" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#what-happened-here">#</a></h2>
<p>In this case, we received reports from multiple of our clients that our certificate change reporting <em>&quot;seemed wrong&quot;</em>. We were sending alerts to clients that their certificates had changed, but they could never quite verify this.</p>
<p>When we connect to a host to do our in-depth certificate analysis, we fetch as much information as we can. It's similar to how OpenSSL's client would do it:</p>
<pre data-lang="bash" class="notranslate">$ openssl s_client \
	-connect clientsite.tld:443 \
	-servername clientsite.tld \
	2&gt;/dev/null \
	| <span class="hl-keyword">grep</span> <span class="hl-value">'CN'</span>

 0 s:/CN=clientsite.tld
   i:/C=US/O=Let<span class="hl-value">'s Encrypt/CN=Let'</span>s Encrypt Authority X3
 1 s:/C=US/O=Let<span class="hl-value">'s Encrypt/CN=Let'</span>s Encrypt Authority X3
   i:/O=Digital Signature Trust Co./CN=DST Root CA X3
subject=/CN=clientsite.tld
issuer=/C=US/O=Let<span class="hl-value">'s Encrypt/CN=Let'</span>s Encrypt Authority X3
</pre>
<p>This is what you'd expect: a connection to the host <code>clientsite.tld</code>, where we make clear that we want to retrieve the certificate information using SNI - Server Name Indication.</p>
<p>The above example is a functioning one, it correctly returned us the certificate of the site, and we could verify everything was intact.</p>
<p>However, every once in a while, this happened:</p>
<pre data-lang="bash" class="notranslate">$ openssl s_client \
	-connect clientsite.tld:443 \
	-servername clientsite.tld \
	2&gt;/dev/null \
	| <span class="hl-keyword">grep</span> <span class="hl-value">'CN'</span>

 0 s:/OU=Domain Control Validated/CN=*.cdnprovider.com
   i:/C=US/ST=State/L=Location/O=GoDaddy.com, Inc./OU=http://certs.godaddy.com/repository//CN=Go Daddy Secure Certificate Authority - G2
 1 s:/OU=Domain Control Validated/CN=*.cdnprovider.com
   i:/C=US/ST=State/L=Location/O=GoDaddy.com, Inc./OU=http://certs.godaddy.com/repository//CN=Go Daddy Secure Certificate Authority - G2
 2 s:/C=US/ST=State/L=Location/O=GoDaddy.com, Inc./OU=http://certs.godaddy.com/repository//CN=Go Daddy Secure Certificate Authority - G2
   i:/C=US/ST=State/L=Location/O=GoDaddy.com, Inc./CN=Go Daddy Root Certificate Authority - G2
 3 s:/C=US/ST=State/L=Location/O=GoDaddy.com, Inc./CN=Go Daddy Root Certificate Authority - G2
   i:/C=US/ST=State/L=Location/O=GoDaddy.com, Inc./CN=Go Daddy Root Certificate Authority - G2
ct6W1bEcW10r8Nvp0lZrYmdIn//gn4ltYpiWtRCRROEa81WO5lNDL5NO5gJ1I6CN
subject=/OU=Domain Control Validated/CN=*.cdnprovider.com
</pre>
<p>We requested the certificate for domain <code>clientsite.tld</code> but got back a certificate for <code>*.cdnprovider.com</code>.</p>
<p>Clearly this shouldn't happen!</p>
<p>As a result, our alerts got sent to the client, because their site was being served by an incorrect certificate.</p>
<h2 id="changes-made-at-oh-dear-to-help-verify-this">Changes made at Oh Dear to help verify this <a href="#changes-made-at-oh-dear-to-help-verify-this" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#changes-made-at-oh-dear-to-help-verify-this">#</a></h2>
<p>When we received the first reports, it wasn't easy to troubleshoot this. You see, we connect to the hostname <code>clientsite.tld</code>, but that might be served by several IP addresses using DNS Round Robin.</p>
<pre data-lang="bash" class="notranslate">$  dig +short +noshort clientsite.tld
clientsite.tld.		66	IN	A	1.2.3.4
clientsite.tld.		66	IN	A	5.6.7.8
</pre>
<p>In fact, that's how most CDNs operate: you point your domain to a CNAME, and they present multiple anycasted IPs, each capable of serving your site.</p>
<p>Our problem was that we didn't present the IP that we used for this certificate check back to you, the user. So how could you know which IP served the wrong certificate?</p>
<p>As part of our debugging, we made this available to all users in the detailed view of their certificate report:</p>
<p><img src="/uploads/blogs/cdn-provider-ip-used-in-check/cdn-provider-ip-retrieved-check.png" alt="Certificate retrieved from what IP?" /></p>
<p>To do this, we <a href="https://github.com/spatie/ssl-certificate/commit/3b1798a9ed8b7b598731594d285be5792b6eb00a">contributed to the upstream SSL package</a> to expose the remote address IP, since this wasn't available out of the box.</p>
<pre data-lang="php" class="notranslate"><span class="hl-comment">/* ... */</span>
<span class="hl-variable">$response</span>[<span class="hl-value">'remoteAddress'</span>] = <span class="hl-property">stream_socket_get_name</span>(<span class="hl-variable">$client</span>, <span class="hl-keyword">true</span>);
</pre>
<p>But, this wasn't enough to easily pinpoint the issue.</p>
<h2 id="working-together-with-the-cdn">Working together with the CDN <a href="#working-together-with-the-cdn" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#working-together-with-the-cdn">#</a></h2>
<p>You see, many of the IPs of a CDN are &quot;anycast&quot; IPs: you connect to an IP address, but there might be multiple servers worldwide responding to that very same IP.</p>
<p>The short version is: the one closest to you (in BGP-terms) will provide you the answer. But for the CDN provider, there's no way to uniquely identify which of their thousands of servers actually responded.</p>
<p>We worked together with them to provide all technical details of our checks and sufficient example/test cases to, at first, convince them of this problem. Then, our examples helped them to troubleshoot and resolve the problem on their end.</p>
<blockquote>
<p>I wanted to let you know that we found two edge servers that were mis-behaving and causing the issue you were seeing. We did a full sweep of the thousands of servers we manage and confirmed no others were in this bad state.</p>
<p>-- Director of Customer Support</p>
</blockquote>
<p>We're thrilled to see the widespread implications of our <em>paranoid</em> certificate monitoring, and that, as a result, we helped thousands of sites worldwide. Even those not using Oh Dear!</p>
]]>
            </summary>
                                    <updated>2020-05-13T05:52:44+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Snoozing alerts and advanced Slack notifications]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/snoozing-alerts-and-advanced-slack-notifications" />
            <id>https://ohdear.app/36</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>We've introduced two very cool new features to Oh Dear: the ability to temporarily silence alerts and advanced Slack notifications.</p>
<h2 id="snoozing-alerts">Snoozing alerts <a href="#snoozing-alerts" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#snoozing-alerts">#</a></h2>
<p>You can now temporarily silence alerts being sent by Oh Dear. These <em>snoozes</em> can be activated per check we perform.</p>
<p>Say you receive a notification your site is down, but you know the fix will take a while to finish? You can snooze for 5 minutes, an hour, 4 hours or a day.</p>
<p><img src="/uploads/blogs/advanced-slack-notifications/snooze-via-ohdear-dashboard.png" alt="Snooze notification via the dashboard" /></p>
<p>This way, you won't be distracted and your teammates won't be disturbed by something you're actively working on!</p>
<p>And if you live in Telegram, there are <a href="/news-and-updates/introducing-our-new-interactive-telegram-notifications">interactive Telegram notifications</a>.</p>
<p><em>Want to learn more? Have a look at <a href="/docs/notifications/how-to-snooze-notifications">our documentation on how snoozing works</a>.</em></p>
<h2 id="advanced-slack-notifications">Advanced Slack notifications <a href="#advanced-slack-notifications" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#advanced-slack-notifications">#</a></h2>
<p>We've rolled out a new way of linking your Slack Workspace to Oh Dear. Our first implementation used <em>Slack Webhooks</em>, but this new method makes advanced use of the Slack API. This allows two-way communication between your Slack Workspace &amp; Oh Dear.</p>
<p>Here's an example of a test notification being sent:</p>
<video autoplay loop controls width="100%">
<source src="/uploads/blogs/advanced-slack-notifications/test-notification-inspire-me.mp4" type="video/mp4">
</video>
<p><em>(This might, in fact, be one of the only implementations of Laravel's <code>php artisan inspire</code> in the wild!)</em></p>
<h2 id="snoozing-amp-rechecking-alerts-via-slack">Snoozing &amp; rechecking alerts via Slack <a href="#snoozing-amp-rechecking-alerts-via-slack" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#snoozing-amp-rechecking-alerts-via-slack">#</a></h2>
<p>Now that we have communications established, we can use these Slack notifications to <em>Snooze</em> incoming notifications and request them be re-checked whenever we want.</p>
<video autoplay loop controls width="100%">
<source src="/uploads/blogs/advanced-slack-notifications/snooze-notification.mp4" type="video/mp4">
</video>
<p>Let's break down what is happening here:</p>
<ol>
<li>You can immediately request a new check to confirm the issue (or have it re-check the moment you fixed the problem)</li>
<li>You can immediately snooze the notification right from Slack, no need to go to the Oh Dear dashboard</li>
</ol>
<p>We can live-update the Slack notification and append any new data to it. You'll see who snoozed it and who requested the recheck.</p>
<p>If a recovery comes in, it'll be appended to the original messages too.</p>
<video autoplay loop controls width="100%">
<source src="/uploads/blogs/advanced-slack-notifications/recovery-message.mp4" type="video/mp4">
</video>
<h2 id="clear-overview-of-your-channels">Clear overview of your channels <a href="#clear-overview-of-your-channels" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#clear-overview-of-your-channels">#</a></h2>
<p>As an added benefit: you can now choose which channel to send the alerts to directly from Oh Dear, this will make it more clear which notifications get sent to which channel.</p>
<p><img src="/uploads/blogs/advanced-slack-notifications/slack-notifications-pick-channel.png" alt="Choose Slack channel in Oh Dear" /></p>
<p>Previously, you might have a list of a couple of webhooks, but it isn't obvious which hook is associated with which channel.</p>
<p>On mobile, urgent alerts can route as <a href="/news-and-updates/we-now-support-pushovers-priority-messages">Pushover priority messages</a>.</p>
<p>Now, you'll see this directly from your Oh Dear notification settings.</p>
<p>Prefer your team chat? We also push alerts to <a href="/news-and-updates/we-now-support-google-chat">Google Chat</a>.</p>
<h2 id="activate-these-new-slack-implementations-in-your-account">Activate these new Slack implementations in your account <a href="#activate-these-new-slack-implementations-in-your-account" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#activate-these-new-slack-implementations-in-your-account">#</a></h2>
<p>To activate these new Slack notifications, head over to your <a href="/team-settings/notifications/slack-api">Slack notification settings</a> and link your Slack Workspace to Oh Dear.</p>
<p>For some more reading, have a look at our <a href="/docs/notifications/slack">documentation on the Slack API integration</a>.</p>
<p>We hope you'll enjoy these new powerful notifications!</p>
]]>
            </summary>
                                    <updated>2020-04-16T14:40:19+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Seeing detailed logs for webhook events]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/seeing-detailed-logs-for-webhook-events" />
            <id>https://ohdear.app/34</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>We've added the ability to see detailed webhook logs to Oh Dear, showing both the <em>sent request</em> and the <em>received response</em> for all notifications we have sent.</p>
<h2 id="debugging-webhooks-can-be-a-pain">Debugging webhooks can be a pain <a href="#debugging-webhooks-can-be-a-pain" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#debugging-webhooks-can-be-a-pain">#</a></h2>
<p>We've been there and it isn't fun. You either add logging logic in your own webhook to capture the payload or you change the endpoint to something like PostBin to get a web-ui view of all events that got triggered.</p>
<p>We can do better, can't we?</p>
<p>We've now shipped a cool update that allows you to see the detailed logs of each webhook that got fired for an alert.</p>
<h2 id="detailed-webhook-logs-in-oh-dear">Detailed Webhook logs in Oh Dear <a href="#detailed-webhook-logs-in-oh-dear" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#detailed-webhook-logs-in-oh-dear">#</a></h2>
<p>In your webhook notification settings, you can see an extra tab called &quot;<em>Logs</em>&quot;. It does what its name implies - it shows all the logs we have for that webhook.</p>
<p><img src="/uploads/blogs/webhook-logs/webhook-logs-in-detail.png" alt="Webhook logs in Oh Dear" /></p>
<p>Per fired event, you can see the detailed request we sent - with all the payload information, HTTP headers etc. - and the response we got back from your endpoint.</p>
<p>To help troubleshooting, there's also a <strong>Resend</strong> button that takes the existing request and just fires it again to your endpoint. Perfect for rapid development!</p>
<p>This view alone should give you all the data and confidence to implement webhooks in your own application without resorting to crazy hacks like logging/dumping the payload and manually having to trigger the webhook again.</p>
<p>If you want to play around with our webhooks, <a href="/docs/tools-and-sdks/webhooks#getting-started">have a look at our documentation</a>. There are plenty of details on what the payload means, how to configure the webhooks, how to <a href="/docs/tools-and-sdks/webhooks#getting-started">validate the request came from us</a>, etc. If you write your webhook integrations in PHP, we also have <a href="/docs/tools-and-sdks/the-oh-dear-php-sdk">a Laravel package</a> available for easy consumption.</p>
]]>
            </summary>
                                    <updated>2020-04-10T12:07:50+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Configure custom SSL certificate expiration thresholds]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/configure-custom-ssl-certificate-expiration-thresholds" />
            <id>https://ohdear.app/32</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>When we first launched Oh Dear, we had a fixed certificate expiration timer: 14 days. As soon as the expiration date came within 14 days, we'd start sending a daily reminder to <em>hurry up</em> and renew those certificates.</p>
<p>Our first exception was made when Let's Encrypt gained more in popularity. We started notifying Let's Encrypt certificates <em>7 days</em> before expiration date. The reason there was most auto-renewal systems will kick in 14 days before expiration date, our default threshold was <em>just</em> at that edge and caused us to send renewal reminders right around the time most scripts would renew automatically.</p>
<p>This 7/14 split worked pretty well for a long time and served most of our customers. But we understand not all renewals are as simple as that.</p>
<p>Because internal rules or validations might take longer, we're adding a custom setting per site that allows you to inrease or shorten the threshold for when you want to start receiving renewal reminders for your <strike>SSL</strike> TLS certificates.</p>
<p><img src="/uploads/blogs/custom-certificate-expiration-dates/setting-custom-certificate-expiration-days.png" alt="Custom threshold for ssl certificate renewals" /></p>
<p>You can now choose any number of days you'd like, whatever suits your need.</p>
<p>For Extended Validation certificates or Organization Validated certificates, we recommend a safety net of 30 days.</p>
]]>
            </summary>
                                    <updated>2020-03-31T11:19:04+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Finding clients hit by Let's Encrypt mass-revocation]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/how-we-identified-clients-with-ssl-certificates-affected-by-lets-encrypt-mass-revocation" />
            <id>https://ohdear.app/30</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>Yesterday, we sent out notifications to all clients whose certificates we <a href="/features/continuous-certificate-monitoring">continuously monitor</a> and that are affected by the <a href="https://community.letsencrypt.org/t/revoking-certain-certificates-on-march-4/114864">Let's Encrypt mass revocation of SSL certificates</a>. In this post, we'll share the details how we found those certificates.</p>
<p>Now, the <em>morning after</em>, we're well rested and in good shape to do a proper write-up on the matter.</p>
<h2 id="getting-a-list-of-all-domains-to-check">Getting a list of all domains to check <a href="#getting-a-list-of-all-domains-to-check" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#getting-a-list-of-all-domains-to-check">#</a></h2>
<p>As part of our <a href="/features/website-uptime-monitoring">uptime monitoring</a>, users can add a site to Oh Dear with specific URL parameters. So in order to get a list of domains we needed to verify, it wasn't as simple as:</p>
<pre data-lang="sql" class="notranslate"><span class="hl-keyword">SELECT</span> <span class="hl-property">domain</span> <span class="hl-keyword">FROM</span> <span class="hl-type">sites</span>;
</pre>
<p>Instead, we used Laravel's lazy collections to quickly filter all teams with active subscriptions and extract the relevant domain name.</p>
<pre data-lang="php" class="notranslate"><span class="hl-type">Team</span>::<span class="hl-property">cursor</span>()
    -&gt;<span class="hl-property">filter</span>(<span class="hl-keyword">fn</span> (<span class="hl-injection"><span class="hl-type">Team</span> $team</span>) =&gt; <span class="hl-variable">$team</span>-&gt;<span class="hl-property">hasActiveSubscriptionOrIsOnGenericTrial</span>())
    -&gt;<span class="hl-property">flatMap</span>(<span class="hl-keyword">fn</span> (<span class="hl-injection"><span class="hl-type">Team</span> $team</span>) =&gt; <span class="hl-variable">$team</span>-&gt;<span class="hl-property">sites</span>)
    -&gt;<span class="hl-property">map</span>(<span class="hl-keyword">fn</span> (<span class="hl-injection"><span class="hl-type">Site</span> $site</span>) =&gt; <span class="hl-variable">$site</span>-&gt;<span class="hl-property">domain</span>())
    -&gt;<span class="hl-property">each</span>(<span class="hl-keyword">fn</span> (<span class="hl-injection"><span class="hl-type">string</span> $output</span>) =&gt; <span class="hl-keyword">echo</span> <span class="hl-variable">$output</span> . <span class="hl-property">PHP_EOL</span>);
</pre>
<p>This produced a new-line separated list of domains that we need to check.</p>
<p>Let's save those in <code>domains.txt</code>, since we're moving to some CLI tricks now.</p>
<h2 id="retrieving-the-serial-for-each-certificate">Retrieving the serial for each certificate <a href="#retrieving-the-serial-for-each-certificate" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#retrieving-the-serial-for-each-certificate">#</a></h2>
<p>Now we find the active <em>Serial Number</em> for each of those certificates. It involves connecting to each site over SSL/TLS, getting the certificate and saving the <em>Serial Number</em>.</p>
<p>The original idea came from <a href="https://news.ycombinator.com/item?id=22475943">a Hacker News comment</a>, we modified it to get some better error handling and control of the output.</p>
<pre data-lang="bash" class="notranslate"><span class="hl-comment"># Create a directory to hold all serial numbers</span>
<span class="hl-keyword">mkdir</span> <span class="hl-attribute">-p</span> serials

<span class="hl-comment"># Loop all domains, connect and fetch the serial</span>
for i in $(cat domains.txt); do
  <span class="hl-keyword">echo</span> <span class="hl-value">&quot;Connecting to $i ... &quot;</span>

  (
    openssl s_client <span class="hl-attribute">-connect</span> <span class="hl-variable">$i</span>:443 <span class="hl-attribute">-servername</span> <span class="hl-variable">$i</span> <span class="hl-attribute">-showcerts</span> &lt; /dev/null 2&gt; /dev/null |
    openssl x509 <span class="hl-attribute">-text</span> <span class="hl-attribute">-noout</span> |
    <span class="hl-keyword">grep</span> <span class="hl-attribute">-A</span> 1 <span class="hl-value">&quot;Serial Number&quot;</span> |
    tr <span class="hl-attribute">-d</span> : |
    tail <span class="hl-attribute">-n</span> 1
  ) | tee serials/<span class="hl-variable">$i</span>;

done
</pre>
<p>The <code>openssl s_client</code> connects to the domain (using Server Name Indication (SNI) with the <code>-servername</code> option) and lists all certificates.</p>
<p>Now, in <code>serials/*</code>, we have a directory full of domain names and their corresponding certificate serial.</p>
<h2 id="combining-all-serials">Combining all serials <a href="#combining-all-serials" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#combining-all-serials">#</a></h2>
<p>We'll make a single list with all the serials we need to check. This way, we can optimize our <code>grep</code> commands for later.</p>
<pre data-lang="bash" class="notranslate">$ <span class="hl-keyword">cat</span> serials/* | tr <span class="hl-attribute">-d</span> <span class="hl-value">&quot; &quot;</span> | sort | uniq &gt; serial-numbers.txt
</pre>
<p>The file <code>serial-numbers.txt</code> is now a gigantic list of serial numbers.</p>
<h2 id="finding-the-serials-in-the-12gb-text-file">Finding the serials in the 1.2GB text file <a href="#finding-the-serials-in-the-12gb-text-file" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#finding-the-serials-in-the-12gb-text-file">#</a></h2>
<p>Let's Encrypt has <a href="https://letsencrypt.org/caaproblem/">released a text-file with all affected certificates</a>. This file includes the Serial Number (which we now have) together with all domains/SANs on the certificate.</p>
<p>Our first attempt was to simply <code>grep</code> our way through the file for each serial found. But <code>grep</code> is single-threaded, so we could only utilize a single CPU core for searching through a pretty big file.</p>
<p>This was taking too long, so we quickly adapted our method and started to search through the log in parallel.</p>
<p>Lucky for us, we started preparing a new set of servers <a href="/features/broken-page-and-mixed-content-detection">for our crawlers</a> that check for broken links last week. Those servers were still idling as they aren't in production yet. This was the perfect time to use that spare capacity.</p>
<p>First, we split the big file of serials (called <code>serial-numbers.txt</code>) in many equal pieces.</p>
<pre data-lang="bash" class="notranslate">$ split <span class="hl-attribute">-l</span> 1000 serial-numbers.txt
</pre>
<p>This gives us a list of many files, all with 1000 serial numbers in it. The file naming is predictable:</p>
<pre data-lang="txt" class="notranslate">$ ls -l x*
-rw-rw-r-- 1 immutable immutable 2672 Mar  3 20:27 xaa
-rw-rw-r-- 1 immutable immutable 2948 Mar  3 20:27 xab
-rw-rw-r-- 1 immutable immutable 2948 Mar  3 20:27 xac
-rw-rw-r-- 1 immutable immutable 2960 Mar  3 20:27 xad
</pre>
<p>In order to utilize all our cores, we used each file as the pattern input to <code>grep</code> and sent the job to the background for processing.</p>
<pre data-lang="bash" class="notranslate">for file in $(ls x*); do
	\grep <span class="hl-attribute">-P</span> <span class="hl-value">&quot;$(cat $file | tr &quot;</span>\n<span class="hl-value">&quot; &quot;</span>|<span class="hl-value">&quot; | sed -e 's/|/\|/g' | sed -e 's/|$//' )&quot;</span> ../ssl-cert/caa-rechecking-incident-affected-serials.txt &gt;&gt; results.txt &amp;
done
</pre>
<p>That rather ugly-looking <code>tr</code> &amp; <code>sed</code> pipeline in there transforms the input file from a new-line separated list of serials, to a <code>|</code>-separated list. This is used in <code>grep</code> to indicate the &quot;or&quot; statement, any line may match.</p>
<p>In the long form, it turns our input of this:</p>
<pre data-lang="bash" class="notranslate">$ <span class="hl-keyword">cat</span> xaa
01009ba...
0111839...
011539e...
0135d43...
</pre>
<p>... into this:</p>
<pre data-lang="txt" class="notranslate">01009ba...|0111839...|011539e...|0135d43...
</pre>
<p>Because we sent each <code>grep</code> command to the background in our <code>for</code>-loop, using the <code>&amp;</code> at the end of the command, we now have many <code>grep</code>'s running in parallel.</p>
<p>What followed was, to me as a sysadmin, a thing of beauty. ?</p>
<p><img src="/uploads/blogs/letsencrypt-revocation-check/server-usage.png" alt="Server Utilization of the Lets Encrypt checks" /></p>
<p>The crunching continued for a while, and we now had a list of affected serials stored in <code>results.txt</code>.</p>
<p>At this point, things were getting a bit late, so we resorted to even <em>weirder Bashness</em> to match these serials back to the domain names.</p>
<h2 id="matching-the-serials-back-to-domains">Matching the serials back to domains <a href="#matching-the-serials-back-to-domains" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#matching-the-serials-back-to-domains">#</a></h2>
<p>We loop each affected serial and match it back to the domain:</p>
<pre data-lang="bash" class="notranslate">for line in $(cat results.txt ); do
	\grep <span class="hl-variable">$line</span> serials/*;
done |
	<span class="hl-keyword">awk</span> <span class="hl-value">'{print $1}'</span> |
	<span class="hl-keyword">sed</span> <span class="hl-value">'s/\// /'</span> |
	<span class="hl-keyword">awk</span> <span class="hl-value">'{print $2}'</span> |
	<span class="hl-keyword">sed</span> <span class="hl-value">'s/:/ /'</span> |
	<span class="hl-keyword">awk</span> <span class="hl-value">'{print $1}'</span>
</pre>
<p>Looking at it now, awake, it could've been much cleaner. But, it got the job done! We now have a list of domain names of clients we need to notify.</p>
<h2 id="sending-the-mails-to-clients">Sending the mails to clients <a href="#sending-the-mails-to-clients" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#sending-the-mails-to-clients">#</a></h2>
<p>To inform our clients, we resorted back to PHP. This allows us to send the notification e-mails in our own style/branding.</p>
<pre data-lang="php" class="notranslate"><span class="hl-type">Mail</span>::<span class="hl-property">to</span>(<span class="hl-variable">$users</span>)-&gt;<span class="hl-property">send</span>(<span class="hl-keyword">new</span> <span class="hl-type">LetsencryptRevokedMail</span>(<span class="hl-variable">$domain</span>));
</pre>
<p>It uses the power of <a href="https://laravel.com/docs/master/mail">Laravel Mailables</a> to make this really easy.</p>
<h2 id="a-rush-job-because-time-was-against-us">A rush job because time was against us <a href="#a-rush-job-because-time-was-against-us" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#a-rush-job-because-time-was-against-us">#</a></h2>
<p>We didn't do as a clean a job as we'd normally do. There were no tests, no clean integrations, and most of it was <em>hacked</em> together on very short-notice.</p>
<p>But, there wasn't much choice. The list of affected certificates was released yesterday, and within 48 hours the revocation was to take place. It was up to us to notify our users <em>asap</em>. After all, those affected still needed to renew their certificates!</p>
<p>We're happy to see the list of affected domains beforehand though. The normal procedure is that the revoced certificates end up in Certificate Revocation Lists (CRL), but at that point the revocation <em>has already happened</em>.</p>
<p>This allowed us to be proactive and inform clients ahead of time!</p>
]]>
            </summary>
                                    <updated>2020-03-04T07:41:06+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[A fresh new look for Oh Dear]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/a-fresh-new-look-for-oh-dear" />
            <id>https://ohdear.app/27</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>We're super proud to show the new <em>us</em> - we have a brand new logo, an entirely new style and a new website. In this post, we'll show you all the changes!</p>
<h2 id="a-rename-of-oh-dear">A rename of Oh Dear <a href="#a-rename-of-oh-dear" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#a-rename-of-oh-dear">#</a></h2>
<p>As part of our change, we're making a tiny change to our name: we are no longer &quot;Oh Dear!&quot; but are now &quot;Oh Dear&quot;. No more exclamation point at the end.</p>
<p>The reason is perhaps even more boring than the name change itself: it's super hard to write good content when it looks like, every time you mention Oh Dear!, the sentence stopped midway. See? Super confusing. Let's stop doing that.</p>
<h2 id="a-brand-new-logo">A brand new logo <a href="#a-brand-new-logo" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#a-brand-new-logo">#</a></h2>
<p>Our previous logo, while original, had sort of lost its appeal to us. We'll miss our British monocle-wearing friend, but couldn' be happier with our newly updated logo.</p>
<p>For old times' sake, here he is one more time. <em>Enjoy your time off, buddy.</em></p>
<p><img src="/uploads/blogs/redesign-202002/ohdear-logo-2018.png" alt="Old Oh Dear logo from 2018" /></p>
<p>For our new logo, we partnered with <a href="https://rikgrafiek.be/">Rik Grafiek, a creative from Ghent, Belgium</a>.</p>
<p>Our new look is clean, professional and modern - with lots going on behind the scenes.</p>
<p><img src="/uploads/blogs/redesign-202002/ohdear-logo-2020.png" alt="New Oh Dear logo (2020)" /></p>
<p>If you have to explain a logo, it isn't a good one. We hope you'll notice the following accents in our new style:</p>
<ul>
<li>It looks like a cardiac flatline - that's when we spring to live and start sending alerts ?</li>
<li>The &quot;o&quot; in Oh Dear is a magnifying glass, because we look for <a href="/features/broken-page-and-mixed-content-detection">broken links</a> and other flaws in your sites ?</li>
</ul>
<p>With a new <em>logo</em> also comes a new <em>avatar</em> for use in social media &amp; <a href="/features/notifications">our notifications</a>.</p>
<p><img src="/uploads/blogs/redesign-202002/ohdear-avatar.png" alt="New Oh Dear avatar (2020)" /></p>
<p>You'll find a subtle reference to the wink of our old monocle-wearing friend. ?</p>
<h2 id="refreshing-our-postcards-as-well">Refreshing our postcards as well <a href="#refreshing-our-postcards-as-well" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#refreshing-our-postcards-as-well">#</a></h2>
<p>Every new client of Oh Dear automatically gets a postcard sent to their address, as a special <em>thank you</em> for trusting us with the important task of monitoring your websites.</p>
<p>Our new look &amp; feel also allowed us to rethink our postcards. Left you'll find our old postcard, to the right our new one.</p>
<p><img src="/uploads/blogs/redesign-202002/postcards-2020.png" alt="Oh Dear postcards in 2020" /></p>
<p>The new cards have an adapted version of our logo as the front cover.</p>
<p>These are true collectors' items. The only way to get one, is to <a href="https://ohdear.app/register">sign up and become an Oh Dear user</a>. ?</p>
<h2 id="the-new-look-amp-feel-of-the-website">The new look &amp; feel of the website <a href="#the-new-look-amp-feel-of-the-website" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#the-new-look-amp-feel-of-the-website">#</a></h2>
<p><a href="/">The homepage</a> got a very slick new feel to it.</p>
<p><img src="/uploads/blogs/redesign-202002/ohdear-homepage-2020-part-1.png" alt="Homepage of Oh Dear - part 1" /></p>
<p><img src="/uploads/blogs/redesign-202002/ohdear-homepage-2020-part-2.png" alt="Homepage of Oh Dear - part 2" /></p>
<p>Our website has changed a lot over the 3 years we've been in business. From left to right, these were the designs in 2017, 2018 and now in 2020.</p>
<p><img src="/uploads/blogs/redesign-202002/ohdear-homepage-collage.png" alt="Collage of homepage designs of Oh Dear" /></p>
<p>For a live version of our new design, <a href="/">have a look at our homepage</a>.</p>
<p>We've been adding more and more whitespace with every iteration. It's given us a tighter design and an overall increase in professionalisme to the website.</p>
<p>At least, <em>we</em> feel we've added professionalism - we can always agree to disagree. ?</p>
<h2 id="updated-documentation-layout">Updated documentation layout <a href="#updated-documentation-layout" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#updated-documentation-layout">#</a></h2>
<p>Our <a href="/docs/features/what-checks-does-oh-dear-perform">documentation section</a> is easily the biggest content-part of our website.</p>
<p><a href="/docs/features/what-checks-does-oh-dear-perform">Every check</a> we perform is documented, we have <a href="/docs/api/introduction">code examples</a> for interacting with our API, there are <a href="/docs/tools-and-sdks/the-oh-dear-php-sdk">PHP packages</a> for working with our webhooks, ... All this to say, our docs are pretty big.</p>
<p>From now on, they'll be visually different from our main website too. They deserve a little something, don't they?</p>
<p><img src="/uploads/blogs/redesign-202002/ohdear-docs-2020.png" alt="The documentation layout of Oh Dear" /></p>
<h2 id="new-stickers-are-coming">New stickers are coming! <a href="#new-stickers-are-coming" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#new-stickers-are-coming">#</a></h2>
<p>We'll be handing these out at conferences like candy, if you want one - make sure to talk to <a href="https://twitter.com/freekmurze">Freek</a> or <a href="https://twitter.com/mattiasgeniar">Mattias</a>!</p>
<p><img src="/uploads/blogs/redesign-202002/ohdear-stickers-2020.png" alt="Collage of homepage designs of Oh Dear" /></p>
<p>Now that we have a professional brand &amp; style guide, we also have name tags and some other ideas for <em>branded gear</em> to hand out.</p>
<h2 id="to-celebrate-were-giving-away-a-year-worth-of-monitoring-services">To celebrate, we're giving away a year worth of monitoring services! <a href="#to-celebrate-were-giving-away-a-year-worth-of-monitoring-services" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#to-celebrate-were-giving-away-a-year-worth-of-monitoring-services">#</a></h2>
<p>Because we're thrilled about this update, we want to share our enthousiasm with the rest of the world!</p>
<p>We're giving away a year worth of monitoring for free to one lucky winner in the contest. To enter the Oh Dear giveaway, complete the form right there. ?</p>
<h2 id="what-do-you-think">What do you think? <a href="#what-do-you-think" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#what-do-you-think">#</a></h2>
<p>We'd love to hear feedback!</p>
<p>Do you like the new design? If not, what bothers you? Do you still see places where we refer to our old logo instead of the new one? Do let us know!</p>
]]>
            </summary>
                                    <updated>2026-06-15T12:32:50+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Adding maintenance windows to Oh Dear]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/adding-maintenance-windows-to-oh-dear" />
            <id>https://ohdear.app/26</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>We've just released a new feature: maintenance windows!</p>
<p>You can now place your site in maintenance which will mute all notifications during that period.</p>
<h2 id="setting-maintenance-windows">Setting maintenance windows <a href="#setting-maintenance-windows" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#setting-maintenance-windows">#</a></h2>
<p>You can add a maintenance period per website. You set the start and end date, and during that timeframe the notifications will be muted.</p>
<p>We'll still perform our checks, so you can see in your website history when the site <em>was</em> unavailable, but all notifications will be stopped so no one will be disturbed.</p>
<p><img src="/uploads/blogs/maintenance-periods/maintenance-period.png" alt="Maintenance Periods" /></p>
<p>In your dashboard you can click through on any site and find the <em>Maintenance periods</em> menu item on the left.</p>
<h2 id="setting-on-demand-maintenance-windows">Setting on-demand maintenance windows <a href="#setting-on-demand-maintenance-windows" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#setting-on-demand-maintenance-windows">#</a></h2>
<p>One implementation we've always frowned upon (a bit) was the idea of recurring maintenance windows. A fixed time where all alerts would be muted.</p>
<p>Sure, in some cases <em>actual</em> maintenance would happen. But how often would a recurring maintenance window be configured where <em>nothing</em> happens?</p>
<p>And if there <em>was</em> real downtime during that time, would anyone know?</p>
<p>For that reason we took a different approach. On the maintenance overview, we give the actual API commands you could use to implement this in your deploy or maintenance script. It's super easy.</p>
<pre data-lang="bash" class="notranslate">$ OHDEAR_TOKEN=<span class="hl-value">&quot;your API token&quot;</span>

$ <span class="hl-keyword">curl</span> <span class="hl-attribute">-X</span> POST https://ohdear.app/api/sites/1/start-maintenance \
    <span class="hl-attribute">-H</span> <span class="hl-value">&quot;Authorization: Bearer $OHDEAR_TOKEN&quot;</span> \
    <span class="hl-attribute">-H</span> <span class="hl-value">'Accept: application/json'</span> \
    <span class="hl-attribute">-H</span> <span class="hl-value">'Content-Type: application/json'</span>
	
$ <span class="hl-keyword">curl</span> <span class="hl-attribute">-X</span> POST https://ohdear.app/api/sites/1/stop-maintenance \
    <span class="hl-attribute">-H</span> <span class="hl-value">&quot;Authorization: Bearer $OHDEAR_TOKEN&quot;</span> \
    <span class="hl-attribute">-H</span> <span class="hl-value">'Accept: application/json'</span> \
    <span class="hl-attribute">-H</span> <span class="hl-value">'Content-Type: application/json'</span>
</pre>
<p>The first API call starts a maintenance window for that site (which defaults to 60 minutes, <a href="/docs/api/maintenance-windows#creating-a-new-maintenance-period-on-demand">but you can customize that</a>), the second API call stops the maintenance window.</p>
<p>If you add this as the first &amp; last steps in your deploy scripts, you've just configured on-demand maintenance windows. No matter when you deploy or how long it might take, the maintenance window will be timed <em>just right</em>.</p>
<h2 id="more-api-commands">More API commands <a href="#more-api-commands" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#more-api-commands">#</a></h2>
<p>Of course the API also allows for pre-defined maintenance windows (as does our dashboard). You can still configure a start- and end date manually for alerts to be muted.</p>
<p>You can find all <a href="/docs/api/maintenance-windows">API calls related to maintenance windows</a> right in our documentation, as well as updated information on using <a href="/docs/tools-and-sdks/the-oh-dear-php-sdk">the PHP SDK to start/stop a maintenance window</a> for a site.</p>
]]>
            </summary>
                                    <updated>2026-06-05T10:22:16+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Chrome's security push makes mixed-content checks vital]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/chromes-next-steps-for-security-mixed-content-checking-more-important-than-ever" />
            <id>https://ohdear.app/25</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>The team at Chrome has <a href="https://security.googleblog.com/2019/10/no-more-mixed-messages-about-https_3.html">announced their plans for handling mixed content last week</a> on their blog.</p>
<p>We'll highlight the most important details and what the potential impact is.</p>
<h2 id="a-security-first-approach-what-is-mixed-content">A security-first approach: what is mixed content? <a href="#a-security-first-approach-what-is-mixed-content" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#a-security-first-approach-what-is-mixed-content">#</a></h2>
<p>As a general reminder: &quot;mixed content&quot; exists when you have a website loaded via <code>HTTPS</code>, that loads insecure resources over plain <code>HTTP</code>. These can be images, CSS or even JavaScript &amp; iFrames.</p>
<p>Certain types of mixed content are already blocked. They won't load in a secure context (= <code>HTTPS</code> site) and your users might find a half-broken or fully broken website. In the console, you'll see notices like these.</p>
<blockquote>
<p>Mixed Content: The page at 'https://domain.tld' was loaded over HTTPS, but requested an insecure resource 'http://domain.tld/iframe'. This request has been blocked; the content must be served over HTTPS.</p>
</blockquote>
<p>The goal is to protect the user: websites served via <code>HTTPS</code> have an encrypted connection between the user and the server. A man-in-the-middle attack is a lot harder to perform when a site is loaded via HTTPS than via HTTP.</p>
<p>Images, CSS or JavaScript loaded via HTTP could then still be intercepted and modified, potentially compromising your users.</p>
<h2 id="chromes-plan-to-auto-upgrade-mixed-content">Chrome's plan to auto-upgrade mixed content <a href="#chromes-plan-to-auto-upgrade-mixed-content" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#chromes-plan-to-auto-upgrade-mixed-content">#</a></h2>
<p>It would have been pretty good for our business use case if Chrome decided to all-out block mixed content. :-)</p>
<p>But, luckily for the smooth operation of the web, <a href="https://security.googleblog.com/2019/10/no-more-mixed-messages-about-https_3.html">they're going about it much smarter</a>.</p>
<p>The timeline is as follows:</p>
<blockquote>
<p>In Chrome 80, mixed audio and video resources will be autoupgraded to https://, and Chrome will block them by default if they fail to load over https://. Chrome 80 will be released to early release channels in January 2020.</p>
</blockquote>
<blockquote>
<p>Also in Chrome 80, mixed images will still be allowed to load, but they will cause Chrome to show a “Not Secure” chip in the omnibox. We anticipate that this is a clearer security UI for users and that it will motivate websites to migrate their images to HTTPS.</p>
</blockquote>
<p>Marking websites as non-secure has been a gradual process since early last year, when <a href="https://www.blog.google/products/chrome/milestone-chrome-security-marking-http-not-secure/">all HTTP sites were shown a similar message</a>.</p>
<p>Early next year, Chrome 81 will force upgrade all non-secure elements on a secure page to HTTPS.</p>
<blockquote>
<p>In Chrome 81, mixed images will be autoupgraded to https://, and Chrome will block them by default if they fail to load over https://. Chrome 81 will be released to early release channels in February 2020.</p>
</blockquote>
<p>In other words: if your images, css or JavaScript aren't able to be served by HTTPS, they'll be blocked by default.</p>
<h2 id="start-scanning-for-mixed-content-today">Start scanning for mixed content today <a href="#start-scanning-for-mixed-content-today" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#start-scanning-for-mixed-content-today">#</a></h2>
<p>This is probably a good time to mention that Oh Dear! has <a href="/docs/features/mixed-content-reporting">mixed content checking</a> available for all our users (on top of our <a href="/features/broken-page-and-mixed-content-detection">broken links checking</a>).</p>
<p>If you don't want to be caught by surprise early next year, make sure you monitor for mixed content and preventively upgrade all your links &amp; resources to HTTPS.</p>
<p>This would be especially important if your assets are served on a subdomain or different server that isn't capable of serving HTTPS just yet. Once Chrome auto-upgrades those to HTTPS, they'll stop working for your users altogether.</p>
]]>
            </summary>
                                    <updated>2026-06-05T10:09:43+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Serving status pages with Caddy & Laravel subdomains]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/how-we-used-caddy-and-laravels-subdomain-routing-to-serve-our-status-pages" />
            <id>https://ohdear.app/23</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>We recently launched our <a href="/features/status-pages">new Status Page feature</a>. Under the hood, it's using the <a href="https://caddyserver.com/">Caddy proxy server</a> and <a href="https://laravel.com/docs/5.8/routing#route-group-sub-domain-routing">Laravel's subdomain routing</a> to serve the right status page on the right domain.</p>
<p>With this technology stack, we can automatically generate, configure &amp; renew the SSL certificates for custom domains of our clients.</p>
<p>In this post we'll deep dive in to our current setup.</p>
<h2 id="caddy-to-serve-and-manage-all-ssltls-certificates">Caddy to serve and manage all SSL/TLS certificates <a href="#caddy-to-serve-and-manage-all-ssltls-certificates" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#caddy-to-serve-and-manage-all-ssltls-certificates">#</a></h2>
<p>Caddy is a powerful server that excels at configuring SSL/TLS certificates on-the-fly, when a user first connects to their domain. You configure Caddy through a single <code>Caddyfile</code> configuration file.</p>
<p>Our latest <code>Caddyfile</code> config can always be found in our repo <a href="https://github.com/ohdearapp/status.ohdear.app-Caddyfile">ohdearapp/status.ohdear.app-Caddyfile</a>. We'll describe the interesting bits in this post.</p>
<p>First, we want to make sure every domain automatically runs on HTTPS instead of the unsecure HTTP.</p>
<pre data-lang="nginx" class="notranslate"><span class="hl-keyword">http</span>:// <span class="hl-operator">{</span>
  	redir https://<span class="hl-operator">{</span>host<span class="hl-operator">}</span><span class="hl-operator">{</span>uri<span class="hl-operator">}</span>
<span class="hl-operator">}</span>
</pre>
<p>This makes sure any connection attempt on <code>http://</code> gets translated to <code>https://</code>. Caddy does this automatically too, but we want to be explicit and prevent our PHP code from first rewriting the domain before an HTTPS upgrade occurs.</p>
<p>Next up is the config that allows Caddy to handle the automatic certificate issuance.</p>
<pre data-lang="txt" class="notranslate">https:// {

	tls {
		ask https://ohdear.app/caddy/allowed-domain
	}
	
	[...]
}
</pre>
<p>We catch every <code>https://</code> URL that makes it to this server. The <code>tls</code> directive is then used to instruct Caddy to issue Let's Encrypt certificates automatically, in case it doesn't yet have a certificate.</p>
<p>Because we don't want to try to issue a certificate for every domain that connects to us <em>(that might trigger our Let's Encrypt rate limits)</em>, we use Caddy's <code>ask</code> feature to callback to Oh Dear! and ask if that domain is <em>allowed</em> to be issued a certificate.</p>
<p>Caddy does this by firing a <code>GET</code> request to our URL. If that returns an <code>HTTP/200</code>, it's allowed to continue. The URLs look like this.</p>
<pre data-lang="txt" class="notranslate">https://ohdear.app/caddy/allowed-domain?domain=status.ohdear.app
https://ohdear.app/caddy/allowed-domain?domain=status.dnsspy.io
... 
</pre>
<p>Because we don't really want to expose all our status page URLs to someone brute forcing that URL, HTTP calls are only allowed from the Caddy servers' IP, using a custom <a href="https://laravel.com/docs/5.8/middleware">HTTP Middleware</a> in Laravel.</p>
<p>Once the domain is allowed, we will proxy the result back to our main application, but modify the <code>Host</code> header ever so slightly.</p>
<pre data-lang="nginx" class="notranslate">https:// <span class="hl-operator">{</span>
	[...]
	
	proxy / <span class="hl-keyword">http</span>://ohdear.app <span class="hl-operator">{</span>
		<span class="hl-comment"># We use Laravel's subdomain routing to match</span>
		<span class="hl-comment"># this domain to the right status page</span>
		header_upstream Host <span class="hl-operator">{</span>host<span class="hl-operator">}</span>.status.ohdearapp.com
		
		<span class="hl-comment"># Confirm the request came from our Caddy proxy</span>
		header_upstream StatusPageHost <span class="hl-operator">{</span>host<span class="hl-operator">}</span>

		<span class="hl-comment"># Add headers any proxy would expect</span>
		header_upstream X-Real-IP <span class="hl-operator">{</span>remote<span class="hl-operator">}</span>
		header_upstream X-Forwarded-For <span class="hl-operator">{</span>remote<span class="hl-operator">}</span>
		header_upstream X-Forwarded-Port <span class="hl-operator">{</span>server_port<span class="hl-operator">}</span>
		header_upstream X-Forwarded-Proto <span class="hl-operator">{</span>scheme<span class="hl-operator">}</span>

		<span class="hl-comment"># We should never take more than 5s to load</span>
		timeout 5s
	<span class="hl-operator">}</span>
	
	[...]
<span class="hl-operator">}</span>
</pre>
<p>By modifying the <code>Host</code> header that gets sent back to us, we can inject the domain name as a subdomain: <code>header_upstream Host {host}.status.ohdearapp.com</code>.</p>
<p>Our application therefore gets a request to <code>status.dnsspy.io.status.ohdearapp.com</code>.</p>
<p>At that point, it's up to Laravel to handle the request.</p>
<h2 id="subdomain-routing-in-laravel">Subdomain routing in Laravel <a href="#subdomain-routing-in-laravel" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#subdomain-routing-in-laravel">#</a></h2>
<p>To make this work, we use <a href="https://laravel.com/docs/5.8/routing#route-group-sub-domain-routing">Laravel's subdomain routing</a>.</p>
<p>Our <code>RouteServiceProvider.php</code> contains something similar to this.</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$router</span>-&gt;<span class="hl-property">domain</span>(<span class="hl-value">'{domain}.'</span>. <span class="hl-property">config</span>(<span class="hl-value">'app.url'</span>))-&gt;<span class="hl-property">group</span>(<span class="hl-keyword">function</span> () {
    <span class="hl-keyword">require</span> <span class="hl-property">base_path</span>(<span class="hl-value">'routes/status-pages.php'</span>);
});
</pre>
<p>The rest of our application (the public site, documentation, the dashboard etc.) get limited to only be served on the <code>ohdear.app</code> domain.</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$router</span>-&gt;<span class="hl-property">group</span>([
    <span class="hl-value">'middleware'</span> =&gt; [<span class="hl-value">'web'</span>, <span class="hl-value">'hasTeam'</span>],
    <span class="hl-value">'domain'</span> =&gt; <span class="hl-property">config</span>(<span class="hl-value">'app.url'</span>) ,
], <span class="hl-keyword">function</span> () {
    <span class="hl-keyword">require</span> <span class="hl-property">base_path</span>(<span class="hl-value">'routes/front.php'</span>);
});
</pre>
<p>Because domain names can contain periods or dashes, we modified the <code>RouteServiceProvider.php</code> to let Laravel receive the full domain name as a variable.</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">class</span> <span class="hl-type">RouteServiceProvider</span> <span class="hl-keyword">extends</span> <span class="hl-type">ServiceProvider</span>
{
    <span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">map</span>(<span class="hl-injection"><span class="hl-type">Router</span> $router</span>)
    {
        <span class="hl-type">Route</span>::<span class="hl-property">pattern</span>(<span class="hl-value">'domain'</span>, <span class="hl-value">'[a-z0-9.-]+'</span>);
    }
}
</pre>
<p>By default, it will only match <code>[a-z0-9]+</code>.</p>
<p>Our <code>CustomSubdomainShowStatusPageController</code> will then get the full domain name and use it to retrieve the details of the correct status page, and render it to our users.</p>
<h2 id="a-slightly-modified-webserver-configuration">A slightly modified webserver configuration <a href="#a-slightly-modified-webserver-configuration" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#a-slightly-modified-webserver-configuration">#</a></h2>
<p>Because we're now receiving a set of unknown subdomains, we modified our Apache vhost to act as a catch-all vhost. In other words: any domain that points to this server, will hit our Oh Dear! application.</p>
<p>We do this by creating a new vhost and adding a <code>ServerAlias</code> of <code>*</code> in Apache.</p>
<pre data-lang="apache" class="notranslate">&lt;<span class="hl-keyword">VirtualHost</span> *:80&gt;
  <span class="hl-keyword">ServerName</span>              status.ohdearapp.com
  <span class="hl-keyword">ServerAlias</span>             *
  
  [...]
  
  <span class="hl-keyword">RewriteCond</span> %{HTTP:StatusPageHost}  ^$
  <span class="hl-keyword">RewriteRule</span> ^ https://ohdear.app%{REQUEST_URI} [L,R=301]
&lt;/<span class="hl-keyword">VirtualHost</span>&gt;
</pre>
<p>The <code>RewriteRule</code> at the bottom makes it so that any domain that hits this vhost, but didn't have the custom <code>StatusPageHost</code> header, will be redirected to our homepage.</p>
<p><em>(Note we're running Apache on port :80 with our own Nginx proxy serving TLS traffic on port :443.)</em></p>
<h2 id="forcing-a-single-domain-for-oh-dear-for-seo-purposes">Forcing a single domain for Oh Dear! for SEO purposes <a href="#forcing-a-single-domain-for-oh-dear-for-seo-purposes" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#forcing-a-single-domain-for-oh-dear-for-seo-purposes">#</a></h2>
<p>If you read the line <em>&quot;any domain that points to this server, will hit our Oh Dear! application</em>&quot; and you cringed a little, you might have thought about the SEO implications of doing such a thing. Well, we sure did.</p>
<p>Because of our modified routes-configuration, where we scope the status pages and our main application to a specific domain, we'll never accidentally show pages of our <em>main</em> app on the <em>status</em> pages.</p>
<p>In our main apache config, we have a hard-defined list of domains we want to serve.</p>
<pre data-lang="apache" class="notranslate">&lt;<span class="hl-keyword">VirtualHost</span> *:80&gt;
  <span class="hl-keyword">ServerName</span>  ohdearapp.com
  <span class="hl-keyword">ServerAlias</span> www.ohdearapp.com ohdear-app.com www.ohdear-app.com ohdear.app www.ohdear.app
  
  [...]
  
  <span class="hl-keyword">Options</span> +FollowSymlinks
  <span class="hl-keyword">RewriteEngine</span> <span class="hl-value">on</span>
  <span class="hl-keyword">RewriteCond</span> %{THE_REQUEST} !\s/api/? [NC]
  <span class="hl-keyword">RewriteCond</span> %{HTTP_HOST}   !^ohdear\.app [NC]
  <span class="hl-keyword">RewriteRule</span> ^              https://ohdear.app%{REQUEST_URI} [L,R=301]
&lt;/<span class="hl-keyword">VirtualHost</span>&gt;
</pre>
<p>Everything that gets processed by that vhost will trigger the rewrite rule at the bottom. In short: if you're accessing that vhost and your domain name isn't <code>ohdear.app</code> (but one of our aliases), we'll rewrite you to our main site.</p>
<p>Additionally, if you hit the <code>HTTP</code> version of our status pages on their subdomain (ie: <code>status.dnsspy.io.status.ohdearapp.com</code>), we consider that a failed request (it should have come from the Caddy proxy on <code>status.dnsspy.io</code>) and we'll redirect you to our main site.</p>
<p>We can test this with <code>curl</code>.</p>
<pre data-lang="txt" class="notranslate">$ curl -I &quot;http://94.176.99.159&quot; \
    -H &quot;Host: status.dnsspy.io.status.ohdearapp.com&quot;
HTTP/1.1 301 Moved Permanently
Location: https://ohdear.app/

$ curl -I https://status.dnsspy.io
HTTP/2 200

$ curl -I https://ohdearapp.com
HTTP/1.1 301 Moved Permanently
Location: https://ohdear.app/
</pre>
<p>Good, SEO secured &amp; status pages served!</p>
<p>And when something breaks, reach for <a href="/news-and-updates/incident-template-library">our incident template library</a>.</p>
<p>On top of this infrastructure you can <a href="/news-and-updates/how-to-create-dynamic-status-pages-on-demand">create dynamic status pages on demand</a>.</p>
<h2 id="want-to-give-our-status-pages-a-try">Want to give our Status Pages a try? <a href="#want-to-give-our-status-pages-a-try" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#want-to-give-our-status-pages-a-try">#</a></h2>
<p>We'd love it if you created your own status page and showed it to the world!</p>
<p>Go ahead and <a href="/register">create your account</a> to monitor your first site and publish your first status page.</p>
]]>
            </summary>
                                    <updated>2020-04-13T15:40:26+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Trigger an on demand uptime & broken links check after a deploy]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/trigger-an-on-demand-uptime-broken-links-check-after-a-deploy" />
            <id>https://ohdear.app/21</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>You can use our <a href="/docs/api/introduction">API</a> to trigger an <em>on demand</em> run of both the uptime check and the broken links checker. If you add this to, say, your deploy script, you can have near-instant validation that your deploy succeeded and didn't break any links &amp; pages.</p>
<h2 id="find-your-check-ids">Find your check IDs <a href="#find-your-check-ids" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#find-your-check-ids">#</a></h2>
<p>Our API allows you to trigger an on demand run <a href="/docs/api/checks">for every check we do</a>. But, it's an API - so it requires a set of IDs. First, let's find the different checks your site has. In order to use this API command, you need 2 things:</p>
<ol>
<li>An API key, which you can <a href="/user/api-tokens">generate in your user settings</a>.</li>
<li>The site ID, which you can find in the &quot;Settings&quot; menu for <a href="/monitors">every site</a>, all the way at the bottom (in this example, we'll take site ID 7024)</li>
</ol>
<p>Let's find the different checks that are enabled.</p>
<pre data-lang="bash" class="notranslate">$ <span class="hl-keyword">curl</span>  https://ohdear.app/api/sites/7024 \
    <span class="hl-attribute">-H</span> <span class="hl-value">'Authorization: Bearer [your-api-token-here] '</span> \
    <span class="hl-attribute">-H</span> <span class="hl-value">'Accept: application/json'</span> \
    <span class="hl-attribute">-H</span> <span class="hl-value">'Content-Type: application/json'</span> | jq

{
  <span class="hl-value">&quot;id&quot;</span>: 7024,
  <span class="hl-value">&quot;url&quot;</span>: <span class="hl-value">&quot;https://ohdear.app&quot;</span>,
  [...]
  <span class="hl-value">&quot;checks&quot;</span>: [
    {
      <span class="hl-value">&quot;id&quot;</span>: 32077,
      <span class="hl-value">&quot;type&quot;</span>: <span class="hl-value">&quot;uptime&quot;</span>,
    },
    {
      <span class="hl-value">&quot;id&quot;</span>: 32078,
      <span class="hl-value">&quot;type&quot;</span>: <span class="hl-value">&quot;broken_links&quot;</span>,
    },
    {
      <span class="hl-value">&quot;id&quot;</span>: 32079,
      <span class="hl-value">&quot;type&quot;</span>: <span class="hl-value">&quot;mixed_content&quot;</span>,
    },
    {
      <span class="hl-value">&quot;id&quot;</span>: 32080,
      <span class="hl-value">&quot;type&quot;</span>: <span class="hl-value">&quot;certificate_health&quot;</span>,
    },
    {
      <span class="hl-value">&quot;id&quot;</span>: 32081,
      <span class="hl-value">&quot;type&quot;</span>: <span class="hl-value">&quot;certificate_transparency&quot;</span>,
    }
  ],
}

</pre>
<p>Some output was truncated, but you get the idea. There's a bunch of checks, each with their own ID.</p>
<p>Take the check you'd like to run on demand (<code>32077</code> for the uptime check, <code>32078</code> for the broken links check etc.).</p>
<h2 id="trigger-an-on-demand-run-with-curl">Trigger an on demand run with curl <a href="#trigger-an-on-demand-run-with-curl" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#trigger-an-on-demand-run-with-curl">#</a></h2>
<p>Most deploy scripts allow you to execute some raw commands. In this example, we'll use <code>curl</code> together with your <a href="/docs/api/introduction">your API key</a> to instruct Oh Dear! to perform a full site check, searching for broken links.</p>
<pre data-lang="bash" class="notranslate">$ <span class="hl-keyword">curl</span> <span class="hl-attribute">-X</span> POST https://ohdear.app/api/checks/32077/request-run \
    <span class="hl-attribute">-H</span> <span class="hl-value">'Authorization: Bearer [your-api-token-here]'</span> \
    <span class="hl-attribute">-H</span> <span class="hl-value">'Accept: application/json'</span> \
    <span class="hl-attribute">-H</span> <span class="hl-value">'Content-Type: application/json'</span> | jq
{
  <span class="hl-value">&quot;id&quot;</span>: 32077,
  <span class="hl-value">&quot;type&quot;</span>: <span class="hl-value">&quot;uptime&quot;</span>,
  <span class="hl-value">&quot;label&quot;</span>: <span class="hl-value">&quot;Uptime&quot;</span>,
  <span class="hl-value">&quot;enabled&quot;</span>: true,
  <span class="hl-value">&quot;latest_run_ended_at&quot;</span>: <span class="hl-value">&quot;2019-07-10 12:35:40&quot;</span>,
  <span class="hl-value">&quot;latest_run_result&quot;</span>: <span class="hl-value">&quot;pending&quot;</span>
}
</pre>
<p>This uses the <code>/request-run</code> endpoint of <a href="/docs/api/checks">our API</a>, which you can trigger for every kind of check we have. In this case, we requested a run for the check with ID <code>32077</code>.</p>
<h2 id="tying-it-all-together">Tying it all together <a href="#tying-it-all-together" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#tying-it-all-together">#</a></h2>
<p>To combine it all in one easy flow, you can call the following <code>curl</code> commands to instruct Oh Dear! to do a full site check.</p>
<pre data-lang="bash" class="notranslate"><span class="hl-comment">#!/bin/bash</span>
API_TOKEN=[your-api-token-here]

UPTIME_ID=32077
BROKENLINKS_ID=32078

<span class="hl-comment"># Trigger an uptime check</span>
<span class="hl-keyword">curl</span> <span class="hl-attribute">-X</span> POST <span class="hl-value">&quot;https://ohdear.app/api/checks/$UPTIME_ID/request-run&quot;</span> \
    <span class="hl-attribute">-H</span> <span class="hl-value">&quot;Authorization: Bearer $API_TOKEN&quot;</span> \
    <span class="hl-attribute">-H</span> <span class="hl-value">&quot;Accept: application/json&quot;</span> \
    <span class="hl-attribute">-H</span> <span class="hl-value">&quot;Content-Type: application/json&quot;</span>

<span class="hl-comment"># Trigger the broken links check</span>
<span class="hl-keyword">curl</span> <span class="hl-attribute">-X</span> POST <span class="hl-value">&quot;https://ohdear.app/api/checks/$BROKENLINKS_ID/request-run&quot;</span> \
    <span class="hl-attribute">-H</span> <span class="hl-value">&quot;Authorization: Bearer $API_TOKEN&quot;</span> \
    <span class="hl-attribute">-H</span> <span class="hl-value">&quot;Accept: application/json&quot;</span> \
    <span class="hl-attribute">-H</span> <span class="hl-value">&quot;Content-Type: application/json&quot;</span>

</pre>
<p>Pretty neat, hu?</p>
]]>
            </summary>
                                    <updated>2021-11-22T19:19:57+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Extending uptime monitoring with POST, PUT & PATCH methods]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/extending-uptime-monitoring-with-post-put-patch-methods" />
            <id>https://ohdear.app/19</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>Next to our standard uptime monitoring through <code>GET</code> requests, we've added support for <code>POST</code>, <code>PUT</code> &amp; <code>PATCH</code> methods too.</p>
<p>This builds on <a href="/news-and-updates/introducing-our-improved-uptime-check">our improved uptime check</a>.</p>
<h2 id="more-uptime-monitoring-capabilities">More uptime monitoring capabilities <a href="#more-uptime-monitoring-capabilities" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#more-uptime-monitoring-capabilities">#</a></h2>
<p>We've had the option to monitor websites via a <code>GET</code> request since the very launch. It allows us to probe and test 99% of the sites out there for their uptime and report the owner if it's down.</p>
<p>But it doesn't quite fit every use case, and extending the available methods opens up many new doors &amp; possibilities.</p>
<h2 id="adding-posts-put-amp-patch">Adding POSTS, PUT &amp; PATCH <a href="#adding-posts-put-amp-patch" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#adding-posts-put-amp-patch">#</a></h2>
<p>In your site settings, you can now choose which method we should use to probe your website. If you're choosing something other than a <code>GET</code> request, you can specify the <em>payload</em> too.</p>
<p><img src="/uploads/blogs/post-put-patch-methods/site_options.png" alt="Site settings - choose the method" /></p>
<p>This allows for some interesting extra checks;</p>
<ul>
<li>You can validate the search results on a page using <code>POST</code>, with the optional &quot;<em>Verify text</em>&quot;, you can make sure the page returns the items you expect</li>
<li>You can simulate a website login</li>
<li>You can simulate any form submission and the resulting page</li>
<li>You can probe advanced API endpoints that modify data with <code>PUT</code> or <code>PATCH</code></li>
</ul>
<p>And that's not everything.</p>
<h2 id="in-the-age-of-serverless-keep-your-functions-warm">In the age of serverless, keep your functions warm <a href="#in-the-age-of-serverless-keep-your-functions-warm" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#in-the-age-of-serverless-keep-your-functions-warm">#</a></h2>
<p>In one of our earlier blogposts, we mentioned how you can use our crawler to <a href="/news-and-updates/using-oh-dear-to-keep-your-varnish-cache-warm">keep your site caches warm</a>.</p>
<p>While this is super useful, it didn't catch every use case: your site is more than a series of pages, strung together.</p>
<p>Your site consists of a login form, a search field, a contact form, ... but our crawler only sees the forms, not the output <em>after</em> a form submission.</p>
<p>These new uptime methods allow you to keep your code &quot;warm&quot;, if you're using serverless solutions. This prevents (sometimes significant) delays in <em>boot</em> times for functions that aren't being hit too often.</p>
<p>By adding monitoring probes to those endpoints, you gain 2 important benefits;</p>
<ul>
<li>If it's down, you'll know about it</li>
<li>Your functions are always <em>warm</em> and ready to serve production traffic, without a delay</li>
</ul>
<p>These new methods also open the door to more advanced <em>scenarios</em>, where you can simulate most of the functionality of any website, giving you complete uptime coverage of your site - not just your homepage.</p>
]]>
            </summary>
                                    <updated>2020-06-22T06:50:27+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[We're a featured .app domain on Google's Registry]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/were-a-featured-app-domain-on-googles-registry" />
            <id>https://ohdear.app/18</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>We're proud to have been listed on <a href="https://www.registry.google/announcements/appsaroundtheworld/#!/">Google's 1 year anniversary post</a> about the launch of the .APP top level domain.</p>
<h2 id="a-year-of-app">A year of .APP <a href="#a-year-of-app" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#a-year-of-app">#</a></h2>
<p>A year ago, on May 8th 2018, Google launched a new top level domain (TLD) called <em>.app</em>.</p>
<p>At that time, Oh Dear! had just launched on the <em>ohdearapp.com</em> domain. It was a no-brainer for us to migrate our domain from <em>ohdearapp.com</em> to <em>ohdear.app</em>. The launch of the .APP TLD was perfectly timed for us!</p>
<p>Within the first couple of weeks, we registered the domain and moved all our services over to the <em>ohdear.app</em> domain.</p>
<h2 id="improved-security-with-hsts">Improved security with HSTS <a href="#improved-security-with-hsts" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#improved-security-with-hsts">#</a></h2>
<p>HSTS stands for <em>HTTP Strict Transport Security</em>. It's a mechanisme that allows a website to signal that it should only be reached via HTTPS - the encrypted HTTP - instead of the plain text HyperText Transfer Protocol.</p>
<p>When we were at our <em>.com</em> domain, we added the following header to our website and all its pages.</p>
<pre data-lang="txt" class="notranslate">$ curl -I https://ohdearapp.com
...
Strict-Transport-Security: max-age=31536000
</pre>
<p>Whenever a browser would visit our site, it would remember for 31536000 seconds (365 days) that it can only reach our domain via HTTPS.</p>
<p>This prevents man-in-the-middle attacks where a connection would be downgraded from HTTPS to HTTP to snoop on the data being transferred over the wire.</p>
<p>With the <em>.app</em> domain, we no long need this.</p>
<h2 id="tld-wide-implementation-of-hsts">TLD-wide implementation of HSTS <a href="#tld-wide-implementation-of-hsts" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#tld-wide-implementation-of-hsts">#</a></h2>
<p>More acronyms! ;-)</p>
<p>One of the nice features of having a <em>.APP</em> domain, is that it <em>automatically requires HTTPS</em>. There is no workaround.</p>
<p>Why? Because browsers have a thing called <em>preloaded HSTS lists</em>. Instead of waiting to visit a site for the first time, to read the HSTS header, browsers have lists of domains that want to have that configuration <em>preloaded</em>. Usually, those lists include specific domains.</p>
<p>However, for <em>.APP</em> (and a few others, like <em>.DEV</em>), there's a TLD-wide preload. That means browsers that trust this list (which is Chrome + Firefox and many others) will <em>automatically upgrade an HTTP connection to HTTPS for every domain ending in <em>.APP</em></em>.</p>
<p>We no longer need this header (although we might as well have just left if there, there's no harm in that) and it makes the entire <em>.APP</em> top level domain safer as it enforces HTTPS.</p>
<p>Since one of our focusses is the <a href="/docs/features/certificate-health-monitoring">extensive monitoring of HTTPS certificates</a>, we applaud any action that encourages the use of HTTPS over HTTP.</p>
]]>
            </summary>
                                    <updated>2020-06-22T06:50:20+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Using Oh Dear! to keep your Varnish cache warm]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/using-oh-dear-to-keep-your-varnish-cache-warm" />
            <id>https://ohdear.app/17</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>Slow websites are annoying, right? We sure think so.</p>
<p>One common solution is to introduce a caching proxy like <a href="https://varnish-cache.org/">Varnish</a> to help cache pages and reduce your server load.</p>
<p>The good news is, if you have Oh Dear!, you can let those 2 work together.</p>
<h2 id="varnish-oh-dear-crawling">Varnish + Oh Dear! crawling = ❤️ <a href="#varnish-oh-dear-crawling" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#varnish-oh-dear-crawling">#</a></h2>
<p>The idea is as follows: if you've enabled our <a href="/docs/features/broken-links-detection">broken links</a> or <a href="/docs/features/mixed-content-reporting">mixed content</a> checks for any of your sites, we'll crawl your sites to find any broken pages.</p>
<p>On top of that, we have the ability to set <a href="/docs/features/configure-your-oh-dear-settings-per-monitor#custom-http-headers">custom HTTP headers per website</a> that get added to both the uptime checks and our crawler.</p>
<p>Combining our crawler and the custom HTTP headers allows you to <em>authorize</em> our crawler in your Varnish configs to let it update the cache.</p>
<h2 id="configuring-varnish-to-refresh-your-cache-when-oh-dear-crawls-your-site">Configuring Varnish to refresh your cache when Oh Dear! crawls your site <a href="#configuring-varnish-to-refresh-your-cache-when-oh-dear-crawls-your-site" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#configuring-varnish-to-refresh-your-cache-when-oh-dear-crawls-your-site">#</a></h2>
<p>There are several default Varnish configs available you can use as the boilerplate for your Varnish service. One of them is maintained by one of the co-founders of Oh Dear! and can be found here: <a href="https://github.com/mattiasgeniar/varnish-6.0-configuration-templates">Varnish 6 configuration template</a>.</p>
<p>If you take this as your basis, it's relatively easy to allow Oh Dear! to update your cached pages.</p>
<p>First, add this line to your <code>vcl_recv</code> routine.</p>
<pre data-lang="txt" class="notranslate">vcl_recv {
  if (req.http.OhDear-Authorized-Request == &quot;ASecretStringOrPassphrase&quot;) {
    set req.hash_always_miss = true;
  }
  
  ...
}
</pre>
<p>Now, every time Varnish receives a request with the header <code>OhDear-Authorized-Request</code> that has the value <code>ASecretStringOrPassphrase</code>, it will refresh the current page from its backend (aka: you won't be served a cached response, but it will go to your backend server).</p>
<p>In Oh Dear!, you can add this <a href="/docs/features/configure-your-oh-dear-settings-per-monitor#custom-http-headers">custom header</a> to each of your sites.</p>
<p><img src="/uploads/blogs/ohdear-varnish-integration/ohdear_custom_http_header.png" alt="Custom HTTP header" /></p>
<p>From now, that extra header will be present every time we crawl your page.</p>
<p>This also allows extra possibilities in your Varnish code, like authorizing our crawler to access otherwise limited pages.</p>
<h2 id="checking-the-uptime-behind-your-cache">Checking the uptime behind your cache <a href="#checking-the-uptime-behind-your-cache" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#checking-the-uptime-behind-your-cache">#</a></h2>
<p>Using similar logic, we can also allow the uptime checks to bypass the cache altogether to check the availability of your site.</p>
<p>It wouldn't be the first time a site <em>appears</em> online, because the monitoring probes were answered by a cached response. Your users that bypass the cache (they might be logged in) are still seeing your error pages.</p>
<p>To let Oh Dear! bypass the cache every time without touching the TTL (Time-To-Live) or currently cached pages, you can use logic similar to this.</p>
<pre data-lang="txt" class="notranslate">vcl_recv {
  if (req.http.OhDear-Authorized-Request == &quot;ASecretStringOrPassphrase&quot;) {
    return (pass);
  }
}
</pre>
<p>We're certain there are other use cases out there that we haven't even thought of. If you're doing something similar, we would love to hear from you in the comments below!</p>
]]>
            </summary>
                                    <updated>2020-06-22T06:49:46+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Adding a Friendly Name to your sites]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/adding-a-friendly-name-to-your-sites" />
            <id>https://ohdear.app/16</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>We've added the ability to show a &quot;friendly name&quot; for your sites, instead of the domain and the URL associated with it.</p>
<p>For each website, you can configure the <em>display name</em> that we should use to present the website.</p>
<p><img src="/uploads/blogs/friendly-name/friendly-name-settings.png" alt="Friendly Name - Settings" /></p>
<p>This will be the name used throughout Oh Dear! in place likes our Slack or Email alerts, the dashboard, the breadcrumb, ...</p>
<p><img src="/uploads/blogs/friendly-name/friendly-name-show.png" alt="Friendly Name - Settings" /></p>
<p>This is especially convenient when you want to check the uptime for multiple pages on a single domain or when you have very long URLs that you can easily abbreviate to an internal project name.</p>
<p>It'll also be a core piece of one of our next features we'll be building: status pages. But more on that later. #teaser ;-)</p>
]]>
            </summary>
                                    <updated>2019-03-29T13:56:23+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Introducing our public tools section: try Oh Dear! without an account]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/introducing-our-public-tools-section-try-oh-dear-without-an-account" />
            <id>https://ohdear.app/15</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>We launched a cool new feature that everyone can enjoy: public versions of all the checks available within Oh Dear!</p>
<p>Now you can try Oh Dear! without even creating an account.</p>
<h2 id="the-philosophy-behind-our-public-checks">The philosophy behind our public checks <a href="#the-philosophy-behind-our-public-checks" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#the-philosophy-behind-our-public-checks">#</a></h2>
<p><em>Why give something away for free if others are paying for it?</em></p>
<p>We love creating tools and features that are useful for everyone, not just our paying customers. We figured there's value in offering <em>one-off</em> checks on our site without devalueing our offer to our paying customers.</p>
<p>It's a perfect way to try us out without the extra <em>hassle</em> of creating an account, even if it <a href="/register">only takes 5 seconds to start a trial</a>.</p>
<p><img src="/uploads/blogs/public-checks/public-check-cover.png" alt="Public Checks - Introduction" /></p>
<p>The public checks have their limitations:</p>
<ul>
<li>They are <em>one-off</em> checks, they don't run every minute</li>
<li>There are no notification options to alert you, other than the browser window</li>
<li>There's no <a href="/docs/api/introduction">API to automate checks</a></li>
</ul>
<p>But, let's focus on the good news!</p>
<h2 id="availability-tool">Availability tool <a href="#availability-tool" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#availability-tool">#</a></h2>
<p>Want to know if a particular site is available worldwide? You can now test using our availability checker which will test from 5 different locations spread across all continents.</p>
<p><img src="/uploads/blogs/public-checks/public-check-uptime.png" alt="Public Checks - Uptime" /></p>
<h2 id="down-for-everyone-or-just-me">Down for everyone or just me? <a href="#down-for-everyone-or-just-me" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#down-for-everyone-or-just-me">#</a></h2>
<p>Many of us know and perhaps use the site <a href="https://downforeveryoneorjustme.com/">downforeveryoneorjustme.com</a> to check if a site is online worldwide. Well, we can now be an alternative for that.</p>
<p>To help users quickly identify problems, there's now <a href="https://chrome.google.com/webstore/detail/oh-dear/ebicpogndlpgfmlecipndcgjnamdnejh">a Chrome extension</a> you can use to check the availability of the site you're currently on.</p>
<p>Alternatively, you can create a new bookmark with the following URL and you it as a <em>bookmarklet</em>. Oldskool, right? :-)</p>
<pre data-lang="txt" class="notranslate">javascript:{window.location='https://ohdear.app/tools/reachable?prefill='+encodeURIComponent(window.location.href)}
</pre>
<p>This snippet, when used, can allow you to quickly test any website. If you're on a site that doesn't load for you, hit that bookmarklet or the Oh Dear! browser extension and let Oh Dear! doublecheck the uptime from different locations.</p>
<p>We'll do a deeper dive into the Chrome extension at a later point, to show all the details.</p>
<h2 id="certificate-health-checks">Certificate health checks <a href="#certificate-health-checks" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#certificate-health-checks">#</a></h2>
<p>Our certificate checks allow you to check the important details of your websites' certificates. We'll check the expiration date, the intermediate &amp; root certificates and some of the encryption algoritms used.</p>
<p><img src="/uploads/blogs/public-checks/public-check-certificate.png" alt="Public Checks - Certificates" /></p>
<p>We actually check a few extra things that don't quite fit the screenshot, go ahead and scan your own domain to see the results.</p>
<p>What we can't show, and what our subscribers do enjoy, is:</p>
<ul>
<li>Notifications when certificates change (renewals or unexpected changes)</li>
<li>Notifications when SANs get added or removed from a certificate</li>
<li>Notifications when a root certificate gets revoked and could render your certificates useless</li>
<li>Notifications when we detect new certificates for your domains in <a href="/docs/features/certificate-health-monitoring">certificate transparency logs</a></li>
</ul>
<p>As you can see, there are still plenty of reasons to <a href="/register">give us a try</a>. :-)</p>
<h2 id="coming-soon-broken-links-amp-mixed-content-checks">Coming soon: broken links &amp; mixed content checks <a href="#coming-soon-broken-links-amp-mixed-content-checks" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#coming-soon-broken-links-amp-mixed-content-checks">#</a></h2>
<p>A crucial piece of our offering is still missing from these public checks: our broken links &amp; mixed content reporting.</p>
<p>These work fundamentally different than the certificate &amp; uptime check. Our crawler can take anywhere between a few minutes up to an hour to complete a run and report the issues with a website.</p>
<p>As a user, you don't want to keep your browser open and wait for that.</p>
<p>We'll create a solution where you can leave your e-mail address and we'll email the report to you as soon as our crawls are done. This takes a bit more work to complete (and we want to get it right - just like the rest of our app).</p>
<p>As soon as that's finished, we'll make sure to blog about it!</p>
<h2 id="sharing-the-results">Sharing the results <a href="#sharing-the-results" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#sharing-the-results">#</a></h2>
<p>Every check will generate a unique URL that can be shared among colleagues, friends or on social media.</p>
<p>If there's downtime or an issue with a certificate, this will allow you to provide all the necessary info to those responsible to take action.</p>
]]>
            </summary>
                                    <updated>2026-06-05T10:17:50+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Why agency Marbles chose Oh Dear for uptime monitoring]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/why-full-service-agency-marbles-choose-oh-dear-for-its-uptime-monitoring" />
            <id>https://ohdear.app/13</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>We reached out to one of our earliest customers, <a href="https://www.marbles.be/">Marbles</a>, to ask why they decided to move from Pingdom and use our service instead. If you're in doubt whether or not to give us a try, maybe they can convince you.</p>
<h2 id="our-interview-with-marbles">Our interview with Marbles <a href="#our-interview-with-marbles" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#our-interview-with-marbles">#</a></h2>
<h3 id="hi-rias-who-are-you-and-what-does-your-business-do">Hi Rias, who are you and what does your business do?</h3>
<p>We are <a href="https://www.marbles.be/">Marbles</a>, an Antwerp based full service agency that does everything from creating identities to marketing &amp; communications, business development and coaching. Of course creating websites and web applications is part of our services and to make sure they're the best they can be we use <em>Oh Dear!</em> for the monitoring.</p>
<h3 id="cool-why-did-you-chose-oh-dear-as-your-monitoring-solution">Cool! Why did you chose Oh Dear! as your monitoring solution?</h3>
<p>It includes all the checks we need for a very fair price.</p>
<h3 id="who-did-you-have-before">Who did you have before?</h3>
<p>We used Pingdom, but only for the basic uptime checks.</p>
<h3 id="what-made-you-switch">What made you switch?</h3>
<p>The detection of mixed content &amp; broken links and the https certificate expiration checks on top of the uptime monitoring.</p>
<h3 id="how-does-oh-dear-help-you-provide-better-service-for-your-clients">How does Oh Dear! help you provide better service for your clients?</h3>
<p>By alerting us of issues before anyone could possibly notice it manually. This allows us to fix issues before our clients are even aware of it, or if they are we can tell them we're already working on a fix.</p>
<h3 id="whats-your-favorite-feature-of-oh-dear">What's your favorite feature of Oh Dear?</h3>
<p>All the extra checks on top of basic uptime monitoring with Slack notifications and webhooks to receive reports.</p>
<h3 id="is-there-a-killer-feature-were-missing-that-could-help-you">Is there a killer feature we're missing that could help you?</h3>
<p>Setting up notification &quot;groups&quot;. We have clients who have an SLA with us, they need very quick solutions and we use the SMS notifications for them, for other clients we only set up the Slack Notifications.</p>
<p>It's bothersome to set up the SMS notifications individually for each site that needs them, and if another person needs to receive these, or the phone numbers need to get changed, we need to do this for each site manually.</p>
<p><em>In response: this is indeed a very good idea still missing, we're taking this up in our development to see if we can introduce an elegant solution to this problem. Thanks for letting us know!</em></p>
<h2 id="what-makes-oh-dear-special-to-you">What makes Oh Dear! special to you? <a href="#what-makes-oh-dear-special-to-you" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#what-makes-oh-dear-special-to-you">#</a></h2>
<p>We'll be reaching out to more of our customers over the next months to find out why they chose us over the (many) competitors out there. We'd love to know what pursuaded them and what items we still need to address to serve our customers even better.</p>
<p>If you want to share your thoughts, feel free to reach out to us on Twitter at <a href="https://twitter.com/ohdearapp">@OhDearApp</a>.</p>
]]>
            </summary>
                                    <updated>2026-06-15T12:38:26+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Our Gitlab CI pipeline for Laravel applications]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/our-gitlab-ci-pipeline-for-laravel-applications" />
            <id>https://ohdear.app/10</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>We've been fairly public about the amount of testing we have for Oh Dear!. Freek has been <a href="https://twitter.com/freekmurze/status/963119438504611842">showing</a> <a href="https://twitter.com/freekmurze/status/927996380932689920">bits</a> and <a href="https://twitter.com/freekmurze/status/969680415618252800">pieces</a> on his Twitter to show the extent of that effort.</p>
<p>Having a huge test suite is nice, but integrating it into your development workflow is even better.</p>
<p>That's why we're releasing our Gitlab CI pipeline that is optimized for Laravel applications. It contains all the elements you'd expect: building (<code>composer</code>, <code>yarn</code> &amp; <code>webpack</code>), database seeding, PHPUnit &amp; copy/paste (mess) detectors &amp; some basic security auditing of our 3rd party dependencies.</p>
<p>If you want to see it in action, skip to the <a href="#how-to-use-our-gitlab-ci">&quot;how to use&quot;</a> section.</p>
<h2 id="our-laravel-ci-pipeline">Our Laravel CI pipeline <a href="#our-laravel-ci-pipeline" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#our-laravel-ci-pipeline">#</a></h2>
<p>Here's what our current setup looks like.</p>
<p><img src="https://ohdear.app/uploads/blogs/gitlab-ci-for-laravel/ohdear_pipelines_all_green.png" alt="Laravel CI pipeline in Gitlab" /></p>
<p>We'll break things down in this blogpost to explain <em>why</em> we've made some of the decisions we've made and how to set up a system like this.</p>
<h2 id="setting-up-gitlab-ci">Setting up Gitlab CI <a href="#setting-up-gitlab-ci" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#setting-up-gitlab-ci">#</a></h2>
<p>We use the free version of <a href="https://gitlab.com/">Gitlab</a> to host our Git repositories and launch the jobs that run all our testing. However, since we run the free version (and as a startup, we're cautious about our running costs), we are limited to what kind of CI we can run on Gitlab.</p>
<p>As such, we've installed and run our own <a href="https://docs.gitlab.com/runner/">Gitlab Runner</a>, that uses Docker containers to run our testing. The Gitlab.com servers essentially instruct <em>our</em> servers to run the entire pipeline and we report back the status to Gitlab for reporting.</p>
<p>Setting up the local <a href="https://docs.gitlab.com/runner/install/docker.html">Gitlab Runner with Docker</a> is pretty straight-forward and their documentation handles this perfectly. Some minor things we changed on our end to speed things up are related to the concurrency of the jobs.</p>
<pre data-lang="bash" class="notranslate">$ <span class="hl-keyword">cat</span> /etc/gitlab-runner/config-main.toml
concurrent = 2
check_interval = 0

[[runners]]
  name = <span class="hl-value">&quot;ohdear/ohdear&quot;</span>
  url = <span class="hl-value">&quot;https://gitlab.com&quot;</span>
  token = <span class="hl-value">&quot;(this is secret, don't share this)&quot;</span>
  executor = <span class="hl-value">&quot;docker&quot;</span>
  [runners.docker]
    tls_verify = false
    image = <span class="hl-value">&quot;php:7.2&quot;</span>
    privileged = false
    disable_cache = false
    volumes = [<span class="hl-value">&quot;/cache&quot;</span>]
    shm_size = 0
  [runners.cache]
</pre>
<p>By default we run our tests on PHP 7.2 and allow for 2 jobs at the same time.</p>
<h2 id="cpu-load-amp-docker-containers">CPU load &amp; Docker containers <a href="#cpu-load-amp-docker-containers" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#cpu-load-amp-docker-containers">#</a></h2>
<p>We run the testsuite on one of our own systems. When the tests start, Docker starts several containers. One of them is a MySQL container where the database seeding will happen.</p>
<pre data-lang="bash" class="notranslate">$ <span class="hl-keyword">docker</span> ps
COMMAND                  CREATED             NAMES
<span class="hl-value">&quot;docker-php-entryp...&quot;</span>   2 minutes ago       runner-...-build-4
<span class="hl-value">&quot;docker-entrypoint...&quot;</span>   2 minutes ago       runner-...-mysql-0
</pre>
<p>When multiple tests run in parallel, CPU load tends to spike.</p>
<p><img src="https://ohdear.app/uploads/blogs/gitlab-ci-for-laravel/ohdear_pipeline_cpu_load.png" alt="Laravel CI pipeline in Gitlab" /></p>
<p>Remember that multiple containers will run at the same time, potentially pushing your server to 100% CPU usage. For this reason we decided to run these on one of our test machines, even though production servers have a healthy abundance of free CPU and memory.</p>
<p>But, in order for us to handle spike usage, we need to keep those resources free &amp; available.</p>
<p>To keep CI fast we ended up <a href="/news-and-updates/running-our-test-suite-in-parallel-on-github-actions">running our test suite in parallel on GitHub Actions</a>.</p>
<h2 id="defining-the-stages">Defining the stages <a href="#defining-the-stages" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#defining-the-stages">#</a></h2>
<p>We created 4 different stages in which our tests run. These will happen stage-by-stage with a job concurrency of 2. During any stage, at most 2 jobs will be running at the same time.</p>
<pre data-lang="bash" class="notranslate">$ <span class="hl-keyword">cat</span> /etc/gitlab-runner/config-main.toml
concurrent = 2
...
</pre>
<p>If your tests require more jobs at the same time, you can increase that <code>concurrent</code> setting.</p>
<p>The previous stage must succeed before the next one can begin. Here's the abbreviated version of our <a href="https://github.com/ohdearapp/gitlab-ci-pipeline-for-laravel/blob/master/.gitlab-ci.yml">.gitlab-ci.yml</a>.</p>
<pre data-lang="yaml" class="notranslate"><span class="hl-keyword">stages</span><span class="hl-property">:</span>
  <span class="hl-property">-</span> preparation
  <span class="hl-property">-</span> building
  <span class="hl-property">-</span> testing
  <span class="hl-property">-</span> security

<span class="hl-keyword">composer</span><span class="hl-property">:</span>
  <span class="hl-keyword">stage</span><span class="hl-property">:</span> preparation

<span class="hl-keyword">yarn</span><span class="hl-property">:</span>
  <span class="hl-keyword">stage</span><span class="hl-property">:</span> preparation

<span class="hl-keyword">build-assets</span><span class="hl-property">:</span>
  <span class="hl-keyword">stage</span><span class="hl-property">:</span> building

<span class="hl-keyword">db-seeding</span><span class="hl-property">:</span>
  <span class="hl-keyword">stage</span><span class="hl-property">:</span> building

<span class="hl-keyword">phpunit</span><span class="hl-property">:</span>
  <span class="hl-keyword">stage</span><span class="hl-property">:</span> testing

<span class="hl-keyword">codestyle</span><span class="hl-property">:</span>
  <span class="hl-keyword">stage</span><span class="hl-property">:</span> testing

<span class="hl-keyword">phpcpd</span><span class="hl-property">:</span>
  <span class="hl-keyword">stage</span><span class="hl-property">:</span> testing

<span class="hl-keyword">sensiolabs</span><span class="hl-property">:</span>
  <span class="hl-keyword">stage</span><span class="hl-property">:</span> security

</pre>
<p>If you have a stage that consists of 3 jobs (like our <code>testing</code> stage), remember that the 3rd job might take longer to complete with a concurrency of <em>only</em> 2 jobs. Those first 2 will run in parallel, the 3rd one will have to wait for a free job <em>slot</em>.</p>
<p>Our pipeline also enforces <a href="/news-and-updates/typehinting-laravel-validation-rules-using-phpstans-type-aliases">PHPStan type aliases</a>.</p>
<h2 id="asset-building-before-testing">Asset building before testing? <a href="#asset-building-before-testing" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#asset-building-before-testing">#</a></h2>
<p>One of the steps we do different than most, is building the assets <em>before</em> we run our unit tests.</p>
<p>The reason is because of a test like this:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">public</span> <span class="hl-keyword">static</span> <span class="hl-keyword">function</span> <span class="hl-property">boot</span>()
{
  <span class="hl-comment">/* ... */</span>

  <span class="hl-variable">$startServerCommand</span> = '
      php -<span class="hl-property">S</span> localhost:9000/ -t \
      ./tests/Server/<span class="hl-keyword">public</span> &gt; /dev/<span class="hl-keyword">null</span> 2&gt;&amp;1 &amp; <span class="hl-keyword">echo</span> $!
  ';

  <span class="hl-variable">$pid</span> = <span class="hl-property">exec</span>(<span class="hl-variable">$startServerCommand</span>);
</pre>
<p>During our unit tests, we spawn a webserver to test several of our key features;</p>
<ul>
<li>Uptime &amp; downtime detection</li>
<li>We crawl that test server to detect mixed content &amp; broken links</li>
<li>We test several of our custom HTTP header features</li>
</ul>
<p>This test webserver spawns <a href="/">our public website</a>, which in turn relies on compiled JavaScript &amp; CSS (<a href="https://laravel.com/docs/5.7/mix">Laravel Mix</a>). That gets compiled with <code>webpack</code>, which is why we run that asset-building stage before our unit tests.</p>
<p>Without it, our tests would simply fail as we can't render our homepage.</p>
<p>Crawling our own site - and various custom endpoints to simulate downtime or slow responses - has the additional benefit that we validate (most of) our website is functioning correctly before we deploy.</p>
<h2 id="setting-dependencies">Setting dependencies <a href="#setting-dependencies" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#setting-dependencies">#</a></h2>
<p>Some of our stages depend on the output of the previous stage in order to work. A good example is our <code>testing</code> stage. When we run <code>phpunit</code>, it will fetch oru homepage which in turn relies on the CSS &amp; JavaScript that got generated in the previous stage.</p>
<p>Gitlab CI allows you to set your dependencies per stage.</p>
<pre data-lang="bash" class="notranslate">phpunit:
  [...]
  dependencies:
    - build-assets
    - <span class="hl-keyword">composer</span>
    - db-seeding
</pre>
<p>Setting the dependency makes sure the <code>artifacts</code> get downloaded from that particular job into this one, essentially copying the output of one job to another.</p>
<p>In this example, before we can run our unit testing we'll need:</p>
<ul>
<li>Our compiled assets (and the <code>public/mix-manifest.json</code> needed by Laravel)</li>
<li>The <code>vendor</code> directory of the <code>composer install</code></li>
<li>A functioning database that contains our seeded data</li>
</ul>
<p>Once that's done, it can run this job.</p>
<h2 id="caches-vs-artifacts">Caches vs. Artifacts <a href="#caches-vs-artifacts" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#caches-vs-artifacts">#</a></h2>
<p>Gitlab CI heavily relies on 2 concepts that we initially misconfigured: caches &amp; artifacts.</p>
<p>A cache is, as the word implies, a local <em>cache</em> of the data. It's available only locally on the server and is not guaranteed to be present.</p>
<p>An artifact is - in our own words - what you want to store in a job to pass on to the next one. <em>Initially</em>, artifacts were what you wanted to <em>survive</em> out of a job. Like generated logs or error files, PHP Unit coverage results etc.</p>
<p>But this feature can be used broader than just exporting test results: you can use it to pass the output of one job onto the next one.</p>
<p>We first thought to use <em>caches</em> for this purpose, as these get stored locally and are available faster. However, the cache isn't guaranteed to be there and about 30% of our pipelines would randomly fail because it was missing a composer <code>vendor</code> directory, compiled assets, ... even though those jobs completed just fine.</p>
<pre data-lang="yaml" class="notranslate"><span class="hl-keyword">composer</span><span class="hl-property">:</span>
  <span class="hl-keyword">stage</span><span class="hl-property">:</span> preparation
  <span class="hl-keyword">script</span><span class="hl-property">:</span>
    <span class="hl-property">-</span> composer install --prefer-dist --no-ansi --no-interaction --no-progress --no-scripts
  <span class="hl-keyword">artifacts</span><span class="hl-property">:</span>
    <span class="hl-keyword">paths</span><span class="hl-property">:</span>
      <span class="hl-property">-</span> vendor/
      <span class="hl-property">-</span> .env
    <span class="hl-keyword">expire_in</span><span class="hl-property">:</span> 1 days
    <span class="hl-keyword">when</span><span class="hl-property">:</span> always
  <span class="hl-keyword">cache</span><span class="hl-property">:</span>
    <span class="hl-keyword">paths</span><span class="hl-property">:</span>
      <span class="hl-property">-</span> vendor/
</pre>
<p>In the example above we define 2 critical pieces:</p>
<ul>
<li><code>artifacts</code>: what we want to upload back to Gitlab in order for our next job(s) to make use of</li>
<li><code>cache</code>: what we want to store locally to speed up the next run of this job</li>
</ul>
<p>So we ended up with Artifacts for everything we want to inherit for our next job and local caches to speed up composer installs, whenever possible.</p>
<p>This feels like code duplication in the YAML file, but it's a necessary step.</p>
<h2 id="some-gotchas-in-your-envexample">Some gotcha's in your .env.example <a href="#some-gotchas-in-your-envexample" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#some-gotchas-in-your-envexample">#</a></h2>
<p>We spawn a separate Docker container for our MySQL database. It uses a set of pre-defined environment variables to create a user with password and a database.</p>
<p>It's defined at the very top of our <a href="https://github.com/ohdearapp/gitlab-ci-pipeline-for-laravel/blob/master/.gitlab-ci.yml">.gitlab-ci.yml</a>.</p>
<pre data-lang="yaml" class="notranslate"><span class="hl-keyword">variables</span><span class="hl-property">:</span>
  <span class="hl-keyword">MYSQL_ROOT_PASSWORD</span><span class="hl-property">:</span> root
  <span class="hl-keyword">MYSQL_USER</span><span class="hl-property">:</span> ohdear_ci
  <span class="hl-keyword">MYSQL_PASSWORD</span><span class="hl-property">:</span> ohdear_secret
  <span class="hl-keyword">MYSQL_DATABASE</span><span class="hl-property">:</span> ohdear_ci
  <span class="hl-keyword">DB_HOST</span><span class="hl-property">:</span> mysql
</pre>
<p>Our Laravel application needs to be aware of these credentials too, in order to connect to this database. We solved this by setting these credentials in our <code>.env.example</code> file.</p>
<pre data-lang="yaml" class="notranslate">DB_CONNECTION=mysql
DB_HOST=mysql
DB_PORT=3306
DB_DATABASE=ohdear_ci
DB_USERNAME=ohdear_ci
DB_PASSWORD=ohdear_secret
</pre>
<p>In our <code>composer</code> job, we also prepare the Laravel config for use.</p>
<pre data-lang="yaml" class="notranslate"><span class="hl-keyword">composer</span><span class="hl-property">:</span>
  <span class="hl-property">[</span>...<span class="hl-property">]</span>
  <span class="hl-keyword">script</span><span class="hl-property">:</span>
    <span class="hl-property">-</span> composer install <span class="hl-property">[</span>...<span class="hl-property">]</span>
    <span class="hl-property">-</span> cp .env.example .env
    <span class="hl-property">-</span> php artisan <span class="hl-keyword">key</span><span class="hl-property">:</span>generate
</pre>
<p>By coping the <code>.env.example</code> file, we provided the MySQL credentials for our testing environment.</p>
<p>Part of a safe pipeline is <a href="/news-and-updates/making-sure-laravels-debug-mode-is-always-disabled-in-production">making sure debug mode is always disabled in production</a>.</p>
<h2 id="missing-deployment-step">Missing deployment step <a href="#missing-deployment-step" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#missing-deployment-step">#</a></h2>
<p>An obvious step missing here is - perhaps - the most important one: deploying the application.</p>
<p>We haven't implemented this in Gitlab CI yet. Our deployment is automated of course, but right now it's not tied to the status of our CI pipeline. We <em>can</em> deploy, even if the tests fail.</p>
<p>We're still a small team and we make the decision to deploy thoughfully, but controlled. As many Laravel users would use <a href="https://envoyer.io/">Envoyer</a> for their deployment, further automation could be done to integrate that too.</p>
<p>We'll highlight our deployment strategy (and the reasoning to not couple this in with Gitlab CI) in a later blogpost, there are a lot of nuances that deserve highlighting for that.</p>
<h2 id="grab-our-gitlab-ci-pipeline">Grab our Gitlab CI pipeline <a href="#grab-our-gitlab-ci-pipeline" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#grab-our-gitlab-ci-pipeline">#</a></h2>
<p>You'll find the config on our Github: <a href="https://github.com/ohdearapp/gitlab-ci-pipeline-for-laravel">ohdearapp/gitlab-ci-pipeline-for-laravel</a>.</p>
<p>If you find issues or have improvements, feel free to contribute them!</p>
<p>The basis for this pipeline has been heavily inspired by <a href="http://lorisleiva.com/laravel-deployment-using-gitlab-pipelines/">Loris Leiva's blogpost on Laravel pipelines</a> and adapted to our needs &amp; desires.</p>
<h2 id="how-to-use-our-gitlab-ci">How to use our Gitlab CI? <a href="#how-to-use-our-gitlab-ci" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#how-to-use-our-gitlab-ci">#</a></h2>
<p>The abbreviated version is;</p>
<ol>
<li>Get a Gitlab account &amp; commit your code to a Gitlab repository</li>
<li>Copy/paste our <a href="https://github.com/ohdearapp/gitlab-ci-pipeline-for-laravel/blob/master/.gitlab-ci.yml">.gitlab-ci.yml</a> into your project</li>
<li>Push to your repository</li>
</ol>
<p>Now watch as Gitlab detects your config and will try to run your job. At this point, you might want to consider either subscribing to Gitlab CI or running your own Gitlab runners to execute the tests.</p>
<p>Oh Dear is built by a Laravel team <a href="/for/php-developers">for Laravel and PHP developers</a> who care about pipelines like this one.</p>
<p>If you spot any improvements, gotcha's or have general remarks, we'd love to hear your thoughts in the comments below!</p>
]]>
            </summary>
                                    <updated>2021-07-05T12:01:56+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Transitioning from laravel-echo-server to laravel-websockets]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/transitioning-from-laravel-echo-server-to-laravel-websockets" />
            <id>https://ohdear.app/8</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>We have just finished our transition from a websocket server based on <a href="https://github.com/tlaverdure/laravel-echo-server">laravel-echo-server</a> to one that is fully driven by PHP: <a href="https://github.com/beyondcode/laravel-websockets">laravel-websockets</a>. In this post, we'll highlight why and how we made that move.</p>
<h2 id="simplifying-our-stack">Simplifying our stack <a href="#simplifying-our-stack" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#simplifying-our-stack">#</a></h2>
<p>As we're built on <a href="https://laravel.com/">Laravel</a>, we already run a fair bit of nodejs during our build phase. Our frontend JavaScript &amp; CSS already get compiled via <a href="https://laravel.com/docs/5.7/mix">webpack</a>. So in a way, our stack already includes <code>node</code> to make this all happen.</p>
<p>Part of our smooth user experience (if we say so ourselves ;-)) comes from the use of websockets, that allows us to give instant feedback to our users in their dashboard &amp; our <a href="/">homepage</a>. To make that work, we've always used <a href="https://github.com/tlaverdure/laravel-echo-server">laravel-echo-server</a>, a <code>node</code> implementation of a websocket server.</p>
<p>To make that websocket server work, you can use 2 methods: use a Redis queue or publish messages directly through HTTP. We used a Redis queue, which means the following events took place for us:</p>
<ol>
<li>Laravel publishes a message to a Redis channel</li>
<li>The echo-server listens to new events being stored there</li>
<li>The echo-server relays those to its subscribers/clients</li>
</ol>
<p>This has worked very well for us, without any issues.</p>
<p>But we found ourselves in the unique spot to test an even simpler approach: run a websocket server fully in PHP without the need for node.</p>
<h2 id="moving-to-php">Moving to PHP <a href="#moving-to-php" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#moving-to-php">#</a></h2>
<p>Our initial reaction was &quot;<em>but surely PHP can't handle the load a Node process could, right?</em>&quot;.</p>
<p>Well, to get started we <a href="https://ma.ttias.be/benchmarking-websocket-server-performance-with-artillery">benchmarked the laravel-websockets package using Artillery</a>. What we found was that the websocket implementation in PHP could handle our load with great ease and keep under 50MB memory consumption.</p>
<p>Performance, during our testing, appeared on-par with the node implementation.</p>
<p>Since we weren't losing anything, we dediced to remove our node dependency for our production machines and run the entire websocket stack in PHP.</p>
<h2 id="adding-tls-and-supervisor">Adding TLS and supervisor <a href="#adding-tls-and-supervisor" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#adding-tls-and-supervisor">#</a></h2>
<p>Our setup is already using Nginx as a TLS proxy as well as Supervisor to keep all our workers running, so we already had the building blocks in place to add some configuration for our new websocket server.</p>
<p>We <a href="https://ma.ttias.be/deploying-laravel-websockets-with-nginx-reverse-proxy-and-supervisord">configured both Nginx and Supervisor</a> to handle the TLS part and the job-running pretty quickly.</p>
<h2 id="transitioning-from-laravel-echo-server-to-laravel-websockets">Transitioning from laravel-echo-server to laravel-websockets <a href="#transitioning-from-laravel-echo-server-to-laravel-websockets" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#transitioning-from-laravel-echo-server-to-laravel-websockets">#</a></h2>
<p>Code-wise, the change was a piece of cake. Unless we're forgetting something, it consisted of:</p>
<ul>
<li>Remove all references to <code>/js/socket.io.js</code> in our frontend (we'll no longer be needing <a href="https://socket.io/docs/client-api/">socket.io</a> for our websockets)</li>
<li>Install <a href="https://github.com/beyondcode/laravel-websockets">laravel-websockets</a> and follow its install instructions</li>
<li>Change our frontend-code from using socket.io to pusher</li>
</ul>
<pre data-lang="js" class="notranslate">     window.<span class="hl-property">Echo</span> = <span class="hl-keyword">new</span> <span class="hl-type">Echo</span>({
-        <span class="hl-property">broadcaster</span>: <span class="hl-value">'socket.io'</span>,
+        <span class="hl-property">broadcaster</span>: <span class="hl-value">'pusher'</span>,
         <span class="hl-property">key</span>: window.<span class="hl-property">pusherKey</span>,
-        <span class="hl-property">host</span>: window.<span class="hl-property">pusherHost</span>,
+        <span class="hl-property">wsHost</span>: window.<span class="hl-property">pusherHost</span>,
+        <span class="hl-property">wsPort</span>: window.<span class="hl-property">pusherPort</span>,
+        <span class="hl-property">disableStats</span>: <span class="hl-keyword">true</span>,
     });
</pre>
<ul>
<li>Change our broadcast driver from Redis to using Pusher in <code>.env</code></li>
</ul>
<pre data-lang="dotenv" class="notranslate">- <span class="hl-keyword">BROADCAST_DRIVER</span>=redis
+ <span class="hl-keyword">BROADCAST_DRIVER</span>=pusher
+ <span class="hl-keyword">PUSHER_ENDPOINT</span>=socket.ohdear.app
+ <span class="hl-keyword">PUSHER_ENDPOINT_SCHEME</span>=https
</pre>
<p>That was enough for us to move to <a href="https://github.com/beyondcode/laravel-websockets">laravel-websockets</a>.</p>
<h2 id="what-did-we-gain">What did we gain? <a href="#what-did-we-gain" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#what-did-we-gain">#</a></h2>
<p>One of its biggest gains is in our development process: we now just need to run <code>php artisan websocket:serve</code> to get a local websocket server going and not have to deal with the (rather confusing) configuration of laravel-echo-server.</p>
<p>Additionally, we simplified our server setup and now fully rely on PHP without Node for running websockets. Managing less software is always a win, especially from a security angle (keeping track of the node ecosystem and the dependencies of the echo-server).</p>
<p>For us, it was a no-brainer to make the switch.</p>
]]>
            </summary>
                                    <updated>2018-12-04T21:21:33+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Redesigning parts of our homepage]]></title>
            <link rel="alternate" href="https://ohdear.app/news-and-updates/redesigning-parts-of-our-homepage" />
            <id>https://ohdear.app/9</id>
            <author>
                <name><![CDATA[Oh Dear]]></name>
            </author>
            <summary type="html">
                <![CDATA[<p>We've launched a fresh now look for the Oh Dear! homepage and a lot of tweaks to the overall look &amp; feel of the public facing pages of our site. Allow us to show those changes in more detail!</p>
<h2 id="a-fresh-clean-homepage">A fresh, clean homepage <a href="#a-fresh-clean-homepage" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#a-fresh-clean-homepage">#</a></h2>
<p>This is visually the most noticeable change we've pushed. When we first launched, our design looked like this.</p>
<p><img src="/uploads/blogs/redesign-201811/ohdear_v1_homepage.png" alt="Homepage version 1" /></p>
<p>We liked that design quite a bit. It stood out. You don't see many startups foolish enough to make their entire homepage screaming red. It was also <em>sort-of</em> a reference to the red error screens you'd see when a site certificated had expired in the browser.</p>
<p>But, the number one piece of feedback we received was: &quot;aargh my eyes!&quot;.</p>
<p>And, well, we couldn't blame them. :-)</p>
<p>So here's a freshly designed homepage, easier on the eyes with updated text to showcase what we do and what our strenghts are.</p>
<p><img src="/uploads/blogs/redesign-201811/ohdear_v2_homepage.png" alt="Homepage version 2" /></p>
<p>There's still some screaming red involved, but it doesn't cover the entire homepage anymore. Besides highlighting what we're good at (doing <em>more</em> in terms of website monitoring than most of our competitors) we also have a live counter of the amount of checks we've run so far, refreshed live through the use of websockets.</p>
<p><img src="/uploads/blogs/redesign-201811/homepage-counter.gif" alt="Homepage version 2" /></p>
<p>Everything inside Oh Dear! is powered by websockets, now a part of the public facing website is too!</p>
<h2 id="updated-blogpost-design">Updated blogpost design <a href="#updated-blogpost-design" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#updated-blogpost-design">#</a></h2>
<p>This is a bit <em>meta</em> as you're already reading this on our blog, but this design got some nice tweaks too. Here's what it looked like before.</p>
<p>It was functional, but not <em>pretty</em>.</p>
<p><img src="/uploads/blogs/redesign-201811/ohdear_v1_blogpost.png" alt="Blogpost version 1" /></p>
<p>Here's <a href="/news-and-updates/automatic-monitoring-of-laravel-forge-managed-sites">that same blogpost</a> with a new coat of paint.</p>
<p><img src="/uploads/blogs/redesign-201811/ohdear_v2_blogpost.png" alt="Blogpost version 2" /></p>
<p>The images get highlighted more (extending the width of the text), clearer headings and some updated typography.</p>
<h2 id="highlighting-our-user-testimonials-amp-quotes">Highlighting our user testimonials &amp; quotes <a href="#highlighting-our-user-testimonials-amp-quotes" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#highlighting-our-user-testimonials-amp-quotes">#</a></h2>
<p>One thing we're very proud of is the feedback we receive from our users. Some big names in our industry have publicly endorsed us and we definitely want to showcase those.</p>
<p>At first, our quotes on the homepage looked like this.</p>
<p><img src="/uploads/blogs/redesign-201811/ohdear_v1_quotes.png" alt="Quotes version 1" /></p>
<p>Since we were actually starting to accumulate <em>so many positive quotes</em>, we needed a new design to feature more. This is the current layout we have.</p>
<p><img src="/uploads/blogs/redesign-201811/ohdear_v2_quotes.png" alt="Quotes version 2" /></p>
<p>More content horizontally and an updated <em>zig-zag</em> layout to break the symmetry on the page.</p>
<h2 id="tweaks-to-the-pricing-amp-documentation">Tweaks to the pricing &amp; documentation <a href="#tweaks-to-the-pricing-amp-documentation" class="heading-permalink" aria-label="Permalink" data-clipboard-text="https://ohdear.app/feed#tweaks-to-the-pricing-amp-documentation">#</a></h2>
<p>We applied the same design principles to our <a href="/pricing">pricing</a> and <a href="/docs/features/what-checks-does-oh-dear-perform">documentation</a> too. We've improved readability by adding contrast between titles &amp; text and by increasing the font-size and line height slightly.</p>
<p>Behind the scenes, we've been extending our <a href="/docs/features/what-checks-does-oh-dear-perform">documentation</a> quite a lot too, which meant our left-hand menu was growing to be a bit <em>too</em> big. That now auto-folds to highlight the current category.</p>
<p>Many of those pages just look a lot better too, like our <a href="/docs/api/uptime">API documentation on uptime reporting</a> or how to <a href="/docs/api/monitors">use our API and manage your sites</a>.</p>
<p>At the same time we added a section to highlight all <a href="/docs/tools-and-sdks/3rd-party-integrations-of-oh-dear">3rd party integrations</a> that make use of our API. There's already a Terraform provider, a Telegram chatbot and a CLI client available to talk to Oh Dear! - how cool is that?</p>
<p>If you find areas of our site or application that needs improvements, we'd love to hear about them!</p>
]]>
            </summary>
                                    <updated>2020-02-25T08:25:45+00:00</updated>
        </entry>
    </feed>
