Back to deblo
deblo

The Segfault That Wasn't Ours: Shipping Déblo's Launch-Day Tracking On Launch Night — Env-Gated Analytics, Native-Store Attribution, Three Bugs The Compiler Could Not See, And An Out-Of-Memory Build We Diagnosed Instead Of Reverting

On July 1, 2026 — launch day — the risk was never the copy. It was the paid campaigns going out blind. This is the build-log of shipping Déblo's analytics and install attribution as code on launch night: env-gated GA4, Meta, and LinkedIn tags that deploy safe before the ad accounts exist; attribution routed through the stores' native channels instead of the web pixel; an adversarial audit that caught three bugs the typechecker and build both passed; and an Easypanel deploy that segfaulted on the first build — which we proved was not our code before we changed a line of it.

Juste A. Gnimavo (Thales) & Claude | July 1, 2026 16 min deblo
EN/ FR/ ES
deblolaunch-dayclaude-opus-4.8claude-codesveltekitsvelte-5-runesadapter-nodega4meta-pixellinkedin-insight-tagenv-dynamic-publicutmplay-install-referrerapple-campaign-tokengoogle-play-cloakingsoft-404install-attributionpost-mortemsegfaultexit-139oomnode-optionsmax-old-space-sizeeasypanelhetznerdockerfileadversarial-auditchrome-devtools-protocolheadless-verificationopen-redirect

By Thales (CEO, ZeroSuite) & Claude Opus 4.8 — Claude Code instance

On launch day the apps were the easy part. Déblo went public on the App Store and Google Play on July 1, 2026, and by the time we sat down for the evening push the store listings were live, the download links resolved, and the launch copy for both the founder channels and the product channels was written and checked. The thing that could actually go wrong on launch day was not the content. It was the paid.

A launch fires organic first, then paid. But paid campaigns spend money the moment they run, and if the pixels and the attribution are not in place, they spend it into the dark. You cannot see which channel converted, you cannot retarget the visitor who bounced, and you cannot tell the install that came from a LinkedIn ad apart from the install that came from a cousin's WhatsApp message. The tracking was not yet built. That was the real launch-day risk, and it was the one we spent the night closing.

This post is the build-log of that night. It covers four things that are each worth a paragraph in anyone's notebook: a store listing that returned 404 to our checks while being perfectly live in a browser; an analytics integration designed to deploy safely before the ad accounts existed; install attribution routed through the stores' own channels instead of the web pixel; and a production build that segfaulted on the first deploy — which we diagnosed as not our code before touching a line of it. The last one is the reason for the title, and it is the part with the most transferable lesson, so it gets the most room.


The Store That Returned 404

The first thing to verify on launch day is dull and non-negotiable: do the download links actually reach an installable app? Déblo's launch links do not point at raw store URLs. They point at deblo.ai/ios, deblo.ai/android, and deblo.ai/app — a set of SvelteKit routes that detect the visitor's platform from the user-agent and 302 them to the right store, with a desktop fallback that renders a landing page with QR codes. So the check was: curl the redirect, follow it, confirm the store page is a real listing.

Both stores returned 404.

deblo.ai/ios     → 302 → App Store listing … 404
deblo.ai/android → 302 → Play listing      … 404

For a few minutes this looked like the apps had not actually been released to production yet — that they were still held in the "pending developer release" and "manual publish" states that both stores use. That was the correct read for the App Store: following the redirect with a browser user-agent and letting it resolve, the canonical Apple URL came back 200 with the full app title, which meant Apple was live. Google Play was the trap. Google Play returns HTTP 200 for a page that does not exist — a soft 404, where the status line lies and the body carries the "we're sorry, the requested URL was not found" message. A naive status-code check reads that as success. A content check reads the empty <title> and the not-found markers and reads it as dead.

Then the founder opened both pages in a signed-in browser and both were unmistakably live, Install button and all.

The reconciliation is worth stating plainly, because it is a rule now: Google Play cloaks. It serves a stripped or soft-404 response to datacenter IPs and bot user-agents while serving the full listing to real browsers. For the question "is the Play listing live," a curl from a build server is not authoritative and a headless request is not authoritative. The signed-in human browser is. We had built a content-based liveness poller — check the App Store title for the app name, check the Play title for the string "Google Play" — and it was more correct than a status check, and it was still going to report Play as dead because the cloak was defeating it too. The lesson cost twenty minutes and it is the cheapest twenty minutes in this post.

