Repository: elastic/synthetics (ts)

Issue:
core vitals: update CLS to new definition
+ Update CLS to the new variant - https://web.dev/evolving-cls/ which uses the windowed approach of `max-session-gap1s-limit5s `. 

+ As the UI does not utilize the previous CLS values, we will go ahead with not suporting the previous value which only calculated the CLS for main frames and instead report only the windowed CLS value for all frames under `cumulativeLayoutshift` name. 

Pull request description:
feat: update CLS to new windowed approach
+ fix #301 
+ This PR updates the CLS to new definition which captures CLS based on max session window of 5 seconds with 1 sec gaps. More details - https://web.dev/evolving-cls/
+ Keeps in sync with Lighthouse, changes includes only the output format and relative traces with updated score for each metric to include in the waterfall. 
+ We capture only the `cumulative layout shift for all frames` and do not capture the old metric which was the total cumulative layout shift which we dont surface in the UI before. 


Patch:
diff --git a/src/common_types.ts b/src/common_types.ts
index 5811c59..0440d46 100644
--- a/src/common_types.ts
+++ b/src/common_types.ts
@@ -42,6 +42,7 @@ export type TraceOutput = {
   type: string;
   start: MetricDuration;
   end?: MetricDuration;
+  score?: number;
 };
 
 type MetricDuration = {
@@ -107,10 +108,9 @@ export type BrowserMessage = {
 
 export type PluginOutput = {
   filmstrips?: Array<Filmstrip>;
-  userTiming?: Array<TraceOutput>;
-  experience?: Array<TraceOutput>;
   networkinfo?: Array<NetworkInfo>;
   browserconsole?: Array<BrowserMessage>;
+  traces?: Array<TraceOutput>;
   metrics?: PerfMetrics;
 };
 
diff --git a/src/reporters/json.ts b/src/reporters/json.ts
index bd88b81..7db8624 100644
--- a/src/reporters/json.ts
+++ b/src/reporters/json.ts
@@ -411,8 +411,7 @@ export default class JSONReporter extends BaseReporter {
         filmstrips,
         networkinfo,
         browserconsole,
-        userTiming,
-        experience,
+        traces,
         metrics,
         status,
         error,
@@ -492,8 +491,7 @@ export default class JSONReporter extends BaseReporter {
             });
           });
         }
