@newrelic/browser-agent 1.239.1 → 1.241.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/README.md +4 -0
  2. package/dist/cjs/cdn/pro.js +3 -2
  3. package/dist/cjs/cdn/spa.js +4 -3
  4. package/dist/cjs/common/config/state/init.js +25 -17
  5. package/dist/cjs/common/constants/env.cdn.js +1 -1
  6. package/dist/cjs/common/constants/env.npm.js +1 -1
  7. package/dist/cjs/common/constants/runtime.js +9 -5
  8. package/dist/cjs/common/harvest/harvest.js +5 -3
  9. package/dist/cjs/common/vitals/constants.js +17 -0
  10. package/dist/cjs/common/vitals/cumulative-layout-shift.js +27 -0
  11. package/dist/cjs/common/vitals/first-contentful-paint.js +49 -0
  12. package/dist/cjs/common/vitals/first-input-delay.js +32 -0
  13. package/dist/{esm/features/page_view_timing → cjs/common/vitals}/first-paint.js +19 -17
  14. package/dist/cjs/common/vitals/interaction-to-next-paint.js +29 -0
  15. package/dist/cjs/common/vitals/largest-contentful-paint.js +41 -0
  16. package/dist/cjs/common/vitals/long-task.js +64 -0
  17. package/dist/cjs/common/vitals/time-to-first-byte.js +36 -0
  18. package/dist/cjs/common/vitals/vital-metric.js +71 -0
  19. package/dist/cjs/features/ajax/aggregate/index.js +4 -1
  20. package/dist/cjs/features/metrics/aggregate/index.js +17 -7
  21. package/dist/cjs/features/page_view_event/aggregate/index.js +18 -40
  22. package/dist/cjs/features/page_view_event/constants.js +2 -8
  23. package/dist/cjs/features/page_view_event/instrument/index.js +0 -22
  24. package/dist/cjs/features/page_view_timing/aggregate/index.js +36 -147
  25. package/dist/cjs/features/page_view_timing/instrument/index.js +0 -3
  26. package/dist/cjs/features/session_replay/aggregate/index.js +81 -35
  27. package/dist/cjs/features/session_trace/aggregate/index.js +13 -1
  28. package/dist/cjs/features/spa/aggregate/index.js +4 -3
  29. package/dist/cjs/loaders/agent.js +3 -0
  30. package/dist/cjs/loaders/api/api.js +2 -0
  31. package/dist/cjs/loaders/api/apiAsync.js +4 -2
  32. package/dist/cjs/loaders/browser-agent.js +2 -1
  33. package/dist/cjs/loaders/configure/configure.js +13 -1
  34. package/dist/cjs/loaders/configure/public-path.js +13 -0
  35. package/dist/cjs/loaders/configure/public-path.npm.js +10 -0
  36. package/dist/esm/cdn/pro.js +2 -1
  37. package/dist/esm/cdn/spa.js +2 -1
  38. package/dist/esm/common/config/state/init.js +25 -17
  39. package/dist/esm/common/constants/env.cdn.js +1 -1
  40. package/dist/esm/common/constants/env.npm.js +1 -1
  41. package/dist/esm/common/constants/runtime.js +5 -3
  42. package/dist/esm/common/harvest/harvest.js +6 -4
  43. package/dist/esm/common/vitals/constants.js +10 -0
  44. package/dist/esm/common/vitals/cumulative-layout-shift.js +20 -0
  45. package/dist/esm/common/vitals/first-contentful-paint.js +41 -0
  46. package/dist/esm/common/vitals/first-input-delay.js +25 -0
  47. package/dist/{cjs/features/page_view_timing → esm/common/vitals}/first-paint.js +12 -24
  48. package/dist/esm/common/vitals/interaction-to-next-paint.js +22 -0
  49. package/dist/esm/common/vitals/largest-contentful-paint.js +34 -0
  50. package/dist/esm/common/vitals/long-task.js +57 -0
  51. package/dist/esm/common/vitals/time-to-first-byte.js +29 -0
  52. package/dist/esm/common/vitals/vital-metric.js +64 -0
  53. package/dist/esm/features/ajax/aggregate/index.js +4 -1
  54. package/dist/esm/features/metrics/aggregate/index.js +18 -8
  55. package/dist/esm/features/page_view_event/aggregate/index.js +20 -42
  56. package/dist/esm/features/page_view_event/constants.js +1 -4
  57. package/dist/esm/features/page_view_event/instrument/index.js +0 -22
  58. package/dist/esm/features/page_view_timing/aggregate/index.js +37 -148
  59. package/dist/esm/features/page_view_timing/instrument/index.js +0 -3
  60. package/dist/esm/features/session_replay/aggregate/index.js +81 -35
  61. package/dist/esm/features/session_trace/aggregate/index.js +13 -1
  62. package/dist/esm/features/spa/aggregate/index.js +4 -3
  63. package/dist/esm/loaders/agent.js +2 -0
  64. package/dist/esm/loaders/api/api.js +2 -0
  65. package/dist/esm/loaders/api/apiAsync.js +5 -3
  66. package/dist/esm/loaders/browser-agent.js +2 -1
  67. package/dist/esm/loaders/configure/configure.js +13 -1
  68. package/dist/esm/loaders/configure/public-path.js +6 -0
  69. package/dist/esm/loaders/configure/public-path.npm.js +3 -0
  70. package/dist/types/common/config/state/init.d.ts.map +1 -1
  71. package/dist/types/common/constants/runtime.d.ts +3 -1
  72. package/dist/types/common/constants/runtime.d.ts.map +1 -1
  73. package/dist/types/common/harvest/harvest.d.ts +0 -1
  74. package/dist/types/common/harvest/harvest.d.ts.map +1 -1
  75. package/dist/types/common/vitals/constants.d.ts +11 -0
  76. package/dist/types/common/vitals/constants.d.ts.map +1 -0
  77. package/dist/types/common/vitals/cumulative-layout-shift.d.ts +3 -0
  78. package/dist/types/common/vitals/cumulative-layout-shift.d.ts.map +1 -0
  79. package/dist/types/common/vitals/first-contentful-paint.d.ts +3 -0
  80. package/dist/types/common/vitals/first-contentful-paint.d.ts.map +1 -0
  81. package/dist/types/common/vitals/first-input-delay.d.ts +3 -0
  82. package/dist/types/common/vitals/first-input-delay.d.ts.map +1 -0
  83. package/dist/types/common/vitals/first-paint.d.ts +3 -0
  84. package/dist/types/common/vitals/first-paint.d.ts.map +1 -0
  85. package/dist/types/common/vitals/interaction-to-next-paint.d.ts +3 -0
  86. package/dist/types/common/vitals/interaction-to-next-paint.d.ts.map +1 -0
  87. package/dist/types/common/vitals/largest-contentful-paint.d.ts +3 -0
  88. package/dist/types/common/vitals/largest-contentful-paint.d.ts.map +1 -0
  89. package/dist/types/common/vitals/long-task.d.ts +3 -0
  90. package/dist/types/common/vitals/long-task.d.ts.map +1 -0
  91. package/dist/types/common/vitals/time-to-first-byte.d.ts +3 -0
  92. package/dist/types/common/vitals/time-to-first-byte.d.ts.map +1 -0
  93. package/dist/types/common/vitals/vital-metric.d.ts +18 -0
  94. package/dist/types/common/vitals/vital-metric.d.ts.map +1 -0
  95. package/dist/types/features/ajax/aggregate/index.d.ts.map +1 -1
  96. package/dist/types/features/metrics/aggregate/index.d.ts.map +1 -1
  97. package/dist/types/features/page_view_event/aggregate/index.d.ts +3 -2
  98. package/dist/types/features/page_view_event/aggregate/index.d.ts.map +1 -1
  99. package/dist/types/features/page_view_event/constants.d.ts +0 -3
  100. package/dist/types/features/page_view_event/constants.d.ts.map +1 -1
  101. package/dist/types/features/page_view_event/instrument/index.d.ts.map +1 -1
  102. package/dist/types/features/page_view_timing/aggregate/index.d.ts +1 -3
  103. package/dist/types/features/page_view_timing/aggregate/index.d.ts.map +1 -1
  104. package/dist/types/features/page_view_timing/instrument/index.d.ts.map +1 -1
  105. package/dist/types/features/session_replay/aggregate/index.d.ts +16 -4
  106. package/dist/types/features/session_replay/aggregate/index.d.ts.map +1 -1
  107. package/dist/types/features/session_trace/aggregate/index.d.ts +9 -1
  108. package/dist/types/features/session_trace/aggregate/index.d.ts.map +1 -1
  109. package/dist/types/features/spa/aggregate/index.d.ts.map +1 -1
  110. package/dist/types/loaders/agent.d.ts.map +1 -1
  111. package/dist/types/loaders/api/api.d.ts.map +1 -1
  112. package/dist/types/loaders/api/apiAsync.d.ts.map +1 -1
  113. package/dist/types/loaders/browser-agent.d.ts.map +1 -1
  114. package/dist/types/loaders/configure/configure.d.ts.map +1 -1
  115. package/dist/types/loaders/configure/public-path.d.ts +2 -0
  116. package/dist/types/loaders/configure/public-path.d.ts.map +1 -0
  117. package/dist/types/loaders/configure/public-path.npm.d.ts +2 -0
  118. package/dist/types/loaders/configure/public-path.npm.d.ts.map +1 -0
  119. package/package.json +2 -3
  120. package/src/cdn/pro.js +2 -0
  121. package/src/cdn/spa.js +2 -0
  122. package/src/common/config/state/init.js +21 -17
  123. package/src/common/constants/runtime.js +7 -3
  124. package/src/common/constants/runtime.test.js +8 -0
  125. package/src/common/harvest/harvest-scheduler.test.js +2 -2
  126. package/src/common/harvest/harvest.js +6 -4
  127. package/src/common/harvest/harvest.test.js +17 -0
  128. package/src/common/vitals/__mocks__/web-vitals.js +19 -0
  129. package/src/common/vitals/constants.js +10 -0
  130. package/src/common/vitals/cumulative-layout-shift.js +13 -0
  131. package/src/common/vitals/cumulative-layout-shift.test.js +71 -0
  132. package/src/common/vitals/first-contentful-paint.js +31 -0
  133. package/src/common/vitals/first-contentful-paint.test.js +124 -0
  134. package/src/common/vitals/first-input-delay.js +20 -0
  135. package/src/common/vitals/first-input-delay.test.js +88 -0
  136. package/src/{features/page_view_timing → common/vitals}/first-paint.js +11 -17
  137. package/src/common/vitals/first-paint.test.js +127 -0
  138. package/src/common/vitals/interaction-to-next-paint.js +13 -0
  139. package/src/common/vitals/interaction-to-next-paint.test.js +74 -0
  140. package/src/common/vitals/largest-contentful-paint.js +29 -0
  141. package/src/common/vitals/largest-contentful-paint.test.js +94 -0
  142. package/src/common/vitals/long-task.js +52 -0
  143. package/src/common/vitals/long-task.test.js +122 -0
  144. package/src/common/vitals/time-to-first-byte.js +21 -0
  145. package/src/common/vitals/time-to-first-byte.test.js +147 -0
  146. package/src/common/vitals/vital-metric.js +60 -0
  147. package/src/common/vitals/vital-metric.test.js +171 -0
  148. package/src/features/ajax/aggregate/index.js +5 -1
  149. package/src/features/metrics/aggregate/index.js +11 -4
  150. package/src/features/page_view_event/aggregate/index.js +20 -43
  151. package/src/features/page_view_event/constants.js +0 -3
  152. package/src/features/page_view_event/instrument/index.js +0 -21
  153. package/src/features/page_view_timing/aggregate/index.component-test.js +86 -0
  154. package/src/features/page_view_timing/aggregate/index.js +31 -108
  155. package/src/features/page_view_timing/instrument/index.js +0 -3
  156. package/src/features/session_replay/aggregate/index.component-test.js +10 -10
  157. package/src/features/session_replay/aggregate/index.js +62 -29
  158. package/src/features/session_trace/aggregate/index.js +15 -1
  159. package/src/features/spa/aggregate/index.js +4 -3
  160. package/src/loaders/agent.js +2 -0
  161. package/src/loaders/api/api.js +2 -0
  162. package/src/loaders/api/apiAsync.js +5 -4
  163. package/src/loaders/browser-agent.js +3 -1
  164. package/src/loaders/configure/configure.js +15 -7
  165. package/src/loaders/configure/public-path.js +6 -0
  166. package/src/loaders/configure/public-path.npm.js +4 -0
  167. package/dist/cjs/common/metrics/paint-metrics.js +0 -13
  168. package/dist/cjs/features/page_view_timing/long-tasks.js +0 -75
  169. package/dist/esm/common/metrics/paint-metrics.js +0 -6
  170. package/dist/esm/features/page_view_timing/long-tasks.js +0 -69
  171. package/dist/types/common/metrics/paint-metrics.d.ts +0 -2
  172. package/dist/types/common/metrics/paint-metrics.d.ts.map +0 -1
  173. package/dist/types/features/page_view_timing/first-paint.d.ts +0 -2
  174. package/dist/types/features/page_view_timing/first-paint.d.ts.map +0 -1
  175. package/dist/types/features/page_view_timing/long-tasks.d.ts +0 -2
  176. package/dist/types/features/page_view_timing/long-tasks.d.ts.map +0 -1
  177. package/src/common/metrics/paint-metrics.js +0 -6
  178. package/src/features/page_view_timing/long-tasks.js +0 -60
