Skip to content

Merge the publish branch into main - #26

Merged
Fiona2016 merged 95 commits into
mainfrom
chore/reconcile-publish-into-main
Aug 21, 2026
Merged

Merge the publish branch into main#26
Fiona2016 merged 95 commits into
mainfrom
chore/reconcile-publish-into-main

Conversation

@Fiona2016

Copy link
Copy Markdown
Collaborator

main and publish had drifted far apart: publish carries the releases — npm is at the version it holds and the release tags are on it — along with the repairs that keep a release working and a CI workflow main does not have, while main carried the newer feature work. Reconciling them makes either branch a place you can release from, and stops the next person having to work out which one is true.

What this measured

The unit suite on main today ends with 42 failures. On this merge it is green: 2861 specs, no failures. Those failures were not fork drift, which is what they looked like from main — they are already fixed on publish.

The three things that needed a decision

The deploy workflows were the only conflict, on all three files. publish rebuilt them into a preflight job that validates the pushed tag against the package version and hands a verified bundle to the deploy job. That shape wins. The ES5 compatibility gate is put back into it after the build, where every bundle exists and the assertion that the modern bundles are rejected as ES5 still means something.

The lockfile is regenerated, not kept as the text merge left it. The merged one fails yarn install --immutable; the regenerated one passes and differs from publish's by the entries this branch actually adds.

One spec still carried a hardcoded internal staging address, which is why it failed once the two sides met: the host had been scrubbed from the source on one side and not from the fixture on the other. It derives the host from the constant now, so the next change cannot leave the fixture behind. That was the last occurrence of the address in this repository.

One correction that rides along

The bundle sync script and its documentation described the old layout, where a release overwrote the directory of its major version. The pipeline this merge adopts publishes each release into its own directory, so a url pins the version it names. Verified against the CDN: both layouts exist there and the major-version directory is stale. The script now defaults to the full version, and the documented urls name a release.

Fiona2016 and others added 29 commits June 12, 2026 18:21
…tion

chore: restore release safety baseline
…ATH precedence

- pageActivationObservable: ignore element-level focus/blur captured at
  window; only window-targeted events indicate page (de)activation.
  Fixes spurious view_change full snapshots when moving focus between
  inputs (broke recorder masking e2e).
- ci.yml: scope BUILD_MODE=release to the bundle build step so script
  tests run in dev mode as their specs expect.
- scripts/cli: prepend node_modules/.bin to PATH so repo-pinned tsc
  wins over globally installed versions on CI runners.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
rumViewEvents[1] is order-dependent: a view emits several update events
and custom_timings only appear on later updates, which made the
assertion flaky on CI. Resolve views by id and assert on the update
with the highest document_version, like the manually-tracked scenario.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fix: CI baseline follow-up — replay focus misfire, BUILD_MODE leak, PATH precedence
check-release.js verifies the release tag is on HEAD, but
actions/checkout@v4 does not fetch tags by default, so the release
preflight failed to resolve v0.0.5.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Initial view metrics (FCP/LCP/FID/loading time) are anchored to the page
navigation start (`clocksOrigin()`) and only collected on the INITIAL_LOAD
view. For pages that load in the background or are pre-warmed and kept hidden
(e.g. a hidden Electron BrowserWindow that only renders content much later via
IPC), LCP ends up measured from the original navigation and is reported as an
abnormally large value. The existing safeguards (`firstHidden`, the 10-minute
cap) don't help because such hidden windows don't reliably report
`document.visibilityState === 'hidden'`.

Add a `trackWebVitals` init option (default `true`). When set to `false`,
`trackInitialViewMetrics` is not started, so no FCP/LCP/FID/loading-time is
collected for that SDK instance — the field is simply absent rather than a
misleading value.

Like `profilingSampleRate` and `propagateTraceBaggage`, this fork-specific
option is not part of the upstream telemetry schema, so it is intentionally
excluded from `serializeRumConfiguration` (the generated telemetry types must
not be hand-edited).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(rum): add trackWebVitals config to opt out of initial view metrics
…dge is present

Add a `sessionReplayDirectUpload` init option. When the SDK runs inside a host
application that injects `DatadogEventBridge` (an Electron renderer process, a
mobile WebView), upstream hands Session Replay over to the host, and drops it
entirely when the host does not declare the `records` capability. Our Electron
SDK never takes it over, so Session Replay is simply lost there.

The option makes the page keep the regular recorder and upload the segments
itself, over the same intake connection a plain web page uses. The intake needs
no change: the payload, the endpoint builder (including `proxy`) and the
authentication are the ones a web page already uses.

Three condition changes, marked `FLASHCAT FORK (n/3)`:

1. `recorderApi.ts` - the bridge check moved from `makeRecorderApi()` to
   `onRumStart()`, since it now depends on a configuration option that is not
   known when `makeRecorderApi()` runs. `makeRecorderApi()` keeps only the
   browser support check.
2. `startRecording.ts` - records go to `startSegmentCollection` instead of
   `startRecordBridge`.
3. `rumSessionManager.ts` - the session is `SAMPLED` so views carry
   `session.has_replay`, which is what the read side keys on.