-        this.writeMetrics(journey, 'relative_trace', userTiming);
-        this.writeMetrics(journey, 'relative_trace', experience);
+        this.writeMetrics(journey, 'relative_trace', traces);
         this.writeMetrics(journey, 'experience', metrics);
 
         this.writeJSON({
diff --git a/src/sdk/trace-metrics.ts b/src/sdk/trace-metrics.ts
index ea730be..373d128 100644
--- a/src/sdk/trace-metrics.ts
+++ b/src/sdk/trace-metrics.ts
@@ -153,36 +153,96 @@ export class ExperienceMetrics {
 }
 
 export class CumulativeLayoutShift {
-  static computeCLSValue(events: Array<TraceEvent>) {
-    const layoutShiftEvents = events.filter(
-      event => event.name === 'LayoutShift' && event.args?.data.is_main_frame
-    );
+  static getLayoutShiftEvents(traceEvents: Array<TraceEvent>) {
+    const layoutShiftEvents = [];
     // Chromium will set `had_recent_input` if there was recent user input, which
     // skips shift events from contributing to CLS. This results in the first few shift
     // events having `had_recent_input` set to true, so ignore it for those events.
-    // See https://bugs.chromium.org/p/chromium/issues/detail?id=1094974.
+    // See https://crbug.com/1094974
     let ignoreHadRecentInput = true;
-    let finalLayoutShiftEvent;
-    let clsScore = 0;
-    for (const event of layoutShiftEvents) {
+
+    for (const event of traceEvents) {
+      // weighted_score_delta was added in chrome 90 - https://crbug.com/1173139
+      if (
+        event.name !== 'LayoutShift' ||
+        !event.args?.data.is_main_frame ||
+        !event.args?.data.weighted_score_delta
+      ) {
+        continue;
+      }
+
       if (event.args.data.had_recent_input) {
         if (!ignoreHadRecentInput) continue;
       } else {
         ignoreHadRecentInput = false;
       }
-      clsScore += event.args.data.score;
-      finalLayoutShiftEvent = event;
+
+      layoutShiftEvents.push({
+        ts: event.ts,
+        weightedScore: event.args.data.weighted_score_delta,
+      });
     }
 
-    return { score: clsScore, event: finalLayoutShiftEvent };
+    return layoutShiftEvents;
+  }
+
+  /**
+   * Calculate cumulative layout shift per sesion window where each session
+   * windows lasts for 5 seconds since the last layoutShift event and has 1 second
+   * gap between them. Return the maximum score between all windows.
+   * More details - https://web.dev/evolving-cls/
+   */
+  static calculateScore(layoutShiftEvents) {
+    const gapMicroseconds = 1_000_000; // 1 seconds gap
+    const limitMicroseconds = 5_000_000; // 5 seconds window
+    let maxScore = 0;
+    let currentScore = 0;
+    let firstTimestamp = Number.NEGATIVE_INFINITY;
+    let prevTimestamp = Number.NEGATIVE_INFINITY;
+
+    for (const event of layoutShiftEvents) {
+      const currTimestamp = event.ts;
+      /**
+       * Two cases
+       * - When the next layout shift event happened after the session window of
+       * 5 seconds.
+       * - When the next and previous layout shift event has a gap of 1 second
+       *
+       * Then consider the layout shift event as the start of
+       * the current session window and reset the cumulative score.
+       */
+      if (
+        currTimestamp - firstTimestamp > limitMicroseconds ||
+        currTimestamp - prevTimestamp > gapMicroseconds
+      ) {
+        firstTimestamp = currTimestamp;
+        currentScore = 0;
+      }
+      prevTimestamp = currTimestamp;
+      currentScore += event.weightedScore;
+      maxScore = Math.max(maxScore, currentScore);
+    }
+
+    return maxScore;
   }
 
   static compute(trace: LHTrace) {
-    const events = trace.mainThreadEvents;
-    const { score } = this.computeCLSValue(events);
+    const layoutShiftEvents = this.getLayoutShiftEvents(trace.frameTreeEvents);
+    const traces: Array<TraceOutput> = layoutShiftEvents.map(event => {
+      return {
+        name: 'layoutShift',
+        type: 'mark',
+        start: {
+          us: event.ts,
+        },
+        score: event.weightedScore,
+      };
+    });
+    const cumulativeLayoutShiftScore = this.calculateScore(layoutShiftEvents);
 
     return {
-      cls: score,
+      cls: cumulativeLayoutShiftScore,
+      traces,
     };
   }
 }
diff --git a/src/sdk/trace-processor.ts b/src/sdk/trace-processor.ts
index 0d00ceb..2903272 100644
--- a/src/sdk/trace-processor.ts
+++ b/src/sdk/trace-processor.ts
@@ -55,6 +55,7 @@ export type TraceEvent = {
       is_main_frame: boolean;
       cumulative_score: number;
       score: number;
+      weighted_score_delta: number;
     } & Record<string, unknown>;
   };
   /**
@@ -89,6 +90,7 @@ export type LHTrace = {
   lcpInvalidated: boolean;
   processEvents: Array<TraceEvent>;
   mainThreadEvents: Array<TraceEvent>;
+  frameTreeEvents: Array<TraceEvent>;
   timestamps: Partial<LHTraceTime>;
   timings: Partial<LHTraceTime>;
 };
@@ -118,13 +120,12 @@ export class TraceProcessor extends LighthouseTraceProcessor {
     };
     const trace = super.computeTraceOfTab({ traceEvents }, options);
     const userTiming = UserTimings.compute(trace);
-    const { traces, metrics } = ExperienceMetrics.compute(trace);
-    const layoutShift = CumulativeLayoutShift.compute(trace);
-    const perfMetrics: Partial<PerfMetrics> = { ...layoutShift, ...metrics };
+    const { traces: expTraces, metrics } = ExperienceMetrics.compute(trace);
+    const { cls, traces: layoutTraces } = CumulativeLayoutShift.compute(trace);
+    const perfMetrics: Partial<PerfMetrics> = { cls, ...metrics };
 
     return {
-      userTiming,
-      experience: traces,
+      traces: userTiming.concat(expTraces, layoutTraces),
       metrics: perfMetrics,
     };
   }


Test patch:
diff --git a/__tests__/plugins/tracing.test.ts b/__tests__/plugins/tracing.test.ts
index 13d63a1..097e29f 100644
--- a/__tests__/plugins/tracing.test.ts
+++ b/__tests__/plugins/tracing.test.ts
@@ -43,7 +43,7 @@ describe('tracing', () => {
     await tracer.start(driver.client);
     await driver.page.goto(server.TEST_PAGE);
     await driver.page.waitForLoadState();
-    const { filmstrips, experience } = await tracer.stop(driver.client);
+    const { filmstrips, traces } = await tracer.stop(driver.client);
     await Gatherer.stop();
     expect(filmstrips.length).toBeGreaterThan(0);
     expect(filmstrips[0]).toMatchObject({
@@ -51,8 +51,8 @@ describe('tracing', () => {
       mime: 'image/jpeg',
       start: { us: expect.any(Number) },
     });
-    expect(experience.length).toBeGreaterThan(0);
-    expect(experience[0]).toMatchObject({
+    expect(traces.length).toBeGreaterThan(0);
+    expect(traces[0]).toMatchObject({
       name: 'navigationStart',
       type: 'mark',
       start: { us: expect.any(Number) },
diff --git a/__tests__/reporters/__snapshots__/json.test.ts.snap b/__tests__/reporters/__snapshots__/json.test.ts.snap
index 6740c5d..fea6f0a 100644
--- a/__tests__/reporters/__snapshots__/json.test.ts.snap
+++ b/__tests__/reporters/__snapshots__/json.test.ts.snap
@@ -354,6 +354,7 @@ exports[`json reporter writes each step as NDJSON to the FD 1`] = `
 {\\"type\\":\\"journey/filmstrips\\",\\"@timestamp\\":1600300800000000,\\"journey\\":{\\"name\\":\\"j1\\",\\"id\\":\\"j1\\"},\\"root_fields\\":{\\"browser\\":{\\"relative_trace\\":{\\"start\\":{\\"us\\":392583998697}}},\\"os\\":{\\"platform\\":\\"darwin\\"},\\"package\\":{\\"name\\":\\"@elastic/synthetics\\",\\"version\\":\\"0.0.1\\"}},\\"payload\\":{\\"index\\":0},\\"blob\\":\\"dummy\\",\\"blob_mime\\":\\"image/jpeg\\",\\"package_version\\":\\"0.0.1\\"}
 {\\"type\\":\\"journey/metrics\\",\\"@timestamp\\":1600300800000000,\\"journey\\":{\\"name\\":\\"j1\\",\\"id\\":\\"j1\\"},\\"root_fields\\":{\\"browser\\":{\\"relative_trace\\":{\\"name\\":\\"navigationStart\\",\\"type\\":\\"mark\\",\\"start\\":{\\"us\\":3065705158085}}},\\"os\\":{\\"platform\\":\\"darwin\\"},\\"package\\":{\\"name\\":\\"@elastic/synthetics\\",\\"version\\":\\"0.0.1\\"}},\\"package_version\\":\\"0.0.1\\"}
 {\\"type\\":\\"journey/metrics\\",\\"@timestamp\\":1600300800000000,\\"journey\\":{\\"name\\":\\"j1\\",\\"id\\":\\"j1\\"},\\"root_fields\\":{\\"browser\\":{\\"relative_trace\\":{\\"name\\":\\"firstContentfulPaint\\",\\"type\\":\\"mark\\",\\"start\\":{\\"us\\":3065705560142}}},\\"os\\":{\\"platform\\":\\"darwin\\"},\\"package\\":{\\"name\\":\\"@elastic/synthetics\\",\\"version\\":\\"0.0.1\\"}},\\"package_version\\":\\"0.0.1\\"}
+{\\"type\\":\\"journey/metrics\\",\\"@timestamp\\":1600300800000000,\\"journey\\":{\\"name\\":\\"j1\\",\\"id\\":\\"j1\\"},\\"root_fields\\":{\\"browser\\":{\\"relative_trace\\":{\\"name\\":\\"layoutShift\\",\\"type\\":\\"mark\\",\\"start\\":{\\"us\\":463045197179},\\"score\\":0.19932291666666668}},\\"os\\":{\\"platform\\":\\"darwin\\"},\\"package\\":{\\"name\\":\\"@elastic/synthetics\\",\\"version\\":\\"0.0.1\\"}},\\"package_version\\":\\"0.0.1\\"}
 {\\"type\\":\\"journey/metrics\\",\\"@timestamp\\":1600300800000000,\\"journey\\":{\\"name\\":\\"j1\\",\\"id\\":\\"j1\\"},\\"root_fields\\":{\\"browser\\":{\\"experience\\":{\\"lcp\\":{\\"us\\":200},\\"fcp\\":{\\"us\\":100},\\"dcl\\":{\\"us\\":300},\\"load\\":{\\"us\\":400},\\"cls\\":0.123}},\\"os\\":{\\"platform\\":\\"darwin\\"},\\"package\\":{\\"name\\":\\"@elastic/synthetics\\",\\"version\\":\\"0.0.1\\"}},\\"package_version\\":\\"0.0.1\\"}
 {\\"type\\":\\"journey/end\\",\\"@timestamp\\":1600300800000000,\\"journey\\":{\\"name\\":\\"j1\\",\\"id\\":\\"j1\\",\\"status\\":\\"succeeded\\"},\\"root_fields\\":{\\"os\\":{\\"platform\\":\\"darwin\\"},\\"package\\":{\\"name\\":\\"@elastic/synthetics\\",\\"version\\":\\"0.0.1\\"}},\\"payload\\":{\\"start\\":0,\\"end\\":11,\\"status\\":\\"succeeded\\"},\\"package_version\\":\\"0.0.1\\"}
 "
diff --git a/__tests__/reporters/json.test.ts b/__tests__/reporters/json.test.ts
index 85a1d5f..b3216aa 100644
--- a/__tests__/reporters/json.test.ts
+++ b/__tests__/reporters/json.test.ts
@@ -140,7 +140,7 @@ describe('json reporter', () => {
           browser: {},
         } as any,
       ],
-      experience: [
+      traces: [
         {
           name: 'navigationStart',
           type: 'mark',
@@ -155,6 +155,14 @@ describe('json reporter', () => {
             us: 3065705560142,
           },
         },
+        {
+          name: 'layoutShift',
+          type: 'mark',
+          start: {
+            us: 463045197179,
+          },
+          score: 0.19932291666666668,
+        },
       ],
       metrics: {
         lcp: { us: 200 },
diff --git a/__tests__/sdk/trace-metrics.test.ts b/__tests__/sdk/trace-metrics.test.ts
index 91b6915..2d3e601 100644
--- a/__tests__/sdk/trace-metrics.test.ts
+++ b/__tests__/sdk/trace-metrics.test.ts
@@ -108,42 +108,104 @@ describe('Trace metrics', () => {
           data: {
             is_main_frame: true,
             had_recent_input: event.had_recent_input,
-            score: event.score,
+            weighted_score_delta: event.score,
           },
         },
       };
     });
-    return traceEvents.concat(shiftEvents);
+    return shiftEvents;
   }
 
   it('computes layout shift', () => {
-    let mainThreadEvents = makeTrace([
+    let frameTreeEvents = makeTrace([
       { score: 1, had_recent_input: true },
       { score: 1, had_recent_input: false },
       { score: 1, had_recent_input: false },
       { score: 1, had_recent_input: true },
       { score: 1, had_recent_input: true },
     ]);
-    expect(CumulativeLayoutShift.compute({ mainThreadEvents } as any)).toEqual({
+    const layoutEvent = {
+      name: 'layoutShift',
+      type: 'mark',
+      start: { us: 15 },
+      score: 1,
+    };
+    expect(CumulativeLayoutShift.compute({ frameTreeEvents } as any)).toEqual({
       cls: 3,
+      traces: [layoutEvent, layoutEvent, layoutEvent],
     });
 
-    mainThreadEvents = makeTrace([
+    frameTreeEvents = makeTrace([
       { score: 1, had_recent_input: true },
       { score: 1, had_recent_input: true },
       { score: 1, had_recent_input: false },
       { score: 1, had_recent_input: false },
     ]);
-    expect(CumulativeLayoutShift.compute({ mainThreadEvents } as any)).toEqual({
+    expect(
+      CumulativeLayoutShift.compute({ frameTreeEvents } as any)
+    ).toMatchObject({
       cls: 4,
     });
   });
 
-  it('returns zero when no layout shift is present ', () => {
+  it('computes cls with session window', () => {
+    expect(
+      CumulativeLayoutShift.compute({ frameTreeEvents: traceEvents } as any)
+    ).toEqual({
+      cls: 0.21037326388888888,
+      traces: [
+        {
+          name: 'layoutShift',
+          score: 0.19932291666666668,
+          start: {
+            us: 463045197179,
+          },
+          type: 'mark',
+        },
+        {
+          name: 'layoutShift',
+          score: 0.21037326388888888,
+          start: {
+            us: 463047103153,
+          },
+          type: 'mark',
+        },
+      ],
+    });
+  });
+
+  it('calculate cls score with simulated sessions', () => {
+    // events with single session
+    let events = [
+      { ts: 0, weightedScore: 1 },
+      { ts: 1_000_000, weightedScore: 1 },
+      { ts: 2_000_000, weightedScore: 1 },
+    ];
+    expect(CumulativeLayoutShift.calculateScore(events)).toBe(3);
+    // 4 more events within the 1 second intervals
+    events = events.concat([
+      { ts: 6_000_000, weightedScore: 1 },
+      { ts: 7_000_000, weightedScore: 1 },
+      { ts: 8_000_000, weightedScore: 1 },
+      { ts: 9_000_000, weightedScore: 1 },
+    ]);
+    expect(CumulativeLayoutShift.calculateScore(events)).toBe(4);
+    // use previous max with new events having more gaps
+    events = events.concat([
+      { ts: 11_000_000, weightedScore: 1 },
+      { ts: 13_000_000, weightedScore: 1 },
+      { ts: 15_000_000, weightedScore: 1 },
+      { ts: 17_000_000, weightedScore: 1 },
+    ]);
+    expect(CumulativeLayoutShift.calculateScore(events)).toBe(4);
+  });
+
+  it('cls to 0 when no events found', () => {
     expect(
-      CumulativeLayoutShift.compute({ mainThreadEvents: traceEvents } as any)
+      CumulativeLayoutShift.compute({ frameTreeEvents: [] } as any)
     ).toEqual({
       cls: 0,
+      traces: [],
     });
   });
 
diff --git a/__tests__/sdk/trace-processor.test.ts b/__tests__/sdk/trace-processor.test.ts
index fb1f1b6..9d589a0 100644
--- a/__tests__/sdk/trace-processor.test.ts
+++ b/__tests__/sdk/trace-processor.test.ts
@@ -31,8 +31,7 @@ describe('Trace processor', () => {
     const { traceEvents } = createTestTrace();
     const output = TraceProcessor.computeTrace(traceEvents as any);
     expect(output).toEqual({
-      userTiming: [],
-      experience: [],
+      traces: [],
       metrics: { cls: 0 },
     });
   });
diff --git a/__tests__/utils/create-test-trace.ts b/__tests__/utils/create-test-trace.ts
index ab6dba5..3c0f65d 100644
--- a/__tests__/utils/create-test-trace.ts
+++ b/__tests__/utils/create-test-trace.ts
@@ -67,6 +67,53 @@ export function createTestTrace() {
       cat: '__metadata',
       args: { name: 'CrRendererMain' },
     },
+    {
+      args: {
+        data: {
+          cumulative_score: 0.23466634114583335,
+          had_recent_input: false,
+          is_main_frame: true,
+          score: 0.19932291666666668,
+          weighted_score_delta: 0.19932291666666668,
+        },
+      },
+      cat: 'loading',
+      name: 'LayoutShift',
+      ph: 'I',
+      tid: 775,
+      ts: 463045197179,
+    },
+    {
+      args: {
+        data: {
+          cumulative_score: 1.1788216688368056,
+          had_recent_input: false,
+          is_main_frame: true,
+          score: 0.21037326388888888,
+          weighted_score_delta: 0.21037326388888888,
+        },
+      },
+      cat: 'loading',
+      name: 'LayoutShift',
+      ph: 'I',
+      tid: 775,
+      ts: 463047103153,
+    },
+    {
+      args: {
+        data: {
+          cumulative_score: 4.809793674045139,
+          had_recent_input: false,
+          score: 0.1991644965277778,
+          weighted_score_delta: 0.1991644965277778,
+        },
+      },
+      cat: 'loading',
+      name: 'LayoutShift',
+      ph: 'I',
+      tid: 775,
+      ts: 463052381097,
+    },
   ];
 
   return { traceEvents };