<?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: Aditya Reddy</title>
    <description>The latest articles on DEV Community by Aditya Reddy (@adityareddy_dev).</description>
    <link>https://dev.to/adityareddy_dev</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%2F4158587%2F45f6422d-df7c-41a5-848a-7b0c815491c5.png</url>
      <title>DEV Community: Aditya Reddy</title>
      <link>https://dev.to/adityareddy_dev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/adityareddy_dev"/>
    <language>en</language>
    <item>
      <title>The pagehide listener that never ran</title>
      <dc:creator>Aditya Reddy</dc:creator>
      <pubDate>Sat, 07 Nov 2026 20:29:54 +0000</pubDate>
      <link>https://dev.to/adityareddy_dev/the-pagehide-listener-that-never-ran-3k3e</link>
      <guid>https://dev.to/adityareddy_dev/the-pagehide-listener-that-never-ran-3k3e</guid>
      <description>&lt;p&gt;TL;DR: I removed a dead &lt;code&gt;pagehide&lt;/code&gt; listener from OpenTelemetry's browser batch processors. Moving it to &lt;code&gt;window&lt;/code&gt; would have made it run, but it would also have introduced an earlier logs flush that could miss final web-vitals records. The removal shipped in &lt;a href="https://github.com/open-telemetry/opentelemetry-js/releases/tag/v3.0.0" rel="noopener noreferrer"&gt;OpenTelemetry JS 3.0.0&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  I found it reading the code
&lt;/h2&gt;

&lt;p&gt;The browser &lt;code&gt;BatchSpanProcessor&lt;/code&gt; and &lt;code&gt;BatchLogRecordProcessor&lt;/code&gt; already flushed when &lt;code&gt;document.visibilityState&lt;/code&gt; became &lt;code&gt;hidden&lt;/code&gt;. They also registered a &lt;code&gt;pagehide&lt;/code&gt; listener as a fallback for an old Safari bug. That listener was attached to &lt;code&gt;document&lt;/code&gt;:&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="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pagehide'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_pageHideListener&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 line comes from the &lt;a href="https://github.com/open-telemetry/opentelemetry-js/pull/7153/files" rel="noopener noreferrer"&gt;removed code in my PR&lt;/a&gt;. The browser dispatches &lt;code&gt;pagehide&lt;/code&gt; at &lt;code&gt;window&lt;/code&gt;, so the document listener never received it during real navigation. The &lt;a href="https://html.spec.whatwg.org/multipage/nav-history-apis.html#fire-a-page-transition-event" rel="noopener noreferrer"&gt;HTML page-transition event definition&lt;/a&gt; specifies the window dispatch.&lt;/p&gt;

&lt;p&gt;My first version moved the listener to &lt;code&gt;window&lt;/code&gt;. I had checked a navigation in Chromium 151, Firefox 153 and WebKit 26.5: the window listener ran in each, and the document listener didn't. Those results are recorded in &lt;a href="https://github.com/open-telemetry/opentelemetry-js/pull/7153" rel="noopener noreferrer"&gt;the PR&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The browser review caught a problem with that change: a working listener would start flushing the logs queue earlier.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test sent the event to the same wrong place
&lt;/h2&gt;

&lt;p&gt;The old logs test created an event:&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="n"&gt;pageHideEvent&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;Event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pagehide'&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;Then it dispatched that event directly on the document:&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="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'by the pagehide event'&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;testDocumentHide&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dispatchEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pageHideEvent&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;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;These are excerpts from the &lt;a href="https://github.com/open-telemetry/opentelemetry-js/blob/4148e2190d6ff5cc68be40932bee22a0e1e63ab7/packages/sdk-logs/test/browser/export/BatchLogRecordProcessor.test.ts#L93-L97" rel="noopener noreferrer"&gt;test before the change&lt;/a&gt;. The synthetic event reached the handler because the test chose &lt;code&gt;document&lt;/code&gt; as its destination. It exercised the handler without checking where the browser sends &lt;code&gt;pagehide&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Navigate away and keep the callbacks
&lt;/h2&gt;

&lt;p&gt;For the navigation check, the small demo has three observation listeners: &lt;code&gt;pagehide&lt;/code&gt; on both objects, plus &lt;code&gt;visibilitychange&lt;/code&gt; on &lt;code&gt;document&lt;/code&gt;. A link loads a second page in the same tab. Each departing-page callback writes its fields to &lt;code&gt;sessionStorage&lt;/code&gt;; the destination displays those saved rows.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu1dajccetiz96xjxc4w8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu1dajccetiz96xjxc4w8.png" alt="The demo start page with three observation listeners and a link to page two" width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Before navigation: one listener on each candidate object, plus the visibility listener. No SDK or exporter is loaded.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The fresh run on November 7, 2026, at 14:14:39 America/Chicago (-06:00), used Chrome 156.0.8078.73 in headless mode. It produced these callbacks:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Order&lt;/th&gt;
&lt;th&gt;Listener that ran&lt;/th&gt;
&lt;th&gt;&lt;code&gt;currentTarget&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Visibility&lt;/th&gt;
&lt;th&gt;&lt;code&gt;isTrusted&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;code&gt;window:pagehide&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;window&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;visible&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;true&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;code&gt;document:visibilitychange&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;document&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;hidden&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;true&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The document &lt;code&gt;pagehide&lt;/code&gt; listener produced zero callbacks.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj82438wo09nyuvpdicdy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj82438wo09nyuvpdicdy.png" alt="Page two showing the two retained browser-generated lifecycle callbacks" width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Actual destination page from that run. The saved rows came from the departing document; this page doesn't dispatch replacement events.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In the &lt;code&gt;pagehide&lt;/code&gt; row, &lt;code&gt;event.target&lt;/code&gt; says &lt;code&gt;document&lt;/code&gt;, even though the callback ran on the window listener. &lt;code&gt;event.currentTarget&lt;/code&gt; says &lt;code&gt;window&lt;/code&gt;. The &lt;a href="https://html.spec.whatwg.org/multipage/nav-history-apis.html#fire-a-page-transition-event" rel="noopener noreferrer"&gt;HTML event algorithm&lt;/a&gt; uses a legacy target override; the &lt;a href="https://dom.spec.whatwg.org/#concept-event-dispatch" rel="noopener noreferrer"&gt;DOM dispatch algorithm&lt;/a&gt; sets that target to the document. The target field alone doesn't tell you which listener ran.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9wbl569h52six51ojvrp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9wbl569h52six51ojvrp.png" alt="Expanded retained output showing the event target and currentTarget fields" width="800" height="864"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The retained event fields include &lt;code&gt;target: document&lt;/code&gt;, &lt;code&gt;currentTarget: window&lt;/code&gt; and &lt;code&gt;isTrusted: true&lt;/code&gt; for &lt;code&gt;pagehide&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This demo observes event dispatch and order. It doesn't run a telemetry exporter or measure record delivery.&lt;/p&gt;