Segments are joined to a session by `(account, session id)`, and nothing
overwrites the session id of a segment uploaded from the page. The stub session
manager therefore reads the id the host actually uses through two new optional
bridge methods, `getSessionId()` and `getAnonymousId()`, falling back to the
placeholder id when a host built against an older SDK does not implement them.
`getAnonymousId()` also fixes the anonymous id being always empty in a host
application, which made unique user counts always zero.

`sessionReplaySampleRate` still defaults to 0, so it must be set explicitly for
anything to be recorded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… host bridge

Two follow-ups from the first end-to-end round on Electron.

Replay segments were all rejected with 401 and nothing reached storage.
`overrideInitConfigurationForBridge()` replaces `clientToken` with the literal
`'empty'` and `applicationId` with a placeholder, on the assumption that a page
hosting a bridge never sends a request of its own. `sessionReplayDirectUpload`
breaks that assumption, so both are now kept when the option is set. Verified by
comparison during the round: same URL and payload, `dd-api-key=empty` gives 401,
the real token gives 400, and a well formed segment with the real token and the
real application id gives 202 and lands with an `acct_id`/`session_id` matching
its session row. RUM events are unaffected, as they never go over HTTP.

Giving the stub session an `anonymousId` also activated the `usr.id` backfill in
`userContext.ts`, and every renderer process event started carrying
`usr.id = anonymous_id`. That backfill is a Flashcat addition that the web needs,
because the web baseline counts only `usr.id` and would otherwise miss every
logged-out visitor. A host application does not need it: its baseline already
prefers the anonymous id, which is stable across logins, so the copy only imports
the web double-counting bug where one device counts as two users around a login.
It is now skipped when a bridge provides the anonymous id, keyed on
`canUseEventBridge()` rather than on the option, since the id is host-owned
whenever a bridge is present. Web behavior is byte for byte unchanged, and a
regression test pins it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sessionReplayDirectUpload markers were numbered out of three while a fourth
site already existed, and the userContext and rumSessionManager identity changes
were tagged with them even though they belong to the host-provided identifier
work and are reachable without the option. Number the four option sites 1/4..4/4
and point the identity comments at DatadogEventBridge instead.
The implementation folds an empty answer into "not implemented" with `||`, and
host applications rely on that to say "no session right now" — but the interface
did not say so, leaving the next reader free to tighten it to `??` and hand the
rest of the SDK an empty session id.
A host application that owns the session id answers `''` through the bridge while
it has none — a session that timed out and has not been renewed yet. The bridge
wrapper folded that empty answer into `undefined`, the same value it returns for
a host that does not implement `getSessionId()` at all, and the session manager
stub then fell back to `STUB_SESSION_ID`.

RUM events survive that: the host overrides their session id, or drops them. But
Session Replay segments are uploaded by this page directly, so they reached the
intake carrying a placeholder that every application built on the host shares.
Records collected during the gap were both lost to the real session and mixed
into a fake one shared across applications.

The two answers are now distinct. `undefined` still means "this host does not
answer for the session id" and still falls back to the placeholder. `''` means
"this host has no session right now", and the stub reports no tracked session
for it: `computeSegmentContext` then finds no session and segment creation stops,
which is the behaviour segmentCollection already documents for a session that
ends. Reusing the id the host held a moment ago would be just as wrong — it would
attribute records to a session that has ended — so nothing is cached.

The stub also watches the host session. The bridge is pull-only, so reading it
again is the only way to notice a transition; that is how the regular session
manager works too, polling its own store every STORAGE_POLL_DELAY and turning
what it sees into expire/renew notifications. Mirroring it lets everything
already subscribed to SESSION_EXPIRED / SESSION_RENEWED behave: the recorder
flushes its pending segment and stops instead of holding it open across the end
of the host session, and restarts on a fresh view — and so a fresh full snapshot,
without which the next segment would be unplayable — once the host has a session
again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…-upload-publish

feat(rum): keep collecting Session Replay in the page when a host bridge is present
The release preflight runs the TypeScript compatibility check, so this has
been blocking every release, whatever its contents, since TypeScript 7
became `latest`.

Two things broke, in sequence. Yarn 4.9.1 could not install TypeScript 7 at
all: it applies a builtin compatibility patch to the package, and that patch
cannot be applied to a TypeScript this new, so `yarn add` failed inside
`patchPackage` before any type checking happened. Yarn 4.17.1 installs it
cleanly.

Past that, the check itself was asking for a configuration that no longer
exists. TypeScript 7 removed `target: es5` and `moduleResolution: node10`,
the two options the test app's tsconfig is built on — so the three checks
that run against `latest` were compiling our type definitions under a setup
nobody on a current TypeScript can have. They now override those two options
with what such a consumer uses instead, at the oldest target still available,
which keeps what the older pinned checks are there to prove.

`ignoreDeprecations: '6.0'` goes with them: the options it silenced are gone
rather than deprecated, so it no longer has anything to do.

The lockfile bump is yarn writing its current format, and the ignore entry is
for a file yarn writes into the test apps by itself.
`yarn install --immutable`, which every CI job starts with, refuses an
install that would rewrite the lockfile — and the new yarn rewrites this one:
it bumps the format version and re-hashes the builtin `resolve` patch.

