<?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: Art Levitt</title>
    <description>The latest articles on DEV Community by Art Levitt (@artl13).</description>
    <link>https://dev.to/artl13</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%2F3904904%2F7c7df89d-0775-4d7c-b86d-183fed374140.jpeg</url>
      <title>DEV Community: Art Levitt</title>
      <link>https://dev.to/artl13</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/artl13"/>
    <language>en</language>
    <item>
      <title>The missing layer between W&amp;B and Datadog: observability for AI robots</title>
      <dc:creator>Art Levitt</dc:creator>
      <pubDate>Fri, 29 May 2026 22:04:58 +0000</pubDate>
      <link>https://dev.to/artl13/you-cant-grep-a-robot-the-case-for-episode-first-observability-26k1</link>
      <guid>https://dev.to/artl13/you-cant-grep-a-robot-the-case-for-episode-first-observability-26k1</guid>
      <description>&lt;p&gt;A backend service falls over at 2am and you know the drill: open the dashboard, follow the trace, find the bad deploy, roll back. Twenty years of tooling (logs, metrics, traces, APM) exists to answer &lt;em&gt;"what just happened, and why?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Now your robot bricks a grasp at 2am. What do you open?&lt;/p&gt;

&lt;p&gt;There's no trace. The "request" was a 40-second episode of a policy reacting to the physical world. The failure isn't in a log line. It's in the half-second where the gripper closed early, which only makes sense if you can see the wrist camera, the joint torques, and the policy's action outputs &lt;strong&gt;on the same clock&lt;/strong&gt;. You can't &lt;code&gt;grep&lt;/code&gt; that. And the regression that caused it shipped because "it worked in sim" and nobody re-ran it against the 3,000 episodes where it used to work.&lt;/p&gt;

&lt;p&gt;We have Datadog for services and Weights &amp;amp; Biases for training. We have almost nothing for the part in between: &lt;strong&gt;the run itself.&lt;/strong&gt; That gap is where robotics observability lives, and it's about to matter a lot, because every team shipping VLA, imitation, and RL policies is hitting the same wall.&lt;/p&gt;

&lt;h2&gt;
  
  
  The unit of debugging is the episode, not the log line
&lt;/h2&gt;

&lt;p&gt;This is the whole thesis. Backend observability is built around the request. Robotics observability has to be built around the &lt;strong&gt;episode&lt;/strong&gt;, a synchronized bundle of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;video&lt;/strong&gt; (one or more camera streams),&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;sensors&lt;/strong&gt; (joint states, forces, IMU, battery),&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;actions&lt;/strong&gt; (what the policy actually commanded),&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;all locked to a single timeline, and tagged with the four things that make it reproducible: &lt;code&gt;policy_version&lt;/code&gt;, &lt;code&gt;env_version&lt;/code&gt;, &lt;code&gt;git_sha&lt;/code&gt;, &lt;code&gt;seed&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Drop any of those and you've got a video you can't trust. Keep them, and an episode stops being a memory and becomes a &lt;strong&gt;test case&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "observability" has to mean here
&lt;/h2&gt;

&lt;p&gt;Steal the shape of service observability, but redefine each pillar for physical runs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Record.&lt;/strong&gt; Capture episodes with near-zero friction from inside the training loop. If logging a run is more than a few lines, nobody does it. Heavy bytes (tens of GB of video) go straight to object storage; only metadata hits your API.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;robotrace&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;rt&lt;/span&gt;

