Adding Observability to a SaaS Running 9 Hotels
GuestSync ran for years on deploy logs and phone calls from hotels. This is how I gave it OpenTelemetry, four SLOs and burn rate alerts without a big vendor, and what it found first.
GuestSync runs daily guest operations for nine hotels in Mykonos. For most of its life, my monitoring was a Railway deploy log, a Supabase dashboard, and the hotel calling me.
That worked because I was the integration layer. I knew which hotel had a big group checking in, which property page was getting traffic, which admin action felt slow. But a system that only tells you it is broken when a client calls is not observable. It is just quiet.
This summer I fixed that. Here is what I built, what I deliberately left out, and the first thing it found.
Why now
Nine tenants share one deployment. One slow query is nine slow hotels. One bad deploy is nine phone calls. The blast radius of a multi-tenant SaaS is the whole customer list, and "Ioannis would notice" stops being a strategy the moment Ioannis is asleep, on a plane, or at a conference in Zurich.
I also wanted to stop guessing. Every time a hotel said the admin panel felt slow, I had an opinion and no data. I wanted the system to have the data.
A deliberately boring stack
I looked at Datadog and Dynatrace and chose Grafana Cloud with OpenTelemetry. Not because the big platforms are bad, but because none of their advantages applied to a solo operator running nine hotels.
- OpenTelemetry means the instrumentation belongs to the app, not to a vendor. If I change backends later, the spans and metrics stay.
- Grafana Cloud in the EU region keeps everything, even the opaque IDs, inside the EU. Guest adjacent data does not cross the Atlantic for a dashboard.
- Grafana's SLO product is exactly the thing I wanted and nothing more: define an objective, get burn rate alerts and an error budget, done.
- Cost. A solo built SaaS should not spend more on observability than on the database.
GuestSync runs as a long lived Node container on Railway, so I used the plain OpenTelemetry Node SDK rather than a framework wrapper. Traces and metrics go straight to Grafana Cloud over OTLP. Every HTTP span is stamped with its route template, so a span says property/[id] instead of a UUID, and the same endpoint aggregates as one line instead of nine hundred.
What a span is allowed to know
This was the first design decision, before any code. Spans carry the tenant ID, the property ID, the route template, the status code and the duration. That is the whole list.
No guest names, no emails, no booking details, no request bodies. Guests never agreed to appear in my traces, and hotel staff did not sign up to have their actions replayed in a vendor UI. If a span needs a guest's name to be useful, the span is wrong.
The opaque IDs are enough. When something breaks I can see which tenant and which property, and then look it up in the database I already control.
Four SLOs instead of forty dashboards
I resisted the urge to build dashboards first. Dashboards answer questions you already have. SLOs tell you when to go look. I defined four:
- Guest portal availability, 99.9%. The public property pages are what guests see. They have to be up.
- Guest portal latency, p95 under 500ms, 99% of the time. A guest on hotel wifi does not wait.
- Admin API latency, p95 under 500ms, 99% of the time. Measured separately from the portal, because hotel staff and guests have different tolerances and different traffic shapes, and I wanted to know which side was hurting.
- Per tenant error rate, 99.5%. This is the one that matters most in a multi-tenant system. A global error rate of 0.3% sounds fine until you realise all of it is one hotel whose integration silently broke. Measuring per tenant means one broken hotel cannot hide inside eight healthy ones.
Each SLO gets burn rate alerts: a fast burn pages me, a slow burn opens something I look at in the morning. Six alert rules in total, one contact point, no noise so far.
Small things that turned out to matter
Database failures used to surface as a generic error. Now they return a 503, so the availability SLO counts them as downtime instead of as a healthy app returning errors. Before this change, a database outage looked like a bad day for users and a good day for the availability number.
The SLOs, alert rules and dashboard are created by a script, not by clicking. I can tear the stack down and rebuild it in one run. The script also taught me that Grafana SLO IDs must be at most 21 lowercase alphanumeric characters, which is the kind of detail you only learn at 11pm.
For the first hours after applying the SLOs, every panel said no data. Nothing was broken. There was simply no traffic yet. Worth knowing before you start debugging a healthy system.
The first thing it found
The admin API latency SLO was the one under pressure, and the traces showed why. The main admin endpoints were making a chain of sequential database round trips that could be batched. I had felt this for months every time a hotel said the panel was slow. Now I had the span waterfall, the affected routes, and a number to beat.
I am fixing it now. That fix, and what the error budget looked like before and after, is the next post.
What changed
I no longer find out from the hotel. That is the whole result, and it is bigger than it sounds. I can ship a change on a Friday and know by Saturday morning whether it hurt anyone, without anyone having to tell me.
Observability for a small SaaS is not about scale. It is about replacing "I would know" with "the system knows". Four SLOs, one boring stack, no guest data in the traces. That was enough.

Written by Ioannis Krokos
Full-stack developer in Zurich building with React, Next.js, and Node.js. Open to opportunities.