&lt;h2&gt;
  
  
  Moving to window starts another flush
&lt;/h2&gt;

&lt;p&gt;In &lt;a href="https://github.com/open-telemetry/opentelemetry-js/pull/7153#pullrequestreview-5449510837" rel="noopener noreferrer"&gt;the browser review&lt;/a&gt;, @overbalance pointed out the hard-navigation sequence: &lt;code&gt;pagehide&lt;/code&gt; arrives before &lt;code&gt;visibilitychange&lt;/code&gt;. A window listener would start flushing the existing logs before the application's final hidden-state records arrive. The review named final INP, CLS and LCP records as examples.&lt;/p&gt;

&lt;p&gt;I had treated the overlap as a narrow case involving a log written by another &lt;code&gt;pagehide&lt;/code&gt; handler. In &lt;a href="https://github.com/open-telemetry/opentelemetry-js/pull/7153#issuecomment-6049608289" rel="noopener noreferrer"&gt;my follow-up&lt;/a&gt;, I corrected that: records emitted on &lt;code&gt;visibilitychange&lt;/code&gt; could hit it too.&lt;/p&gt;

&lt;p&gt;The logs processor's existing &lt;code&gt;_flushAll()&lt;/code&gt; explains why. This excerpt is from the &lt;a href="https://github.com/open-telemetry/opentelemetry-js/blob/4148e2190d6ff5cc68be40932bee22a0e1e63ab7/packages/sdk-logs/src/export/BatchLogRecordProcessorBase.ts#L221-L280" rel="noopener noreferrer"&gt;source at the PR base&lt;/a&gt;:&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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_flushing&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="p"&gt;}&lt;/span&gt;
    &lt;span class="kc"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_flushing&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A concurrent call returns without waiting for the running flush. That running flush has already taken its set of records:&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;let&lt;/span&gt; &lt;span class="n"&gt;toFlush&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_finishedLogRecords&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kc"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_finishedLogRecords&lt;/span&gt; &lt;span class="o"&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;New records enter the fresh queue, outside the snapshot the earlier flush took.&lt;/p&gt;

&lt;h2&gt;
  
  
  Follow one late record
&lt;/h2&gt;

&lt;p&gt;In the review's scenario, the first flush is still running when the application emits its final record. Assume that record arrives before the SDK's hidden-state handler calls flush:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pagehide on window
  proposed listener starts forceFlush()
  earlier records move into the flush snapshot
  export remains in flight

visibilitychange to hidden
  application emits final record into the fresh queue
  SDK calls forceFlush()
  _flushing is still true, so the call returns

page leaves
  final record can remain outside the earlier export
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Sequence from the PR review and queue implementation. This is an explanation of the proposed change, not captured exporter output.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The second call doesn't delete the final record. It leaves that record queued while the first export handles its earlier snapshot. If the page leaves before another export opportunity, the record can miss delivery. The &lt;a href="https://github.com/open-telemetry/opentelemetry-js/blob/4148e2190d6ff5cc68be40932bee22a0e1e63ab7/packages/sdk-logs/src/export/BatchLogRecordProcessorBase.ts#L221-L280" rel="noopener noreferrer"&gt;guard and snapshot code&lt;/a&gt; explain that behavior for logs; this isn't a description of the trace processor's concurrency handling.&lt;/p&gt;

&lt;p&gt;The fresh Chrome run above has the event order discussed in the review. Other browsers and transitions need their own captures before treating that order as universal.&lt;/p&gt;

&lt;h2&gt;
  
  
  The merged change removes the fallback
&lt;/h2&gt;

&lt;p&gt;I removed the &lt;code&gt;pagehide&lt;/code&gt; handler, registration and cleanup from both browser processors, along with the synthetic &lt;code&gt;pagehide&lt;/code&gt; tests. The &lt;a href="https://github.com/open-telemetry/opentelemetry-js/pull/7153/files" rel="noopener noreferrer"&gt;merged diff&lt;/a&gt; keeps the existing &lt;code&gt;visibilitychange&lt;/code&gt; path.&lt;/p&gt;

&lt;p&gt;The logs handler at the final PR head still reads:&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="kc"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_visibilityChangeListener&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;visibilityState&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'hidden'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="kc"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forceFlush&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;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s1"&gt;'visibilitychange'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="kc"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_visibilityChangeListener&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;&lt;a href="https://github.com/open-telemetry/opentelemetry-js/blob/a2d077c2ed065bf1c791bf569b66e59d68ac0aff/packages/sdk-logs/src/platform/browser/export/BatchLogRecordProcessor.ts#L36-L45" rel="noopener noreferrer"&gt;Source at the final PR head&lt;/a&gt;. The release also includes separate error handling for that flush, listed as &lt;a href="https://github.com/open-telemetry/opentelemetry-js/releases/tag/v3.0.0" rel="noopener noreferrer"&gt;another change in the release notes&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The old Safari fallback no longer needed reviving either. The &lt;a href="https://github.com/open-telemetry/opentelemetry-js/pull/7153#pullrequestreview-5449510837" rel="noopener noreferrer"&gt;browser review&lt;/a&gt; records that its underlying Safari bug was fixed in 2020, before the project's supported browser baseline.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/open-telemetry/opentelemetry-js/releases/tag/v3.0.0" rel="noopener noreferrer"&gt;JS 3.0.0 lists the removal for both processors under Internal&lt;/a&gt;. The old document listener never ran on real navigation, so removing it preserved the existing hidden-state flush and avoided introducing the earlier window flush. It didn't redesign the queue or establish a delivery guarantee for every shutdown path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check the record after the handler runs
&lt;/h2&gt;

