Skip to content

feat(instrumentation): add Long Animation Frames instrumentation - #429

Open
SyedTashfin wants to merge 19 commits into
open-telemetry:mainfrom
SyedTashfin:feat/long-animation-frame-instrumentation
Open

SyedTashfin wants to merge 19 commits into
open-telemetry:mainfrom
SyedTashfin:feat/long-animation-frame-instrumentation

Conversation

@SyedTashfin

@SyedTashfin SyedTashfin commented Sep 14, 2026 •

Copy link
Copy Markdown

Which problem is this PR solving?

Browser instrumentations are being consolidated into this repository, and #401 asks for Long Animation Frames instrumentation. LoAF is the successor to the deprecated Long Tasks API.

This is a port of the implementation I first opened in opentelemetry-js-contrib#3751, adapted to the conventions here: it emits log records instead of the span-based version in contrib, matching the console and errors instrumentations.

Fixes #401

Short description of the changes

  • Observes long-animation-frame performance entries and emits each one as a log record carrying the frame timing breakdown (start_time, duration, blocking_duration, render_start, style_and_layout_start, first_ui_event_timestamp) and the per-script attribution (scripts).
  • The URL-bearing script fields (invoker for classic-script / module-script entries, and source_url) go through sanitizeUrl, which defaults to defaultSanitizeUrl. That is on by default here unlike url.full for navigation, fetch and xhr: this value sits inside the scripts array where a page token is easy to miss. Values that are not URLs (DOMWindow.onclick, an unresolved source_url) are emitted as reported and never handed to the hook.
  • Buffered entries are replayed on the first enable() only, so a disable() / enable() cycle resumes live observation instead of re-emitting frames that were already logged.
  • Adds packages/instrumentation/src/long-animation-frame/ (instrumentation, types, semantic attribute constants, tests), the ./experimental/long-animation-frame export and keyword in packages/instrumentation/package.json, a Long Animation Frames section in the package README, and the sandbox demo with a Long Frame button.
  • Extends the browser-native InstrumentationBase introduced in refactor(instrumentation)!: add browser-native InstrumentationBase and migrate instrumentations #278.
  • The attribute names are candidates for the registry (chore: add semantic convention registry #404), per david-luna's note.

Type of change

  • New feature (non-breaking change which adds functionality)

How Has This Been Tested?

Local run on the pushed head, in packages/instrumentation — 18 vitest cases in src/long-animation-frame: buffered-entry emission and the full attribute set, the log timestamp taken from the entry start time, unsupported-environment guards, disable/re-enable, replay-on-first-enable-only, per-entry failure containment, and the sanitization contract (only non-empty URLs reach the hook, a sanitizer that parses its argument is safe to pass, one failing entry does not drop the frames after it).

Whole tree on the same head (7c4eb4d, node 26.12.1 / npm 11.20.0 as devEngines requires): npx turbo build --force uncached 5/5, npm run lint:ci (biome, tsc, eslint) clean, npm test (433 instrumentation tests in 21 files, 127 sdk tests in 15 files), npm run test:e2e 29/29.

The instrumentation extends the browser-native InstrumentationBase added in #278, so enable() / disable() drive _onEnable() / _onDisable() and init() asserts the API is present; the package no longer imports @opentelemetry/instrumentation.

The workflow runs on this head are waiting for approval, so the runs above are the local stand-in for CI.

Generative AI disclosure

I used Hermes Agent to assist with repository research, porting the code to this repository's conventions, test drafting and documentation. I reviewed the diff line by line, ran the checks above on the pushed head, and take responsibility for the change.

@SyedTashfin
SyedTashfin requested a review from a team as a code owner September 14, 2026 15:07
Copilot AI lite review requested due to automatic review settings September 14, 2026 15:07

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: to be discussed in browser SIG and added in the registry

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would not hold this PR on this. We can add these to the registry once it is ready.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — the constants stay local to the instrumentation in semconv.ts until #404 lands in the registry. Nothing here depends on the registry shape, since the names are only used to build this instrumentation's log record, so it can move when #404 does, as @martinkuba noted above.

}

