index/engineering/time-to-answer/Debugging Production Without Leaving the IDE
engineeringtime-to-answer

Debugging Production Without Leaving the IDE

The fastest way to tell a code bug from a config bug is to ask the database and the API the same question without leaving the editor — a chain that settled a production discount complaint in minutes, and the finding was that no bug existed: the sale had ended the day before the customer bought. The real lesson isn't the tool; it's the habit of handing a value you're already looking at to the next question you need answered.

tags: jetbrains, datagrip, feedback-latency, debugging, shortcuts

Part 3 of 3.

A book wasn’t selling. Not slowly — dead. A promotion had run, someone had bought a day late, and the discount hadn’t applied. Now the business was asking why, and the answer had a deadline attached to it, because every hour the price stayed wrong was money walking past the shelf.

The question underneath was simple: is this a code bug, or a config bug? Is the discount failing to apply when it should — or did somebody configure a window that had already closed?

That distinction decides how you investigate. And the fastest way I know to answer it isn’t a test harness or a staging reproduction. It’s asking the database directly, then asking the API the same question, without leaving the editor once.

Stage one: find the row

I work in DataGrip, and for a quick lookup I don’t open a scratchpad and write SQL from nothing. I write the question as a comment and let Copilot’s inline autocomplete do the typing:

-- context of tables (Brain Dump)
CREATE TABLE books (
   id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
   title           VARCHAR(255) NOT NULL,
   slug            VARCHAR(255) NOT NULL UNIQUE,
   author          VARCHAR(255),
   isbn            VARCHAR(20),
   pages           INTEGER NOT NULL CHECK (pages > 0),
   publisher       VARCHAR(255),
   published_date  DATE,
   description     TEXT,
   cover_image     VARCHAR(500),
   language        VARCHAR(50),
   series          VARCHAR(255),
   series_number   INTEGER,
   rating          NUMERIC(2,1) CHECK (rating >= 0 AND rating <= 5),
   status          VARCHAR(20) DEFAULT 'unread' CHECK (status IN ('unread', 'reading', 'read', 'dnf')),
   created_at      TIMESTAMP NOT NULL DEFAULT now(),
   updated_at      TIMESTAMP NOT NULL DEFAULT now()
);

CREATE INDEX idx_books_author ON books (author);

-- a book can have multiple discounts over time (e.g. sale periods),
-- so this is a separate table rather than columns on books
CREATE TABLE book_discounts (
   id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
   book_id         UUID NOT NULL REFERENCES books(id) ON DELETE CASCADE,
   discount_type   VARCHAR(20) NOT NULL CHECK (discount_type IN ('percentage', 'fixed')),
   discount_value  NUMERIC(10,2) NOT NULL CHECK (discount_value > 0),
   starts_at       TIMESTAMP NOT NULL DEFAULT now(),
   ends_at         TIMESTAMP NOT NULL,
   created_at      TIMESTAMP NOT NULL DEFAULT now(),
   CHECK (ends_at IS NULL OR ends_at > starts_at)
);

CREATE INDEX idx_book_discounts_book_id ON book_discounts (book_id);

-- production case: the user says the discount does not apply to their checkout
-- they checked out on 2026-06-01, but the discount window ended the day before, on 2026-05-31
-- a comment like this is what the ai autocompletion reads to generate the query,
-- then datagrip validates whether the schema is correct
-- no red wiggly line underneath, no red colored token, then you're good to go

-- show book slug, discount_value, ends_at
-- fetch book discount by book name "testing is fun" and discount effective to date 2026-06-01
SELECT b.slug, bd.discount_value, bd.ends_at
FROM book_discounts bd
JOIN books b ON bd.book_id = b.id
WHERE b.title = 'testing is fun'
  AND bd.ends_at::date = '2026-06-01';

-- for the purpose of this story, there is no such row, so this returns zero records

Copilot starts writing the select. Tab, tab to walk through the suggestion, then Cmd+Return or any shortcut to run it. A new table view opens and comes back empty: no discount row for that book whose window ends on June 1. The completion named real tables and real columns, the query ran clean, and the grid stayed empty.

Yes… I know, a chat window could generate a cleaner query. But this isn’t about clean — it’s about the distance between a question in my head and a result under my cursor. Inline is faster for a lookup this light, and I still read the SQL before I run it. The autocomplete does my typing, not my judgment.

Still, the database alone only tells me what the config says. It can’t tell me what the customer actually experienced. An empty grid can’t separate a code bug from a config bug — so the DB’s answer has to be checked against the API’s. If the two disagree, there’s a code bug. If they agree, the code is doing what the config says, and the question narrows to the dates — when the promotion was meant to start and end, and who decided that. That’s a different question from whether anyone got it wrong. Sometimes the sale ended exactly when it was meant to and the buyer was simply late.