&lt;p&gt;When testing telemetry on navigation, put observation listeners on the actual browser dispatch objects and leave the page through a real navigation. Preserve the callback fields across that transition, including &lt;code&gt;currentTarget&lt;/code&gt;, visibility state and &lt;code&gt;isTrusted&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Then test a record emitted late, while an export is still running. Check whether its ID reaches the exporter and whether it belongs to the snapshot the flush took. If the application emits on &lt;code&gt;visibilitychange&lt;/code&gt;, check its ordering against the SDK's own handler too.&lt;/p&gt;

</description>
      <category>opentelemetry</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>observability</category>
    </item>
    <item>
      <title>What broke when I moved Grafana Faro to OpenTelemetry JS 3.0</title>
      <dc:creator>Aditya Reddy</dc:creator>
      <pubDate>Wed, 04 Nov 2026 01:51:43 +0000</pubDate>
      <link>https://dev.to/adityareddy_dev/what-broke-when-i-moved-grafana-faro-to-opentelemetry-js-30-imb</link>
      <guid>https://dev.to/adityareddy_dev/what-broke-when-i-moved-grafana-faro-to-opentelemetry-js-30-imb</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; I opened the three PRs that move &lt;a href="https://github.com/grafana/faro-web-sdk" rel="noopener noreferrer"&gt;Grafana Faro's web SDK&lt;/a&gt; onto OpenTelemetry JS SDK 3.0. The version bump was the easy part. What took the time was a deep import the new &lt;code&gt;exports&lt;/code&gt; maps reject, and then the tests. Jest was quietly running the Node build, and a disabled XHR instrumentation turned out to still be making spans. All three PRs are open and waiting on review.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi2ri4kjfqm9tdd0u4o11.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi2ri4kjfqm9tdd0u4o11.png" alt="The Faro epic on GitHub, Migrate to OpenTelemetry JS SDK 3.x, with the plan split into three steps" width="799" height="571"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Faro had to move at all
&lt;/h2&gt;

&lt;p&gt;OpenTelemetry JS SDK 3.0, experimental 0.300 and API 1.10 came out on Oct 20. They only work with each other, so you can't take one and leave the rest. Faro was on 2.x, 0.223 and API 1.9.&lt;/p&gt;

&lt;p&gt;That sounds like something you can sit on for a while, and for the stable 2.x packages you can, they're supported until about Oct 2027. The problem showed up somewhere else. The contrib browser instrumentations (document load, user interaction, long task) had already moved to core &lt;code&gt;^3.0.0&lt;/code&gt;. Install one of those next to &lt;code&gt;@grafana/faro-web-tracing&lt;/code&gt; and npm resolves &lt;code&gt;@opentelemetry/api&lt;/code&gt; to 1.9.1, cause Faro asks for &lt;code&gt;&amp;lt;1.10.0&lt;/code&gt;. Instrumentation 0.300 imports &lt;code&gt;logs&lt;/code&gt; from the API, and 1.9.1 doesn't have it. So your app breaks on a package you didn't even touch.&lt;/p&gt;

&lt;p&gt;The Faro team wrote this up as an &lt;a href="https://github.com/grafana/faro-web-sdk/issues/2341" rel="noopener noreferrer"&gt;epic with the plan&lt;/a&gt;. I picked up the steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three PRs, stacked
&lt;/h2&gt;

&lt;p&gt;I could have done it in one PR. It would have been one big diff touching about 30 files, and nobody wants to review that.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;PR&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;Ships in&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/grafana/faro-web-sdk/pull/2358" rel="noopener noreferrer"&gt;#2358&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Moves tracing setup to &lt;code&gt;@opentelemetry/sdk-trace&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;any 2.x minor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/grafana/faro-web-sdk/pull/2363" rel="noopener noreferrer"&gt;#2363&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Removes deep imports into OTel packages&lt;/td&gt;
&lt;td&gt;any 2.x minor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/grafana/faro-web-sdk/pull/2364" rel="noopener noreferrer"&gt;#2364&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;The actual 3.0 / 0.300 / 1.10 bump&lt;/td&gt;
&lt;td&gt;open decision&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first two change nothing for users and work on 2.x, so they can merge whenever. Only the last one raises what your build needs. Stacking them means the risky diff is small and the boring ones can go first.&lt;/p&gt;

&lt;h2&gt;
  
  
  WebTracerProvider is gone
&lt;/h2&gt;

&lt;p&gt;SDK 3.x drops &lt;code&gt;@opentelemetry/sdk-trace-web&lt;/code&gt; and &lt;code&gt;@opentelemetry/sdk-trace-base&lt;/code&gt;. Everything Faro used from them is already in &lt;code&gt;@opentelemetry/sdk-trace&lt;/code&gt; 2.12, which is why this step can land on 2.x.&lt;/p&gt;

&lt;p&gt;The one real change is that &lt;code&gt;WebTracerProvider.register()&lt;/code&gt; used to do several things for you. With the plain &lt;code&gt;TracerProvider&lt;/code&gt; you do them yourself, in order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-    provider.register({&lt;/span&gt;
&lt;span class="gd"&gt;-      propagator: new FaroPausablePropagator(/* ... */),&lt;/span&gt;
&lt;span class="gd"&gt;-      contextManager: options.contextManager,&lt;/span&gt;
&lt;span class="gd"&gt;-    });&lt;/span&gt;
&lt;span class="gi"&gt;+    trace.setGlobalTracerProvider(provider);&lt;/span&gt;
&lt;span class="gi"&gt;+    propagation.setGlobalPropagator(new FaroPausablePropagator(/* ... */));&lt;/span&gt;
&lt;span class="gi"&gt;+&lt;/span&gt;
&lt;span class="gi"&gt;+    const contextManager = options.contextManager ?? new StackContextManager();&lt;/span&gt;
&lt;span class="gi"&gt;+    contextManager.enable();&lt;/span&gt;
&lt;span class="gi"&gt;+    context.setGlobalContextManager(contextManager);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The order matters more than it looks. The context manager has to be enabled before it's registered, and a second Faro instance must not register again over the first. I wrote tests for each of those paths, cause that's exactly the kind of thing that works on the happy path and breaks for the one app that passes its own propagator.&lt;/p&gt;

