<?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: Adithya Hebbar</title>
    <description>The latest articles on DEV Community by Adithya Hebbar (@adithya_hebbar_04afb7db68).</description>
    <link>https://dev.to/adithya_hebbar_04afb7db68</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%2F2201363%2Ff88fc786-8fc0-453b-a0a1-3a62356d267f.jpg</url>
      <title>DEV Community: Adithya Hebbar</title>
      <link>https://dev.to/adithya_hebbar_04afb7db68</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/adithya_hebbar_04afb7db68"/>
    <language>en</language>
    <item>
      <title>Session replay knows what the user did. Traces know what the server did. Neither knows both.</title>
      <dc:creator>Adithya Hebbar</dc:creator>
      <pubDate>Thu, 10 Sep 2026 16:15:32 +0000</pubDate>
      <link>https://dev.to/adithya_hebbar_04afb7db68/session-replay-knows-what-the-user-did-traces-know-what-the-server-did-neither-knows-both-24h6</link>
      <guid>https://dev.to/adithya_hebbar_04afb7db68/session-replay-knows-what-the-user-did-traces-know-what-the-server-did-neither-knows-both-24h6</guid>
      <description>&lt;p&gt;A customer called to say OTPs weren't reaching them.&lt;/p&gt;

&lt;p&gt;We checked the logs. Every request came in. Every SMS went out. The provider said delivered. Nothing looked broken, which is about the worst state a bug report can be in.&lt;/p&gt;

&lt;p&gt;So we asked for a screen recording. Then two days of guessing at timestamps and flipping between two tools that don't share a clock. We found it. It should not have taken two days.&lt;/p&gt;

&lt;p&gt;The bug isn't the part I still think about. The two days are.&lt;/p&gt;

&lt;h2&gt;
  
  
  The seam
&lt;/h2&gt;

&lt;p&gt;The replay showed what the user did. It didn't know what it called. The traces showed the calls. They didn't know what the user did.&lt;/p&gt;

&lt;p&gt;The answer was sitting in the gap between two tools, and closing that gap was hand work every single time. Watch the video. Note a timestamp. Switch tabs. Search a window around it. Get back forty traces. Guess.&lt;/p&gt;

&lt;p&gt;Both tools are good at their own layer and blind at the seam between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;Syncline plays the session recording, the backend trace, and the SQL that ran on one scrubber.&lt;/p&gt;

&lt;p&gt;Drag to 00:42 and you see the user click Checkout, the &lt;code&gt;POST /api/checkout&lt;/code&gt; it fired, the four spans that fanned out from it, and the Prisma query in there that took 1.8 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the correlation works
&lt;/h2&gt;

&lt;p&gt;Probably the only part you care about, so it goes first.&lt;/p&gt;

&lt;p&gt;The browser SDK records with &lt;a href="https://github.com/rrweb-io/rrweb" rel="noopener noreferrer"&gt;rrweb&lt;/a&gt; and patches &lt;code&gt;fetch&lt;/code&gt; and &lt;code&gt;XHR&lt;/code&gt; to mint a W3C &lt;code&gt;traceparent&lt;/code&gt; for every outgoing request. That trace ID gets written into the replay stream itself, as an rrweb custom event, at the frame the request fired. The recording is self-describing. There's no side table mapping time ranges to traces.&lt;/p&gt;

&lt;p&gt;Your backend takes no SDK from me. OpenTelemetry auto-instrumentation already reads an incoming &lt;code&gt;traceparent&lt;/code&gt; and continues the trace, so you point &lt;code&gt;OTEL_EXPORTER_OTLP_ENDPOINT&lt;/code&gt; at Syncline and that's it. The viewer resolves player time to trace ID to span tree, and draws the backend lanes under the video.&lt;/p&gt;

&lt;p&gt;Frontend:&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;startRecording&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;syncline-browser&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;startRecording&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pk_live_...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://syncline.example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;traceOrigins&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="s1"&gt;https://app.acme.com&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="s1"&gt;https://api.acme.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;release&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;web@2.4.1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;currentUser&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Backend:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;OTEL_EXPORTER_OTLP_ENDPOINT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://syncline.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you already run OTel, the server side is one env var.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two decisions I'd defend
&lt;/h2&gt;

&lt;p&gt;The link is by ID, not by time. Clock skew between a browser and a server can misdraw a lane by a few milliseconds. It can't put a request on the wrong trace. Timestamp matching gets both of those wrong, and it gets the second one wrong quietly, which is the exact thing that cost me two days.&lt;/p&gt;

