<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Jamie Folsom</title>
    <description>The latest articles on DEV Community by Jamie Folsom (@jamie_folsom_fc88c37582d8).</description>
    <link>https://dev.to/jamie_folsom_fc88c37582d8</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3807525%2Fe5778cb4-82bd-4e0e-9c8b-27d5af69fcd9.jpg</url>
      <title>DEV Community: Jamie Folsom</title>
      <link>https://dev.to/jamie_folsom_fc88c37582d8</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jamie_folsom_fc88c37582d8"/>
    <language>en</language>
    <item>
      <title>Why I Shipped a Linux Desktop App as an AppImage (and Skipped Snap/Flatpak)</title>
      <dc:creator>Jamie Folsom</dc:creator>
      <pubDate>Sat, 21 Mar 2026 08:40:12 +0000</pubDate>
      <link>https://dev.to/jamie_folsom_fc88c37582d8/why-i-shipped-a-linux-desktop-app-as-an-appimage-and-skipped-snapflatpak-53kb</link>
      <guid>https://dev.to/jamie_folsom_fc88c37582d8/why-i-shipped-a-linux-desktop-app-as-an-appimage-and-skipped-snapflatpak-53kb</guid>
      <description>&lt;p&gt;Linux packaging is where good desktop apps go to die.&lt;br&gt;