&lt;p&gt;Side effect I didn't plan for: the tracing IIFE went from 90,474 to 87,299 bytes minified. Dropping a package you don't use is a nice way to lose 3 KB.&lt;/p&gt;

&lt;h2&gt;
  
  
  The exports map says no
&lt;/h2&gt;

&lt;p&gt;This is the one that would have broken the upgrade if you just bumped versions.&lt;/p&gt;

&lt;p&gt;All the 0.300 packages declare an &lt;code&gt;exports&lt;/code&gt; map in &lt;code&gt;package.json&lt;/code&gt;. Faro had imports straight into &lt;code&gt;build/src&lt;/code&gt; of &lt;code&gt;@opentelemetry/otlp-transformer&lt;/code&gt;, for the OTLP payload types and for &lt;code&gt;createExportTraceServiceRequest&lt;/code&gt; and &lt;code&gt;JSON_ENCODER&lt;/code&gt;. With an exports map, any subpath that isn't listed fails to resolve. It doesn't matter if the file is sitting right there.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjnafmudxmahotqkurayk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjnafmudxmahotqkurayk.png" alt="Node fails with ERR_PACKAGE_PATH_NOT_EXPORTED and tsc fails with TS2307 when importing from otlp-transformer 0.300's build/src" width="800" height="237"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Pointing the paths at the new &lt;code&gt;dist/&lt;/code&gt; layout doesn't help either. Unlisted is unlisted.&lt;/p&gt;

&lt;p&gt;So I moved the OTLP types (&lt;code&gt;IResourceSpans&lt;/code&gt;, &lt;code&gt;IKeyValue&lt;/code&gt;, &lt;code&gt;ESpanKind&lt;/code&gt; and the rest) into &lt;code&gt;@grafana/faro-core&lt;/code&gt;, and swapped the exporter over to the public &lt;code&gt;JsonTraceSerializer&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- import { JSON_ENCODER } from '@opentelemetry/otlp-transformer/build/src/common/utils';&lt;/span&gt;
&lt;span class="gd"&gt;- import { createExportTraceServiceRequest } from '@opentelemetry/otlp-transformer/build/src/trace/internal';&lt;/span&gt;
&lt;span class="gi"&gt;+ import { JsonTraceSerializer } from '@opentelemetry/otlp-transformer';&lt;/span&gt;

&lt;span class="gd"&gt;- const traceEvent = createExportTraceServiceRequest(spans, JSON_ENCODER);&lt;/span&gt;
&lt;span class="gi"&gt;+ const traceEvent: TraceEvent = JSON.parse(&lt;/span&gt;
&lt;span class="gi"&gt;+   new TextDecoder().decode(JsonTraceSerializer.serializeRequest(spans))&lt;/span&gt;
&lt;span class="gi"&gt;+ );&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's one extra encode and parse per batch. I don't love it, though it's the public API in both 0.223 and 0.300 and the payload that goes out is the same. A snapshot test of the trace payload and the derived &lt;code&gt;faro.tracing.*&lt;/code&gt; event now pins that down.&lt;/p&gt;

&lt;p&gt;One detail I'd point out to anyone doing the same thing. I didn't copy &lt;code&gt;ESpanKind&lt;/code&gt; as a TypeScript enum. &lt;code&gt;TraceEvent&lt;/code&gt; is public through &lt;code&gt;faro.api.pushTraces&lt;/code&gt;, and enums from two different declarations aren't assignable to each other. If Faro shipped its own enum, any app building payloads with otlp-transformer's enum would stop compiling. A const object with a numeric union type keeps both working.&lt;/p&gt;

&lt;p&gt;This step also took &lt;code&gt;@opentelemetry/otlp-transformer&lt;/code&gt; out of core and the OTLP transport, they only used its types. Without that, every Faro user would pick up 0.300's &lt;code&gt;engines.node &amp;gt;=22.15.0&lt;/code&gt;, not just the ones using tracing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bump, and what it found
&lt;/h2&gt;

&lt;p&gt;The version change itself is a few lines in &lt;code&gt;package.json&lt;/code&gt;. &lt;code&gt;core&lt;/code&gt;, &lt;code&gt;resources&lt;/code&gt; and &lt;code&gt;sdk-trace&lt;/code&gt; to &lt;code&gt;^3.0.0&lt;/code&gt;, the experimental packages to &lt;code&gt;^0.300.0&lt;/code&gt;, the API to &lt;code&gt;^1.10.0&lt;/code&gt;. Typecheck passed without a single change. That was honestly a bit suspicious.&lt;/p&gt;

&lt;h3&gt;
  
  
  Jest was running the Node build
&lt;/h3&gt;

&lt;p&gt;Then Jest. Under Jest 30 with jsdom, OTel's packages resolve to their Node builds, not the browser ones. Nothing fails. You just test the wrong code. This is a one-line test that prints &lt;code&gt;SDK_INFO&lt;/code&gt; from &lt;code&gt;@opentelemetry/core&lt;/code&gt; 3.0, run once without the resolver and once with it:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv16rv1calx6fgazapi7l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv16rv1calx6fgazapi7l.png" alt="The same jsdom test reports telemetry.sdk.language nodejs without the resolver and webjs with it, and passes both times" width="800" height="399"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SDK_INFO&lt;/code&gt; reporting &lt;code&gt;nodejs&lt;/code&gt; inside a browser test environment is the tell. The &lt;a href="https://github.com/open-telemetry/opentelemetry-js/blob/main/doc/3.x/migration-guide.md" rel="noopener noreferrer"&gt;3.x migration guide&lt;/a&gt; has a Jest resolver for this, and adding it flips it to &lt;code&gt;webjs&lt;/code&gt;. If you have your own Jest setup with OTel 3.x and jsdom, check this before you trust your green tests.&lt;/p&gt;