&lt;span class="c1"&gt;# One call: uploads the artifacts, stamps the reproducibility fields,
# and returns an Episode you can open in the portal.
&lt;/span&gt;&lt;span class="n"&gt;ep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log_episode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;policy_version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;grasp-v7&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;env_version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cell-3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;video&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wrist_cam.mp4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;sensors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;joint_states.npz&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# timestamped
&lt;/span&gt;    &lt;span class="n"&gt;actions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;policy_outputs.npz&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;# same clock
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Replay.&lt;/strong&gt; Scrub every run frame-accurate, all streams on one timeline. Pause on the frame where it broke, copy a &lt;code&gt;?t=12840ms&lt;/code&gt; link, and a teammate lands on the exact moment. This is the "follow the trace" of robotics.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Explain.&lt;/strong&gt; A failed run should &lt;em&gt;tell you why&lt;/em&gt;, not hand you a metadata dump. Ranked root causes (replay regression, raised exception, battery brownout, action saturation) surfaced the moment the episode finalizes.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Verify.&lt;/strong&gt; The one service observability never needed. Before you ship a new policy to real hardware, &lt;strong&gt;re-roll it against thousands of historical episodes&lt;/strong&gt; and read the diff: where does the candidate do better, where does it regress? Gate the deploy on that, without booking another hour on the arm.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last pillar is the point. It closes the loop from &lt;em&gt;"we recorded a run"&lt;/em&gt; to &lt;em&gt;"we won't ship that regression to a real robot."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;RoboTrace: log an episode in a few lines, get a portal URL back&lt;/p&gt;

&lt;h2&gt;
  
  
  Why now
&lt;/h2&gt;

&lt;p&gt;Three things are converging. Foundation/VLA policies are getting cheap enough to iterate on weekly, so the bottleneck moves from &lt;em&gt;training&lt;/em&gt; to &lt;em&gt;trusting&lt;/em&gt;. Real-robot time stays expensive and scarce, so you can't validate by re-running on hardware; you validate against history. And teams are scaling from one robot to fleets, where "it worked on my arm" is no longer an argument.&lt;/p&gt;

&lt;p&gt;The teams that win won't be the ones with the flashiest policy. They'll be the ones who can answer &lt;em&gt;"is this safe to ship?"&lt;/em&gt; in minutes instead of days, because they treated every run as a reproducible, replayable, re-rollable artifact from day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bet
&lt;/h2&gt;

&lt;p&gt;Robotics is about to get its observability moment, the same way backend did in the 2010s and ML training did with experiment trackers. The winning tool won't be a log viewer bolted onto robots. It'll be &lt;strong&gt;episode-first&lt;/strong&gt;: replay, explain, and regression-gating as first-class primitives, with reproducibility baked into the data model instead of bolted on later.&lt;/p&gt;

&lt;p&gt;That's the bet we're making with &lt;a href="https://robotrace.dev" rel="noopener noreferrer"&gt;&lt;strong&gt;RoboTrace&lt;/strong&gt;&lt;/a&gt;, observability and evals for AI-powered robots. The SDK is &lt;code&gt;pip install robotrace-dev&lt;/code&gt; (early access during alpha), and the source lives on &lt;a href="https://github.com/Artl13/robotrace-dev" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. If you're training policies and debugging them with screen recordings and a spreadsheet, I'd genuinely like to hear how you're doing it today.&lt;/p&gt;

&lt;p&gt;You can't grep a robot. So let's build the thing you &lt;em&gt;can&lt;/em&gt; do instead.&lt;/p&gt;

</description>
      <category>robotics</category>
      <category>ai</category>
      <category>observability</category>
      <category>mlops</category>
    </item>
    <item>
      <title>Two Supabase sessions, one browser: cookie partitioning for admin and customer auth</title>
      <dc:creator>Art Levitt</dc:creator>
      <pubDate>Wed, 29 Apr 2026 20:01:21 +0000</pubDate>
      <link>https://dev.to/artl13/two-supabase-sessions-one-browser-cookie-partitioning-for-admin-and-customer-auth-327</link>
      <guid>https://dev.to/artl13/two-supabase-sessions-one-browser-cookie-partitioning-for-admin-and-customer-auth-327</guid>
      <description>&lt;p&gt;A small Next.js + Supabase pattern that lets the same admin be logged in to both portals at once, without weird middleware hacks.&lt;/p&gt;