Missed on the first pass because building and testing locally reuses an
existing `node_modules` and never re-runs the install the way CI does.
Running the new yarn rewrote `.yarnrc.yml` on its own: it dropped the
comment explaining why the global cache is off, and added
`approvedGitRepositories: "**"`, `enableScripts: true` and
`npmMinimalAgeGate: 0` — the last two of which turn off a supply chain
protection and let any git host serve a dependency. None of it belongs in a
toolchain upgrade, and none of it is needed: `yarn install --immutable` and
all six compatibility checks pass with the file as it was, plus the new
yarn path.

The ignore entry goes with them. The file it covered is written into the
test apps by yarn only when the settings above are set at the root, so with
those gone nothing generates it, and ignoring it would only have hidden them
coming back.
Yarn 4.17 stopped running them by default, where 4.9 ran them, and the e2e
suite depends on one: puppeteer fetches the Chrome it drives from its own
install script. Without it the suite fails on every run looking for a browser
nothing ever downloaded.

Restoring it keeps the upgrade to the toolchain. Leaving scripts off is a
worthwhile change on its own, but it needs the browser fetched some other way
first, and that is not this.
fix(ci): unblock releases from TypeScript 7
Two things stood between a tag and a release, and both had to be worked
around by hand every time.

Checkout failed before any job did real work. This workflow is triggered *by*
the tag, so the tag is the ref being checked out and is already present —
asking for `fetch-tags` on top makes checkout fetch the commit and the tag
into the same `refs/tags/*`, which git refuses. That took down every
tag-triggered release from v0.0.5 onward; v0.0.5, v0.0.6 and v0.0.7 all went
out through the manual workflow instead. `deploy-manual.yml` keeps the flag,
and needs it: it runs from a branch, where the tag is not the ref.

`yarn release` failed before that, on the machine running it. `lerna version`
starts with `git remote update`, which fetches every remote and recurses into
their submodules; branches on the upstream fork reference `rum-events-format`
commits its own remote does not serve. Nothing in a release reads upstream,
so it is now kept out of that update — from the release command itself, so it
holds on any clone rather than on whichever machine happened to be configured.
fix(ci): make a pushed tag actually release again
The two lines had drifted far apart: publish carries the releases (npm is
at the version it holds, and the release tags are on it) along with the
repairs that keep a release working, while main had the newer feature
work. Reconciling them makes either branch a place you can release from
and stops the next person guessing which one is true.

Three files needed a decision. The deploy workflows exist on both sides:
publish rebuilt them into a preflight job that validates the tag against
the package version and hands a verified bundle to the deploy job, which
is strictly better than what main had, so that shape wins. The ES5
compatibility gate main added is put back into it, after the build,
where every bundle exists and the check that the modern bundles are
rejected still means something.

The lockfile is regenerated rather than kept as the text merge left it -
a merged lockfile is not a lockfile, and the merged one failed
--immutable.

One spec still carried a hardcoded internal staging address, which is
also why it failed once the two sides met: the host had been scrubbed
from the source on one side and not from the fixture on the other. It
derives the host from the constant now, so the next change to it cannot
leave the fixture behind.

The bundle sync script and its documentation followed the old layout,
where a release overwrote the directory of its major version. The
pipeline this merge adopts publishes each release to its own directory,
so a url pins the version it names, and the script defaults to the full
version.
Merging the two lines brought a CI workflow main did not have, and it
runs checks main never ran. Two of them fail, both because this package
was added while nothing was looking:

The package metadata check requires an .npmignore per package, and this
one had none. It follows the convention the others use — exclude
everything, then name what ships — which here is the bundle alone.

The deploy and source map specs still described three packages. Adding a
fourth changed what those scripts do without changing what the specs
expected of them, so every one of them failed. They now expect the
legacy bundle to be uploaded, renamed and invalidated alongside the
others.

This was raised in review earlier and set aside because these specs did
not run anywhere. They do now.
The previous attempt inserted the legacy command inside the object that
held the slim one, which is two strings in a row and parses as nothing.
Each expectation is now copied as a whole and rewritten.
The repository-wide typecheck compiles the specs of every package
together, which main's CI never did. Two things only show up there:

A spec elsewhere augments the global Window with its own type for the
FC_RUM property, so an interface here extending Window with a different
type for it is two declarations of one global disagreeing. This one no
longer extends Window; it only needs somewhere to put the api.

Two event assembly fixtures predate the view start time being part of a
view's context and had not been given one.

The source map expectations also had the legacy bundle renamed between
the slim bundle's script and its source map, rather than after both. The
renames happen a package at a time.
The fixture gained a start time, which dates the event rather than
travelling inside the view object, so comparing the whole fixture to
what was assembled started failing.
The clock was frozen after the view had already opened, so the view read
the real wall clock for its start and the test read a frozen one two
seconds later - a millisecond between the two lines was enough to fail
it. It froze once on a CI runner and would have again.
@Fiona2016
Fiona2016 merged commit 6d810ea into main Aug 21, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants