Skip to content

Commit

Permalink
feat: send events when user leaves the page (#1146)
Browse files Browse the repository at this point in the history
* feat: send events when user leaves the page

* chore: add tests for fetch

* chore: fix flaky tests

* chore: observe page visibility when initialising

* chore: set limit to 60 KiB

* chore: describe reason behind the unobserve

* chore: use existing constants for services

* chore: change global state restore strategy

* chore: do not process calls to agent endpoints

* chore: improve keepalive check

* chore: avoid executing keepalive if beforeSend defined

* chore: move variables inside function

* chore: use customEvent util

* chore: remove needless spy
  • Loading branch information
devcorpio authored Feb 10, 2022
1 parent 5a37568 commit 2429814
Show file tree
Hide file tree
Showing 43 changed files with 1,355 additions and 185 deletions.
2 changes: 1 addition & 1 deletion dev-utils/karma.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const { getWebpackConfig, BUNDLE_TYPES } = require('./build')
const polyfills = 'test/polyfills.+(js|ts)'

const specPattern =
'test/{*.spec.+(js|ts),!(e2e|integration|node|bundle|types)/*.spec.+(js|ts)}'
'test/{*.spec.+(js|ts),!(e2e|integration|node|bundle|types)/**/*.spec.+(js|ts)}'
const { tunnelIdentifier } = getSauceConnectOptions()

// makes all object properties configurable by default
Expand Down
19 changes: 12 additions & 7 deletions dev-utils/webdriver.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,21 @@ const logLevels = {
const debugMode = false
const debugLevel = logLevels.INFO.value

function isLogEntryATestFailure(entry, whitelist) {
function isLogEntryATestFailure(entry, whitelist = []) {
var result = false
if (logLevels[entry.level].value > logLevels.WARNING.value) {
result = true
if (whitelist) {
for (var i = 0, l = whitelist.length; i < l; i++) {
if (entry.message.indexOf(whitelist[i]) !== -1) {
result = false
break
}

// Chrome's versions lower than 81 had a bug where a preflight request with keepalive specified was not supported
// Bug info: https://bugs.chromium.org/p/chromium/issues/detail?id=835821
whitelist.push(
'Preflight request for request with keepalive specified is currently not supported'
)

for (var i = 0, l = whitelist.length; i < l; i++) {
if (entry.message.indexOf(whitelist[i]) !== -1) {
result = false
break
}
}
}
Expand Down
17 changes: 0 additions & 17 deletions packages/rum-core/src/bootstrap.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ let enabled = false
export function bootstrap() {
if (isPlatformSupported()) {
patchAll()
bootstrapPerf()
state.bootstrapTime = now()
enabled = true
} else if (isBrowser) {
Expand All @@ -44,19 +43,3 @@ export function bootstrap() {

return enabled
}

export function bootstrapPerf() {
if (document.visibilityState === 'hidden') {
state.lastHiddenStart = 0
}

window.addEventListener(
'visibilitychange',
() => {
if (document.visibilityState === 'hidden') {
state.lastHiddenStart = performance.now()
}
},
{ capture: true }
)
}
99 changes: 49 additions & 50 deletions packages/rum-core/src/common/apm-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,13 @@
import Queue from './queue'
import throttle from './throttle'
import NDJSON from './ndjson'
import { XHR_IGNORE } from './patching/patch-utils'
import { truncateModel, METADATA_MODEL } from './truncate'
import { ERRORS, TRANSACTIONS } from './constants'
import {
ERRORS,
HTTP_REQUEST_TIMEOUT,
QUEUE_FLUSH,
TRANSACTIONS
} from './constants'
import { noop } from './utils'
import { Promise } from './polyfills'
import {
Expand All @@ -38,6 +42,8 @@ import {
compressPayload
} from './compress'
import { __DEV__ } from '../state'
import { sendFetchRequest, shouldUseFetchWithKeepAlive } from './http/fetch'
import { sendXHR } from './http/xhr'

/**
* Throttling interval defaults to 60 seconds
Expand Down Expand Up @@ -75,6 +81,10 @@ class ApmServer {
() => this._loggingService.warn('Dropped events due to throttling!'),
{ limit, interval: THROTTLE_INTERVAL }
)

this._configService.observeEvent(QUEUE_FLUSH, () => {
this.queue.flush()
})
}

_postJson(endPoint, payload) {
Expand Down Expand Up @@ -124,58 +134,33 @@ class ApmServer {
_makeHttpRequest(
method,
url,
{ timeout = 10000, payload, headers, beforeSend } = {}
{ timeout = HTTP_REQUEST_TIMEOUT, payload, headers, beforeSend } = {}
) {
return new Promise(function (resolve, reject) {
var xhr = new window.XMLHttpRequest()
xhr[XHR_IGNORE] = true
xhr.open(method, url, true)
xhr.timeout = timeout

if (headers) {
for (var header in headers) {
if (headers.hasOwnProperty(header)) {
xhr.setRequestHeader(header, headers[header])
}
}
}

xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
const { status, responseText } = xhr
// An http 4xx or 5xx error. Signal an error.
if (status === 0 || (status > 399 && status < 600)) {
reject({ url, status, responseText })
} else {
resolve(xhr)
}
// This bring the possibility of sending requests that outlive the page.
if (!beforeSend && shouldUseFetchWithKeepAlive(method, payload)) {
return sendFetchRequest(method, url, {
keepalive: true,
timeout,
payload,
headers
}).catch(reason => {
// Chrome, before the version 81 had a bug where a preflight request with keepalive specified was not supported
// xhr will be used as a fallback to cover fetch network errors, more info: https://fetch.spec.whatwg.org/#concept-network-error
// Bug info: https://bugs.chromium.org/p/chromium/issues/detail?id=835821
if (reason instanceof TypeError) {
return sendXHR(method, url, { timeout, payload, headers, beforeSend })
}
}

xhr.onerror = () => {
const { status, responseText } = xhr
reject({ url, status, responseText })
}
// bubble other kind of reasons to keep handling the failure
throw reason
})
}

let canSend = true
if (typeof beforeSend === 'function') {
canSend = beforeSend({ url, method, headers, payload, xhr })
}
if (canSend) {
xhr.send(payload)
} else {
reject({
url,
status: 0,
responseText: 'Request rejected by user configuration.'
})
}
})
return sendXHR(method, url, { timeout, payload, headers, beforeSend })
}

fetchConfig(serviceName, environment) {
const serverUrl = this._configService.get('serverUrl')
var configEndpoint = `${serverUrl}/config/v1/rum/agents`
var { configEndpoint } = this.getEndpoints()
if (!serviceName) {
return Promise.reject(
'serviceName is required for fetching central config.'
Expand Down Expand Up @@ -336,10 +321,24 @@ class ApmServer {
this.ndjsonTransactions(filteredPayload[TRANSACTIONS], compress)
)
const ndjsonPayload = ndjson.join('')
const { intakeEndpoint } = this.getEndpoints()
return this._postJson(intakeEndpoint, ndjsonPayload)
}

getEndpoints() {
const serverUrl = this._configService.get('serverUrl')
const apiVersion = this._configService.get('apiVersion')
const serverUrlPrefix =
cfg.get('serverUrlPrefix') || `/intake/v${apiVersion}/rum/events`
const endPoint = cfg.get('serverUrl') + serverUrlPrefix
return this._postJson(endPoint, ndjsonPayload)
this._configService.get('serverUrlPrefix') ||
`/intake/v${apiVersion}/rum/events`

const intakeEndpoint = serverUrl + serverUrlPrefix
const configEndpoint = `${serverUrl}/config/v1/rum/agents`

return {
intakeEndpoint,
configEndpoint
}
}
}

Expand Down
8 changes: 8 additions & 0 deletions packages/rum-core/src/common/config-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,14 @@ class Config {
storage.setItem(LOCAL_CONFIG_KEY, JSON.stringify(config))
}
}

dispatchEvent(name, args) {
this.events.send(name, args)
}

observeEvent(name, fn) {
return this.events.observe(name, fn)
}
}

export default Config
14 changes: 13 additions & 1 deletion packages/rum-core/src/common/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ const TRANSACTION_END = 'transaction:end'
* Internal Events
*/
const CONFIG_CHANGE = 'config:change'
const QUEUE_FLUSH = 'queue:flush'
const QUEUE_ADD_TRANSACTION = 'queue:add_transaction'

/**
* Events types that are used to toggle auto instrumentations
Expand Down Expand Up @@ -147,6 +149,7 @@ const TRANSACTIONS = 'transactions'
*/
const CONFIG_SERVICE = 'ConfigService'
const LOGGING_SERVICE = 'LoggingService'
const TRANSACTION_SERVICE = 'TransactionService'
const APM_SERVER = 'ApmServer'

/**
Expand All @@ -164,6 +167,11 @@ const KEYWORD_LIMIT = 1024
*/
const SESSION_TIMEOUT = 30 * 60000

/**
* Default http request is set to 10 seconds (in milliseconds)
*/
const HTTP_REQUEST_TIMEOUT = 10000

export {
SCHEDULE,
INVOKE,
Expand All @@ -180,6 +188,8 @@ export {
TRANSACTION_START,
TRANSACTION_END,
CONFIG_CHANGE,
QUEUE_FLUSH,
QUEUE_ADD_TRANSACTION,
XMLHTTPREQUEST,
FETCH,
HISTORY,
Expand All @@ -204,11 +214,13 @@ export {
TRANSACTIONS,
CONFIG_SERVICE,
LOGGING_SERVICE,
TRANSACTION_SERVICE,
APM_SERVER,
TRUNCATED_TYPE,
FIRST_INPUT,
LAYOUT_SHIFT,
OUTCOME_SUCCESS,
OUTCOME_FAILURE,
SESSION_TIMEOUT
SESSION_TIMEOUT,
HTTP_REQUEST_TIMEOUT
}
107 changes: 107 additions & 0 deletions packages/rum-core/src/common/http/fetch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* MIT License
*
* Copyright (c) 2017-present, Elasticsearch BV
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
*/

import { HTTP_REQUEST_TIMEOUT } from '../constants'
import { isResponseSuccessful } from './response-status'

// keepalive flag tends to limit the payload size to 64 KB
// although this size if set, will be up to the user agent
// in order to be conservative we set a limit a little lower than that
export const BYTE_LIMIT = 60 * 1000

export function shouldUseFetchWithKeepAlive(method, payload) {
if (!isFetchSupported()) {
return false
}

const isKeepAliveSupported = 'keepalive' in new Request('')
if (!isKeepAliveSupported) {
return false
}

const size = calculateSize(payload)
return method === 'POST' && size < BYTE_LIMIT
}

export function sendFetchRequest(
method,
url,
{ keepalive = false, timeout = HTTP_REQUEST_TIMEOUT, payload, headers }
) {
let timeoutConfig = {}
if (typeof AbortController === 'function') {
const controller = new AbortController()
timeoutConfig.signal = controller.signal
setTimeout(() => controller.abort(), timeout)
}

let fetchResponse
return window
.fetch(url, {
body: payload,
headers,
method,
keepalive, // used to allow the request to outlive the page.
credentials: 'omit',
...timeoutConfig
})
.then(response => {
fetchResponse = response
return fetchResponse.text()
})
.then(responseText => {
const bodyResponse = {
url,
status: fetchResponse.status,
responseText
}

if (!isResponseSuccessful(fetchResponse.status)) {
throw bodyResponse
}

return bodyResponse
})
}

export function isFetchSupported() {
return (
typeof window.fetch === 'function' && typeof window.Request === 'function'
)
}

function calculateSize(payload) {
if (!payload) {
// IE 11 cannot create Blob from undefined
return 0
}

// If the payload is compressed it is going to be already a Blob
if (payload instanceof Blob) {
return payload.size
}

return new Blob([payload]).size
}
Loading

0 comments on commit 2429814

Please sign in to comment.