&lt;p&gt;Most apps that have an admin area and a customer portal hit the same problem eventually: the admin who's debugging an issue wants to log in to a customer account without logging out of admin. With one shared Supabase session cookie, they can't. They sign into the customer portal, the admin session dies, and now they have to re-sign-in to admin afterwards.&lt;/p&gt;

&lt;p&gt;Annoying for daily-driver admins. Disastrous when the admin is mid-debug at 2am and loses their place.&lt;/p&gt;

&lt;p&gt;The fix is small and clean: give each "area" of the app its own Supabase cookie name. Two cookies, two parallel sessions, same browser.&lt;/p&gt;

&lt;p&gt;The setup&lt;br&gt;
In Next.js (with @supabase/ssr), the Supabase server client takes a cookieOptions.name option. By default it's a single global cookie name. Override it per area.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;AuthArea&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;portal&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;AREA_COOKIE_NAME&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;AuthArea&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sb-admin-auth-token&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;portal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sb-portal-auth-token&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;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;AREA_HEADER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-app-area&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In your middleware (Next 16: proxy.ts), resolve the area from the URL and write it to a request header so server code downstream knows which cookie to read:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&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;proxy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&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;area&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nextUrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/admin&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;portal&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;requestHeaders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;requestHeaders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;AREA_HEADER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;area&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// ... refresh the right session, return response&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the Supabase server client reads the area from headers and picks the right cookie:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&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;getSupabaseServerClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;area&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;AuthArea&lt;/span&gt;&lt;span class="p"&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;cookieStore&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;cookies&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;hdrs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;headers&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;resolvedArea&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AuthArea&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;area&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hdrs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;AREA_HEADER&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;portal&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;createServerClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SUPABASE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;SUPABASE_ANON_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* getAll / setAll */&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;cookieOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AREA_COOKIE_NAME&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;resolvedArea&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole pattern. Two cookies, two sessions, one browser. The admin can be logged in as themselves on /admin/* and as a test customer on /portal/* simultaneously.&lt;/p&gt;

