<?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: Grain64</title>
    <description>The latest articles on DEV Community by Grain64 (@grain64).</description>
    <link>https://dev.to/grain64</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4216062%2F71a2df10-0774-4b0a-9bfc-807377406ad4.png</url>
      <title>DEV Community: Grain64</title>
      <link>https://dev.to/grain64</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/grain64"/>
    <language>en</language>
    <item>
      <title>Cheapest per token is not cheapest per finished job: what 2,400 runs show</title>
      <dc:creator>Grain64</dc:creator>
      <pubDate>Thu, 05 Nov 2026 00:47:31 +0000</pubDate>
      <link>https://dev.to/grain64/cheapest-per-token-is-not-cheapest-per-finished-job-what-2400-runs-show-1484</link>
      <guid>https://dev.to/grain64/cheapest-per-token-is-not-cheapest-per-finished-job-what-2400-runs-show-1484</guid>
      <description>&lt;p&gt;A model with a 33% pass rate turned out to be the cheapest way to finish a task in a recent 10-model benchmark. The same benchmark shows why that result is less of a free lunch than it looks. Here is the metric behind it and how to compute it on your own prompts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The metric&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Cost per successful task = everything the model spent across every attempt (failed, retried, timed out) divided by the number of attempts that passed.&lt;/p&gt;

&lt;p&gt;Per-token price cannot see retries, malformed outputs or runs that never finish. This metric sees all of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The numbers&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Arize and Fireworks ran 10 models on 40 real command-line tasks from Terminal-Bench, 6 trials each, 2,400 runs. The graded outcome is the task's own test script. A selection:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Pass rate&lt;/th&gt;
&lt;th&gt;Mean $ per attempt&lt;/th&gt;
&lt;th&gt;$ per successful task&lt;/th&gt;
&lt;th&gt;Attempts per success&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;gpt-oss-120b&lt;/td&gt;
&lt;td&gt;33%&lt;/td&gt;
&lt;td&gt;$0.018&lt;/td&gt;
&lt;td&gt;$0.054&lt;/td&gt;
&lt;td&gt;3.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemini-3.1-flash-lite&lt;/td&gt;
&lt;td&gt;40%&lt;/td&gt;
&lt;td&gt;$0.026&lt;/td&gt;
&lt;td&gt;$0.063&lt;/td&gt;
&lt;td&gt;2.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.5&lt;/td&gt;
&lt;td&gt;67%&lt;/td&gt;
&lt;td&gt;$0.424&lt;/td&gt;
&lt;td&gt;$0.636&lt;/td&gt;
&lt;td&gt;1.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kimi K3&lt;/td&gt;
&lt;td&gt;66%&lt;/td&gt;
&lt;td&gt;$0.441&lt;/td&gt;
&lt;td&gt;$0.670&lt;/td&gt;
&lt;td&gt;1.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Sonnet 5&lt;/td&gt;
&lt;td&gt;49%&lt;/td&gt;
&lt;td&gt;$0.494&lt;/td&gt;
&lt;td&gt;$1.014&lt;/td&gt;
&lt;td&gt;2.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemini-3.5-flash&lt;/td&gt;
&lt;td&gt;23%&lt;/td&gt;
&lt;td&gt;$0.288&lt;/td&gt;
&lt;td&gt;$1.233&lt;/td&gt;
&lt;td&gt;4.3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Source: &lt;a href="https://arize.com/blog/cost-per-successful-task-ai-model-benchmark/" rel="noopener noreferrer"&gt;Arize and Fireworks, cost per successful task&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Three things to read from it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Retries are a tax you pay in dollars.&lt;/strong&gt; gpt-oss-120b needs about three attempts per success, and is still roughly 12 times cheaper per finished task than GPT-5.5 ($0.054 against $0.636).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A cheap model is not always a cheap result.&lt;/strong&gt; gemini-3.5-flash costs $0.288 an attempt but passes 23% of the time: $1.233 per success, more than twenty times gpt-oss-120b's.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Coverage is the catch.&lt;/strong&gt; Counting only tasks a model solved reliably (4 of 6 trials or better), gpt-oss-120b covers 8 of 40, GPT-5.5 covers 25 and Kimi K3 covers 26. Part of the cheap model's advantage is that it only wins the tasks it can win. Swap it in wholesale and the bill and the feature set both shrink.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;By difficulty, price tier did not predict who wins the easy tasks: GPT-5.5 passed 69% of them, Kimi K3 (also frontier-priced) 100%, and gpt-oss-120b 65% at roughly a twenty-fourth of the cost per attempt. On hard tasks the gap was large (GPT-5.5 51% against 14% for gpt-oss-120b). With 6 trials per cell, the source puts the pass rates at about plus or minus 6 points, so read the difficulty split as a direction, not a decimal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What does and does not transfer&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;These are agentic terminal tasks, not classification or extraction, so do not read the dollar figures as your numbers. The method transfers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Run it on your own prompts (an afternoon)&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Take 100 real inputs and the output you would accept for each. Real traffic, not a demo set.&lt;/li&gt;
&lt;li&gt;Write the pass rule before you run anything: exact match, a JSON schema plus field checks, or a rubric. Set the bar (say 90%).&lt;/li&gt;
&lt;li&gt;Run your current model and two or three cheaper candidates. Record the cost of every call from the provider's usage field, including calls that were retried or rejected by your validator.&lt;/li&gt;
&lt;li&gt;For each model compute: passes / total, total spend / passes, and the share of calls that needed a retry.&lt;/li&gt;
&lt;li&gt;Throw out any candidate under your bar. Of the rest, the lowest cost per success wins. If that is your current model, you have learned that for free.&lt;/li&gt;
&lt;li&gt;Re-run when a price drops. A cheaper candidate you rejected last quarter may clear the bar at a lower price now, and prices move monthly.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The headline of that benchmark is the one worth remembering: the label (open or closed, frontier or budget) was the wrong axis. Cost per successful task, on your task, was the right one.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>ai</category>
      <category>benchmark</category>
      <category>programming</category>
    </item>
    <item>
      <title>Umami self-hosting problems: 8 reports from 14 public threads, and the migration failure that keeps coming back</title>
      <dc:creator>Grain64</dc:creator>
      <pubDate>Wed, 04 Nov 2026 03:32:41 +0000</pubDate>
      <link>https://dev.to/grain64/umami-self-hosting-problems-8-reports-from-14-public-threads-and-the-migration-failure-that-keeps-4dij</link>
      <guid>https://dev.to/grain64/umami-self-hosting-problems-8-reports-from-14-public-threads-and-the-migration-failure-that-keeps-4dij</guid>
      <description>&lt;p&gt;Umami self-hosting problems, counted. Of the 66 items Friction holds about Umami (all dates, from GitHub issues and Hacker News; the reports, plus those already fixed upstream), 8 are about self-hosting and upgrades, resting on 14 separate public threads. Six of those 14 threads are one failure: a Prisma migration that breaks an upgrade, reported from August 2022 to May 2025.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The migration that fails, six times in three years&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Six GitHub issues, one problem: a database migration fails or leaves the schema wrong when you upgrade.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;2022-08, &lt;a href="https://github.com/umami-software/umami/issues/1393" rel="noopener noreferrer"&gt;#1393&lt;/a&gt;: "The migration &lt;code&gt;01_init&lt;/code&gt; is already recorded as applied in the database". Closed as completed, 2022-08.&lt;/li&gt;