The order that follows from all this is strict, and it is the order we held: release both apps and confirm both listings resolve in a browser before a single launch post or ad goes out. Every link in the launch pack pointed at those store pages. Posting the founder pin or firing the WhatsApp broadcast while either store was dark would have spent the single most valuable window of the day — the first hours, when attention is highest — on a dead link, and you cannot un-send a first impression.


Analytics You Can Ship Before The Accounts Exist

Here is the constraint that shaped the whole tracking design: on launch night, none of the ad accounts existed yet. There was no GA4 property, no Meta pixel, no LinkedIn Insight Tag. Those get created in the ad managers, and creating them is a founder task that had not happened. But the code to fire them needed to ship now, so that the moment the IDs existed they could be dropped in without another deploy.

The pattern that satisfies both is env-gated tags. A single Svelte component reads three variables — PUBLIC_GA4_MEASUREMENT_ID, PUBLIC_META_PIXEL_ID, PUBLIC_LINKEDIN_PARTNER_ID — from $env/dynamic/public, and each tag renders only if its ID is present. An empty or absent variable means the tag does not load at all: no script tag injected, no network call made. The component is a genuine no-op until an ID appears, which makes it completely safe to deploy before the accounts exist.

The reason it is $env/dynamic/public and not $env/static/public matters. Static public env is inlined at build time; an absent variable there either bakes in as undefined or breaks the build depending on how it is referenced, and changing an ID means a rebuild. Dynamic public env is read at runtime by the Node adapter when the server boots. That means the founder can paste the three IDs into the Easypanel environment and restart the container — no rebuild, no code change — and the tags come alive. On a launch night where the ad accounts might get created at any hour, "restart, don't rebuild" is the property you want.

The tags themselves are injected the way the vendors' own "programmatic install" snippets do it — in onMount, browser-only, building the dataLayer/gtag stub, the fbq queue, and the LinkedIn partner-id array by hand and then appending the async loader script. Scripts inserted through innerHTML never execute, so a <svelte:head> {@html} approach would silently do nothing; the programmatic path is the correct one, and it also keeps the whole thing out of server-side rendering entirely, which is what you want for third-party analytics on a prerendered marketing site.

There is one honest gap, and we wrote it into the code as a comment rather than pretending it away: the tags fire on load with no consent gate. For a product whose dominant audience is in West Africa and the diaspora, that was the deliberate launch-day call, with a consent management platform for EU visitors filed as a documented follow-up rather than a launch blocker. Naming the gap in the source is cheaper than discovering it in an audit later — and, as it happens, the audit confirmed it was the only such gap.


Attribution Belongs In The Store, Not The Pixel

A web pixel tells you who visited deblo.ai. It cannot tell you who installed the app, because the install happens inside the App Store or Google Play, on the other side of a redirect, where no web pixel follows. Install attribution has to ride the store's own channel.

So the three redirect routes — /ios, /android, /app — do not just bounce the visitor to a fixed store URL anymore. They read the incoming UTM parameters and forward them into each store's native attribution mechanism:

  • Google Play takes an Install Referrer: the UTM string goes into a referrer= query parameter, URL-encoded exactly once, and Play surfaces it to the app through the Install Referrer API.
  • Apple takes a campaign token: utm_campaign becomes the ct value on the App Store link, capped at Apple's forty-character limit, alongside an optional provider token and mt=8.

The base store URLs are module constants. Nothing user-supplied ever touches the redirect host — only the encoded UTM values enter, and only as query parameters. That is deliberate: a redirect route that builds its destination from request input is an open-redirect waiting to happen, and the way you avoid it is to make the host un-derivable from anything the caller controls.

The whole thing hangs off a single UTM convention, because attribution is only as good as the discipline of the links feeding it. One entry point — deblo.ai/app?utm_source=…&utm_medium=…&utm_campaign=launch-j0&utm_content=… — with kebab-case, accent-free values (the campaign is launch-j0, the source is the exact platform, the medium separates paid from organic-social from broadcast, and the content names the creative so the losers can be cut). The web side sees it in GA4; the install side sees it in the Play referrer and the Apple token. Same link, both sides of the redirect.


The Audit Found Three Bugs The Compiler Could Not

Before any of this shipped, it went through the read-only audit pass we run on anything touching a data path — a separate agent, briefed as a senior reviewer, told to try to break it. The typecheck was clean across all 5,572 files. The production build was green. Neither of those can see a semantic bug, and the audit found three.

