Repository: elastic/synthetics (ts)

Issue:
Improve mapping for additional metrics
For newly added metrics in https://github.com/elastic/synthetics/pull/194

If you want to build visualisation for example on LCP.

First you will have to do filter on `browser.experience.name` and then use value from `browser.experience.name` , so they metric type is stored as value instead of field. 

I think we should add each metric as new field. Since this will make querying es data easier.

Pull request description:
fix: perf relative traces and values
+ fix #303 
+ Relative traces and metrics are grouped under two keys now to provide clear distinction. 
+ Grouped all traces under `browser.relative_trace` to keep the mapping similar and these traces are mark or measure events with `start` and `end` time denoting the time when particular event (LCP, FCP, etc) occurred during the waterfall. 
+ All distinct values like FCP, LCP, CLS, etc are grouped under `browser.experience.<metric-name>` to keep the querying easier in the UI and useful for displaying values in step detail page. 

### Relative trace events
```json
{
  "type": "journey/metrics",
  "@timestamp": 1623300251603855.2,
  "journey": {
    "name": "check if title is present",
    "id": "check if title is present"
  },
  "root_fields": {
    "browser": {
      "relative_trace": {
        "name": "firstContentfulPaint",
        "type": "mark",
        "start": {
          "us": 142089104934
        }
      }
    },
  }
}

{
  "type": "journey/metrics",
  "journey": {
    "name": "inline",
    "id": "inline"
  },
  "root_fields": {
    "browser": {
      "relative_trace": {
        "name": "widget_core:widget_loaded",
        "type": "measure",
        "start": {
          "us": 142095075042
        },
        "end": {
          "us": 142095312098
        }
      }
    },
  }
}

```

### Experience metrics
```json
{
  "type": "journey/metrics",
  "@timestamp": 1623300251603949.5,
  "journey": {
    "name": "check if title is present",
    "id": "check if title is present"
  },
  "root_fields": {
    "browser": {
      "experience": {
        "cls": 0,
        "fcp": {
          "us": 189382
        },
        "lcp": {
          "us": 189382
        },
        "dcl": {
          "us": 209052
        },
        "load": {
          "us": 209115
        }
      }
    },
}
```

Patch:
diff --git a/src/common_types.ts b/src/common_types.ts
index 77bc4c2..5811c59 100644
--- a/src/common_types.ts
+++ b/src/common_types.ts
@@ -38,27 +38,28 @@ export type StatusValue = 'succeeded' | 'failed' | 'skipped';
 export type Reporters = keyof typeof reporters;
 
 export type TraceOutput = {
-  name?: string;
-  start: number;
-  end?: number;
-};
-
-export type UserTiming = TraceOutput & {
   name: string;
   type: string;
-  duration?: number;
+  start: MetricDuration;
+  end?: MetricDuration;
 };
 