&lt;li&gt;2022-08, &lt;a href="https://github.com/umami-software/umami/issues/1406" rel="noopener noreferrer"&gt;#1406&lt;/a&gt;: "The update to 1.37.0 did not work out however". Closed as completed, 2022-08.&lt;/li&gt;
&lt;li&gt;2022-10, &lt;a href="https://github.com/umami-software/umami/issues/1604" rel="noopener noreferrer"&gt;#1604&lt;/a&gt;: "The column &lt;code&gt;account.account_uuid&lt;/code&gt; does not exist in the current database after upgrading to 1.39.2". Closed as completed, 2022-12.&lt;/li&gt;
&lt;li&gt;2023-08, &lt;a href="https://github.com/umami-software/umami/issues/2206" rel="noopener noreferrer"&gt;#2206&lt;/a&gt;: "I updated the docker image to the latest version and got a &lt;code&gt;prisma migrate deploy&lt;/code&gt; command error after container boot". Closed as completed, 2024-02.&lt;/li&gt;
&lt;li&gt;2024-04, &lt;a href="https://github.com/umami-software/umami/issues/2645" rel="noopener noreferrer"&gt;#2645&lt;/a&gt;: "A migration failed to apply. New migrations cannot be applied before the error is recovered from". Closed as completed, 2025-01.&lt;/li&gt;
&lt;li&gt;2025-05, &lt;a href="https://github.com/umami-software/umami/issues/3408" rel="noopener noreferrer"&gt;#3408&lt;/a&gt;: "I tried to go back to V2.17 and I have the same problem". Closed as not planned, 2025-08.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Five were closed as completed, the quickest within days, the slowest after about nine months; the newest, a Docker upgrade from 2.17 to 2.18 that ends in Prisma error P3009 (a failed migration in the target database), was closed as not planned. If you self-host, this is the failure to expect around a version boundary: back up the database before you pull a new image, and read the open and recently closed issues for the version you are moving to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two Heroku deploy failures, different causes&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two issues, three and a half years apart, both Heroku deploys that did not come up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;2022-04, &lt;a href="https://github.com/umami-software/umami/issues/1071" rel="noopener noreferrer"&gt;#1071&lt;/a&gt;: "jsxDEV is not a function error" after updating to 1.29.2 and deploying on Heroku. Closed as completed within five weeks, 2022-05.&lt;/li&gt;
&lt;li&gt;2025-11, &lt;a href="https://github.com/umami-software/umami/issues/3687" rel="noopener noreferrer"&gt;#3687&lt;/a&gt;: "When deploying umami 3.0.0 to Heroku, the build fails at the check-db step even though the database connection works". Closed as not planned, 2026-05.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The rest of the self-hosting list&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Six more reports, one thread each. Three are operational failures: a Docker container that restarts repeatedly after an update, a Podman healthcheck that cannot reach Umami on localhost, and a production build that runs out of memory on shared hosting (2022). One is a request, for a Docker image variant with a base path for subpath hosting (marked resolved, 2026-03).&lt;/p&gt;