If you’ve shipped a Linux desktop application, you already know the pain: you can build something solid, fast, and stable… and then lose days (or weeks) fighting packaging quirks across distros, runtimes, sandboxes, portals, GLIBC mismatches, and “works on my machine” dependency landmines.&lt;br&gt;
I’m building OpenChat for Linux, a lightweight ChatGPT desktop client built with Tauri (Rust). Early on, I decided to focus on one distribution format that would actually let Linux users install it easily without creating a maintenance nightmare for me: AppImage.&lt;br&gt;
This post is the reasoning behind that decision, what went wrong with other formats, and what I learned.&lt;br&gt;
The goal: one build that works across distros&lt;br&gt;
When you’re distributing a desktop app to Linux users, you’re really targeting:&lt;br&gt;
different distros (Ubuntu, Fedora, Arch, etc.)&lt;br&gt;
different package managers&lt;br&gt;
different libc/runtime realities&lt;br&gt;
different desktop environments and portal behaviors&lt;br&gt;
different expectations about sandboxing and filesystem access&lt;br&gt;
For a small team (or one person), the biggest risk is not that you can’t build packages—it’s that you spend more time keeping packages alive than improving the app.&lt;br&gt;
So the goal became:&lt;br&gt;
Make installation stupid-simple across distros and minimize maintenance.&lt;br&gt;
Why AppImage “just works” (most of the time)&lt;br&gt;
AppImage has a few properties that make it extremely pragmatic:&lt;br&gt;
No distro-specific repo required&lt;br&gt;
Runs on most distros with minimal friction&lt;br&gt;
One artifact to ship (great for fast iteration)&lt;br&gt;
Users can download, mark executable, and run&lt;br&gt;
Updates are straightforward: ship a new file (and optionally use AppImageUpdate later)&lt;br&gt;
In practice, AppImage is the least “ecosystem heavy” option. It doesn’t ask you to become an expert in every distro’s packaging norms.&lt;br&gt;
For my use case—getting a stable Linux desktop client into people’s hands quickly—this mattered more than ideological purity.&lt;br&gt;
Why Snap became a time sink for me&lt;br&gt;
Snap isn’t “bad,” but it can become expensive in time.&lt;br&gt;
The big issues I ran into were runtime and sandbox friction—things like:&lt;br&gt;
portal behaviors&lt;br&gt;
sandboxing assumptions that don’t match how your app needs to access resources&lt;br&gt;
dependency/runtime differences that are hard to predict&lt;br&gt;
the “it launches, but key features fail” class of bugs&lt;br&gt;
Even when you get it packaged, you can burn hours chasing down problems that don’t exist in your core app at all—they only exist in the packaging environment.&lt;br&gt;
At some point you have to ask:&lt;br&gt;
Is this helping users, or just dragging development velocity down?&lt;br&gt;
For me, Snap was pulling too much time away from improving the product.&lt;br&gt;
Why Flatpak failed for a more “boring” reason: GLIBC mismatch&lt;br&gt;
Flatpak has a lot going for it: consistent runtimes, sandboxing, and a strong ecosystem.&lt;br&gt;
But the reality is: if your build target and runtime don’t line up perfectly, you can hit hard failures.&lt;br&gt;
In my case, I hit a GLIBC mismatch issue (e.g., runtime missing a required GLIBC version). That’s the kind of problem that isn’t solved by “try harder”—it’s solved by rebuilding under the correct runtime constraints and aligning everything.&lt;br&gt;
If you’re a larger project with time to invest, that’s totally doable.&lt;br&gt;
But if your priority is shipping and reducing maintenance, it’s easy for Flatpak to turn into an engineering project on its own.&lt;br&gt;
So I paused Flatpak and kept the focus where it had the best ROI.&lt;br&gt;
A practical distribution strategy that actually scales&lt;br&gt;
Here’s the strategy I landed on:&lt;br&gt;
AppImage as the primary distribution&lt;br&gt;
Provide .deb / tarball for users who prefer them&lt;br&gt;
Only invest in Snap/Flatpak again when:&lt;br&gt;
there’s clear demand&lt;br&gt;
the ecosystem value outweighs the maintenance&lt;br&gt;
the build pipeline is ready to support it sustainably&lt;br&gt;
That’s not anti-Snap or anti-Flatpak. It’s pro-shipping.&lt;br&gt;
Bonus: performance matters as much as packaging&lt;br&gt;
Packaging isn’t the only Linux pain point.&lt;br&gt;
A lot of chat clients (and some desktop wrappers) can bog down during long sessions. OpenChat uses a technique I call message windowing:&lt;br&gt;
keep an active slice of the chat rendered&lt;br&gt;
load older messages only as you scroll&lt;br&gt;
avoid long-thread memory growth and UI slowdown&lt;br&gt;
It’s a simple idea that makes a big difference: long conversations stay responsive instead of turning into a laggy mess.&lt;br&gt;
What I’m hoping to learn from the Linux community&lt;br&gt;
If you’ve shipped desktop apps on Linux, I’d genuinely like feedback on:&lt;br&gt;
whether AppImage is still the “lowest friction” path for most users&lt;br&gt;
where Snap/Flatpak provide real value today (not just in theory)&lt;br&gt;
what install experience you personally prefer&lt;br&gt;
what breaks most often for you across distros&lt;br&gt;
Shipping on Linux is worth it—but the packaging ecosystem can punish you if you try to do everything at once.&lt;br&gt;
Link (if you want to check it out)&lt;br&gt;
OpenChat for Linux:&lt;br&gt;
&lt;a href="https://snippetsupply.com/product/openchat-for-linux-openai-chat-by-snippetsupply-com-2?utm_source=lemmy&amp;amp;utm_medium=community&amp;amp;utm_campaign=openchat_launch&amp;amp;utm_content=appimage_article" rel="noopener noreferrer"&gt;https://snippetsupply.com/product/openchat-for-linux-openai-chat-by-snippetsupply-com-2?utm_source=lemmy&amp;amp;utm_medium=community&amp;amp;utm_campaign=openchat_launch&amp;amp;utm_content=appimage_article&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>programming</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Building a Hotjar-Class Analytics Platform in PHP: Heatmaps, Replays, Funnels, and Feedback</title>
      <dc:creator>Jamie Folsom</dc:creator>
      <pubDate>Thu, 05 Mar 2026 09:19:35 +0000</pubDate>
      <link>https://dev.to/jamie_folsom_fc88c37582d8/building-a-hotjar-class-analytics-platform-in-php-heatmaps-replays-funnels-and-feedback-421b</link>
      <guid>https://dev.to/jamie_folsom_fc88c37582d8/building-a-hotjar-class-analytics-platform-in-php-heatmaps-replays-funnels-and-feedback-421b</guid>
      <description>&lt;p&gt;Most analytics tools answer what happened.&lt;/p&gt;

&lt;p&gt;Behavior analytics answers why:&lt;/p&gt;

&lt;p&gt;Where users clicked&lt;/p&gt;

&lt;p&gt;How far they scrolled&lt;/p&gt;

&lt;p&gt;Where they got stuck in forms&lt;/p&gt;

&lt;p&gt;Where they dropped off in funnels&lt;/p&gt;