@@ -0,0 +1,29 @@
1
+ import { onLCP } from 'web-vitals'
2
+ import { VitalMetric } from './vital-metric'
3
+ import { VITAL_NAMES } from './constants'
4
+ import { initiallyHidden, isBrowserScope } from '../constants/runtime'
5
+ import { cleanURL } from '../url/clean-url'
6
+
7
+ export const largestContentfulPaint = new VitalMetric(VITAL_NAMES.LARGEST_CONTENTFUL_PAINT)
8
+
9
+ if (isBrowserScope) {
10
+ onLCP(({ value, entries }) => {
11
+ /* Largest Contentful Paint - As of WV v3, it still imperfectly tries to detect document vis state asap and isn't supposed to report if page starts hidden. */
12
+ if (initiallyHidden || largestContentfulPaint.isValid) return
13
+
14
+ const lcpEntry = entries[entries.length - 1] // this looks weird if we only expect one, but this is how cwv-attribution gets it so to be sure...
15
+ largestContentfulPaint.update({
16
+ value,
17
+ entries,
18
+ ...(entries.length > 0 && {
19
+ attrs: {
20
+ size: lcpEntry.size,
21
+ eid: lcpEntry.id,
22
+ ...(!!lcpEntry.url && { elUrl: cleanURL(lcpEntry.url) }),
23
+ ...(!!lcpEntry.element?.tagName && { elTag: lcpEntry.element.tagName })
24
+ }
25
+ }),
26
+ shouldAddConnectionAttributes: true
27
+ })
28
+ })
29
+ }
@@ -0,0 +1,94 @@
1
+ beforeEach(() => {
2
+ jest.resetModules()
3
+ jest.resetAllMocks()
4
+ jest.clearAllMocks()
5
+ })
6
+
7
+ const getFreshLCPImport = async (codeToRun) => {
8
+ const { largestContentfulPaint } = await import('./largest-contentful-paint')
9
+ codeToRun(largestContentfulPaint)
10
+ }
11
+
12
+ describe('lcp', () => {
13
+ test('reports lcp from web-vitals', (done) => {
14
+ getFreshLCPImport(metric => metric.subscribe(({ value, attrs }) => {
15
+ expect(value).toEqual(1)
16
+ expect(attrs).toMatchObject({
17
+ size: expect.any(Number),
18
+ eid: expect.any(String),
19
+ elUrl: expect.any(String),
20
+ elTag: expect.any(String)
21
+ })
22
+ done()
23
+ }))
24
+ })
25
+
26
+ test('does NOT report if not browser scoped', (done) => {
27
+ jest.doMock('../constants/runtime', () => ({
28
+ __esModule: true,
29
+ isBrowserScope: false
30
+ }))
31
+
32
+ getFreshLCPImport(metric => {
33
+ metric.subscribe(() => {
34
+ console.log('should not have reported...')
35
+ expect(1).toEqual(2)
36
+ })
37
+ setTimeout(done, 1000)
38
+ })
39
+ })
40
+
41
+ test('Does NOT report values if initiallyHidden', (done) => {
42
+ jest.doMock('../constants/runtime', () => ({
43
+ __esModule: true,
44
+ initiallyHidden: true,
45
+ isBrowserScope: true
46
+ }))
47
+
48
+ getFreshLCPImport(metric => {
49
+ metric.subscribe(() => {
50
+ console.log('should not have reported')
51
+ expect(1).toEqual(2)
52
+ })
53
+ setTimeout(done, 1000)
54
+ })
55
+ })
56
+
57
+ test('multiple subs get same value', done => {
58
+ jest.doMock('../constants/runtime', () => ({
59
+ __esModule: true,
60
+ isBrowserScope: true
61
+ }))
62
+ let sub1, sub2
63
+ getFreshLCPImport(metric => {
64
+ const remove1 = metric.subscribe(({ entries }) => {
65
+ sub1 ??= entries[0].id
66
+ if (sub1 === sub2) { remove1(); remove2(); done() }
67
+ })
68
+
69
+ const remove2 = metric.subscribe(({ entries }) => {
70
+ sub2 ??= entries[0].id
71
+ if (sub1 === sub2) { remove1(); remove2(); done() }
72
+ })
73
+ })
74
+ })
75
+
76
+ test('reports only once', (done) => {
77
+ jest.doMock('../constants/runtime', () => ({
78
+ __esModule: true,
79
+ initiallyHidden: false,
80
+ isBrowserScope: true
81
+ }))
82
+ let triggered = 0
83
+ getFreshLCPImport(metric => metric.subscribe(({ value }) => {
84
+ triggered++
85
+ expect(value).toEqual(1)
86
+ expect(triggered).toEqual(1)
87
+ setTimeout(() => {
88
+ expect(triggered).toEqual(1)
89
+ done()
90
+ }, 1000)
91
+ })
92
+ )
93
+ })
94
+ })
@@ -0,0 +1,52 @@
1
+ import { isBrowserScope } from '../constants/runtime'
2
+ import { subscribeToEOL } from '../unload/eol'
3
+ import { VITAL_NAMES } from './constants'
4
+ import { VitalMetric } from './vital-metric'
5
+
6
+ export const longTask = new VitalMetric(VITAL_NAMES.LONG_TASK)
7
+
8
+ if (isBrowserScope) {
9
+ const handleEntries = (entries) => {
10
+ entries.forEach(entry => {
11
+ longTask.update({
12
+ value: entry.duration,
13
+ entries: [entry],
14
+ attrs: {
15
+ ltFrame: entry.name, // MDN: the browsing context or frame that can be attributed to the long task
16
+ ltStart: entry.startTime, // MDN: a double representing the time (millisec) when the task started
17
+ ltCtr: entry.attribution[0].containerType, // MDN: type of frame container: 'iframe', 'embed', or 'object' ... but this can also be 'window',
18
+ ...(entry.attribution[0].containerType !== 'window' && {
19
+ ltCtrSrc: entry.attribution[0].containerSrc, // MDN: container's 'src' attribute
20
+ ltCtrId: entry.attribution[0].containerId, // MDN: container's 'id' attribute
21
+ ltCtrName: entry.attribution[0].containerName // MDN: container's 'name' attribute
22
+ })
23
+ }
24
+ })
25
+ })
26
+ }
27
+
28
+ let observer
29
+ try {
30
+ if (PerformanceObserver.supportedEntryTypes.includes('longtask')) {
31
+ observer = new PerformanceObserver((list) => {
32
+ // Delay by a microtask to workaround a bug in Safari where the
33
+ // callback is invoked immediately, rather than in a separate task.
34
+ // See: https://github.com/GoogleChrome/web-vitals/issues/277
35
+ Promise.resolve().then(() => {
36
+ handleEntries(list.getEntries())
37
+ })
38
+ })
39
+ observer.observe({ type: 'longtask', buffered: true })
40
+ }
41
+ } catch (e) {
42
+ // Do nothing.
43
+ }
44
+
45
+ if (observer) {
46
+ subscribeToEOL(() => {
47
+ handleEntries(observer.takeRecords())
48
+ }, true) // this bool is a temp arg under staged BFCache work that runs the func under the new page session logic -- tb removed w/ the feature flag later
49
+
50
+ /* No work needed on BFCache restore for long task. */
51
+ }
52
+ }
@@ -0,0 +1,122 @@
1
+ beforeEach(() => {
2
+ jest.resetModules()
3
+ jest.resetAllMocks()
4
+ jest.clearAllMocks()
5
+
6
+ const mockPerformanceObserver = jest.fn(cb => ({
7
+ observe: () => {
8
+ const callCb = () => {
9
+ // eslint-disable-next-line
10
+ cb({
11
+ getEntries: () => ([{
12
+ name: 'longtask',
13
+ duration: 1,
14
+ startTime: 1,
15
+ attribution: [{
16
+ containerType: 'object',
17
+ containerSrc: 'src',
18
+ containerId: 'id',
19
+ containerName: 'name'
20
+ }]
21
+ }])
22
+ })
23
+ setTimeout(callCb, 250)
24
+ }
25
+ setTimeout(callCb, 250)
26
+ },
27
+ disconnect: jest.fn()
28
+ }))
29
+ global.PerformanceObserver = mockPerformanceObserver
30
+ global.PerformanceObserver.supportedEntryTypes = ['longtask']
31
+ })
32
+
33
+ const getFreshLTImport = async (codeToRun) => {
34
+ const { longTask } = await import('./long-task')
35
+ codeToRun(longTask)
36
+ }
37
+
38
+ describe('lt', () => {
39
+ test('reports lt', (done) => {
40
+ getFreshLTImport(metric => metric.subscribe(({ value, attrs }) => {
41
+ expect(value).toEqual(1)
42
+ expect(attrs).toMatchObject({
43
+ ltFrame: 'longtask',
44
+ ltStart: 1,
45
+ ltCtr: 'object',
46
+ ltCtrSrc: 'src',
47
+ ltCtrId: 'id',
48
+ ltCtrName: 'name'
49
+ })
50
+ done()
51
+ }))
52
+ })
53
+
54
+ test('does NOT report if not browser scoped', (done) => {
55
+ jest.doMock('../constants/runtime', () => ({
56
+ __esModule: true,
57
+ isBrowserScope: false
58
+ }))
59
+
60
+ getFreshLTImport(metric => {
61
+ metric.subscribe(() => {
62
+ console.log('should not have reported...')
63
+ expect(1).toEqual(2)
64
+ })
65
+ setTimeout(done, 1000)
66
+ })
67
+ })
68
+
69
+ test('does NOT report if browser does not support longtask', (done) => {
70
+ jest.doMock('../constants/runtime', () => ({
71
+ __esModule: true,
72
+ isBrowserScope: true
73
+ }))
74
+
75
+ global.PerformanceObserver.supportedEntryTypes = ['paint']
76
+
77
+ getFreshLTImport(metric => {
78
+ metric.subscribe(() => {
79
+ console.log('should not have reported...')
80
+ expect(1).toEqual(2)
81
+ })
82
+ setTimeout(done, 1000)
83
+ })
84
+ })
85
+
86
+ test('multiple subs get same value', done => {
87
+ jest.doMock('../constants/runtime', () => ({
88
+ __esModule: true,
89
+ isBrowserScope: true
90
+ }))
91
+ let sub1, sub2
92
+ getFreshLTImport(metric => {
93
+ const remove1 = metric.subscribe(({ entries }) => {
94
+ sub1 ??= entries[0].id
95
+ if (sub1 === sub2) { remove1(); remove2(); done() }
96
+ })
97
+
98
+ const remove2 = metric.subscribe(({ entries }) => {
99
+ sub2 ??= entries[0].id
100
+ if (sub1 === sub2) { remove1(); remove2(); done() }
101
+ })
102
+ })
103
+ })
104
+
105
+ test('reports more than once', (done) => {
106
+ jest.doMock('../constants/runtime', () => ({
107
+ __esModule: true,
108
+ isBrowserScope: true
109
+ }))
110
+ let triggered = 0
111
+ getFreshLTImport(metric => metric.subscribe(({ value }) => {
112
+ triggered++
113
+ expect(value).toEqual(1)
114
+ expect(triggered).toEqual(1)
115
+ setTimeout(() => {
116
+ expect(triggered).toBeGreaterThanOrEqual(3)
117
+ done()
118
+ }, 1000)
119
+ })
120
+ )
121
+ })
122
+ })
@@ -0,0 +1,21 @@
1
+ import { globalScope, isBrowserScope, isiOS, offset } from '../constants/runtime'
2
+ import { VITAL_NAMES } from './constants'
3
+ import { VitalMetric } from './vital-metric'
4
+ import { onTTFB } from 'web-vitals'
5
+
6
+ export const timeToFirstByte = new VitalMetric(VITAL_NAMES.TIME_TO_FIRST_BYTE)
7
+
8
+ if (isBrowserScope && typeof PerformanceNavigationTiming !== 'undefined' && !isiOS) {
9
+ onTTFB(({ value, entries }) => {
10
+ if (!timeToFirstByte.isValid) timeToFirstByte.update({ value, entries })
11
+ })
12
+ } else {
13
+ if (!timeToFirstByte.isValid) {
14
+ const entry = {}
15
+ // convert real timestamps to relative timestamps to match web-vitals behavior
16
+ for (let key in globalScope?.performance?.timing || {}) entry[key] = Math.max(globalScope?.performance?.timing[key] - offset, 0)
17
+
18
+ // ttfb is equiv to document's responseStart property in timing API --> https://developer.mozilla.org/en-US/docs/Web/API/PerformanceTiming/responseStart
19
+ timeToFirstByte.update({ value: entry.responseStart, entries: [entry] })
20
+ }
21
+ }
@@ -0,0 +1,147 @@
1
+ beforeEach(() => {
2
+ jest.resetModules()
3
+ jest.resetAllMocks()
4
+ jest.clearAllMocks()
5
+ })
6
+
7
+ const getFreshTTFBImport = async (codeToRun) => {
8
+ const { timeToFirstByte } = await import('./time-to-first-byte')
9
+ codeToRun(timeToFirstByte)
10
+ }
11
+
12
+ describe('ttfb', () => {
13
+ test('reports ttfb from web-vitals', (done) => {
14
+ jest.doMock('../constants/runtime', () => ({
15
+ __esModule: true,
16
+ isiOS: false,
17
+ isBrowserScope: true
18
+ }))
19
+ global.PerformanceNavigationTiming = jest.fn()
20
+
21
+ getFreshTTFBImport(metric => metric.subscribe(({ value }) => {
22
+ expect(value).toEqual(1)
23
+ done()
24
+ }))
25
+ })
26
+
27
+ test('does NOT report if not browser scoped', (done) => {
28
+ jest.doMock('../constants/runtime', () => ({
29
+ __esModule: true,
30
+ isBrowserScope: false
31
+ }))
32
+
33
+ getFreshTTFBImport(metric => {
34
+ metric.subscribe(() => {
35
+ console.log('should not have reported...')
36
+ expect(1).toEqual(2)
37
+ })
38
+ setTimeout(done, 1000)
39
+ })
40
+ })
41
+
42
+ test('does NOT report ttfb from web-vitals if no PNT', (done) => {
43
+ jest.doMock('../constants/runtime', () => ({
44
+ __esModule: true,
45
+ isiOS: false,
46
+ isBrowserScope: true
47
+ }))
48
+ global.PerformanceNavigationTiming = undefined
49
+
50
+ getFreshTTFBImport(metric => {
51
+ metric.subscribe(() => {
52
+ console.log('should not have reported...')
53
+ expect(1).toEqual(2)
54
+ })
55
+ setTimeout(done, 1000)
56
+ })
57
+ })
58
+
59
+ test('does NOT report ttfb from web-vitals if is iOS', (done) => {
60
+ jest.doMock('../constants/runtime', () => ({
61
+ __esModule: true,
62
+ isiOS: true,
63
+ isBrowserScope: true
64
+ }))
65
+ global.PerformanceNavigationTiming = jest.fn()
66
+
67
+ getFreshTTFBImport(metric => {
68
+ metric.subscribe(() => {
69
+ console.log('should not have reported...')
70
+ expect(1).toEqual(2)
71
+ })
72
+ setTimeout(done, 1000)
73
+ })
74
+ })
75
+
76
+ test('reports from performance.timing if cant use web-vitals', (done) => {
77
+ jest.doMock('../constants/runtime', () => ({
78
+ __esModule: true,
79
+ isiOS: true,
80
+ globalScope: {
81
+ performance: {
82
+ timing: {
83
+ responseStart: 2
84
+ }
85
+ }
86
+ },
87
+ offset: 1,
88
+ isBrowserScope: true
89
+ }))
90
+ global.PerformanceNavigationTiming = undefined
91
+
92
+ getFreshTTFBImport(metric => {
93
+ metric.subscribe(({ value }) => {
94
+ expect(value).toEqual(1) // responseStart (2) - offset (1) === 1
95
+ done()
96
+ })
97
+ })
98
+ })
99
+
100
+ test('multiple subs get same value', done => {
101
+ jest.doMock('../constants/runtime', () => ({
102
+ __esModule: true,
103
+ isBrowserScope: true,
104
+ isiOS: false
105
+ }))
106
+ global.PerformanceNavigationTiming = jest.fn()
107
+ let sub1, sub2
108
+ getFreshTTFBImport(metric => {
109
+ const remove1 = metric.subscribe(({ entries }) => {
110
+ sub1 ??= entries[0].id
111
+ if (sub1 === sub2) { remove1(); remove2(); done() }
112
+ })
113
+
114
+ const remove2 = metric.subscribe(({ entries }) => {
115
+ sub2 ??= entries[0].id
116
+ if (sub1 === sub2) { remove1(); remove2(); done() }
117
+ })
118
+ })
119
+ })
120
+
121
+ test('reports only once', (done) => {
122
+ jest.doMock('../constants/runtime', () => ({
123
+ __esModule: true,
124
+ isiOS: true,
125
+ globalScope: {
126
+ performance: {
127
+ timing: {
128
+ responseStart: 2
129
+ }
130
+ }
131
+ },
132
+ offset: 1,
133
+ isBrowserScope: true
134
+ }))
135
+ let triggered = 0
136
+ getFreshTTFBImport(metric => metric.subscribe(({ value }) => {
137
+ triggered++
138
+ expect(value).toEqual(1)
139
+ expect(triggered).toEqual(1)
140
+ setTimeout(() => {
141
+ expect(triggered).toEqual(1)
142
+ done()
143
+ }, 1000)
144
+ })
145
+ )
146
+ })
147
+ })
@@ -0,0 +1,60 @@
1
+ export class VitalMetric {
2
+ #subscribers = new Set()
3
+ history = []
4
+
5
+ constructor (name, roundingMethod) {
6
+ this.name = name
7
+ this.attrs = {}
8
+ this.roundingMethod = typeof roundingMethod === 'function' ? roundingMethod : Math.floor
9
+ }
10
+
11
+ update ({ value, entries = [], attrs = {}, shouldAddConnectionAttributes = false }) {
12
+ if (value < 0) return
13
+ const state = {
14
+ value: this.roundingMethod(value),
15
+ name: this.name,
16
+ entries,
17
+ attrs
18
+ }
19
+ if (shouldAddConnectionAttributes) addConnectionAttributes(state.attrs)
20
+ this.history.push(state)
21
+ this.#subscribers.forEach(cb => {
22
+ try {
23
+ cb(this.current)
24
+ } catch (e) {
25
+ // ignore errors
26
+ }
27
+ })
28
+ }
29
+
30
+ get current () {
31
+ return this.history[this.history.length - 1] || {
32
+ value: undefined,
33
+ name: this.name,
34
+ entries: [],
35
+ attrs: {}
36
+ }
37
+ }
38
+
39
+ get isValid () {
40
+ return this.current.value >= 0
41
+ }
42
+
43
+ subscribe (callback, buffered = true) {
44
+ if (typeof callback !== 'function') return
45
+ this.#subscribers.add(callback)
46
+ // emit full history on subscription ("buffered" behavior)
47
+ if (this.isValid && !!buffered) this.history.forEach(state => { callback(state) })
48
+ return () => { this.#subscribers.delete(callback) }
49
+ }
50
+ }
51
+
52
+ function addConnectionAttributes (obj) {
53
+ var connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection // to date, both window & worker shares the same support for connection
54
+ if (!connection) return
55
+
56
+ if (connection.type) obj['net-type'] = connection.type
57
+ if (connection.effectiveType) obj['net-etype'] = connection.effectiveType
58
+ if (connection.rtt) obj['net-rtt'] = connection.rtt
59
+ if (connection.downlink) obj['net-dlink'] = connection.downlink
60
+ }
@@ -0,0 +1,171 @@
1
+ import { VitalMetric } from './vital-metric'
2
+
3
+ let vitalMetric
4
+ beforeEach(() => {
5
+ vitalMetric = new VitalMetric('test')
6
+ })
7
+
8
+ afterEach(() => {
9
+ jest.resetAllMocks()
10
+ })
11
+
12
+ describe('vital-metric', () => {
13
+ test('default values', () => {
14
+ expect(vitalMetric).toMatchObject({
15
+ name: 'test',
16
+ history: []
17
+ })
18
+
19
+ expect(vitalMetric.current).toMatchObject({
20
+ name: 'test',
21
+ attrs: {},
22
+ entries: [],
23
+ value: undefined
24
+ })
25
+ })
26
+
27
+ test('update', () => {
28
+ let i = 0
29
+ vitalMetric.update({ value: i, entries: [{ test: i + 1 }], attrs: { test: i + 2 }, shouldAddConnectionAttributes: true })
30
+ expect(vitalMetric.current).toMatchObject({
31
+ value: i,
32
+ entries: [{ test: i + 1 }],
33
+ attrs: { test: i + 2 }
34
+ })
35
+
36
+ i++
37
+ vitalMetric.update({ value: i, entries: [{ test: i + 1 }], attrs: { test: i + 2 }, shouldAddConnectionAttributes: true })
38
+ expect(vitalMetric.current).toMatchObject({
39
+ value: i,
40
+ entries: [{ test: i + 1 }],
41
+ attrs: { test: i + 2 }
42
+ })
43
+ })
44
+
45
+ test('rounding', () => {
46
+ // default rounding
47
+ vitalMetric.update({ value: 1.234 })
48
+ expect(vitalMetric.current.value).toEqual(1)
49
+ // custom rounding
50
+ vitalMetric = new VitalMetric('test', (x) => x * 100)
51
+ vitalMetric.update({ value: 5 })
52
+ expect(vitalMetric.current.value).toEqual(500)
53
+ })
54
+
55
+ test('isValid', () => {
56
+ expect(vitalMetric.isValid).toEqual(false)
57
+
58
+ vitalMetric.update({ value: -1 })
59
+ expect(vitalMetric.isValid).toEqual(false)
60
+
61
+ vitalMetric.update({ value: 1 })
62
+ expect(vitalMetric.isValid).toEqual(true)
63
+ })
64
+
65
+ test('subscribers get updates when valid', (done) => {
66
+ vitalMetric.subscribe(({ value }) => {
67
+ expect(value).toEqual(1)
68
+ done()
69
+ })
70
+
71
+ vitalMetric.update({ value: 1 })
72
+ })
73
+
74
+ test('multiple subscribers get same update when valid', (done) => {
75
+ let sub1, sub2
76
+ let stop1 = vitalMetric.subscribe(({ entries }) => {
77
+ sub1 ??= entries[0].id
78
+ if (sub1 === sub2) { stop1(); stop2(); done() }
79
+ })
80
+
81
+ let stop2 = vitalMetric.subscribe(({ entries }) => {
82
+ sub2 ??= entries[0].id
83
+ if (sub1 === sub2) { stop1(); stop2(); done() }
84
+ })
85
+
86
+ vitalMetric.update({ value: 1, entries: [{ id: 'abcd' }] })
87
+ })
88
+
89
+ test('subscribers do not get updates when not valid', (done) => {
90
+ vitalMetric.subscribe(({ value }) => {
91
+ console.log(value)
92
+ console.log('should not have reached subscriber')
93
+ expect(1).toEqual(2)
94
+ })
95
+
96
+ vitalMetric.update({ value: -1 })
97
+ setTimeout(done, 1000)
98
+ })
99
+
100
+ test('subscribers get latest value immediately if already valid', (done) => {
101
+ vitalMetric.update({ value: 1 })
102
+
103
+ vitalMetric.subscribe(({ value }) => {
104
+ expect(value).toEqual(1)
105
+ done()
106
+ })
107
+ })
108
+
109
+ test('unsubscribe', (done) => {
110
+ const unsubscribe = vitalMetric.subscribe(({ value }) => {
111
+ console.log('should not have reached subscriber')
112
+ expect(1).toEqual(2)
113
+ })
114
+
115
+ unsubscribe()
116
+ vitalMetric.update({ value: 1 })
117
+ setTimeout(done, 1000)
118
+ })
119
+
120
+ test('addConnectionAttributes', () => {
121
+ global.navigator.connection = {}
122
+ vitalMetric.update({ value: 1, shouldAddConnectionAttributes: true })
123
+ expect(vitalMetric.current.attrs).toEqual(expect.objectContaining({}))
124
+
125
+ global.navigator.connection.type = 'type'
126
+ vitalMetric.update({ value: 1, shouldAddConnectionAttributes: true })
127
+ expect(vitalMetric.current.attrs).toEqual(expect.objectContaining({
128
+ 'net-type': 'type'
129
+ }))
130
+
131
+ global.navigator.connection.effectiveType = 'effectiveType'
132
+ vitalMetric.update({ value: 1, shouldAddConnectionAttributes: true })
133
+ expect(vitalMetric.current.attrs).toEqual(expect.objectContaining({
134
+ 'net-type': 'type',
135
+ 'net-etype': 'effectiveType'
136
+ }))
137
+
138
+ global.navigator.connection.rtt = 'rtt'
139
+ vitalMetric.update({ value: 1, shouldAddConnectionAttributes: true })
140
+ expect(vitalMetric.current.attrs).toEqual(expect.objectContaining({
141
+ 'net-type': 'type',
142
+ 'net-etype': 'effectiveType',
143
+ 'net-rtt': 'rtt'
144
+ }))
145
+
146
+ global.navigator.connection.downlink = 'downlink'
147
+ vitalMetric.update({ value: 1, shouldAddConnectionAttributes: true })
148
+ expect(vitalMetric.current.attrs).toEqual(expect.objectContaining({
149
+ 'net-type': 'type',
150
+ 'net-etype': 'effectiveType',
151
+ 'net-rtt': 'rtt',
152
+ 'net-dlink': 'downlink'
153
+ }))
154
+
155
+ global.navigator.connection = {
156
+ type: 'type',
157
+ effectiveType: 'effectiveType',
158
+ rtt: 'rtt',
159
+ downlink: 'downlink'
160
+ }
161
+ vitalMetric.update({ value: 1, shouldAddConnectionAttributes: true })
162
+
163
+ expect(vitalMetric.current.attrs).toEqual(expect.objectContaining({
164
+ 'net-type': 'type',
165
+ 'net-etype': 'effectiveType',
166
+ 'net-rtt': 'rtt',
167
+ 'net-dlink': 'downlink'
168
+ }))
169
+ global.navigator.connection = {}
170
+ })
171
+ })