&lt;p&gt;Two come from Hacker News and are about where Umami is going rather than how it breaks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;2025-12, on the website's cloud-first pivot: "now you can't even find what you can self-host it on the front page!" (&lt;a href="https://news.ycombinator.com/item?id=46397564" rel="noopener noreferrer"&gt;thread&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;2026-01, from a homelab user: "I can't self host the blog's analytics service (umami.is) because obviously a home server not exposed online can't receive queries from the internet" (&lt;a href="https://news.ycombinator.com/item?id=46835779" rel="noopener noreferrer"&gt;thread&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What people say when they leave&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Self-hosting is the most-reported area among Umami's 29 recent reports (6). Three reports describe a move away, and 4 users named where they went: Plausible (2), PikaPods (1) and Hotjar (1). A count is the number of reports naming a tool, not a market share; the list, with sources, is on the &lt;a href="https://friction.grain64.com/alternatives/umami/?utm_source=devto" rel="noopener noreferrer"&gt;Umami alternatives page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to read these numbers&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A "report" is one problem, grouped from one or more public threads; a "source thread" is one issue or comment thread. The six migration issues and the Heroku issues above link to GitHub, and the two Hacker News comments link to their threads; the excerpts are copied word for word. The set is GitHub issues (78 threads) and Hacker News (9 threads) for Umami, 87 in all, so it shows what people filed and said in public, not every Umami user. The full list, with each report's sources, is on &lt;a href="https://friction.grain64.com/p/umami/?utm_source=devto" rel="noopener noreferrer"&gt;Umami's page&lt;/a&gt;, and &lt;a href="https://friction.grain64.com/data/?utm_source=devto" rel="noopener noreferrer"&gt;what is stored and how to dispute a report&lt;/a&gt; is on the data page.&lt;/p&gt;

&lt;p&gt;Planning to move to Umami, or building something against it? Say which product you want counted next at &lt;a href="https://friction.grain64.com/request/?utm_source=devto" rel="noopener noreferrer"&gt;https://friction.grain64.com/request/?utm_source=devto&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>opensource</category>
      <category>analytics</category>
      <category>docker</category>
    </item>
    <item>
      <title>Build a Stripe cancel flow: cancel_at_period_end and a webhook handler that doesn't double-count</title>
      <dc:creator>Grain64</dc:creator>
      <pubDate>Wed, 04 Nov 2026 00:09:22 +0000</pubDate>
      <link>https://dev.to/grain64/build-a-stripe-cancel-flow-cancelatperiodend-and-a-webhook-handler-that-doesnt-double-count-1cp2</link>
      <guid>https://dev.to/grain64/build-a-stripe-cancel-flow-cancelatperiodend-and-a-webhook-handler-that-doesnt-double-count-1cp2</guid>
      <description>&lt;p&gt;A cancel flow on Stripe is three pieces: a place that catches the cancel click, an offer step, and a webhook that tells your app whether the cancellation was scheduled, stopped or finished. Set &lt;code&gt;cancel_at_period_end&lt;/code&gt; to &lt;code&gt;true&lt;/code&gt; so the subscriber keeps what they paid for, and revoke access only when &lt;code&gt;customer.subscription.deleted&lt;/code&gt; arrives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cancel at period end, not immediately
