Blog · May 30, 2026
The Slack message I built EZLogs to kill.
At three jobs in a row, the same message kept landing in the engineering channel: “hey can someone check what this user did yesterday for the support thread I’m on?” The dance after that was always the same.
Open Kibana, or production logs, or whichever centralized log thing the company had. Grep for the customer’s email. Scroll past three hundred lines of SQL and Rack noise. Find the request. Trace it into the background jobs it spawned. Translate the result into a sentence a person could read. Paste the sentence into the support thread. Get pinged two hours later by someone else asking about a different user.
Once a week, fine. Five times a day, it’s a job nobody listed in the offer letter.
The thing that finally clicked for me wasn’t that the tooling was bad. It’s that the role was wrong. Engineers were the translators because nothing else could read the logs. The production-log file is a plain-text dump intended for the human who wrote the code. There’s no story in it, no chronology of one customer’s action across HTTP and jobs and DB writes, no plain-English summary. The dashboard tools that exist on top of logs — Datadog, New Relic, Honeycomb — tell you whether the system is healthy. They don’t tell you what one customer did.
I got tired of being the translator, so I wrote the thing that translates.
What I shipped
EZLogs is a Rails gem and a server. The install is two lines:
bundle add ez_logs_agent
rails generate ez_logs_agent:install
The generator drops one initializer that reads ENV["EZLOGS_API_KEY"]. No per-controller code. No include Auditable on your models. No manual correlation IDs to thread through your code.
The gem hooks into three places in Rails:
- Rack middleware for HTTP requests. Captures the path, status, duration, current user if your app sets one, and the params (sensitive fields redacted).
- ActiveJob and Sidekiq lifecycle hooks for background jobs. Captures the job class, queue, arguments, outcome, and timing for everything from
perform_nowtoperform_lateracross processes. - ActiveRecord callbacks for database writes. Captures the model, primary key, operation, and before/after values for the columns that changed.
Events are buffered in memory and shipped out-of-band over a separate HTTP connection. The agent never raises into your request path. If the EZLogs server is unreachable, the agent buffers up to ten thousand events and retries with backoff. If the buffer overflows, the oldest events drop. I’d rather lose the oldest events than 500 a customer.
The server is where the magic happens, and the magic is boring on purpose. Every event from one user’s action shares a correlation ID. That ID travels from the HTTP request into the jobs the request enqueued, into the DB writes those jobs make, and out the other side. The server stitches the three streams together and produces one card per user action — not one per request.
The card looks like this:
The title is a sentence. Created order #4300. Triggered by Sarah. The body has the entities the action touched, the outcome, when it started, and how long it took. The What happened behind the scenes section is collapsed by default and contains the underlying events — the HTTP POST to /orders, the database change that created the row, every job and email that fired afterward.
That’s the part the support team opens to answer “what happened to this order?” That’s the part the product team reads when they want to understand a flow. That’s the part you can paste into a Notion doc when you’re explaining a bug to the CEO.
Sensitive fields are redacted by default. Anything matching password, token, secret, key, or any *_at timestamp column is dropped before the event leaves your process. You can extend the redaction list in the initializer. The reason redaction is on by default and not opt-in is that the audience for the card is the whole company. If the CS lead has to wonder whether a card is safe to send to the CEO, the gem has already failed.
It’s not metrics. It’s not APM. It doesn’t replace Datadog and doesn’t try to. If you want p99 latency graphs, this is the wrong tool. If you want logs your CS team can read without asking an engineer, that’s the whole pitch.
The first week
I launched EZLogs on Wednesday. r/rails first, then r/ruby, then a Twitter post from a brand-new account, then a Hacker News attempt that the algorithm downweighted because the account was twelve hours old. The Reddit posts landed in the boring way good launches do: a few thousand views, a handful of upvotes, a comment thread that asked the load-bearing question (“is this usable without an LLM?” — yes, the core pipeline is deterministic templates).
Two days in, I went back to read my own install instructions the way a new user would. Run the generator. Open the file it creates. Try to figure out what to put.
This is what was there:
# URL of the EzLogs server (required)
# config.server_url = "https://your-ezlogs-server.com"
That URL is a placeholder. It was the placeholder from the very first version of the gem, when I wasn’t sure whether EZLogs would be a hosted service or self-host-only. By launch day it should have been the real SaaS endpoint. It wasn’t.
Worse: the line was commented out. So a new user has to uncomment it and edit it on top of guessing the right value. And the natural guess, with a tenant slug visible in the account settings, is that the server URL is tenant-scoped — something like https://<your-slug>.ezlogs.io. It isn’t. The server URL is the same for every customer. The tenant is identified by the API key, not by the URL. That’s the kind of thing that’s obvious in your head when you build the product and completely opaque to the first person who reads the install file.
Four hours later I shipped ez_logs_agent 0.1.8 to RubyGems. The install template now pre-fills the SaaS URL, uncommented, and reads the API key from ENV["EZLOGS_API_KEY"]. The only thing a customer has to set is one environment variable.
config.server_url = ENV.fetch("EZLOGS_SERVER_URL", "https://app.ezlogs.io")
config.project_token = ENV["EZLOGS_API_KEY"]
That’s the whole config now. Self-hosters override EZLOGS_SERVER_URL. Everyone else just sets the API key.
The lesson I keep coming back to: the install matters more than the pitch. The landing page can be perfect, the README can be polished, the gem can ship at 854 specs green, and a placeholder URL in the generated initializer still kills your first conversion. The pitch is for people who haven’t signed up yet. The install is for the people who already trust you enough to try. Getting the install wrong is the more expensive of the two.
I’m writing this on day four. Stars on the gem repo are trickling in from people I don’t know. Ruby Weekly might pick it up next Thursday. The Hacker News attempt is going to get one more shot once my account is old enough to not be a spam vector.
If your support team currently pings engineering to translate logs, I’d like to hear how they ask, how often, and what about the answer they get back today hurts the most. Reply on Hacker News if this is on the front page when you read it. Email me at hello@ezlogs.io if it isn’t. The next people who try EZLogs shape what ships in 0.2.0, and the next person who installs the gem should not have to figure out which URL goes where.
Or read the docs at ezlogs.io/rails. Or just bundle add ez_logs_agent and see what happens.
Written by Razvan Dezsi, founder of EZLogs. The gem is open-source at github.com/dezsirazvan/ez_logs_agent. The server is a paid SaaS at app.ezlogs.io with a free tier for small projects.
← Back to blog