&lt;h3&gt;
  
  
  A disabled instrumentation that kept tracing
&lt;/h3&gt;

&lt;p&gt;Running the tests against the real &lt;code&gt;XMLHttpRequestInstrumentation&lt;/code&gt; (the old ones mocked it) turned up a bug that wasn't about 3.0 at all. &lt;code&gt;FaroXhrInstrumentation&lt;/code&gt; overrides &lt;code&gt;_patchOpen&lt;/code&gt;, and the override didn't check &lt;code&gt;_enabled&lt;/code&gt; like the parent does. &lt;code&gt;disable()&lt;/code&gt; leaves &lt;code&gt;open&lt;/code&gt; patched, so a disabled Faro XHR instrumentation kept creating spans.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjy5qxp0k4sr4lcvgvtt2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjy5qxp0k4sr4lcvgvtt2.png" alt="The new XHR test fails on the #2363 tree with one span after disable(), and all three tests pass on #2364" width="800" height="558"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It's a one-line check. It's also the kind of bug that lives for years cause the unit tests mock the exact thing that would catch it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Size and tests
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbt286nxo6434bfc2l73y.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbt286nxo6434bfc2l73y.png" alt="yarn build output for the tracing IIFE: 74.82 kB and 22.19 kB gzipped on #2363, 76.68 kB and 22.55 kB on #2364" width="799" height="258"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The tracing bundle grows from 74.8 kB to 76.7 kB (22.1 to 22.5 kB gzipped) against #2363. The web-sdk and core bundles stay the same size, and there's no Node platform code in the browser bundle.&lt;/p&gt;

&lt;p&gt;On the #2364 head the full suite passes, 1,218 unit tests across seven packages with one skipped.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftuz0dics2mw71nz5s8ww.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftuz0dics2mw71nz5s8ww.png" alt="yarn quality:test on #2364: every package passes, 204, 56, 82, 193, 646, 31 and 6 tests" width="800" height="659"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I checked outside the repo
&lt;/h2&gt;

&lt;p&gt;Unit tests only tell you so much for a change like this, so I packed the packages and tried them in real apps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A webpack 5.54 app and a Vite 7 app both build and run. Fetch and XHR spans get exported with &lt;code&gt;telemetry.sdk.language&lt;/code&gt; set to &lt;code&gt;webjs&lt;/code&gt;, and the &lt;code&gt;faro.tracing.*&lt;/code&gt; events still go out.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;.d.ts&lt;/code&gt; files typecheck with both &lt;code&gt;bundler&lt;/code&gt; and &lt;code&gt;node16&lt;/code&gt; module resolution.&lt;/li&gt;
&lt;li&gt;On the other end, Alloy's &lt;code&gt;faro.receiver&lt;/code&gt; uses the collector's &lt;code&gt;ptrace.JSONUnmarshaler&lt;/code&gt;, so span attributes with object, array and bytes values still parse.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I also ran my own &lt;a href="https://github.com/adityareddy-dev/otel-js-upgrade" rel="noopener noreferrer"&gt;&lt;code&gt;otel-js-upgrade&lt;/code&gt;&lt;/a&gt; codemod as a dry run on the #2363 tree. It proposed the same version bumps and no code changes. Which is the right answer, and kind of the point of this post: the parts that took time are the ones no codemod can see.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's still open
&lt;/h2&gt;

&lt;p&gt;The 3.0 PR has no &lt;code&gt;!&lt;/code&gt; in the title on purpose. Whether this ships as Faro v3 or a 2.x minor is still an open call in the epic. Faro's own API doesn't change, but the floor for your build does: webpack 5.54+, Jest 29.4+ or Parcel with &lt;code&gt;packageExports&lt;/code&gt;, and Node 22.15+ for installs. Those are old versions to still be on, though a minor release breaking someone's build is still a minor release breaking someone's build.&lt;/p&gt;

&lt;p&gt;There's one thing I left alone. &lt;code&gt;sendFaroEvents&lt;/code&gt; turns attribute values into strings with &lt;code&gt;String()&lt;/code&gt;, so array and object values come out as &lt;code&gt;[object Object]&lt;/code&gt; in the derived events. Arrays already did that on 2.x, 3.x just lets objects through too. That's a small follow up if the maintainers want JSON there.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxenv9zr92onv04sy6ioa.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxenv9zr92onv04sy6ioa.png" alt="PR #2364 on GitHub, the OpenTelemetry JS SDK 3.0 upgrade, open and waiting on review" width="799" height="571"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After this, the next step is &lt;a href="https://github.com/grafana/faro-web-sdk/issues/2340" rel="noopener noreferrer"&gt;#2340&lt;/a&gt;, replacing Faro's fetch and XHR instrumentations with the ones from the OpenTelemetry Browser SIG. That changes trace data, so it's a bigger conversation.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you're on Faro today
&lt;/h2&gt;

&lt;p&gt;Until the upgrade ships, if you add contrib browser instrumentations next to Faro, pin the last versions on 2.x:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm i @opentelemetry/instrumentation-document-load@0.68.0 \
  @opentelemetry/instrumentation-user-interaction@0.67.0 \
  @opentelemetry/instrumentation-long-task@0.67.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That keeps &lt;code&gt;@opentelemetry/api&lt;/code&gt; on one version and your app out of the &lt;code&gt;logs&lt;/code&gt; import error.&lt;/p&gt;