&lt;p&gt;What they said (surveys/feedback) alongside what they did&lt;/p&gt;

&lt;p&gt;I built Spyglass360, a Hotjar-style behavior analytics + CRO platform, using a stack a lot of people overlook:&lt;/p&gt;

&lt;p&gt;PHP&lt;/p&gt;

&lt;p&gt;MySQL&lt;/p&gt;

&lt;p&gt;Vanilla JavaScript&lt;/p&gt;

&lt;p&gt;Bootstrap&lt;/p&gt;

&lt;p&gt;Square for subscriptions&lt;/p&gt;

&lt;p&gt;This post is the engineering breakdown: the event model, storage strategy, and the “gotchas” you hit when you ship a real tracker.&lt;/p&gt;

&lt;p&gt;The install contract: one snippet, stable URL&lt;/p&gt;

&lt;p&gt;If install takes more than one snippet, you’ve already lost half your users.&lt;/p&gt;

&lt;p&gt;Your tracker needs a stable public endpoint (don’t leak internal file paths):&lt;/p&gt;

&lt;p&gt;(function(){&lt;br&gt;
  var s = document.createElement("script");&lt;br&gt;
  s.src = "&lt;a href="https://spyglass360.com/track.js?api=YOUR_API_KEY" rel="noopener noreferrer"&gt;https://spyglass360.com/track.js?api=YOUR_API_KEY&lt;/a&gt;";&lt;br&gt;
  s.async = true;&lt;br&gt;
  document.head.appendChild(s);&lt;br&gt;
})();&lt;/p&gt;

&lt;p&gt;What is this?&lt;/p&gt;

&lt;p&gt;Rule: /track.js should remain stable even if you refactor your internal /assets/... paths later.&lt;/p&gt;

&lt;p&gt;Event design: collect the minimum data that enables the most features&lt;/p&gt;

&lt;p&gt;A lot of tracking systems fail because they capture too much (privacy risk, storage cost), or too little (can’t compute anything useful).&lt;/p&gt;

&lt;p&gt;A workable base event model:&lt;/p&gt;

&lt;p&gt;pageview (url, referrer, viewport)&lt;/p&gt;

&lt;p&gt;click (normalized x/y + element hint)&lt;/p&gt;

&lt;p&gt;scroll (depth percent)&lt;/p&gt;

&lt;p&gt;form_focus, form_blur, form_submit (never raw values)&lt;/p&gt;

&lt;p&gt;custom_event (for funnels/goals)&lt;/p&gt;

&lt;p&gt;Example payload:&lt;/p&gt;