private _isSupported(): boolean {
if (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've seen this in other instrumentations (more than two). IMO this is a candidate to be a utility function. out of the scope of this PR

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and left out of this PR. Worth noting that the shared pieces this instrumentation does need now come from the base added in #278: assertPerformanceObserver for the support check and _safeExecute for containing a failing entry, so a general helper for the record-building loop would sit next to those rather than in this PR. Happy to open a follow-up issue for it if you want it tracked.

},
};

const hook = this.getConfig().applyCustomLogRecordData;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the apply function does get additional context is easier to use a LogRecordProcessor and do not more complexity to the instrumentation. ref: #359 (comment)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@SyedTashfin

I'd suggest to remove it. The entry contains all the context and is translated to a log. There is no user action attached or event related to it. This can be done with a LogRecordProcessor when configuring the LoggerProvider

@overbalance

Copy link
Copy Markdown
Contributor

@SyedTashfin The ticket is assigned to me because the instrumentation code is already completed. I think the cardinality of the code in your PR may be a bit high. I'll review in depth later.

SyedTashfin added a commit to SyedTashfin/opentelemetry-browser that referenced this pull request Sep 24, 2026
A LogRecordProcessor configured on the LoggerProvider covers the same case, and the emitted record already carries the full entry context.

Addresses review feedback on open-telemetry#429.
@SyedTashfin

Copy link
Copy Markdown
Author

Removed the applyCustomLogRecordData hook in 13bee56. Agreed that a LogRecordProcessor on the LoggerProvider does the same job, and the record already carries the whole entry, so the hook was redundant.

Worth flagging: navigation, user-action and web-vitals in this repo still expose the same hook. I took it out here as asked, but if you'd rather keep the instrumentations consistent I can put it back.

I've left the semconv attribute names local to the instrumentation for now and will move them to the registry once #404 is in.

@overbalance you mentioned the ticket is yours because the instrumentation is already completed. I couldn't find a branch or a PR for it, so I can't tell what state it is in. If you're porting it, tell me and I'll close this one; I just don't want two implementations of the same thing running in parallel. If it's the size of the diff that's the concern, tell me which parts you'd cut and I'll strip it down.

@mquentin mquentin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for your contribution, I juste have a small suggestion and I would love to have your input on a question:

Suggestion:

I think having a small throttle safeguard for this instrumentation would be great but this instrumentation is indeed a good first step toward having a better performance monitoring.

My main question is

How could we aggregate these long animation frame ? I feel most of the value of these events comes from being able to identify which user interaction generated this long animation frame and on which page did it occurs.

Do you have any take on this ?


const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
this._emitLongAnimationFrame(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion:

could we have a debounce or throttle mechanism here if somehow too many long animation framed occurred at the same time ?

I know there are supposed to have a meaningful duration for each frames and you're not supposed to have thousands of very small frames but just in case a throttle might be a good safeguard.

@SyedTashfin

Copy link
Copy Markdown
Author

Answering the question first, then the throttle, because I disagree with that one.

On aggregation: I think the record is already an attribution object, so this is mostly a grouping problem rather than a data problem.

For which interaction — firstUIEventTimestamp is the timestamp of the first UI event processed in that frame, and a click's timeStamp is a DOMHighResTimeStamp on the same clock, so a frame can be joined to the interaction that triggered it without any context plumbing. The scripts array then names the culprit: invoker: "DOMWindow.onclick", invokerType: "event-listener", plus sourceURL, sourceFunctionName, sourceCharPosition, and a per-script split of blockingDuration against forcedStyleAndLayoutDuration.

The other half of that join is already in this repo: the web-vitals instrumentation timestamps the INP record with attribution.interactionTime, and puts the raw attribution in the body when includeRawAttribution is on. So matching a LoAF's firstUIEventTimestamp against a browser.web_vital INP record gives you interaction target and type together with the scripts that blocked the frame. That's the aggregation I'd build: INP tells you which interaction was slow, LoAF tells you which script made it slow.

For which page — nothing to add here. The SDK's createDocumentLogRecordProcessor already stamps browser.document.url.full on every log record, and LocationDocumentProvider reads location.href at emit time so pushState navigation is reflected. One caveat: with buffered: true the replayed entries get the current URL rather than the URL they happened on, so those first records can be mislabelled on a page that navigates early.

Where I'd keep the line is not in the instrumentation. A rollup here needs a time window and a flush policy, which turns a faithful record emitter into a metric reporter with config surface. source_url and source_function_name are unbounded — fine as log attributes, a trap as metric dimensions. If we want counters or histograms, the bounded attribute set belongs in the registry (#404) as an explicit event or metric definition, not in this PR.

On the throttle: I'd rather not, and I think it would actively hurt. LoAF is a jank signal — entries only exist for frames past 50ms, and the browser caps the timeline buffer at 200 entries. A throttle drops the worst frames first: a two second jank that produces dozens of entries would report one, and the p95 downstream gets better while the page gets worse. That's the one failure mode I don't want from a performance instrumentation.

If you want a hard ceiling, I'd do it with accounting instead: a per-callback cap, plus a dropped count on the next record so the loss is visible. Or reuse the shape resource-timing already has — batchSize, maxQueueSize, forceProcessingAfter, drained on an idle callback. Either is a follow-up; a first version doesn't need it.

@mquentin

mquentin commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Answering the question first, then the throttle, because I disagree with that one.

...

@SyedTashfin Thanks for all the inputs and for answering my question.

Indeed if the browser already caps the buffer at ~200 entries no need to implement something right now. I also feel that having a per-callback cap like you suggest should probably be part of the InstrumentationBase out of the box to ease new instrumentation implementations (and obviously is out of the scope of this PR).

Suggestion:

Given your meaningful inputs about the native browser ~200 entries cap and the existing out of the box timing interaction and vital aggregations, could you document this instrumentation to reflect these two main points ?

});