The first was the worst, because it silently defeated the entire point of the exercise. GA4 was configured with send_page_view: false — the standard move when you intend to send page views manually on a single-page app — but the only manual page view lived in the afterNavigate handler, which deliberately skips its first invocation to avoid double-counting the entry hit. The net result: GA4 never recorded the landing page view at all. A session with no in-app navigation would report zero page views. On launch day, with paid traffic landing and bouncing, GA4 would have shown a fraction of reality and we would have trusted it. The fix was one line — fire the entry page view explicitly in the init — but the bug was invisible to every static check because the code was perfectly valid; it was the behavior that was wrong.

The second was a race. The afterNavigate skip was guarded by a ready flag that could, depending on the ordering of onMount versus the first afterNavigate, swallow the first real navigation as well. The fix was to decouple the skip from readiness so the landing hit always comes from init and the flag flips deterministically on the first call regardless of order.

The third was an encoding bug in the Play referrer. The UTM values were being encoded once when the string was assembled and then again when the whole string was placed in the referrer parameter — a double-encode that most Install Referrer parsers, which decode once, would surface as mojibake and mis-attribute. The fix was to encode exactly once, at the boundary, and to lean on the convention that UTM values are ASCII kebab-case so there is no structural ambiguity inside a value.

Three real bugs, zero compiler complaints. This is the entire argument for the adversarial pass existing: the typechecker proves the code is well-formed and the build proves it compiles, and neither of them can tell you that your analytics will under-count every session on the busiest day of the year.

Fixes applied, re-verified, and committed as de8e757. Then it went to deploy, and deploy is where the night got interesting.


The Segfault That Wasn't Ours

The first Easypanel build of de8e757 died with this:

Segmentation fault (core dumped)
ERROR: process "/bin/sh -c npm run build" did not complete successfully: exit code: 139

Exit 139 is SIGSEGV — a segmentation fault, a memory-access violation at the native level. It landed mid-build, partway through compiling the Svelte components. On launch night, with the tracking commit sitting on top of it, the reflexive read is: the new code broke the build, revert it. That reflex is wrong often enough that it is worth having a rule against acting on it, and the rule is: prove whose bug it is before you fix it.

The evidence assembled quickly, and all of it pointed away from the code:

  • The same build passed locally. Running the production build on the developer machine with the exact three tracking IDs set — the same values Easypanel was passing — exited 0, source maps and all. A compile error does not pass on one machine and segfault on another.
  • The crash moved. The first failed build died in one component; the second failed build, same commit, died in a different, unrelated component. A bug in the code fails deterministically at the same place every time. A crash that wanders across files with each run is a native-level failure — memory pressure — not a logic error.
  • The dependencies had not changed. The Docker layer that runs npm ci was cached and unchanged from the last successful deploy, so the native binaries doing the compiling — the same ones that had built the app fine days earlier — were byte-for-byte identical.

Same code that builds locally, plus a non-deterministic crash location, plus unchanged cached dependencies, equals one conclusion: the build host ran out of memory, and the compiler died with a segfault under the pressure instead of a clean out-of-memory message. It was not our code. Reverting the tracking commit would have deployed nothing and diagnosed nothing, because the next build would have hit the same wall — the app had simply grown to the edge of what the build container's memory allowed, and this was the first fresh, uncached compile to cross it.

The durable fix has two parts, and only one of them is in the repository. In the Dockerfile builder stage, before npm run build, we gave V8 explicit heap headroom:

dockerfileENV NODE_OPTIONS=--max-old-space-size=4096

That helps when the host has the memory but Node was not reaching for it. The other half is not code at all — it is the build host needing enough actual RAM or swap for a large SvelteKit compile, which is an infrastructure lever, not a commit. We shipped the Dockerfile change as 35f4e99 and named the infrastructure half out loud, because a fix you write into the repo that silently depends on a machine having more memory is a fix that will confuse the next person who reads only the diff.

The point of this section is not the NODE_OPTIONS line. It is the fifteen minutes of not reverting. On launch night, with a hospital day behind the founder and the whole social push waiting, the pressure to grab the most recent change and throw it overboard is enormous, and it would have cost the tracking, deployed nothing, and left the real cause — a memory ceiling — to detonate on the following deploy. The discipline that mattered was reading the crash honestly: non-determinism, reproduction, cached deps. Three facts, one conclusion, no panic.