-export type Filmstrip = TraceOutput & {
-  blob: string;
-  mime: string;
+type MetricDuration = {
+  us: number;
 };
 
-export type LayoutShift = {
-  exists: boolean;
-  score: number;
-  name: string;
-  start?: number;
+export type PerfMetrics = {
+  fcp: MetricDuration;
+  lcp: MetricDuration;
+  dcl: MetricDuration;
+  load: MetricDuration;
+  cls: number;
+};
+
+export type Filmstrip = {
+  start: MetricDuration;
+  blob: string;
+  mime: string;
 };
 
 export type DefaultPluginOutput = {
@@ -106,11 +107,11 @@ export type BrowserMessage = {
 
 export type PluginOutput = {
   filmstrips?: Array<Filmstrip>;
-  userTiming?: Array<UserTiming>;
-  experience?: Array<UserTiming>;
+  userTiming?: Array<TraceOutput>;
+  experience?: Array<TraceOutput>;
   networkinfo?: Array<NetworkInfo>;
   browserconsole?: Array<BrowserMessage>;
-  layoutShift?: LayoutShift;
+  metrics?: PerfMetrics;
 };
 
 export type CliArgs = {
diff --git a/src/core/runner.ts b/src/core/runner.ts
index 16996ad..a16e7fc 100644
--- a/src/core/runner.ts
+++ b/src/core/runner.ts
@@ -31,7 +31,7 @@ import { Step } from '../dsl/step';
 import { reporters, Reporter } from '../reporters';
 import {
   CACHE_PATH,
-  getMonotonicTime,
+  monotonicTimeInSeconds,
   getTimestamp,
   now,
   runParallel,
@@ -133,7 +133,7 @@ export default class Runner extends EventEmitter {
   screenshotPath = join(CACHE_PATH, 'screenshots');
 
   static async createContext(options: RunOptions): Promise<JourneyContext> {
-    const start = getMonotonicTime();
+    const start = monotonicTimeInSeconds();
     const driver = await Gatherer.setupDriver(options);
     const pluginManager = await Gatherer.beginRecording(driver, options);
     return {
@@ -240,7 +240,7 @@ export default class Runner extends EventEmitter {
     const results: Array<StepResult> = [];
     let skipStep = false;
     for (const step of journey.steps) {
-      const start = getMonotonicTime();
+      const start = monotonicTimeInSeconds();
       this.emit('step:start', { journey, step });
       let data: StepResult = { status: 'succeeded' };
       if (skipStep) {
@@ -256,7 +256,7 @@ export default class Runner extends EventEmitter {
         journey,
         step,
         start,
-        end: getMonotonicTime(),
+        end: monotonicTimeInSeconds(),
         ...data,
       });
       if (options.pauseOnError && data.error) {
@@ -291,7 +291,7 @@ export default class Runner extends EventEmitter {
       error,
       params,
       start,
-      end: getMonotonicTime(),
+      end: monotonicTimeInSeconds(),
       ssblocks: options.ssblocks,
       ...pluginOutput,
       browserconsole: status == 'failed' ? pluginOutput.browserconsole : [],
diff --git a/src/helpers.ts b/src/helpers.ts
index ff0944d..35deba6 100644
--- a/src/helpers.ts
+++ b/src/helpers.ts
@@ -53,25 +53,26 @@ export function generateTempPath() {
 }
 
 /**
- * We internally use the clock timing similar to the
- * chrome devtools protocol network events for
- * journey and step start/end fields to make
- * querying in the UI easier
+ * Get Monotonically increasing time in seconds since
+ * an arbitrary point in the past.
+ *
+ * We internally use the monotonically increasing clock timing
+ * similar to the chrome devtools protocol network events for
+ * journey,step start/end fields to make querying in the UI easier
  */
-export function getMonotonicTime() {
+export function monotonicTimeInSeconds() {
   const hrTime = process.hrtime(); // [seconds, nanoseconds]
   return hrTime[0] * 1 + hrTime[1] / 1e9;
 }
 
 /**
- * Converts the trace events timestamp field from the
- * format -  hrTime[0] * 1e6 + Math.round(hrTime[1] / 1000) to
- * the internal timestamp similar to other event types (journey, step, etc)
+ * Converts the trace events timestamp field from microsecond
+ * resolution to monotonic seconds timestamp similar to other event types (journey, step, etc)
  * Reference - https://github.com/samccone/chrome-trace-event/blob/d45bc8af3b5c53a3adfa2c5fc107b4fae054f579/lib/trace-event.ts#L21-L22
  *
  * Tested and verified on both Darwin and Linux
  */
-export function convertTraceTimestamp(ts: number) {
+export function microSecsToSeconds(ts: number) {
   return ts / 1e6;
 }
 
@@ -237,3 +238,7 @@ const cwd = process.cwd();
  * once we move to executing journeys in parallel
  */
 export const CACHE_PATH = join(cwd, '.synthetics', process.pid.toString());
+
+export function getDurationInUs(duration: number) {
+  return Math.trunc(duration * 1e6);
+}
diff --git a/src/reporters/json.ts b/src/reporters/json.ts
index 0aeca4a..bd88b81 100644
--- a/src/reporters/json.ts
+++ b/src/reporters/json.ts
@@ -34,15 +34,15 @@ import {
   CACHE_PATH,
   totalist,
   isDirectory,
+  getDurationInUs,
 } from '../helpers';
 import { Journey, Step } from '../dsl';
 import snakeCaseKeys from 'snakecase-keys';
 import {
-  Filmstrip,
-  LayoutShift,
   NetworkInfo,
-  UserTiming,
+  TraceOutput,
   StatusValue,
+  PerfMetrics,
 } from '../common_types';
 import { Protocol } from 'playwright-chromium/types/protocol';
 import { Metrics } from '../plugins';
@@ -387,7 +387,7 @@ export default class JSONReporter extends BaseReporter {
           step: {
             ...step,
             duration: {
-              us: Math.trunc((end - start) * 1e6),
+              us: getDurationInUs(end - start),
             },
           },
           url,
@@ -413,7 +413,7 @@ export default class JSONReporter extends BaseReporter {
         browserconsole,
         userTiming,
         experience,
-        layoutShift,
+        metrics,
         status,
         error,
         ssblocks,
@@ -472,9 +472,9 @@ export default class JSONReporter extends BaseReporter {
             this.writeJSON({
               type: 'journey/filmstrips',
               journey,
-              payload: {
-                index,
-                start: strip.start,
+              payload: { index },
+              root_fields: {
+                browser: { relative_trace: { start: strip.start } },
               },
               blob: strip.blob,
               blob_mime: strip.mime,
@@ -492,9 +492,9 @@ export default class JSONReporter extends BaseReporter {
             });
           });
         }
-        this.writeMetrics(journey, 'user_timing', userTiming);
-        this.writeMetrics(journey, 'layout_shift', layoutShift);
-        this.writeMetrics(journey, 'experience', experience);
+        this.writeMetrics(journey, 'relative_trace', userTiming);
+        this.writeMetrics(journey, 'relative_trace', experience);
+        this.writeMetrics(journey, 'experience', metrics);
 
         this.writeJSON({
           type: 'journey/end',
@@ -514,7 +514,7 @@ export default class JSONReporter extends BaseReporter {
   writeMetrics(
     journey: Journey,
     type: string,
-    events: Array<UserTiming> | Array<Filmstrip> | LayoutShift
+    events: Array<TraceOutput> | PerfMetrics
   ) {
     const metrics = Array.isArray(events) ? events : [events];
     metrics.forEach(event => {
diff --git a/src/sdk/trace-metrics.ts b/src/sdk/trace-metrics.ts
index b2fa2bd..ea730be 100644
--- a/src/sdk/trace-metrics.ts
+++ b/src/sdk/trace-metrics.ts
@@ -23,15 +23,14 @@
  *
  */
 
-import { Filmstrip, LayoutShift, UserTiming } from '../common_types';
-import { convertTraceTimestamp } from '../helpers';
+import { Filmstrip, TraceOutput } from '../common_types';
 import type { LHTrace, TraceEvent } from './trace-processor';
 
 export class UserTimings {
   static compute(trace: LHTrace) {
     const { processEvents } = trace;
     const measuresMap = new Map();
-    const userTimings: Array<UserTiming> = [];
+    const userTimings: Array<TraceOutput> = [];
 
     for (const event of processEvents) {
       const { name, ph, ts, args } = event;
@@ -59,7 +58,9 @@ export class UserTimings {
         userTimings.push({
           name,
           type: 'mark',
-          start: convertTraceTimestamp(ts),
+          start: {
+            us: ts,
+          },
         });
       } else if (phase === 'b') {
         measuresMap.set(name, ts);
@@ -68,8 +69,12 @@ export class UserTimings {
         userTimings.push({
           name,
           type: 'measure',
-          start: convertTraceTimestamp(startTime),
-          end: convertTraceTimestamp(ts),
+          start: {
+            us: startTime,
+          },
+          end: {
+            us: ts,
+          },
         });
       }
     }
@@ -78,31 +83,27 @@ export class UserTimings {
 }
 
 export class ExperienceMetrics {
-  static buildMetric(event: TraceEvent, name?: string) {
-    if (!event) {
+  static buildMetric(name: string, timestamp?: number) {
+    if (!timestamp) {
       return;
     }
-
     return {
-      name: name || event.name,
+      name,
       type: 'mark',
-      start: convertTraceTimestamp(event.ts),
+      start: {
+        us: timestamp,
+      },
     };
   }
 
   static compute(trace: LHTrace) {
-    const experienceMetrics: Array<UserTiming> = [];
-    const {
-      domContentLoadedEvt,
-      firstContentfulPaintEvt,
-      largestContentfulPaintEvt,
-      loadEvt,
-      timeOriginEvt,
-      lcpInvalidated,
-    } = trace;
-
-    experienceMetrics.push(this.buildMetric(timeOriginEvt));
-    experienceMetrics.push(this.buildMetric(firstContentfulPaintEvt));
+    const traces: Array<TraceOutput> = [];
+    const { timestamps, timings, lcpInvalidated } = trace;
+
+    traces.push(this.buildMetric('navigationStart', timestamps.timeOrigin));
+    traces.push(
+      this.buildMetric('firstContentfulPaint', timestamps.firstContentfulPaint)
+    );
     /**
      * lcpInvalidated - Denotes when all of the LCP events that comes from the
      * current trace are invalidated. Happens if the previous LCP candidates were
@@ -110,22 +111,51 @@ export class ExperienceMetrics {
      * More info - https://github.com/WICG/largest-contentful-paint/#the-last-candidate
      */
     !lcpInvalidated &&
-      experienceMetrics.push(
-        this.buildMetric(largestContentfulPaintEvt, 'largestContentfulPaint')
+      traces.push(
+        this.buildMetric(
+          'largestContentfulPaint',
+          timestamps.largestContentfulPaint
+        )
       );
-    experienceMetrics.push(this.buildMetric(domContentLoadedEvt));
-    experienceMetrics.push(this.buildMetric(loadEvt));
+    traces.push(
+      this.buildMetric('domContentLoaded', timestamps.domContentLoaded)
+    );
+    traces.push(this.buildMetric('loadEvent', timestamps.load));
+
+    /**
+     * Lighthouse tracer extracts these metrics in milliseconds resolution,
+     * we convert them to microseconds resolution to keep them in sync with
+     * other timings
+     */
+    const millisToMicros = (value?: number) =>
+      value ? value * 1000 : undefined;
+    const keys = [
+      'firstContentfulPaint',
+      'largestContentfulPaint',
+      'domContentLoaded',
+      'load',
+    ];
+    const values = ['fcp', 'lcp', 'dcl', 'load'];
+    const metrics = {};
+
+    for (let i = 0; i < keys.length; i++) {
+      const microSecs = millisToMicros(timings[keys[i]]);
+      if (microSecs) {
+        metrics[values[i]] = { us: microSecs };
+      }
+    }
 
-    return experienceMetrics.filter(b => Boolean(b));
+    return {
+      metrics,
+      traces: traces.filter(b => Boolean(b)),
+    };
   }
 }
 
 export class CumulativeLayoutShift {
-  static type = 'LayoutShift';
-
   static computeCLSValue(events: Array<TraceEvent>) {
     const layoutShiftEvents = events.filter(
-      event => event.name === this.type && event.args?.data.is_main_frame
+      event => event.name === 'LayoutShift' && event.args?.data.is_main_frame
     );
     // 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
@@ -147,19 +177,12 @@ export class CumulativeLayoutShift {
     return { score: clsScore, event: finalLayoutShiftEvent };
   }
 
-  static compute(trace: LHTrace): LayoutShift {
+  static compute(trace: LHTrace) {
     const events = trace.mainThreadEvents;
-    const { score, event } = this.computeCLSValue(events);
+    const { score } = this.computeCLSValue(events);
 
-    const metric = { name: this.type, score, exists: false };
-    if (!event) {
-      return metric;
-    }
     return {
-      name: this.type,
-      score: score,
-      exists: true,
-      start: convertTraceTimestamp(event.ts),
+      cls: score,
     };
   }
 }
@@ -192,7 +215,9 @@ export class Filmstrips {
     return Filmstrips.filterExcesssiveScreenshots(traceEvents).map(event => ({
       blob: event.args.snapshot,
       mime: 'image/jpeg',
-      start: convertTraceTimestamp(event.ts),
+      start: {
+        us: event.ts,
+      },
     }));
   }
 }
diff --git a/src/sdk/trace-processor.ts b/src/sdk/trace-processor.ts
index 22f6946..0d00ceb 100644
--- a/src/sdk/trace-processor.ts
+++ b/src/sdk/trace-processor.ts
@@ -23,6 +23,7 @@
  *
  */
 
+import { PerfMetrics } from '../common_types';
 import LighthouseTraceProcessor from './lh-trace-processor';
 import {
   ExperienceMetrics,
@@ -59,6 +60,8 @@ export type TraceEvent = {
   /**
    * Platform specific monotonic non decreasing clock time
    * https://source.chromium.org/chromium/chromium/src/+/master:base/time/time.h;l=936;bpv=0;bpt=0
+   *
+   * The tracing clock timestamp of the event. The timestamps are provided at microsecond granularity.
    */
   ts: number;
 };
@@ -66,6 +69,17 @@ export type TraceEvent = {
 /**
  * Exported data from Lighthouse trace processor
  */
+type LHTraceTime = {
+  timeOrigin: number;
+  firstPaint: number;
+  firstContentfulPaint: number;
+  largestContentfulPaint: number;
+  largestContentfulPaintAllFrames: number;
+  domContentLoaded: number;
+  load: number;
+  traceEnd: number;
+};
+
 export type LHTrace = {
   domContentLoadedEvt: TraceEvent;
   firstContentfulPaintEvt: TraceEvent;
@@ -75,6 +89,8 @@ export type LHTrace = {
   lcpInvalidated: boolean;
   processEvents: Array<TraceEvent>;
   mainThreadEvents: Array<TraceEvent>;
+  timestamps: Partial<LHTraceTime>;
+  timings: Partial<LHTraceTime>;
 };
 
 /**
@@ -100,15 +116,16 @@ export class TraceProcessor extends LighthouseTraceProcessor {
     const options = {
       timeOriginDeterminationMethod: 'lastNavigationStart',
     };
-    const trace: LHTrace = super.computeTraceOfTab({ traceEvents }, options);
+    const trace = super.computeTraceOfTab({ traceEvents }, options);
     const userTiming = UserTimings.compute(trace);
-    const experience = ExperienceMetrics.compute(trace);
+    const { traces, metrics } = ExperienceMetrics.compute(trace);
     const layoutShift = CumulativeLayoutShift.compute(trace);
+    const perfMetrics: Partial<PerfMetrics> = { ...layoutShift, ...metrics };
 
     return {
       userTiming,
-      experience,
-      layoutShift,
+      experience: traces,
+      metrics: perfMetrics,
     };
   }
 }


Test patch:
diff --git a/__tests__/helper.test.ts b/__tests__/helper.test.ts
index c75b56c..83ccbe0 100644
--- a/__tests__/helper.test.ts
+++ b/__tests__/helper.test.ts
@@ -26,13 +26,13 @@
 import { cwd } from 'process';
 import {
   indent,
-  getMonotonicTime,
+  monotonicTimeInSeconds,
   formatError,
   findPkgJsonByTraversing,
   generateTempPath,
   rewriteErrorStack,
   findPWLogsIndexes,
-  convertTraceTimestamp,
+  microSecsToSeconds,
 } from '../src/helpers';
 
 it('indent message with seperator', () => {
@@ -46,13 +46,13 @@ it('get monotonic clock time', () => {
   jest.spyOn(process, 'hrtime').mockImplementation(() => {
     return [392583, 998697551];
   });
-  const elapsedTime = getMonotonicTime();
+  const elapsedTime = monotonicTimeInSeconds();
   expect(elapsedTime).toBe(392583.998697551);
 });
 
 it('convert trace timestamp to internal time', () => {
   const traceTimestamp = 392583998697;
-  const elapsedTime = convertTraceTimestamp(traceTimestamp);
+  const elapsedTime = microSecsToSeconds(traceTimestamp);
   expect(elapsedTime).toBe(392583.998697);
 });
 
diff --git a/__tests__/plugins/tracing.test.ts b/__tests__/plugins/tracing.test.ts
index 5fd399b..13d63a1 100644
--- a/__tests__/plugins/tracing.test.ts
+++ b/__tests__/plugins/tracing.test.ts
@@ -49,13 +49,13 @@ describe('tracing', () => {
     expect(filmstrips[0]).toMatchObject({
       blob: expect.any(String),
       mime: 'image/jpeg',
-      start: expect.any(Number),
+      start: { us: expect.any(Number) },
     });
     expect(experience.length).toBeGreaterThan(0);
     expect(experience[0]).toMatchObject({
       name: 'navigationStart',
       type: 'mark',
-      start: expect.any(Number),
+      start: { us: expect.any(Number) },
     });
   });
 });
diff --git a/__tests__/reporters/__snapshots__/json.test.ts.snap b/__tests__/reporters/__snapshots__/json.test.ts.snap
index 9c9cd16..6740c5d 100644
--- a/__tests__/reporters/__snapshots__/json.test.ts.snap
+++ b/__tests__/reporters/__snapshots__/json.test.ts.snap
@@ -351,9 +351,10 @@ exports[`json reporter writes each step as NDJSON to the FD 1`] = `
 {\\"type\\":\\"journey/start\\",\\"@timestamp\\":1600300800000000,\\"journey\\":{\\"name\\":\\"j1\\",\\"id\\":\\"j1\\"},\\"root_fields\\":{\\"os\\":{\\"platform\\":\\"darwin\\"},\\"package\\":{\\"name\\":\\"@elastic/synthetics\\",\\"version\\":\\"0.0.1\\"}},\\"payload\\":{\\"params\\":{\\"environment\\":\\"testing\\"},\\"source\\":\\"() => { }\\"},\\"package_version\\":\\"0.0.1\\"}
 {\\"type\\":\\"step/end\\",\\"@timestamp\\":1600300800000000,\\"journey\\":{\\"name\\":\\"j1\\",\\"id\\":\\"j1\\"},\\"step\\":{\\"name\\":\\"s1\\",\\"index\\":1,\\"status\\":\\"succeeded\\",\\"duration\\":{\\"us\\":10000000}},\\"root_fields\\":{\\"os\\":{\\"platform\\":\\"darwin\\"},\\"package\\":{\\"name\\":\\"@elastic/synthetics\\",\\"version\\":\\"0.0.1\\"}},\\"payload\\":{\\"source\\":\\"() => { }\\",\\"url\\":\\"dummy\\",\\"status\\":\\"succeeded\\"},\\"url\\":\\"dummy\\",\\"package_version\\":\\"0.0.1\\"}
 {\\"type\\":\\"journey/network_info\\",\\"@timestamp\\":1600300800000000,\\"journey\\":{\\"name\\":\\"j1\\",\\"id\\":\\"j1\\"},\\"root_fields\\":{\\"user_agent\\":{},\\"http\\":{\\"request\\":{\\"body\\":{\\"bytes\\":0,\\"content\\":\\"\\"}}},\\"os\\":{\\"platform\\":\\"darwin\\"},\\"package\\":{\\"name\\":\\"@elastic/synthetics\\",\\"version\\":\\"0.0.1\\"}},\\"payload\\":{\\"browser\\":{},\\"is_navigation_request\\":true},\\"package_version\\":\\"0.0.1\\"}
-{\\"type\\":\\"journey/filmstrips\\",\\"@timestamp\\":1600300800000000,\\"journey\\":{\\"name\\":\\"j1\\",\\"id\\":\\"j1\\"},\\"root_fields\\":{\\"os\\":{\\"platform\\":\\"darwin\\"},\\"package\\":{\\"name\\":\\"@elastic/synthetics\\",\\"version\\":\\"0.0.1\\"}},\\"payload\\":{\\"index\\":0,\\"start\\":392583.998697},\\"blob\\":\\"dummy\\",\\"blob_mime\\":\\"image/jpeg\\",\\"package_version\\":\\"0.0.1\\"}
-{\\"type\\":\\"journey/metrics\\",\\"@timestamp\\":1600300800000000,\\"journey\\":{\\"name\\":\\"j1\\",\\"id\\":\\"j1\\"},\\"root_fields\\":{\\"browser\\":{\\"experience\\":{\\"name\\":\\"navigationStart\\",\\"type\\":\\"mark\\",\\"start\\":3065705.158085}},\\"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\\":{\\"name\\":\\"firstContentfulPaint\\",\\"type\\":\\"mark\\",\\"start\\":3065705.560142}},\\"os\\":{\\"platform\\":\\"darwin\\"},\\"package\\":{\\"name\\":\\"@elastic/synthetics\\",\\"version\\":\\"0.0.1\\"}},\\"package_version\\":\\"0.0.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\\":{\\"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 e510e71..85a1d5f 100644
--- a/__tests__/reporters/json.test.ts
+++ b/__tests__/reporters/json.test.ts
@@ -127,7 +127,9 @@ describe('json reporter', () => {
         {
           blob: 'dummy',
           mime: 'image/jpeg',
-          start: 392583.998697,
+          start: {
+            us: 392583998697,
+          },
         },
       ],
       networkinfo: [
@@ -142,14 +144,25 @@ describe('json reporter', () => {
         {
           name: 'navigationStart',
           type: 'mark',
-          start: 3065705.158085,
+          start: {
+            us: 3065705158085,
+          },
         },
         {
           name: 'firstContentfulPaint',
           type: 'mark',
-          start: 3065705.560142,
+          start: {
+            us: 3065705560142,
+          },
         },
       ],
+      metrics: {
+        lcp: { us: 200 },
+        fcp: { us: 100 },
+        dcl: { us: 300 },
+        load: { us: 400 },
+        cls: 0.123,
+      },
     });
     runner.emit('end', 'done');
     expect((await readAndCloseStream()).toString()).toMatchSnapshot();
diff --git a/__tests__/sdk/trace-metrics.test.ts b/__tests__/sdk/trace-metrics.test.ts
index 08fd413..91b6915 100644
--- a/__tests__/sdk/trace-metrics.test.ts
+++ b/__tests__/sdk/trace-metrics.test.ts
@@ -41,66 +41,60 @@ describe('Trace metrics', () => {
     expect(metrics).toMatchInlineSnapshot(`
       Array [
         Object {
-          "end": 3069485.988748,
+          "end": Object {
+            "us": 3069485988748,
+          },
           "name": "Next.js-before-hydration",
-          "start": 3069484.776113,
+          "start": Object {
+            "us": 3069484776113,
+          },
           "type": "measure",
         },
         Object {
           "name": "beforeRender",
-          "start": 3069485.988763,
+          "start": Object {
+            "us": 3069485988763,
+          },
           "type": "mark",
         },
         Object {
           "name": "afterHydrate",
-          "start": 3069486.106274,
+          "start": Object {
+            "us": 3069486106274,
+          },
           "type": "mark",
         },
       ]
     `);
   });
 
-  it('compute user experience metrics', () => {
-    const domContentLoadedEvt = {
-      name: 'domContentLoadedEventEnd',
-      ts: 10,
-      ph: 'R',
-      cat: 'blink.user_timing,rail',
-    };
-    const firstContentfulPaintEvt = {
-      name: 'firstContentfulPaint',
-      ts: 8,
-      ph: 'R',
-      cat: 'loading,rail,devtools.timeline',
+  it('compute user experience trace and metrics', () => {
+    const timings = {
+      firstContentfulPaint: 3,
+      largestContentfulPaint: 15,
+      domContentLoaded: 20,
     };
-    const largestContentfulPaintEvt = {
-      name: 'largestContentfulPaint::Candidate',
-      ts: 20,
-      ph: 'R',
-      cat: 'loading,rail,devtools.timeline',
+    const timestamps = {
+      timeOrigin: 5000,
+      firstContentfulPaint: 8000,
+      largestContentfulPaint: 20000,
+      domContentLoaded: 25000,
     };
-    const metrics = ExperienceMetrics.compute({
-      domContentLoadedEvt,
-      firstContentfulPaintEvt,
-      largestContentfulPaintEvt,
+    const { metrics, traces } = ExperienceMetrics.compute({
+      timestamps,
+      timings,
     } as any);
 
-    expect(metrics).toEqual([
-      {
-        name: 'firstContentfulPaint',
-        type: 'mark',
-        start: 0.000008,
-      },
-      {
-        name: 'largestContentfulPaint',
-        type: 'mark',
-        start: 0.00002,
-      },
-      {
-        name: 'domContentLoadedEventEnd',
-        type: 'mark',
-        start: 0.00001,
-      },
+    expect(metrics).toEqual({
+      fcp: { us: 3000 },
+      lcp: { us: 15000 },
+      dcl: { us: 20000 },
+    });
+    expect(traces).toEqual([
+      { name: 'navigationStart', type: 'mark', start: { us: 5000 } },
+      { name: 'firstContentfulPaint', type: 'mark', start: { us: 8000 } },
+      { name: 'largestContentfulPaint', type: 'mark', start: { us: 20000 } },
+      { name: 'domContentLoaded', type: 'mark', start: { us: 25000 } },
     ]);
   });
 
@@ -131,10 +125,7 @@ describe('Trace metrics', () => {
       { score: 1, had_recent_input: true },
     ]);
     expect(CumulativeLayoutShift.compute({ mainThreadEvents } as any)).toEqual({
-      name: 'LayoutShift',
-      score: 3,
-      exists: true,
-      start: 0.000015,
+      cls: 3,
     });
 
     mainThreadEvents = makeTrace([
@@ -144,10 +135,7 @@ describe('Trace metrics', () => {
       { score: 1, had_recent_input: false },
     ]);
     expect(CumulativeLayoutShift.compute({ mainThreadEvents } as any)).toEqual({
-      name: 'LayoutShift',
-      score: 4,
-      exists: true,
-      start: 0.000015,
+      cls: 4,
     });
   });
 
@@ -155,9 +143,7 @@ describe('Trace metrics', () => {
     expect(
       CumulativeLayoutShift.compute({ mainThreadEvents: traceEvents } as any)
     ).toEqual({
-      name: 'LayoutShift',
-      score: 0,
-      exists: false,
+      cls: 0,
     });
   });
 
@@ -168,7 +154,9 @@ describe('Trace metrics', () => {
     expect(metrics[0]).toEqual({
       blob: expect.any(String),
       mime: 'image/jpeg',
-      start: 3086640.09036,
+      start: {
+        us: 3086640090360,
+      },
     });
   });
 });
diff --git a/__tests__/sdk/trace-processor.test.ts b/__tests__/sdk/trace-processor.test.ts
index 78b43c6..fb1f1b6 100644
--- a/__tests__/sdk/trace-processor.test.ts
+++ b/__tests__/sdk/trace-processor.test.ts
@@ -32,8 +32,8 @@ describe('Trace processor', () => {
     const output = TraceProcessor.computeTrace(traceEvents as any);
     expect(output).toEqual({
       userTiming: [],
-      experience: [{ name: 'navigationStart', type: 'mark', start: 0 }],
-      layoutShift: { name: 'LayoutShift', score: 0, exists: false },
+      experience: [],
+      metrics: { cls: 0 },
     });
   });
 });