try {
observer.observe({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we only replay buffered frames on the first enable? Right now, I believe disabling and reenabling logs the same frames again.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 0ccb7f6, and it survived both merges of main: the observer asks for buffered only until the first successful observe(), so a disable() / enable() cycle resumes live observation instead of replaying what was already logged.

Two tests pin it in instrumentation.test.ts: replays buffered entries on the first enable only (the second observe() gets buffered: false) and keeps the buffered replay pending when the first observe fails (the flag is set only after observe() returns, so a browser that rejects the first call still gets its replay).

Unchanged by the base migration in 7c4eb4d.

Port the Long Animation Frames instrumentation from opentelemetry-js-contrib, emitting long-animation-frame performance entries as logs (matching the existing long-task pattern).
A LogRecordProcessor configured on the LoggerProvider covers the same case, and the emitted record already carries the full entry context.

Addresses review feedback on open-telemetry#429.
`buffered: true` was requested on every `enable()`, so a disable/enable
cycle replayed the frames the browser had retained and emitted the same
frames again.

Request the buffered replay on the first enable only, and keep it pending
when the first `observe()` throws so a failed start does not consume it.
Cover the buffered replay and the browser's ~200 entry cap with the reason
no client-side throttle is applied, how `first_ui_event_timestamp` joins a
frame to the interaction that triggered it and to the web-vitals INP record,
and the page-attribution caveat that comes with the replay.
@SyedTashfin
SyedTashfin force-pushed the feat/long-animation-frame-instrumentation branch from 13bee56 to f610ec1 Compare September 29, 2026 13:17
@SyedTashfin

Copy link
Copy Markdown
Author

Both points are addressed.

@JacksonWeber — fixed in 0ccb7f6. buffered: true was requested on every enable(), so a disable/enable cycle replayed the buffer and re-emitted frames that were already logged. The replay is now requested on the first enable only; a later cycle resumes live observation. The flag is set after a successful observe(), so a failed first start keeps the replay pending instead of burning it — one test pins that ordering, and the replay test fails against the previous code.

@mquentin — documented in f610ec1, as a new Long Animation Frames section in packages/instrumentation/README.md. It covers the two points you asked for:

  • the buffered replay, and why no throttle is applied — the browser caps the buffer at roughly 200 entries and discards the oldest first, so a throttle would drop the worst frames first and improve the downstream p95 while the page gets worse. It notes the accounting alternative (per-callback cap plus a dropped count, or the resource-timing batching shape) if a hard ceiling is ever needed.
  • the interaction join: first_ui_event_timestamp matches the web-vitals INP record's attribution.interactionTime on the same clock, and scripts then names the culprit. INP says which interaction was slow; this says which script made it slow.

It also records one caveat worth knowing up front: replayed frames carry the URL current at emit time, not the URL they happened on, via LocationDocumentProvider, so the first records can be mislabelled on a page that navigates early.

Also rebased onto main per CONTRIBUTING. Unit project: 223 passing. biome ci reports no issues on the changed files.

Note on how this was written: an agent drafted the patch and the README section; I read the diff line by line and can answer for it in review.

@mquentin mquentin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM in term of code and more than up to introduce this new experimental instrumentation.

Before my approval, I have a last suggestion:

Could add this new instrumentation in the demo sandbox: here

It would then be great to have a new btn that mock a long animation frame to generate the new event you introduced and demo the long animation frames instrumentation (on top have helping QAing the change of this PR by running the sandbox locally for other reviewers)


---

### Long Animation Frames

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks ! Great addition

Register LongAnimationFrameInstrumentation in the sandbox SDK and add a
Long Frame button that blocks the main thread for 300 ms. A long animation
frame is reported only once a frame passes the 50 ms threshold, so the
handler does real blocking work in the click task, which is what the
browser attributes the frame to in `scripts[].invoker`.

The button warns instead of doing nothing on browsers without the API.
Event-based instrumentations (navigation, resource timing, user action,
long animation frames) emit a record with an eventName and no body, so the
sandbox event log printed "[log] INFO · undefined" for all of them. Fall
back to the event name when there is no body to render.
@SyedTashfin

Copy link
Copy Markdown
Author

Sandbox demo is in — 0481004.

  • LongAnimationFrameInstrumentation is registered in sandbox/src/otel.ts.
  • New Long Frame button in the Events group. It busy-waits 300 ms inside the click task, which is what the browser attributes the frame to: on this head the entry reports duration ≈ 322 ms, blocking_duration ≈ 266 ms, a set first_ui_event_timestamp, and scripts naming the culprit (DIV#root.onclick, event-listener, ~300 ms). On Firefox and Safari, where the API isn't implemented, the button warns instead of silently doing nothing.

One fix was needed for the demo to be readable: event-based records carry an eventName and no body, so the sandbox event log printed [log] INFO · undefined for navigation, resource timing, user action and long animation frames alike. 6a1df0d falls back to the event name, so the button now logs browser.long_animation_frame.

Branch is merged with main (it had gone behind again). The workflow runs on this head are waiting on approval, so I ran the checks locally against this tree: npm run build, npm run lint:ci, npm test (32 test files) and npm run test:e2e (29 tests) all pass.

Patch drafted with an agent; I read the diff line by line and can answer for it.

Comment on lines +129 to +130
[ATTR_LONG_ANIMATION_FRAME_NAME]: entry.name,
[ATTR_LONG_ANIMATION_FRAME_ENTRY_TYPE]: entry.entryType,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

name and entryType are always "long-animation-frame" per the spec, and for each script both are always "script" (lines 142-143). The event name already says what the record is, so I'd drop all four. They're repeated on every record and every script without telling the backend anything. The README row for entry_type says "normally" too, that can go with it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 4a53140, resolving.

Comment thread packages/instrumentation/README.md Outdated

`first_ui_event_timestamp` is a `DOMHighResTimeStamp` on the same clock as `Event.timeStamp`, so a frame can be joined to the interaction that triggered it without any context plumbing. The `scripts` array then names the culprit: `invoker` (for example `DOMWindow.onclick`), `invoker_type` (`event-listener`), `source_url`, `source_function_name`, `source_char_position`, and the per-script split of `blocking_duration` against `forced_style_and_layout_duration`.

The other half of that join is already in this repository. The [Web Vitals](#web-vitals) instrumentation timestamps its INP record with `attribution.interactionTime` and puts the raw attribution in the record body when `includeRawAttribution` is enabled, so matching a frame's `first_ui_event_timestamp` against a `browser.web_vital` INP record yields the interaction target and type together with the scripts that blocked the frame: INP says which interaction was slow, Long Animation Frames say which script made it slow.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An exact match on first_ui_event_timestamp will miss frames. Per the spec it's the timeStamp of the first UI event whose listener ran in that frame, any UI event, while interactionTime is the start of the interaction's first event entry. Those only line up when that frame happened to handle the interaction's first event before anything else. A slow interaction can also overlap more than one frame (the one that delayed the input, the one running the handlers) and at most one of them can match.

web-vitals doesn't join on equality, it takes every LoAF that overlaps the interaction. With includeRawAttribution on, those frames are already in the INP record body as attribution.longAnimationFrameEntries, scripts included, so for INP the join is already done. Might be simpler to point at that and describe an overlap match for anything else.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 2057e44, resolving.

…ng script fields

The spec fixes the entry name to `long-animation-frame` and every script
entry name to `script`, so `name` and `entry_type` were constant on every
record and on every script entry. The event name already says what the
record is, so drop all four.

`invoker` and `sourceURL` are emitted as the browser reports them, which
for an inline script or a listener defined on the page is the page URL
including its query string. Add the `sanitizeUrl` config option that
navigation, fetch and xhr already expose and route both fields through it,
so a `token` in the page URL cannot leak through the scripts array while
being stripped from `url.full`.
…P join notes

The performance timeline buffer keeps the first ~200 entries and leaves out
the newer ones once full, so it only bounds the buffered replay; a live
observer still receives every frame. Drop the claim that it caps what the
instrumentation emits, and describe the INP correlation as an overlap match
rather than an exact `first_ui_event_timestamp`/`interactionTime`
comparison, pointing at the frames web-vitals already inlines in the INP
record body.

Also document the `sanitizeUrl` option and drop the two attribute rows the
implementation no longer emits.
@SyedTashfin

Copy link
Copy Markdown
Author

@adityareddy-dev all four are in 4a53140 / 2057e44.

The four fields — dropped. name and entryType are fixed by the spec for the entry and for every script, so they carried nothing per record. The README rows go with them, "normally" included.

invoker / sourceURL — I added sanitizeUrl to the config, the same shape navigation, fetch and xhr take, and both fields go through it. I'd rather not leave this to a LogRecordProcessor: a page that sanitizes url.full (or sets it on LocationDocumentProvider) still ships the token inside scripts, and that's a silent leak in the default setup rather than a documented gap. It stays opt-in like the others, so nothing changes unless you pass it, and defaultSanitizeUrl composes.

One thing I didn't decide for you: should this instrumentation apply defaultSanitizeUrl by default, unlike navigation/fetch/xhr? I matched them and left it opt-in, but the nested shape makes the leak easier to miss than a top-level url.full. Say the word and I'll flip it.

The buffer — you're right, I had it backwards. Fixed: the timeline buffer keeps the first ~200 entries and leaves the newer ones out once it's full, so it only bounds the buffered replay, a live observer still gets every frame, and it isn't a reason not to throttle. The sampling-hides-the-worst-frames point stands on its own.

The INP join — rewritten as an overlap match. first_ui_event_timestamp is the first UI event whose listener ran in the frame; interactionTime is the start of the interaction's first event entry, so equality only holds for the frame that handled that first event, and an interaction spanning two frames can match at most one. The README now points at attribution.longAnimationFrameEntries for INP and describes overlap for anything else.

Merged main in and re-ran build, lint:ci and test locally on 14c63fe; CI still needs a maintainer to approve the workflows before they run on the fork.

@adityareddy-dev

Copy link
Copy Markdown
Member

Ran the long animation frame tests at 14c63fe since the fork CI hasn't run yet, 14 of 14 pass. Thanks for turning it round this fast.

On the default, I'd flip it, for the reason you gave, nobody goes looking for a URL inside scripts. Matching navigation, fetch and xhr makes less sense here, they put it in url.full where people do look. It hasn't merged so nobody's output changes, and defaultSanitizeUrl leaves an invoker like BUTTON#go.onclick alone (it isn't a URL, so it comes back as is).

It won't catch everything though. The semantic conventions name five query keys to redact by default (X-Amz-Signature, sig and the rest) and defaultSanitizeUrl has none of them. It doesn't touch the fragment either, and Chrome keeps the fragment in an inline script's invoker, so an #access_token= from an OAuth redirect can still go out with the default on. That's the helper's problem rather than this PR's.

Two more, mostly in the README. blocking_duration is described as the time the main thread was blocked, in the table and in semconv.ts. The spec adds up the part of each task past 50 ms instead, with the render time added to the longest one. In Chrome 151 a 120 ms setTimeout with nothing to render came back with duration 120 and blockingDuration 70. The "per-script split of blocking_duration" line doesn't hold either, scripts don't carry one.

The other is page attribution, it isn't only the replayed frames. Every record goes out from the observer callback after the frame is over, so a frame that changed the URL gets stamped with the new one. I tried a click handler that blocks for 100 ms and then calls history.pushState, and by the callback location.pathname was already /next/page. A slow click that opens a new route is probably the frame people most want to place.

And one from the new text. The overlap match uses the frame's start_time, but the frame record doesn't have one, only the scripts do. The frame start only goes out as the record timestamp. Adding start_time next to duration would put it on the same clock as render_start and first_ui_event_timestamp and make that sentence true as written.

@adityareddy-dev

Copy link
Copy Markdown
Member

The sanitizer gaps are their own issue now, so they don't hold this one up.

start_time: script.startTime,
duration: script.duration,
execution_start: script.executionStart,
invoker: sanitize(script.invoker),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

invoker isn’t always a URL. Event listeners can report DOMWindow.onclick, which throws in a sanitizer using new URL(url). That also skips the remaining frames in this callback because record construction is outside the error guard. Can we only sanitize URL-valued invokers and add a test with an event-listener entry and a URL-parsing sanitizer?

`invoker` is the invoking script's source URL only for `classic-script` and
`module-script` entries; for an `event-listener` entry it is a selector-like
string such as `DOMWindow.onclick`. `source_url` is a URL, but the browser
reports an empty string when it cannot resolve one. Both were handed to
`sanitizeUrl` unconditionally, so a sanitizer that parses its argument —
`new URL(url)`, the obvious implementation — threw, and because the record was
built outside the error guard, that throw skipped the remaining frames in the
same observer callback.

Only non-empty URLs now reach the sanitizer, and every entry is processed
inside `safeExecuteInTheMiddle`, so a failure in one costs that frame alone.

Also, from the same review:

- `sanitizeUrl` defaults to `defaultSanitizeUrl`. The value sits inside the
  `scripts` array, where a page token is easy to miss, so it is on by default
  here rather than opt-in as `url.full` is for navigation, fetch and xhr.
  `sanitizeUrl: undefined` emits both fields unsanitized.
- Add `browser.long_animation_frame.start_time`, so the overlap match in the
  README is expressible from the frame record.
- Describe `blocking_duration` as the spec defines it — the sum over the frame's
  tasks longer than 50 ms, with the rendering time added to the longest — and
  drop the claim that scripts carry a split of it.
- Page attribution applies to every record, not only the buffered replay: the
  observer callback runs after the frame, so a frame whose handler navigated is
  stamped with the new URL.
@SyedTashfin

Copy link
Copy Markdown
Author

Both points are fixed in 01b0d93. I also merged main in (67a8584), so the branch is no longer behind.

@JacksonWeber — you were right on both counts, and the second one was worse than the first.

invoker is a URL only for classic-script and module-script entries; I was handing every entry type to sanitizeUrl, so an event-listener entry's DOMWindow.onclick reached a sanitizer that parses its argument. Then, because the record was built outside the error guard, that throw escaped _emitLongAnimationFrame and took the rest of the callback's frames with it — the frames after the bad one were dropped silently. Only non-empty URLs reach the hook now (sourceURL is skipped too when the browser reports the empty string, which new URL rejects the same way), and each entry runs inside safeExecuteInTheMiddle, with the failure logged per entry.

Tests: an event-listener entry with a URL-parsing sanitizer (new URL(url).href) no longer throws and leaves the invoker as reported — that one fails against the previous head, I checked; an unresolved source_url never reaches the hook; and a callback whose first entry throws still emits the second. One existing case now constructs with sanitizeUrl: undefined to pin the opt-out.

@adityareddy-dev — the three from your last review are in the same commit:

  • The default — flipped, as you argued. It defaults to defaultSanitizeUrl, and sanitizeUrl: undefined is the opt-out. The reason is recorded next to the config and in the README: url.full is somewhere people look, a token inside scripts is not.
  • blocking_duration — now described as the spec defines it: the sum over the frame's tasks longer than 50 ms, with the rendering time added to the longest of them. The "per-script split" line is gone from the README and from semconv.ts. Your Chrome 151 measurement is what the new text predicts — a 120 ms task with nothing to render gives 70 ms of blocking.
  • Page attribution — rewritten as the general case rather than a replay-only caveat: the callback runs after the frame, so a frame whose handler called pushState is stamped with the new route, as your test showed. start_time is now on the frame record, so the overlap match is expressible from the record itself, and both the record timestamp and start_time stay on the frame's clock.

Two things I'm not hiding:

start_time also joins the attribute set waiting on the registry (#404). martinkuba said not to hold this PR on that — if you'd rather not grow that set before the registry lands, I'll drop the attribute and lean on the record timestamp instead.

Drafted with an agent from the review threads; I read the diff line by line and ran the checks locally, and I can answer for it in review.

@adityareddy-dev adityareddy-dev left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Default sanitizing now leaves the selector invokers alone, and the buffer and INP notes cover what I raised.

One small README fix, start_time is relative to the time origin, while the record timestamp adds performance.timeOrigin. Could you drop 'the same value as the record timestamp' from that row?

`start_time` is the frame's start relative to the time origin, while the
record timestamp adds `performance.timeOrigin` to it, so the row could not
call them the same value. The page attribution note now states that
relationship instead of implying the two are equal.
@SyedTashfin

Copy link
Copy Markdown
Author

Dropped in 0710159. The start_time row now says it is relative to the time origin and that the record timestamp is that value plus performance.timeOrigin, and the page attribution note says the same rather than calling the two equal.

Thanks for the review and the approval.

…-frame-instrumentation

One conflict, in `sandbox/src/otel.ts`: main adds the fetch instrumentation and
this branch adds the long animation frame one on the same import line. Both
kept, both registered.
@SyedTashfin

Copy link
Copy Markdown
Author

Merged current main in (f252b71), so the conflict is gone. The one conflict was in sandbox/src/otel.ts, where main adds the fetch instrumentation and this branch adds the long animation frame one on the same import line — both kept, both registered.

@david-luna — the three things you raised, as they stand now:

  • Semconv attributes are still local to the instrumentation, waiting on the registry in chore: add semantic convention registry #404. martinkuba said not to hold this PR on that. If you would rather the attribute set landed after the registry, say so and I will drop it from this PR and put it back when chore: add semantic convention registry #404 merges.
  • The applyCustomLogRecordData hook is gone (13bee56). A LogRecordProcessor on the LoggerProvider does the same job, as you said.
  • Throttling and the utility function are not in this PR: you called the shared helper out of scope yourself, and mquentin agreed no throttle is needed for now.

Local runs on this head: npm run build, npm run lint:ci (biome, tsc, eslint), 343 instrumentation tests, 111 sdk tests, 29 e2e — all pass.

Could you take another look? If one of the three is still open in your view, tell me which and I will fix that one.

@adityareddy-dev adityareddy-dev left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The start_time row now distinguishes the frame's relative time from the epoch record timestamp, which closes my last README note. I also checked the merge resolution, both the fetch and long animation frame imports and registrations are retained, and the long animation frame implementation is unchanged since my previous review.

@SyedTashfin

SyedTashfin commented Oct 18, 2026 •

Copy link
Copy Markdown
Author

@david-luna — adityareddy-dev approved this at f252b71 and confirmed the merge resolution keeps both instrumentations registered and leaves the long animation frame code untouched since his earlier pass.

Your three points from September are addressed as far as I can tell:

  • the applyCustomLogRecordData hook is gone (13bee56);
  • the semconv attributes are still local to the instrumentation, waiting on the registry (chore: add semantic convention registry #404) — martinkuba said not to hold this PR on it;
  • no throttle, which mquentin agreed with.

Your change request from 23 Sep is still standing, so the PR cannot merge. I tried to re-request your review and the org does not let contributors do that, so this comment is the ask. If something is still open for you, tell me which one and I'll fix that one — if not, could you clear the request?

Separately, the workflow runs on this branch are still waiting for a maintainer to approve them, so the checks on the head are only EasyCLA; the local run on f252b71 is build, lint:ci, 343 instrumentation tests, 111 sdk tests and 29 e2e, all passing.

@opentelemetry-pr-dashboard

opentelemetry-pr-dashboard Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Pull request dashboard status

Waiting on reviewers · refreshed 2026-10-28 16:18 UTC

Review the latest changes.

Also blocked by: Merge conflicts.

Status above doesn't look right?
  • Just replied or pushed? Anything around or after the refresh time above may not be picked up yet — give it a few minutes.
  • Anything look wrong? Report it with what you expected; it helps us improve the dashboard.

…nBase

main replaced the @opentelemetry/instrumentation base with the package's own
browser-native one (open-telemetry#278) and dropped the former from the package's
dependencies, so this instrumentation imported a package it no longer declared.

Move it to the new base. `_init()` asserts PerformanceObserver and the
long-animation-frame entry type, throwing so the instrumentation stays off in
browsers without the API, as the other observer-based instrumentations do.
`enable()` and `disable()` become `_onEnable()` and `_onDisable()`, the
`_isEnabled` flag becomes the base's `isEnabled()`, configuration is read from
`_config`, and the per-entry and emit guards use `_safeExecute`.

The buffered replay is unchanged: `buffered` is asked for only until the first
successful `observe()`, so a disable/enable cycle resumes live observation
without re-emitting frames that were already logged.

The tests construct and enable explicitly, since the browser-native base has no
constructor-time auto-enable, and read the sanitizer from `_config`.
@SyedTashfin

Copy link
Copy Markdown
Author

Merged main again and moved this instrumentation onto the base that merge introduced.

621f7a0 — merge of upstream/main (16 commits), including #278, which added the browser-native InstrumentationBase and dropped @opentelemetry/instrumentation from packages/instrumentation's dependencies.

7c4eb4d — migration to that base, so the package stops importing a dependency it no longer declares:

  • _init() asserts PerformanceObserver and the long-animation-frame entry type, throwing so the instrumentation stays off in browsers without the API, the way resource-timing and web-vitals do it.
  • enable() / disable() become _onEnable() / _onDisable(), the private _isEnabled flag becomes the base's isEnabled(), config is read from _config, and the per-entry and emit guards use _safeExecute.
  • The buffered-replay behaviour is untouched: buffered is requested only until the first successful observe().
  • Tests now construct and enable explicitly, since the base has no constructor-time auto-enable.

Verification on 7c4eb4d (node 26.12.1 / npm 11.20.0, as devEngines requires):

  • npx turbo build --force — uncached, 5/5 tasks; the built long-animation-frame output no longer imports @opentelemetry/instrumentation.
  • npm run lint:ci — biome ci, tsc and eslint clean.
  • npm test — 433 instrumentation tests (21 files, 18 of them long-animation-frame) and 127 sdk tests (15 files).
  • npm run test:e2e — 29/29.

Review items: the three the dashboard lists are answered in their threads. The replay ask from @JacksonWeber was already implemented in 0ccb7f6 with two tests, and it is untouched by the migration; the semconv note and the utility-function suggestion are deferred with reasons, and the latter can become a follow-up issue.

Remaining blocker is not on my side: @david-luna's CHANGES_REQUESTED review from 17 Sep still stands, while @adityareddy-dev approved at f252b71 and again after the earlier merge. The workflow runs on 7c4eb4d will sit at action_required until a maintainer approves them, so the numbers above are the local stand-in for CI.

@adityareddy-dev adityareddy-dev left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked the move onto the new InstrumentationBase against resource-timing on main. _onEnable returns early when an observer exists, _onDisable disconnects it, and the isEnabled() check in the callback covers an entry that lands after disable. Buffered replay on the first enable only matches how resource-timing does it, so a re-enable doesn't emit old frames twice.

This branch has not been deployed

No deployments
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.

Create LoAF Instrumentation

8 participants