</description>
      <category>opentelemetry</category>
      <category>javascript</category>
      <category>grafana</category>
      <category>observability</category>
    </item>
    <item>
      <title>Why your INP can fall after one more click</title>
      <dc:creator>Aditya Reddy</dc:creator>
      <pubDate>Mon, 02 Nov 2026 21:49:13 +0000</pubDate>
      <link>https://dev.to/adityareddy_dev/why-your-inp-can-fall-after-one-more-click-2p2k</link>
      <guid>https://dev.to/adityareddy_dev/why-your-inp-can-fall-after-one-more-click-2p2k</guid>
      <description>&lt;p&gt;By Aditya Reddy&lt;/p&gt;

&lt;p&gt;Say you have a page with 49 interactions. The slowest took 480 ms, followed by 320 ms and 240 ms. Everything else was faster. Your INP is 480 ms.&lt;/p&gt;

&lt;p&gt;Now click something fast. You have changed no code, and that slow interaction still takes 480 ms. The score for the visit drops to 320 ms anyway.&lt;/p&gt;

&lt;p&gt;That's the part of INP I wanted to explain in &lt;a href="https://github.com/w3c/event-timing/pull/175" rel="noopener noreferrer"&gt;my Event Timing specification PR&lt;/a&gt;. The PR is still open. A single number on a dashboard hides quite a bit of bookkeeping.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened at click 50?
&lt;/h2&gt;

&lt;p&gt;INP skips one longest interaction for every complete group of 50 interactions. At 49, it picks the longest. At 50, it picks the second longest. The &lt;a href="https://web.dev/articles/inp#what-is-inp" rel="noopener noreferrer"&gt;INP guide&lt;/a&gt; explains the reason for excluding these outliers on pages with lots of interactions.&lt;/p&gt;

&lt;p&gt;For our example, assuming all the extra interactions are below 240 ms:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Interactions so far&lt;/th&gt;
&lt;th&gt;Longest values skipped&lt;/th&gt;
&lt;th&gt;INP candidate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;49&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;480 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;320 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;99&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;320 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;240 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So you can cross two of these boundaries and go from 480 to 240 ms without fixing the 480 ms interaction. Nice dashboard, same slow click.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/GoogleChrome/web-vitals/blob/main/src/lib/InteractionManager.ts" rel="noopener noreferrer"&gt;selection code in &lt;code&gt;web-vitals&lt;/code&gt;&lt;/a&gt; amounts to this, with the latencies sorted from longest to shortest:&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="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;skip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;interactionCount&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;50&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="n"&gt;index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;skip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;descendingLatencies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&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="n"&gt;candidate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;descendingLatencies&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;index&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;You need at least one candidate for this fragment. It is only the selection step, not a complete collector. &lt;code&gt;web-vitals&lt;/code&gt; retains up to ten of the longest interactions, so the list can be much shorter than the interaction count.&lt;/p&gt;

&lt;h2&gt;
  
  
  Count interactions, not event entries
&lt;/h2&gt;

&lt;p&gt;There is another place to get the numbers wrong before you even reach that selection.&lt;/p&gt;

&lt;p&gt;A click can leave several &lt;code&gt;PerformanceEventTiming&lt;/code&gt; entries. Take these made-up values from one visit:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Interaction ID&lt;/th&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;th&gt;Duration&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;pointerdown&lt;/td&gt;
&lt;td&gt;320 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;pointerup&lt;/td&gt;
&lt;td&gt;296 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;click&lt;/td&gt;
&lt;td&gt;280 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;click&lt;/td&gt;
&lt;td&gt;480 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;keydown&lt;/td&gt;
&lt;td&gt;240 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first three rows belong to one interaction. Its latency is 320 ms, the longest duration in that group. Adding them gives 896 ms, but those durations overlap.&lt;/p&gt;

&lt;p&gt;Five rows here give us three interactions, with latencies of 320, 480 and 240 ms. Group by the nonzero &lt;code&gt;interactionId&lt;/code&gt;. Leave entries with ID zero out of this calculation. The &lt;a href="https://w3c.github.io/event-timing/#sec-event-timing-interactions" rel="noopener noreferrer"&gt;Event Timing spec&lt;/a&gt; describes how those IDs group related events.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your observer may have missed some of it
&lt;/h2&gt;

&lt;p&gt;For entries of type &lt;code&gt;event&lt;/code&gt;, the default duration threshold is 104 ms. You can register an observer with &lt;code&gt;durationThreshold: 16&lt;/code&gt; to receive shorter events going forward. That does not bring back all the fast events from before registration.&lt;/p&gt;

&lt;p&gt;Even with buffered delivery, you only get the earlier entries that were kept under the default threshold. This is covered in the spec's &lt;a href="https://w3c.github.io/event-timing/#sec-event-timing-when-measured" rel="noopener noreferrer"&gt;measurement section&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;That's why the total interaction count and your observed list length need to stay separate. A fast interaction can increase the count without appearing in that list. Lowering the threshold late won't recover its duration.&lt;/p&gt;

&lt;p&gt;I would use &lt;a href="https://github.com/GoogleChrome/web-vitals" rel="noopener noreferrer"&gt;&lt;code&gt;web-vitals&lt;/code&gt;&lt;/a&gt; for production collection. There is first-input handling, page visibility and back/forward cache restoration to account for too. Code running in one window also cannot see every interaction in an embedded frame.&lt;/p&gt;

&lt;p&gt;When debugging the score, keep the selected interaction's ID and latency along with the total count. If the score drops, check which interaction it picked. And if you are fixing that 480 ms click, compare the click itself before and after the change under similar conditions.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/w3c/event-timing/pull/175/files" rel="noopener noreferrer"&gt;proposed spec section&lt;/a&gt; puts the grouping, selection, observer threshold and retained-list limit together. The fast click at interaction 50 is a useful example of why those details matter.&lt;/p&gt;

</description>
      <category>webperf</category>
      <category>javascript</category>
      <category>browsers</category>
    </item>
    <item>
      <title>How one Desk Studio job gets from roughs to sign-off</title>
      <dc:creator>Aditya Reddy</dc:creator>
      <pubDate>Tue, 20 Oct 2026 04:43:48 +0000</pubDate>
      <link>https://dev.to/adityareddy_dev/how-one-desk-studio-job-gets-from-roughs-to-sign-off-4h22</link>
      <guid>https://dev.to/adityareddy_dev/how-one-desk-studio-job-gets-from-roughs-to-sign-off-4h22</guid>
      <description>&lt;p&gt;The awkward part of a design job is deciding whether it's done. A file exists, the headline fits, and it's easy to accept it cause opening another round means deciding what to ask for. I built Desk Studio around that decision. I wanted the roughs and the reason for the pick to stay visible, and I wanted a failed check to send the file back with something specific to fix.&lt;/p&gt;