Verifying In Production With A Headless Browser

Once the build was green and the founder had pasted the three IDs into the Easypanel environment and restarted the container, the last question was the only one that counts: do the tags actually fire in production, with the real IDs, reading them at runtime the way the design intended?

The tags inject client-side, so you cannot see them with curl — you need a browser that runs the JavaScript. We drove a headless Chrome through the DevTools Protocol against the live deblo.ai/app, navigated with a UTM-tagged self-test URL, waited for onMount and the async loaders to run, and read the page back:

json{ "ga4": true,  "ga4Script": true,  "dataLayer": 3,
  "meta": true, "metaScript": true,
  "linkedin": "…", "linkedinScript": true }

gtag defined and dataLayer populated, googletagmanager.com/gtag/js loaded; fbq defined, connect.facebook.net/fbevents.js loaded; the LinkedIn partner id set and snap.licdn.com/li.lms-analytics/insight.min.js loaded. All three tags live, reading their IDs from the runtime environment through $env/dynamic/public, exactly as designed. The founder confirmed the same from the other end — GA4 Realtime and Meta's event tester both receiving. End to end, with evidence, not assumption.


What The Night Was Actually About

Strip the specifics away and what is left is a set of small disciplines that only pay off under pressure, which is the only time they are hard to hold:

  • Gate third-party tags on env vars so the integration deploys safe before the accounts exist and comes alive with a restart, not a rebuild.
  • Route install attribution through the store's native channel, never the web pixel, and never let a redirect build its host from request input.
  • Run the adversarial pass even when the typechecker and the build are both green, because they cannot see a page view that never fires or a referrer encoded twice.
  • Read a crash before you blame the newest change. Non-deterministic location plus a clean local reproduction plus unchanged cached dependencies is not your code, and the fifteen minutes it takes to establish that will save you from reverting something that was never the problem.

None of this is exotic. It is the ordinary discipline of shipping, applied on the one night when skipping it would have been most tempting and most expensive. The apps were the easy part. The tracking — the unglamorous plumbing that decides whether the money you are about to spend teaches you anything — was the launch, and it went out with its lights on.

Déblo is live on iOS and Android. The paid can see.


Try Déblo

Déblo is live — a real-time voice and eyes AI you talk to and show things to. From 100 FCFA (~$0.16).

Follow Déblo:

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles

Thales & Claude thales

Thirteen Agents, Forty-Three Minutes: The First Claude Fable 5 Workflow Session, And What A Deterministic Orchestration Script Changes About Multi-Agent Builds

One prompt, thirteen agents, forty-three minutes: the first production session with Claude Fable 5 and Claude Code's Workflow tool shipped a complete seven-page production website plus a backend lead-capture endpoint in a single commit. The build log: the deterministic orchestration script, the contract-injection pattern between phases, the per-agent economics of the parallel fan-out, and the session-limit cliffhanger the resume journal turned into a non-event.

20 min Jun 12, 2026
claude-fable-5claude-codeworkflow-toolmulti-agent +10
Thales & Claude casp

The gate caught its own drift: one day inside CASP with Claude Fable 5

We handed the most autonomous Claude model yet the keys to CASP — the open-source CLI that keeps AI coding agents honest against git — with the authority to reject our own roadmap. It rejected five things, found two real bugs in the validator by dogfooding it, fixed them under a two-auditor gate, and left casp check fully green on its own repo for the first time. CASP 0.3.0 is the result.

14 min Jun 10, 2026
caspzerosuiteworkflowai-cto +9
Thales & Claude zerosuite

The CASP Transplant: How The Six-File Discipline Moved From Conductor To An Anti-Fraud Transport ERP, What The /next Skill Adds When The Operator Just Types 'next', And Why The Cost Of CASP Drift Rises When The Project Is Someone Else's Cash

The CASP discipline that ran thirty-five Conductor sessions is product-agnostic. The build log of transplanting it to KASSIA, an anti-fraud transport ERP for a Côte d'Ivoire fleet operator: what moved, what did not (the bespoke validator — and what its absence costs), what the /next skill adds when the operator types one word, and where the CASP stops — the deployment bug it could not see because it records intent, not infrastructure reality.

20 min Jun 8, 2026
kassiaerp-kassia-transport-logistiquezerosuiteCASP +15