&lt;/h2&gt;

&lt;p&gt;By default, cancelling a subscription through the API takes effect immediately and no more invoices are generated. To let the subscriber keep the period they already paid for, update the subscription with &lt;code&gt;cancel_at_period_end=true&lt;/code&gt;.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Schedule it:&lt;/strong&gt; set &lt;code&gt;cancel_at_period_end&lt;/code&gt; to &lt;code&gt;true&lt;/code&gt;. Stripe sends &lt;code&gt;customer.subscription.updated&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offer something (optional):&lt;/strong&gt; the subscriber is still active, so there is time to show an offer and apply it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stop it:&lt;/strong&gt; set &lt;code&gt;cancel_at_period_end&lt;/code&gt; to &lt;code&gt;false&lt;/code&gt; any time before the period ends. Stripe sends &lt;code&gt;customer.subscription.updated&lt;/code&gt; again. You cannot reactivate a subscription that has already been cancelled.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let it end:&lt;/strong&gt; when the period ends Stripe cancels the subscription and sends &lt;code&gt;customer.subscription.deleted&lt;/code&gt;. That is the event that tells you to revoke access.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Stripe recommends using the subscription status, rather than the flag, as the trigger for starting and stopping service.&lt;/p&gt;

&lt;h2&gt;
  
  
  The webhook handler
&lt;/h2&gt;

&lt;p&gt;This Node handler verifies the signature, ignores repeats and reacts to the three situations above. It takes the raw request body, the &lt;code&gt;Stripe-Signature&lt;/code&gt; header and a small store you provide (a database table is enough). Pass the exact bytes Stripe sent: a body that has been parsed and re-serialised fails the signature check.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stripe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Handles the three Stripe events a cancel flow cares about.&lt;/span&gt;
&lt;span class="c1"&gt;// rawBody must be the exact bytes Stripe sent (string or Buffer), not re-serialised JSON.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createCancelWebhook&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;webhookSecret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signatureHeader&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signatureHeader&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;webhookSecret&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bad signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// seen() must record the id in the same transaction as the writes below, so a failed write is retried, not skipped.&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;duplicate&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;previous_attributes&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;customer.subscription.updated&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cancel_at_period_end&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cancel_at_period_end&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// A cancellation was scheduled: the subscriber keeps access until the period ends.&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cancelScheduled&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
          &lt;span class="na"&gt;subscription&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cancellation_details&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;feedback&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;comment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cancellation_details&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;comment&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cancel_at_period_end&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cancel_at_period_end&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// The scheduled cancellation was stopped: a candidate save, counted after your waiting period.&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reactivated&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;subscription&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;customer.subscription.deleted&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// The real end, from a direct cancel or from reaching the end of the paid period: revoke access here.&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;accessEnded&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;subscription&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The dedupe contract: &lt;code&gt;seen()&lt;/code&gt; must record the event id in the same database transaction as the writes the handler makes, so that if a write fails Stripe's retry is processed again instead of being skipped as a duplicate.&lt;/p&gt;

&lt;p&gt;The handler ran in six tests with stripe-node 23.1.0, using events signed the way Stripe signs them: a wrong secret and a tampered body are rejected, a scheduled cancellation records the reason and comment, a stopped one is reported as a reactivation, an unrelated update does nothing, a deleted subscription revokes access, and a repeated event id is processed once. Before you deploy it, send a test event from your Dashboard or the Stripe CLI and watch it arrive end to end.&lt;/p&gt;

&lt;p&gt;Where the reason lives: in the subscription object it sits under &lt;code&gt;cancellation_details&lt;/code&gt; (fields &lt;code&gt;comment&lt;/code&gt;, &lt;code&gt;feedback&lt;/code&gt; and &lt;code&gt;reason&lt;/code&gt;). The handler stores whatever string arrives and does not depend on a fixed list of values.&lt;/p&gt;

&lt;h2&gt;
  
  
  Applying an offer
&lt;/h2&gt;