&lt;p&gt;{&lt;br&gt;
  "apiKey": "…",&lt;br&gt;
  "siteId": 123,&lt;br&gt;
  "visitorId": "v_abc",&lt;br&gt;
  "sessionId": "s_xyz",&lt;br&gt;
  "type": "click",&lt;br&gt;
  "url": "&lt;a href="https://example.com/pricing" rel="noopener noreferrer"&gt;https://example.com/pricing&lt;/a&gt;",&lt;br&gt;
  "ts": 1710000000000,&lt;br&gt;
  "viewport": { "w": 1440, "h": 900 },&lt;br&gt;
  "event": { "x": 0.62, "y": 0.41, "selector": "button#start" }&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Normalization matters: store coordinates as percentages so heatmaps work across screen sizes.&lt;/p&gt;

&lt;p&gt;Storage strategy: raw events + aggregates&lt;/p&gt;

&lt;p&gt;PHP can handle ingestion just fine. The real problem is query shape.&lt;/p&gt;

&lt;p&gt;The common pattern:&lt;/p&gt;

&lt;p&gt;Write all events to an append-only raw store&lt;/p&gt;

&lt;p&gt;Maintain aggregate tables for dashboard queries&lt;/p&gt;

&lt;p&gt;Raw table (append-only)&lt;/p&gt;

&lt;p&gt;fast inserts&lt;/p&gt;

&lt;p&gt;flexible schema via JSON payload&lt;/p&gt;

&lt;p&gt;used for replay + deep analysis&lt;/p&gt;

&lt;p&gt;Aggregate tables (fast reads)&lt;/p&gt;

&lt;p&gt;daily/site stats&lt;/p&gt;

&lt;p&gt;funnels summary&lt;/p&gt;

&lt;p&gt;form dropoff stats&lt;/p&gt;

&lt;p&gt;heatmap bins per page&lt;/p&gt;

&lt;p&gt;This lets you keep MySQL without instantly needing ClickHouse/BigQuery.&lt;/p&gt;

&lt;p&gt;Heatmaps: bins + rendering&lt;/p&gt;

&lt;p&gt;Heatmaps are basically “count points and draw a gradient”.&lt;/p&gt;

&lt;p&gt;Implementation approach:&lt;/p&gt;

&lt;p&gt;Normalize x,y&lt;/p&gt;

&lt;p&gt;Convert to bins (e.g., 100×100 grid)&lt;/p&gt;

&lt;p&gt;Increment bin counters&lt;/p&gt;

&lt;p&gt;Render bins as an overlay in the dashboard&lt;/p&gt;

&lt;p&gt;Why bins:&lt;/p&gt;

&lt;p&gt;you avoid storing every pixel as its own row&lt;/p&gt;

&lt;p&gt;you can pre-aggregate quickly&lt;/p&gt;

&lt;p&gt;rendering becomes predictable&lt;/p&gt;

&lt;p&gt;Session replay: not video, a timeline&lt;/p&gt;

&lt;p&gt;Replay looks like video, but it’s really:&lt;/p&gt;

&lt;p&gt;an initial snapshot&lt;/p&gt;

&lt;p&gt;a stream of events&lt;/p&gt;

&lt;p&gt;a player that reconstructs the session&lt;/p&gt;

&lt;p&gt;An MVP replay can start with:&lt;/p&gt;

&lt;p&gt;click trail + scroll positions + page navigation timeline&lt;/p&gt;

&lt;p&gt;Then evolve into:&lt;/p&gt;

&lt;p&gt;DOM snapshots&lt;/p&gt;

&lt;p&gt;mutations&lt;/p&gt;

&lt;p&gt;masking rules&lt;/p&gt;

&lt;p&gt;Big rule:&lt;/p&gt;

&lt;p&gt;Mask and sanitize by default. Replay must never leak passwords, credit cards, or sensitive fields.&lt;/p&gt;

&lt;p&gt;Funnels: pay attention to correlation, not just counts&lt;/p&gt;

&lt;p&gt;Funnels work when you can answer:&lt;/p&gt;

&lt;p&gt;“Did this same session reach step 1 → 2 → 3?”&lt;/p&gt;

&lt;p&gt;“How long between steps?”&lt;/p&gt;

&lt;p&gt;“Where is the dropoff?”&lt;/p&gt;

&lt;p&gt;Implementation:&lt;/p&gt;

&lt;p&gt;define funnel steps by URL patterns and/or event names&lt;/p&gt;

&lt;p&gt;compute step completion per session&lt;/p&gt;

&lt;p&gt;aggregate dropoffs&lt;/p&gt;

&lt;p&gt;Funnels are one of the highest paid-value features because they directly tie to revenue.&lt;/p&gt;

&lt;p&gt;Form analytics: capture friction without capturing values&lt;/p&gt;

&lt;p&gt;Form tools get creepy fast. Don’t collect raw input.&lt;/p&gt;

&lt;p&gt;Instead track:&lt;/p&gt;

&lt;p&gt;field focus/blur&lt;/p&gt;

&lt;p&gt;time-in-field&lt;/p&gt;

&lt;p&gt;submit attempt&lt;/p&gt;

&lt;p&gt;validation errors (optional)&lt;/p&gt;

&lt;p&gt;Output:&lt;/p&gt;

&lt;p&gt;“field X is where 60% abandon”&lt;/p&gt;

&lt;p&gt;“average time spent here is 12s”&lt;/p&gt;

&lt;p&gt;“users rage-click submit then bounce”&lt;/p&gt;

&lt;p&gt;Feedback widgets + surveys: the “why” layer&lt;/p&gt;

&lt;p&gt;Behavior tells you what. Feedback tells you why.&lt;/p&gt;

&lt;p&gt;What matters is targeting:&lt;/p&gt;

&lt;p&gt;show surveys based on pages, events, or user segments&lt;/p&gt;

&lt;p&gt;connect responses to session context (without exposing identity)&lt;/p&gt;

&lt;p&gt;Once you can connect:&lt;/p&gt;

&lt;p&gt;“users rage click here”&lt;br&gt;
with&lt;/p&gt;

&lt;p&gt;“the pricing is confusing”&lt;br&gt;
you get actionable insight.&lt;/p&gt;

&lt;p&gt;Experiments (A/B testing): integrated beats bolted-on&lt;/p&gt;

&lt;p&gt;Once you have:&lt;/p&gt;

&lt;p&gt;visitors/sessions&lt;/p&gt;

&lt;p&gt;conversion goals (events)&lt;/p&gt;

&lt;p&gt;segmentation&lt;/p&gt;

&lt;p&gt;…experiments become much easier:&lt;/p&gt;

&lt;p&gt;assign variant consistently per visitor&lt;/p&gt;

&lt;p&gt;measure goal completion&lt;/p&gt;

&lt;p&gt;break results down by segment&lt;/p&gt;

&lt;p&gt;The win isn’t “A/B testing exists”, it’s:&lt;/p&gt;

&lt;p&gt;“Testing is integrated with behavior + funnels + feedback.”&lt;/p&gt;

&lt;p&gt;Billing gotchas: Square subscription plan variation IDs&lt;/p&gt;

&lt;p&gt;If you use Square subscriptions, here’s the sharp edge:&lt;/p&gt;

&lt;p&gt;Subscription enrollment requires the SUBSCRIPTION_PLAN_VARIATION ID, not the plan ID.&lt;/p&gt;

&lt;p&gt;If you pass the wrong one, you’ll see errors like:&lt;/p&gt;

&lt;p&gt;Object does not have type SUBSCRIPTION_PLAN_VARIATION&lt;/p&gt;

&lt;p&gt;Fix:&lt;/p&gt;

&lt;p&gt;create the plan AND variation in your admin tooling&lt;/p&gt;

&lt;p&gt;store both IDs&lt;/p&gt;

&lt;p&gt;always enroll by variation ID&lt;/p&gt;

&lt;p&gt;Launch gotcha: never grant access before payment success&lt;/p&gt;

&lt;p&gt;The classic SaaS bug:&lt;/p&gt;

&lt;p&gt;user picks paid plan&lt;/p&gt;

&lt;p&gt;you create account and log them in immediately&lt;/p&gt;

&lt;p&gt;payment never completes&lt;/p&gt;

&lt;p&gt;they still get access&lt;/p&gt;

&lt;p&gt;Fix with a proper state machine:&lt;/p&gt;

&lt;p&gt;pending_payment&lt;/p&gt;

&lt;p&gt;active&lt;/p&gt;

&lt;p&gt;failed/canceled&lt;/p&gt;

&lt;p&gt;Gate the dashboard/API based on active.&lt;/p&gt;

&lt;p&gt;What I’d recommend if you build something like this&lt;/p&gt;

&lt;p&gt;Start with funnels + form analytics (they sell)&lt;/p&gt;

&lt;p&gt;Heatmaps are easy value&lt;/p&gt;

&lt;p&gt;Replay is hardest (build in layers)&lt;/p&gt;

&lt;p&gt;Lock down privacy early&lt;/p&gt;

&lt;p&gt;Keep /track.js stable forever&lt;/p&gt;

&lt;p&gt;Add “install verification” UX so users know it’s working&lt;/p&gt;

&lt;p&gt;Wrapping up&lt;/p&gt;

&lt;p&gt;Building behavior analytics is less about “writing a tracker” and more about:&lt;/p&gt;

&lt;p&gt;event design&lt;/p&gt;

&lt;p&gt;storage strategy&lt;/p&gt;

&lt;p&gt;privacy discipline&lt;/p&gt;

&lt;p&gt;and turning data into actionable UX&lt;/p&gt;

&lt;p&gt;If you’re building something similar, I’d love to hear:&lt;/p&gt;

&lt;p&gt;what feature is hardest for you?&lt;/p&gt;

&lt;p&gt;what you wish Hotjar-style tools did better?&lt;/p&gt;

&lt;p&gt;(If you want to try Spyglass360, it’s at spyglass360.com.)&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>php</category>
      <category>javascript</category>
      <category>mysql</category>
    </item>
  </channel>
</rss>