&lt;p&gt;Here's how that worked on the studio's own launch card. The &lt;a href="https://github.com/adityareddy-dev/deskstudio/blob/4f0affa6b0f004ac7a15bdd6102e52ce7695500c/evals/golden/social-card/desk-studio-launch/state.json" rel="noopener noreferrer"&gt;saved job state&lt;/a&gt; has the pick, each round's proof result and scores, and the cost by tier. The &lt;a href="https://github.com/adityareddy-dev/deskstudio/blob/4f0affa6b0f004ac7a15bdd6102e52ce7695500c/evals/golden/social-card/desk-studio-launch/golden.json" rel="noopener noreferrer"&gt;golden record&lt;/a&gt; keeps the final result. It finished at 8.9 over three polish rounds, costing $1.34 at list price. Those are this job's results, not a promise about the next one.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7by1opjqla52lm0bfdhq.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7by1opjqla52lm0bfdhq.gif" alt="A team of desk robots passes one job along: three roughs, the pick, polish, proof and the sign-off" width="600" height="338"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The whole job as a cartoon: three roughs on Sonnet, the pick on Opus, polish on Haiku, proof with no model, the sign-off on Opus, then every signed-off piece back to you.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Give the pick something to choose from
&lt;/h2&gt;

&lt;p&gt;The launch card started with three directions: editorial split, type only, and frame. These were separate roughs made by the UI Designer at the senior tier. The point of a rough is to expose a layout decision while changing direction is still cheap. If everything already looks finished, it's tempting to spend the next round adjusting details in a direction that never deserved the pick.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw1106enq8uzzolm3a3lc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw1106enq8uzzolm3a3lc.png" alt="Rough 1, editorial split" width="800" height="418"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi14zsoyb07n05g5wmc7n.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi14zsoyb07n05g5wmc7n.png" alt="Rough 2, type only" width="800" height="418"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxo4hg8wz2i40qqfxq3ui.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxo4hg8wz2i40qqfxq3ui.png" alt="Rough 3, frame" width="800" height="418"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Art Director chose rough 2, the type-only direction. The saved reason is useful: the headline read first at both sizes, the small lamp was the only amber element, and the fixes were type sizes rather than layout. That gives the builder a boundary. Keep the structure that earned the pick and work on the type.&lt;/p&gt;

&lt;p&gt;The card had LinkedIn and Instagram post sizes. A direction had to work across both, so the pick was made from renders. Reading the source wouldn't show whether the headline actually won attention at each size.&lt;/p&gt;
&lt;h2&gt;
  
  
  Put the revisions where they're cheap
&lt;/h2&gt;

&lt;p&gt;After the pick, the UI Designer moved to the builder tier for polish. The launch job used Sonnet for roughs, Haiku for the revisions, and Opus for the pick and sign-offs. Those assignments are in the state alongside the completed desk runs.&lt;/p&gt;

&lt;p&gt;I want the expensive judgment to arrive with a concrete job to do. The builder already has a chosen direction, the notes and a screenshot. It can spend its round revising that file, then hand back something the next check can inspect. Starting from an empty prompt again would lose the decision that was just made.&lt;/p&gt;

&lt;p&gt;This also makes the cost breakdown interesting. The job's director work cost $0.81, rounded, for the pick and sign-offs. Senior work was $0.43 and builder work $0.10. The underlying total is $1.3354, which rounds to $1.34. These are recorded list-price costs, not a subscription bill or a measured saving against hiring someone. Most of this particular job's cost went into choosing and reviewing.&lt;/p&gt;
&lt;h2&gt;
  
  
  Proof gets the rendered file
&lt;/h2&gt;

&lt;p&gt;Each polished file goes through proof before the Creative Director scores it. Proof checks the rendered pixels for text contrast, along with checks for overflow, clipping, safe zones, palette, type scale and required copy. There's no model deciding whether to waive a mechanical failure. A failure goes back to the builder with the screenshot.&lt;/p&gt;

&lt;p&gt;The launch card passed proof in every round. To see an actual rejection, look at the separate &lt;a href="https://github.com/adityareddy-dev/deskstudio/blob/4f0affa6b0f004ac7a15bdd6102e52ce7695500c/evals/golden/social-card/desk-studio-floor/state.json" rel="noopener noreferrer"&gt;dashboard demo card's state&lt;/a&gt;. Its first polish failed a safe-zone check: “deskstudio.ai” sat 32px from the top edge on LinkedIn, while the safe zone started at 38px. The state keeps that note and the failed proof result. The following round passed proof and proceeded to sign-off.&lt;/p&gt;

&lt;p&gt;That's a small error, though it's exactly the sort of thing I want a check to catch before asking for a judgment on composition. The text being present isn't enough. Where it lands in the rendered output matters. Keeping the failed result also means the final screenshot doesn't erase the work needed to reach it.&lt;/p&gt;
&lt;h2&gt;
  
  
  An average can still fail
&lt;/h2&gt;

&lt;p&gt;The Creative Director scores hierarchy, typography, spacing, colour, composition, originality, brand fit and copy. The &lt;a href="https://github.com/adityareddy-dev/deskstudio/blob/4f0affa6b0f004ac7a15bdd6102e52ce7695500c/skills/desk/rubric.md" rel="noopener noreferrer"&gt;visual rubric&lt;/a&gt; requires every axis to reach 8 and the average to reach 8.5. These are the studio's own evaluations. They aren't independent ratings of design quality.&lt;/p&gt;