&lt;p&gt;Once the reason is known, the three standard offers map to three Stripe changes.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Discount:&lt;/strong&gt; a coupon with &lt;code&gt;percent_off&lt;/code&gt; or &lt;code&gt;amount_off&lt;/code&gt;, lasting &lt;code&gt;once&lt;/code&gt; (the default), &lt;code&gt;repeating&lt;/code&gt; for a set number of months, or &lt;code&gt;forever&lt;/code&gt;. Then set &lt;code&gt;cancel_at_period_end&lt;/code&gt; back to &lt;code&gt;false&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Downgrade:&lt;/strong&gt; switch the subscription item to a cheaper price and set &lt;code&gt;cancel_at_period_end&lt;/code&gt; to &lt;code&gt;false&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pause:&lt;/strong&gt; &lt;code&gt;pause_collection&lt;/code&gt; with a behavior (&lt;code&gt;keep_as_draft&lt;/code&gt;, &lt;code&gt;mark_uncollectible&lt;/code&gt; or &lt;code&gt;void&lt;/code&gt;) and an optional &lt;code&gt;resumes_at&lt;/code&gt;. The subscription stays active and the customer keeps access, so decide in your app what a paused subscriber may use.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Checklist before you ship
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The webhook endpoint reads the raw body and checks the signature.&lt;/li&gt;
&lt;li&gt;Event ids are stored, so a retry changes nothing.&lt;/li&gt;
&lt;li&gt;Access ends on &lt;code&gt;customer.subscription.deleted&lt;/code&gt; or on status, not when the cancel button is clicked.&lt;/li&gt;
&lt;li&gt;Every offer path ends with &lt;code&gt;cancel_at_period_end&lt;/code&gt; set to &lt;code&gt;false&lt;/code&gt;, and a "Cancel anyway" button is always one click away.&lt;/li&gt;
&lt;li&gt;Every cancel attempt is logged with the reason, the offer shown and what the subscriber chose, so you can count saves later.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Choosing which offer to show is the harder part. If you want to see one decided from a subscriber's own words, the &lt;a href="https://stayput.grain64.com/try/?src=dev-build-a-cancel-flow" rel="noopener noreferrer"&gt;sample cancel session&lt;/a&gt; runs in 90 seconds with no sign-up, and shows the offer, the wording and the Stripe change it would make.&lt;/p&gt;