Debugging isn’t a script. Some days it’s a tight question first, then wider; some days it’s wide first, then tighter. The order is whatever the question suggests. What stays constant is the checking: a result isn’t trusted until it’s been read against the next question. An empty grid is information about how tightly I asked — and the next move is to ask looser.

Stage two: ask the API the same question

-- the same flow as the previous example: write the comment, tab, tab, and the query is generated

-- actual current date is 2026-06-01
-- show book slug, discount_value, ends_at
-- fetch book discount by book name "testing is fun" and discount ends_at >= 2026-05-01 and < 2026-06-01
SELECT b.slug, bd.discount_value, bd.ends_at
FROM book_discounts bd
JOIN books b ON bd.book_id = b.id
WHERE b.title = 'testing is fun'
  AND bd.ends_at::date >= '2026-05-01'
  AND bd.ends_at::date < '2026-06-01';

-- click Cmd+Enter/Return
-- for the purpose of this story, the query is valid and returns one record
-- slug = 'testing-is-fun'
-- discount_value = 10
-- ends_at = 2026-05-31
-- highlight the slug, then launch the external tool
-- the api will automatically use the current date, 2026-06-01
-- the external tool's curl returns an empty discount_value; response: { ... "slug": "testing-is-fun", "discount_value": null }

I highlight the slug from the result row — it could be any value; the slug is just what this endpoint happens to take — and hit the shortcut bound to an External Tool. Underneath it’s nothing more than curl wrapped in a shell script: the tool’s argument field carries $SelectedText$, the IDE macro for whatever I’ve highlighted, and the script hits the endpoint with it. The response comes back in the Run panel — not a terminal pane, just another panel of the IDE I’m already in.

The API answers, and it agrees with the database: no active discount for that book. Two independent sources, same answer. The code did its job and so did the config — nobody had typed the wrong dates, the promotion ended on 2026-05-31, the checkout came on 2026-06-01, and nothing between the two dates was broken. The sale had ended, and the customer didn’t know it had.

The whole investigation took minutes, and I never left the IDE. Most of those minutes were reading; the gap between the two answers was one keystroke and a script run. That’s worth being precise about, because no single step in the chain is fast. The query is a query, the call is a call. What’s gone is the slow furniture around them — the export, the re-type, the second window you have to go live in, the context you have to re-enter. Faster isn’t one fast step. It’s the absence of the slow ones.

The platform, not the category

Here’s the part that only occurred to me after doing this enough times: I retired an API testing tool — Postman, gone, quietly — from inside a database client. Those aren’t supposed to be the same category of tool. DataGrip doesn’t even ship an HTTP client; it’s a plugin you install. And the one sitting there is objectively more capable than my bash one-liner — yet it still loses for this loop, for a real reason: its variables come from environment files, in-place values, or scripts that set them. None of them can reach out and grab whatever I’ve just highlighted somewhere else. There’s no {{SelectedText}} in it. But the same IDE has it one dialog away: External Tools take $SelectedText$ and hand it to a script as an argument. No clipboard hop, no fixed request shape: what I select decides what happens next.

To be fair, the plugin isn’t helpless — you can run a pre-request script before the call fires, and in principle it could bridge to whatever I’ve selected. In practice it means dragging other tools into the setup to make that work: more scripts to keep alive, more moving parts. That’s adding tooling to the thing that was supposed to remove tooling — and it starts working against the whole point of a one-keystroke lookup.

The glue is the pattern

The actual pattern worth passing on isn’t the tools, it’s the glue: the habit of asking what could I do with this value without leaving where I am, then spending fifteen minutes wiring a shortcut that makes it one keypress from now on.

I’ve been running this for a while, so let me be honest about its shape: for light-to-medium lookups it’s quick and it does the job. It has limits — the moment a question needs a join or an aggregation, or a column I can’t name, the autocomplete runs out of schema context and I write the SQL by hand like everyone else. But a firefight doesn’t want the elegant query. It wants the fast one, because in a firefight speed is correctness. A bug found in minutes is a bug fixed before the business loses another day of sales. And when the code turns out to be innocent, the same minutes are what keep me from spending a week suspecting it.

And yes… I know, write a proper test, build the harness, reproduce it in staging. That’s the right instinct for a known invariant. This wasn’t that — I was chasing an unknown with a human waiting on the answer. The test comes after you know what the behavior should be. This is how you find out.

So that’s what I reach for when something’s on fire: not a bigger tool, a tighter chain. The tools will get replaced — DataGrip, Copilot, whatever comes next. The reflex isn’t theirs to take. It’s the habit of never accepting that a value on my screen has to stay stuck there when one shortcut away it could be answering a question.