&lt;p&gt;Sampling is inverted. If a session is being recorded, the browser forces &lt;code&gt;sampled=1&lt;/code&gt; on the traceparent. The usual arrangement lets the backend decide, so sooner or later you open the replay of the one bug that matters and its spans were sampled away. I'd rather have no replay than a replay I trusted that can't explain itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Smaller things, all of which came from a real bug
&lt;/h2&gt;

&lt;p&gt;The SDK never breaks the page. Every patched path is wrapped and falls through to the original &lt;code&gt;fetch&lt;/code&gt; on any failure, and there's a test that makes both instrumentation hooks throw and asserts the request still completes. A recording tool that takes down checkout is worse than no recording tool.&lt;/p&gt;

&lt;p&gt;It never injects cross-origin. &lt;code&gt;traceparent&lt;/code&gt; goes only to origins you list in &lt;code&gt;traceOrigins&lt;/code&gt;, which defaults to the page's own. Sending it to a third party leaks internal trace IDs and adds a header their CORS policy doesn't allow, so a working request turns into a failed preflight. Subdomains don't match either, because a third-party widget can be parked on one.&lt;/p&gt;

&lt;p&gt;Masking is on by default. Query values are stripped out of recorded URLs and only the keys kept, so &lt;code&gt;?token=abc&amp;amp;page=2&lt;/code&gt; becomes &lt;code&gt;?token&amp;amp;page&lt;/code&gt;. Fragments get dropped entirely, since that's where implicit-flow tokens live.&lt;/p&gt;

&lt;p&gt;Errors are captured, console output isn't, unless you ask for it. An uncaught error is the thing the recording exists to explain. Console arguments are whatever the app decided to print, which on plenty of codebases means tokens and request bodies.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it doesn't do
&lt;/h2&gt;

&lt;p&gt;No SSO. No way to export a project before you delete it. The SDK is pre-1.0, so the integration surface is settled enough to build on but a minor version may still move it.&lt;/p&gt;

&lt;p&gt;Everything else is there and running: ingest, trace stitching, the viewer, accounts and orgs, error and console capture, search, retention, an audit log.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running it
&lt;/h2&gt;

&lt;p&gt;Three Node processes (&lt;code&gt;api&lt;/code&gt;, &lt;code&gt;worker&lt;/code&gt;, &lt;code&gt;web&lt;/code&gt;) plus Postgres, Redis, and anything that speaks S3.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; .env.production.example .env.production   &lt;span class="c"&gt;# nothing has a working default&lt;/span&gt;
docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.prod.yml &lt;span class="nt"&gt;--env-file&lt;/span&gt; .env.production up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Point &lt;code&gt;DATABASE_URL&lt;/code&gt;, &lt;code&gt;REDIS_URL&lt;/code&gt; and &lt;code&gt;S3_ENDPOINT&lt;/code&gt; at managed services and that's the deployment. On a single box with none of those, &lt;code&gt;--profile bundled&lt;/code&gt; brings up all three alongside.&lt;/p&gt;

&lt;p&gt;AGPL-3.0. Self-host it, modify it. Run a modified Syncline as a network service and you share the modifications.&lt;/p&gt;

&lt;h2&gt;
  
  
  Go break it
&lt;/h2&gt;

&lt;p&gt;It's early. I'd rather hear about a bug from you than find it myself.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Demo: &lt;a href="https://youtu.be/0yoq6Rpnt1g" rel="noopener noreferrer"&gt;https://youtu.be/0yoq6Rpnt1g&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Live: &lt;a href="https://syncline.co.in" rel="noopener noreferrer"&gt;https://syncline.co.in&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Code: &lt;a href="https://github.com/Adithya-Adi/syncline" rel="noopener noreferrer"&gt;https://github.com/Adithya-Adi/syncline&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;SDK: &lt;a href="https://www.npmjs.com/package/syncline-browser" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/syncline-browser&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What I actually want to know is what you do instead. Everyone I've asked about this has their own manual workaround for it, and they're all different, and I don't think any of us should need one.&lt;/p&gt;

&lt;p&gt;Next time someone tells me the OTP never arrived, I want that to be a 30-second answer.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>observability</category>
      <category>opentelemetry</category>
      <category>typescript</category>
    </item>
  </channel>
</rss>
