Ominvo beta launches July 23, 2026 — only 10 spots remain Join the waitlist →
All posts
Building Ominvo

Day 66: What 'shipped' actually means

June 29, 20266 min read

Three commits shipped today. One was planned. The other two were things I'd already "shipped" that didn't work until I used them.

This has been the pattern for at least a week. Day 65 was the health dashboard showing green for four days while the database write was silently gone. Day 64 was a block-reason template that was 35 characters over the server-side validation limit — the Block button would have failed every time in production, and I only caught it by clicking it myself. Today added an auth loop, a misidentified API secret, a whitelist I didn't know existed, and a missing price to the same list.

"TypeScript clean, Vercel READY, CI green" is not the same thing as working.

The Errors tab — the one thing that was actually planned

I've been checking Sentry by logging into sentry.io directly or waiting for their digest emails. The admin has 11 tabs — Customers, Revenue, Health, IP Abuse, and so on — but no tab for errors. Every time I wanted to know whether something was throwing in production, I had to leave the page.

The Errors tab fixes that. It's the 12th admin tab: a Sentry issues list with time filter (1h/24h/7d/14d/30d), level filter (all/error/warning), manual refresh button, and lazy-load on first tab visit. Each row shows the level dot, title, culprit, event count, and last seen timestamp. Clicking "Open in Sentry" links out to the full issue in the Sentry UI.

It's not a Sentry clone. The intent is: enough context to know whether something new is throwing or a known issue is growing, without leaving the admin. Deep dives are still on sentry.io.

The empty-data-fails-loud pattern from Day 65 applies here. Zero issues → green banner confirming that, so I know the filter ran and returned nothing rather than silently failing. Sentry unreachable → red banner. A blank page with nothing in it would be indistinguishable from a route that silently failed.

The auth loop that wasn't a loop

On first tab load, requests were firing every 2 seconds. The tab body was blank. The network tab showed a stream of 401s.

My first read: broken useEffect dependency array — something triggering re-renders in a loop. I started tracing the effect dependencies.

It wasn't the effect. The sequence was:

  1. Tab opens → errorsData is null → effect fires the fetch
  2. Fetch returns 401 (wrong token — more on that next) → errorsData stays null
  3. Guard condition: "auto-load only when errorsData is null" → condition still true → effect fires again

One bug producing two symptoms. The 401 and the loop were the same thing. Once the auth was fixed, the loop stopped on its own.

The pattern worth internalizing: when multiple things look broken at once, check whether one is downstream of the other before debugging both separately. I nearly spent 20 minutes on the wrong thing.

The wrong Sentry secret

After the tab structure was working, all requests returned 401.

Sentry Internal Integrations have three secret fields after you save: Client ID, Client Secret, and Token. The Token is what API calls need — it goes in the Authorization: Bearer header.

I had pasted the Client Secret.

Both fields start with sntrys_. Both are long hex strings. They look identical at a glance. I checked the environment variable twice, confirmed the value matched what I'd copied, and spent 30 minutes in "the token format must be wrong" territory before going back to the Sentry UI and actually reading the field labels.

The fix: replace the env var value with the actual Token field. 30 seconds. The diagnosis took 30 minutes.

The statsPeriod constraint

After fixing the token, the 24h time filter worked. The 1h, 7d, and 30d filters returned 400.

Sentry's /issues/ endpoint accepts a statsPeriod query parameter, but it has a hardcoded whitelist: 24h, 14d, or empty. Three values. And it doesn't filter the issue list — it controls the stats graph data rendered inside each individual issue row. Not what I needed.

The actual recency filter for Sentry issues is their search query syntax: is:unresolved age:-{period}. That accepts arbitrary durations with h/d/w suffixes. Switched the implementation to append age:-{period} to the query string and dropped statsPeriod from the request. All five dropdown values work now.

The lesson is the same one that applies to any third-party API: read the docs before writing the spec, not after dogfood. I assumed statsPeriod did what it sounded like. It did something different with a narrower input set than I expected.

The /lifetime price that wasn't there

The /lifetime page went live on Day 57. I built it. I proofread it while building it. I knew the price was $79.

Today I opened it in a fresh private window with no prior context and looked for the price. It wasn't there.

The $79 existed only inside the buy-button label — "Get lifetime access — $79". That button is hidden when PRE_LAUNCH=true, which the page has been in since Day 57. Waitlist visitors were seeing the hero, the feature list, the FAQ, and a "Join the waitlist" CTA — with no price signal anywhere on the page.

Nine days. The page was asking people to join a waitlist for a product with no stated price.

Fix: a standalone price block inserted above the CTA in both modes — a gradient $79 heading, "One-time · Yours forever · 50 AI replies/month" subtext. Renders whether the buy button is visible or not. The waitlist CTA copy also picked up a price anchor: "Join the waitlist · Lock in $79."

What this week is saying

Three weeks in a row, dogfood catches have outnumbered planned ships:

  • Day 65: health tab showing green for four days while the DB write was silently gone
  • Day 64: block reason template 35 chars over the validation limit, zero DB writes during the failed attempt
  • Day 62: Manage Billing button locking on "Redirecting..." during graceful-degradation testing
  • Today: 401 loop, Sentry secret confusion, statsPeriod whitelist, nine days of missing price

None of these would have surfaced from TypeScript or CI. They surface when you use the feature from a clean state, like a real user would, before declaring it done.

The rule isn't "ship slower." The rule is: ship a feature, then open a fresh private window and use it before calling it done. Everything on the changelog should have passed that test before it landed there.

Written by

The founder of Ominvo

Building review management for single-location small businesses. Join the waitlist →