&lt;p&gt;The launch card shows why both conditions matter. Its first round averaged 8.4, with originality at 7. The next round reached an average of 8.5, but originality was still 7, so it failed again. An average alone would have accepted that second round.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4fd9iuafjpa7coulzbco.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4fd9iuafjpa7coulzbco.png" alt="The signed-off card, round three" width="800" height="418"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The third round passed at 8.9. Originality reached 8 and the other axes were 9. That is a more useful record than a final score on its own, cause it shows which weakness kept the job open. It also keeps proof separate from taste: every launch-card round passed the mechanical checks, and the first two still failed the design review.&lt;/p&gt;
&lt;h2&gt;
  
  
  Try a job you can inspect
&lt;/h2&gt;

&lt;p&gt;In Claude Code, install the plugin:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/plugin install desk --marketplace adityareddy-dev/deskstudio
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Cursor, Codex, Gemini CLI, Copilot, Windsurf or OpenCode, add it to the project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx deskstudio add
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then ask your coding agent for a design in plain words. The installer adds the desk briefs and an instruction block so the agent can run the job. The &lt;a href="https://deskstudio.ai" rel="noopener noreferrer"&gt;gallery&lt;/a&gt; links the outputs, and the &lt;a href="https://github.com/adityareddy-dev/deskstudio" rel="noopener noreferrer"&gt;public repository&lt;/a&gt; retains the records used here.&lt;/p&gt;

&lt;p&gt;The API-key paths remain unverified end to end. I wouldn't infer that they work just cause the adapters exist. For a first job, use an output you can judge yourself, read the pick reason, and check the weakest axis against the rendered file before accepting the sign-off.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>design</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Why the OpenTelemetry browser SDK wouldn't start on Safari 17</title>
      <dc:creator>Aditya Reddy</dc:creator>
      <pubDate>Fri, 02 Oct 2026 22:23:11 +0000</pubDate>
      <link>https://dev.to/adityareddy_dev/why-the-opentelemetry-browser-sdk-wouldnt-start-on-safari-17-55j5</link>
      <guid>https://dev.to/adityareddy_dev/why-the-opentelemetry-browser-sdk-wouldnt-start-on-safari-17-55j5</guid>
      <description>&lt;p&gt;On September 30 someone filed an issue on the OpenTelemetry browser SDK. &lt;code&gt;startBrowserSdk&lt;/code&gt; was throwing &lt;code&gt;TypeError: URL.parse is not a function&lt;/code&gt; on older browsers, so the SDK never started there at all.&lt;/p&gt;

&lt;p&gt;Nothing caught the error, so an app that started the SDK on load (the issue's example was while rendering a React provider) went down with it. Blank page, in production builds. The issue came with a clean reproduction too (Chromium 125 failed, Chromium 127 started fine), which made it easy to pick up.&lt;/p&gt;

&lt;p&gt;I went looking and it was one line. The SDK checks the export URL every time it starts, and it did that with &lt;code&gt;URL.parse&lt;/code&gt;:&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="n"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&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;Problem is, &lt;code&gt;URL.parse&lt;/code&gt; is new. Safari only got it in version 18, Chrome and Firefox in 126, all in 2024. Anyone on Safari 17, which includes iPhones still on iOS 17, or on a Chrome or Firefox older than 126 doesn't have it. The older way, &lt;code&gt;new URL(url)&lt;/code&gt;, has worked everywhere for years. It throws on a bad URL instead of returning &lt;code&gt;null&lt;/code&gt; though, which is a big part of why &lt;code&gt;URL.parse&lt;/code&gt; was added in the first place.&lt;/p&gt;

&lt;p&gt;So the fix goes back to the try/catch:&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;let&lt;/span&gt; &lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;URL&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="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Not `URL.parse`, which Safari &amp;lt; 18 and Chrome/Firefox &amp;lt; 126 lack.&lt;/span&gt;
  &lt;span class="n"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="no"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&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;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;parsed&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A URL that doesn't parse, or isn't http or https, still returns &lt;code&gt;null&lt;/code&gt; and logs an error like before, and the SDK still refuses to start on it. Other parts of the repo already parsed URLs this way, so it wasn't a new pattern for anyone reviewing it.&lt;/p&gt;

&lt;p&gt;Testing was the annoying part, cause I didn't have an old Safari lying around. What I did instead was delete &lt;code&gt;URL.parse&lt;/code&gt; in current Chrome and start the SDK:&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="n"&gt;urlParse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getOwnPropertyDescriptor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'parse'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nc"&gt;Reflect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deleteProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'parse'&lt;/span&gt;&lt;span class="p"&gt;)&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="n"&gt;browserSdk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;startBrowserSdk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* short batch delay for the test */&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="c1"&gt;// emit a log and a span, then wait for the export&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&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="n"&gt;urlParse&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;defineProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'parse'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urlParse&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 test failed on main with the exact error from the issue and passed with the fix. I never ran it on a real Safari 17 or Chrome 125, and I said so in the PR.&lt;/p&gt;

&lt;p&gt;The bit I keep thinking about is why lint didn't catch it. The repo runs eslint-plugin-baseline-js set to &lt;code&gt;available: 'widely'&lt;/code&gt;, which is there to stop exactly this kind of thing. It let &lt;code&gt;URL.parse&lt;/code&gt; through cause its data counts &lt;code&gt;URL.parse&lt;/code&gt; as part of the older &lt;code&gt;url&lt;/code&gt; feature, and that one has been widely available for a long time. So a method Safari has had for about two years passed the check. I left the lint config alone, it felt like its own conversation.&lt;/p&gt;

&lt;p&gt;David Luna merged it on October 2.&lt;/p&gt;

&lt;p&gt;PR: &lt;a href="https://github.com/open-telemetry/opentelemetry-browser/pull/447" rel="noopener noreferrer"&gt;https://github.com/open-telemetry/opentelemetry-browser/pull/447&lt;/a&gt;&lt;br&gt;
Issue: &lt;a href="https://github.com/open-telemetry/opentelemetry-browser/issues/445" rel="noopener noreferrer"&gt;https://github.com/open-telemetry/opentelemetry-browser/issues/445&lt;/a&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>opentelemetry</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