&lt;p&gt;Why pass area as a parameter, not just rely on the header&lt;br&gt;
The auth callback is the one place this matters. When a user signs in to the customer portal but the magic link is being processed in a route handler that runs outside the /portal/* URL prefix, the header-based detection picks the wrong area. Passing an explicit area argument lets the auth callback say "I know I'm finishing an admin sign-in - set the admin cookie".&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getSupabaseServerClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;"&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;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exchangeCodeForSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the kind of edge case that bites you on day 3 of the migration if you don't plan for it.&lt;/p&gt;

&lt;p&gt;Caveats&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;RLS policies don't change. Both cookies authenticate the same auth.users row. If your admin happens to also have a customer record, RLS sees them as that customer in the portal area. That's almost always what you want.&lt;/li&gt;
&lt;li&gt;Cookie size. You now ship two auth cookies on every request. They're small (~few hundred bytes each) but if you have other large cookies, watch your headers.&lt;/li&gt;
&lt;li&gt;Sign-out has to be area-scoped too. Don't write a global "sign out" that nukes both cookies — let each area sign itself out independently.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When you don't need this&lt;br&gt;
If your admin and customer flows don't overlap - same person never plays both roles — a single session is simpler. Don't add this complexity speculatively. Add it the first time an admin asks you "can I be logged in as both?".&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>supabase</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>Inngest's instanceof lies: why custom error classes vanish across step.run</title>
      <dc:creator>Art Levitt</dc:creator>
      <pubDate>Wed, 29 Apr 2026 19:09:12 +0000</pubDate>
      <link>https://dev.to/artl13/inngests-instanceof-lies-why-custom-error-classes-vanish-across-steprun-1pbk</link>
      <guid>https://dev.to/artl13/inngests-instanceof-lies-why-custom-error-classes-vanish-across-steprun-1pbk</guid>
      <description>&lt;p&gt;A small bug that will silently break your retry logic, your error reporting, and your dashboards.&lt;/p&gt;

&lt;p&gt;We run a long pipeline through Inngest - voice generation that calls OpenAI, then ElevenLabs, then writes to storage, with a consent gate at the top. Each vendor call lives in its own step.run so a transient failure on one vendor doesn't replay the rest of the pipeline.&lt;/p&gt;

&lt;p&gt;We classify failures with custom error classes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TTSUpstreamError&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;TTSUpstreamError&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;span class="nx"&gt;And&lt;/span&gt; &lt;span class="nx"&gt;the&lt;/span&gt; &lt;span class="nx"&gt;failure&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt; &lt;span class="nx"&gt;did&lt;/span&gt; &lt;span class="nx"&gt;the&lt;/span&gt; &lt;span class="nx"&gt;obvious&lt;/span&gt; &lt;span class="nx"&gt;thing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ttsReasonFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&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;err&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;TTSUpstreamError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`elevenlabs_upstream:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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;err&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;TTSNotConfiguredError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;elevenlabs_not_configured&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;elevenlabs_unknown&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worked in dev. Worked in tests. In production, every failed run was logged as elevenlabs_unknown - including a real 402 from levenLabs that should have been classified as elevenlabs_upstream:402. We were debugging blind.&lt;/p&gt;

&lt;p&gt;What's actually happening&lt;br&gt;
Inngest's step.run wraps your function so the step result (success or failure) can be memoized. On a retry, instead of re-running the step, Inngest replays the memoized outcome. That requires serializing everything across the step boundary - including thrown errors.&lt;/p&gt;

&lt;p&gt;When an error crosses that boundary, Inngest preserves error.name and error.message (and error.stack if you're lucky). What it does not preserve is class identity. By the time your catch block sees the error, it's a plain Error object with the right shape but a different prototype.&lt;/p&gt;

&lt;p&gt;So err instanceof TTSUpstreamError returns false. Always.&lt;/p&gt;

&lt;p&gt;The fix&lt;br&gt;
Match on err.name, not on class identity. name survives serialization. Keep the instanceof check too, for the case where the throw happens outside a step.run - there, the original class identity is intact.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ttsReasonFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&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="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;elevenlabs_unknown&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;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&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;msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&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;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;TTSNotConfiguredError&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;elevenlabs_not_configured&lt;/span&gt;&lt;span class="dl"&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;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;TTSUpstreamError&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/status=&lt;/span&gt;&lt;span class="se"&gt;(\d{3})&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;)?.[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`elevenlabs_upstream:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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;// Direct throws (outside step.run) keep class identity intact.&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;err&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;TTSUpstreamError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`elevenlabs_upstream:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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;err&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;TTSNotConfiguredError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;elevenlabs_not_configured&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;elevenlabs_unknown&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to notice:&lt;br&gt;
We pull the status code back out of the message string. That's only possible because we put it there in the first place when constructing the error: new TTSUpstreamError(402, "ElevenLabs failed (status=402)"). If your error messages don't include structured data, add it now - your future self inside an Inngest step will thank you.&lt;br&gt;
We keep the instanceof checks as a fallback. Because the rule is "class identity dies when crossing a step boundary" - but if the throw originates outside step.run, identity is intact and instanceof is the cleaner check.&lt;br&gt;
Why this matters beyond Inngest&lt;br&gt;
Any serialization boundary does this. Same problem appears in:&lt;/p&gt;

&lt;p&gt;Web Workers (postMessage strips prototypes)&lt;br&gt;
Any RPC framework&lt;br&gt;
Anything that JSON-serializes errors for logging&lt;br&gt;
If you're going to depend on error type in a system that crosses any of these boundaries, encode the type in name and structured data in message (or in a custom code / data field). Treat instanceof as a happy-path optimization, not a guarantee.&lt;/p&gt;

&lt;p&gt;The rule of thumb: if your error has to leave the process - or even just leave the Promise it was thrown in - assume the prototype chain is gone when you catch it.&lt;/p&gt;

</description>
      <category>inngest</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