&lt;p&gt;Full guide with sources: &lt;a href="https://stayput.grain64.com/guides/build-a-cancel-flow-with-stripe/?src=dev-build-a-cancel-flow" rel="noopener noreferrer"&gt;stayput.grain64.com/guides/build-a-cancel-flow-with-stripe&lt;/a&gt;&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>saas</category>
      <category>node</category>
      <category>webdev</category>
    </item>
    <item>
      <title>What users of Jira and Asana keep complaining about, counted by thread, and where they say they went</title>
      <dc:creator>Grain64</dc:creator>
      <pubDate>Mon, 02 Nov 2026 08:47:55 +0000</pubDate>
      <link>https://dev.to/grain64/what-users-of-jira-and-asana-keep-complaining-about-counted-by-thread-and-where-they-say-they-went-42jn</link>
      <guid>https://dev.to/grain64/what-users-of-jira-and-asana-keep-complaining-about-counted-by-thread-and-where-they-say-they-went-42jn</guid>
      <description>&lt;p&gt;Building against a named tool, or choosing where to position against it? Here is the count Friction holds for 54 B2B products, with every report linked to the comment it came from: for Jira and Asana, which complaint keeps coming back and where people say they went.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Jira: five of its 21 recurring themes&lt;/strong&gt; (in order of reports; all dates; 154 Hacker News threads read; theme names as written on &lt;a href="https://friction.grain64.com/p/jira/?utm_source=devto" rel="noopener noreferrer"&gt;https://friction.grain64.com/p/jira/?utm_source=devto&lt;/a&gt;)&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;"Slow Jira page and board performance": 26 reports across 33 threads, from 2008 to 2026 (one report can rest on several threads). Typical: "have to wait minutes for the program to respond" (HN, 2025-07).&lt;/li&gt;
&lt;li&gt;"Time-consuming Jira configuration": 8 reports in 8 threads. Typical: "the power to actually configure it to be sane is always reserved by a few people who don't want to bother" (HN, 2026-05).&lt;/li&gt;
&lt;li&gt;"Jira feels too burdensome for simple tracking": 7 reports in 7 threads, 2013 to 2026.&lt;/li&gt;
&lt;li&gt;"High Jira subscription costs": 7 reports in 7 threads. Typical, from a team that moved to Plane: "Jira wanting 26k per year, per customer we have was unacceptable" (HN, 2025-07).&lt;/li&gt;
&lt;li&gt;"Complex Jira navigation and workflows": 6 reports in 7 threads. Typical: "historically i have been quite critical of Jira - because its always felt so complicated for me" (HN, 2024-09).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Asana: four of its 22 recurring themes&lt;/strong&gt; (in order of reports; all dates; 92 threads; theme names as written on &lt;a href="https://friction.grain64.com/p/asana/?utm_source=devto" rel="noopener noreferrer"&gt;https://friction.grain64.com/p/asana/?utm_source=devto&lt;/a&gt;)&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;"Slow page loads and navigation": 10 reports in 9 threads, 2015 to 2020.&lt;/li&gt;
&lt;li&gt;"High memory and CPU usage": 6 reports in 9 threads, 2013 to 2023.&lt;/li&gt;
&lt;li&gt;"Flexible task and workflow structure": 6 reports in 6 threads, 2011 to 2024.&lt;/li&gt;
&lt;li&gt;"High pricing for paid features": 4 reports in 4 threads. Typical: "columns and some sorting options behind a paywall" (HN, 2024-03).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Which tool should be counted next?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Name the product you build against, replace or compete with, in the comments here or at &lt;a href="https://friction.grain64.com/request/?utm_source=devto" rel="noopener noreferrer"&gt;https://friction.grain64.com/request/?utm_source=devto&lt;/a&gt;. A product gets a page when public sources hold at least 10 sourced reports about it; otherwise the request is declined with the reason and the coverage numbers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where people say they went&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;26 Jira reports describe leaving and 16 Asana reports do (&lt;a href="https://friction.grain64.com/alternatives/jira/?utm_source=devto" rel="noopener noreferrer"&gt;https://friction.grain64.com/alternatives/jira/?utm_source=devto&lt;/a&gt;, &lt;a href="https://friction.grain64.com/alternatives/asana/?utm_source=devto" rel="noopener noreferrer"&gt;https://friction.grain64.com/alternatives/asana/?utm_source=devto&lt;/a&gt;).&lt;br&gt;
Where a destination is named, Jira users name Linear most (5 reports), then GitHub Issues (2) and GitLab Issues (2), and five more tools with one report each, Plane among them; for Asana, Workflowy (2) and Clubhouse (2, from 2018) lead, with six more at one report each. A count is the number of reports naming a tool, not a market share.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Across the category&lt;/strong&gt; (all dates, four tools; items, not recent reports; &lt;a href="https://friction.grain64.com/state-of/project-and-issue-tracking/?utm_source=devto" rel="noopener noreferrer"&gt;https://friction.grain64.com/state-of/project-and-issue-tracking/?utm_source=devto&lt;/a&gt;)&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;Items&lt;/th&gt;
&lt;th&gt;Jira&lt;/th&gt;
&lt;th&gt;ClickUp&lt;/th&gt;
&lt;th&gt;Plane&lt;/th&gt;
&lt;th&gt;Asana&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;UI &amp;amp; usability&lt;/td&gt;
&lt;td&gt;118&lt;/td&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;37&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Performance&lt;/td&gt;
&lt;td&gt;72&lt;/td&gt;
&lt;td&gt;31&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Customization&lt;/td&gt;
&lt;td&gt;34&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Integrations&lt;/td&gt;
&lt;td&gt;33&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reliability &amp;amp; bugs&lt;/td&gt;
&lt;td&gt;26&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pricing &amp;amp; plans&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;How to read these numbers&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Sources: Hacker News (plus GitHub issues for open-source tools), so the counts are the developer-tooling view of each product. A count is reports, not users: a report counts once per product however many people agreed. Each product page states how many source threads it rests on (Jira 154, Asana 92), so you can judge the depth before you rely on it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check or dispute a report&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Each report has a link to flag it, and &lt;a href="https://friction.grain64.com/data/" rel="noopener noreferrer"&gt;https://friction.grain64.com/data/&lt;/a&gt; says what is stored and how to ask for removal. Vendors can claim a page for free and reply on any report. Friction covers 54 products.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance. Posted by Friction's publishing account (Grain64).&lt;/em&gt;&lt;/p&gt;

</description>
      <category>saas</category>
      <category>productmanagement</category>
    </item>
  </channel>
</rss>
