├── .nvmrc ├── lib ├── phases.ts ├── allowedOrigins.ts ├── index.ts ├── __mocks__ │ ├── vars.ts │ ├── performance.ts │ ├── util.ts │ └── browser.ts ├── hooks │ ├── __mocks__ │ │ └── unhandledError.ts │ ├── unhandledRejection.ts │ ├── normalizeUrl.ts │ ├── timers.ts │ ├── xhrHelpers.ts │ ├── eventHandlers.ts │ └── userTiming.ts ├── types │ └── globals.d.ts ├── resources │ ├── consts.ts │ └── resources.ts ├── performance.ts ├── states │ ├── waitForPageLoad.ts │ └── pageLoaded.ts ├── utilWrap.ts ├── events │ ├── onLoad.ts │ └── onLastChance.ts ├── localStorage.ts ├── fsm.ts ├── eventBus.ts ├── pageTransitionData.ts ├── serverTiming.ts ├── excessiveUsageIdentification.ts ├── transmission │ ├── util.ts │ ├── lineEncoding.ts │ ├── index.ts │ └── formEncoded.ts ├── debug.ts ├── browser.ts ├── queryTrackedDomainList.ts ├── webVitals.ts ├── stripSecrets.ts ├── sop.ts ├── ignoreRules.ts ├── pageChange.ts ├── util.ts ├── customEvents.ts └── trie.ts ├── test ├── e2e │ ├── 05_fetch │ │ ├── graphql_apollo │ │ │ ├── .gitignore │ │ │ ├── package.json │ │ │ └── src │ │ │ │ └── index.js │ │ ├── graphql_apollo.html │ │ ├── beforePageLoad.html │ │ ├── fetchStripSecrets.html │ │ ├── fetchWithFormData.html │ │ ├── error.html │ │ ├── fetchWithRequestObjectAndFormData.html │ │ ├── afterPageLoad.html │ │ ├── requestObjectAndInitObjectWithoutHeaders.html │ │ ├── captureHeaders.html │ │ ├── requestObjectAndInitObject.html │ │ ├── withPolyfill.html │ │ ├── requestObject.html │ │ ├── fetchNoZoneImpact.html │ │ ├── ignoredFetch.html │ │ └── fetchWithCsrfToken.html │ ├── .eslintrc.js │ ├── 07_wrapEventHandlers │ │ ├── buttonEventHandlers.js │ │ ├── sameOriginErrors.html │ │ ├── crossOriginErrors.html │ │ └── eventListenerRemoval.html │ ├── 04_serverTiming │ │ ├── serverTiming.html │ │ └── serverTiming.spec.js │ ├── 00_pageLoad │ │ ├── pageLoad.html │ │ ├── customPage.html │ │ ├── apiKey.html │ │ ├── apiKeyViaKey.html │ │ ├── backendTraceId.html │ │ ├── ignoredWindowLocation.html │ │ ├── resourceStripSecrets.html │ │ ├── pageLoadStripSecrets.html │ │ ├── pageLoadRedactFragment.html │ │ ├── queryTrackedDomainList │ │ │ ├── www.example1.com.html │ │ │ ├── www.example2.com.html │ │ │ └── www.exampleWithRedactionCase.com.html │ │ ├── resourceTimings.html │ │ ├── multiComplicatedMeta.html │ │ ├── tooMuchMeta.html │ │ ├── ignoredResources.html │ │ ├── meta.html │ │ └── navigationTimings.html │ ├── 15_agentVersion_check │ │ ├── agentVersion_check.html │ │ └── agentVersion_check.spec.js │ ├── 06_wrapTimers │ │ ├── buttonEventHandlers.js │ │ ├── sameOriginErrors.html │ │ └── crossOriginErrors.html │ ├── 02_error │ │ ├── manualWithErrorString.html │ │ ├── manualWithPageChange.html │ │ ├── manualWithErrorObject.html │ │ └── automatic.html │ ├── 10_pageChanges │ │ ├── pageChangesDuringOnLoad.html │ │ ├── pageChanges.html │ │ └── changingBackToPreviousPage.html │ ├── 03_transmission │ │ └── transmission.html │ ├── 01_xhr │ │ ├── xhrBeforePageLoad.html │ │ ├── xhrBeforePageLoadSynchronous.html │ │ ├── xhrStripSecrets.html │ │ ├── xhrAfterPageLoad.html │ │ ├── xhrTimeout.html │ │ ├── xhrError.html │ │ ├── ignoredXhr.html │ │ └── xhrCaptureHeaders.html │ ├── 08_unhandledRejections │ │ ├── unhandledRejection.html │ │ └── unhandledRejections.spec.js │ ├── 09_customEvents │ │ ├── simpleEventReporting.html │ │ ├── simpleEventReportingWithoutBackendTraceId.html │ │ └── simpleEventReportingWithoutCustomMetric.html │ ├── 14_backendTraceId │ │ ├── backendTraceId_check.spec.js │ │ └── backendTraceId_check.html │ ├── 11_userTiming │ │ ├── userTiming.html │ │ └── userTiming.spec.js │ ├── 16_autoPageDetection │ │ ├── autoPageDetection.html │ │ ├── autoPageDetectionTitleAsPageName.html │ │ └── autoPageDetectionMappingRuleCheck.html │ ├── base.js │ ├── initializer.js │ └── 13_repeatedInjection │ │ └── repeatedInjection.spec.js ├── unit │ ├── transmission │ │ ├── __snapshots__ │ │ │ ├── .eslintrc.js │ │ │ └── lineEncoding.test.js.snap │ │ ├── lineEncoding.test.js │ │ ├── formEncoded.test.js │ │ └── util.test.js │ ├── .eslintrc.js │ ├── util.test.js │ ├── allowedOrigins.test.js │ ├── eventBus.test.js │ ├── hooks │ │ ├── normalizeUrl.test.js │ │ ├── XMLHttpRequest.test.js │ │ └── unhandledRejection.test.js │ ├── __snapshots__ │ │ └── resources.test.js.snap │ ├── stripSecrets.test.js │ ├── excessiveUsageIdentification.test.js │ └── ignoreRules.test.js ├── experiments │ └── errors │ │ ├── errorTypes │ │ ├── parsingError.js │ │ ├── undefinedVariable.js │ │ ├── setTimeout.js │ │ ├── tryCatchWrapped.js │ │ ├── thrownString.js │ │ └── thrownError.js │ │ └── unhandledErrorHandler.js ├── server │ ├── .eslintrc.js │ ├── __snapshots__ │ │ └── lineEncodingParser.test.js.snap │ ├── graphql.js │ ├── lineEncodingParser.test.js │ ├── lineEncodingParser.js │ └── controls.js └── util.js ├── whitesource.config ├── .yarnrc.yml ├── .vscode └── settings.json ├── .eslintignore ├── .whitesource ├── sonar-project.properties ├── .editorconfig ├── .gitignore ├── .prettierrc ├── .sonar.setup.bash ├── jest.config.js ├── .babelrc ├── protractor.local.config.js ├── secureWebVitalsLoader.js ├── .concourse ├── scripts │ └── check-update-weasel-version.sh └── tasks │ └── build-and-test.yml ├── .eslintrc.js ├── .github ├── workflows │ └── verify.yml └── pull_request_template.md ├── LICENSE ├── CONTRIBUTING.md ├── rollup.config.js ├── .secrets.baseline └── CHANGELOG.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v18.18.0 2 | -------------------------------------------------------------------------------- /lib/phases.ts: -------------------------------------------------------------------------------- 1 | export const pageLoad = 'pl'; 2 | -------------------------------------------------------------------------------- /test/e2e/05_fetch/graphql_apollo/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /whitesource.config: -------------------------------------------------------------------------------- 1 | excludes=**/.*,**/node_modules,**/test/** 2 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | yarnPath: .yarn/releases/yarn-3.2.0.cjs 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "sonarlint.connectedMode.project": { 3 | "projectKey": "weasel" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /test/unit/transmission/__snapshots__/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rules: { 3 | quotes: 0 4 | } 5 | }; 6 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | test/e2e/05_fetch/graphql_apollo/**/*.js 2 | test/experiments/errors/**/*.js 3 | test/experiments/errors/errorTypes/parsingError.js 4 | -------------------------------------------------------------------------------- /test/experiments/errors/errorTypes/parsingError.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | if (true) { 4 | while true) { 5 | console.log('Info!'); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.whitesource: -------------------------------------------------------------------------------- 1 | { 2 | "settingsInheritedFrom": "whitesource-config/whitesource-config@master", 3 | "scanSettings": { 4 | "configMode": "LOCAL" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /test/unit/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | jest: true, 4 | node: true 5 | }, 6 | globals: { 7 | Promise: false 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /test/experiments/errors/errorTypes/undefinedVariable.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | if (true) { 4 | thisThingIsNotDefinedAndWillThereforeBreakDuringScriptParsing(); 5 | } 6 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.sources=lib/ 2 | sonar.exclusions=lib/__mocks__/** 3 | sonar.javascript.lcov.reportPaths=./coverage/lcov.info 4 | sonar.dynamicAnalysis=reuseReports 5 | -------------------------------------------------------------------------------- /test/experiments/errors/errorTypes/setTimeout.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | setTimeout(function() { 4 | throw new Error('This is an expected runtime error within setTimeout'); 5 | }, 0); 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | indent_style = space 7 | indent_size = 2 8 | trim_trailing_whitespace = true 9 | charset = utf-8 10 | -------------------------------------------------------------------------------- /lib/allowedOrigins.ts: -------------------------------------------------------------------------------- 1 | import {matchesAny} from './util'; 2 | import vars from './vars'; 3 | 4 | export function isAllowedOrigin(url: string) { 5 | return matchesAny(vars.allowedOrigins, url); 6 | } 7 | -------------------------------------------------------------------------------- /test/experiments/errors/errorTypes/tryCatchWrapped.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | tryCatchAndReport(function() { 4 | throw new Error('This is an expected runtime error within setTimeout'); 5 | }); 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /target 3 | /coverage 4 | /.idea/ 5 | .npm-debug* 6 | .yarn/* 7 | !.yarn/patches 8 | !.yarn/plugins 9 | !.yarn/releases 10 | !.yarn/sdks 11 | !.yarn/versions 12 | /tsconfig.tsbuildinfo 13 | -------------------------------------------------------------------------------- /test/e2e/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | globals: { 3 | Promise: false 4 | }, 5 | env: { 6 | node: true, 7 | browser: false, 8 | protractor: true, 9 | jasmine: true 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /test/server/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | globals: { 3 | Promise: false 4 | }, 5 | env: { 6 | node: true, 7 | browser: false 8 | }, 9 | rules: { 10 | 'no-console': 'off' 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "arrowParens": "avoid", 6 | "endOfLine": "auto", 7 | "quoteProps": "preserve", 8 | "bracketSpacing": false 9 | } 10 | -------------------------------------------------------------------------------- /.sonar.setup.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # yarn install --immutable 4 | 5 | # create unit-test coverage report - fail on any errors 6 | #yarn coverage 7 | npm uninstall yarn 8 | corepack enable 9 | yarn init 10 | yarn 11 | yarn test:quick 12 | -------------------------------------------------------------------------------- /test/experiments/errors/errorTypes/thrownString.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | function a() { 4 | b(); 5 | } 6 | 7 | function b() { 8 | c(); 9 | } 10 | 11 | function c() { 12 | throw 'This is an expected runtime error.'; 13 | } 14 | 15 | a(); 16 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | const config = { 4 | testEnvironment: 'jsdom', 5 | globals: { 6 | DEBUG: true 7 | }, 8 | moduleNameMapper: { 9 | '^@lib/(.*)$': '/lib/$1' 10 | } 11 | }; 12 | 13 | module.exports = config; 14 | -------------------------------------------------------------------------------- /test/experiments/errors/errorTypes/thrownError.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | function a() { 4 | b(); 5 | } 6 | 7 | function b() { 8 | c(); 9 | } 10 | 11 | function c() { 12 | throw new Error('This is an expected runtime error.'); 13 | } 14 | 15 | a(); 16 | -------------------------------------------------------------------------------- /test/e2e/07_wrapEventHandlers/buttonEventHandlers.js: -------------------------------------------------------------------------------- 1 | /* eslint-env browser */ 2 | 3 | (function() { 4 | document.getElementById('clickError').addEventListener('click', throwAnError); 5 | 6 | function throwAnError() { 7 | throw new Error('This is intended for testing purposes'); 8 | } 9 | })(); 10 | -------------------------------------------------------------------------------- /test/e2e/04_serverTiming/serverTiming.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | server timing test 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /test/e2e/00_pageLoad/pageLoad.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | pageLoad test 6 | 7 | 8 | Hello World! 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /test/e2e/15_agentVersion_check/agentVersion_check.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | agent version check test 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /test/unit/util.test.js: -------------------------------------------------------------------------------- 1 | import {generateUniqueId} from '../../lib/util'; 2 | 3 | describe('util', () => { 4 | describe('generateUniqueId', () => { 5 | it('must stick to the zipkin trace id contract', () => { 6 | for (let i = 0; i < 500; i++) { 7 | expect(generateUniqueId()).toMatch(/^[0-9a-f]{1,16}$/i); 8 | } 9 | }); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | import waitForPageLoad from './states/waitForPageLoad'; 2 | import {registerState, transitionTo} from './fsm'; 3 | import pageLoaded from './states/pageLoaded'; 4 | import init from './states/init'; 5 | 6 | registerState('init', init); 7 | registerState('waitForPageLoad', waitForPageLoad); 8 | registerState('pageLoaded', pageLoaded); 9 | 10 | transitionTo('init'); 11 | -------------------------------------------------------------------------------- /lib/__mocks__/vars.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | reportingUrl: 'http://eum.example.com', 3 | xhrTransmissionTimeout: 5432, 4 | beaconBatchingTime: 0, 5 | secretPropertyKey: '__secret__', 6 | sessionId: undefined, 7 | sessionStorageKey: 'session', 8 | defaultSessionInactivityTimeoutMillis: 100, 9 | defaultSessionTerminationTimeoutMillis: 200, 10 | maxAllowedSessionTimeoutMillis: 500 11 | }; 12 | -------------------------------------------------------------------------------- /lib/hooks/__mocks__/unhandledError.ts: -------------------------------------------------------------------------------- 1 | const reportedErrors: Array = []; 2 | 3 | export function hookIntoGlobalErrorEvent() { 4 | // noop 5 | } 6 | 7 | export function reportError(error: ErrorLike) { 8 | reportedErrors.push(error); 9 | } 10 | 11 | export function getReportedErrors(): Array { 12 | return reportedErrors; 13 | } 14 | 15 | export function clearReportedErrors() { 16 | reportedErrors.length = 0; 17 | } 18 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/env", 4 | "@babel/preset-typescript" 5 | ], 6 | "plugins": [ 7 | "@babel/plugin-transform-class-properties", 8 | "@babel/plugin-transform-object-rest-spread", 9 | "@babel/plugin-transform-modules-commonjs", 10 | "@babel/plugin-transform-shorthand-properties", 11 | ["@babel/plugin-transform-block-scoping", {"throwIfClosureRequired": true, "tdzEnabled": true}] 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /test/e2e/06_wrapTimers/buttonEventHandlers.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | $('#forceSetTimeout').on('click', function myClickHandler() { 3 | setTimeout(throwAnError, 100, 'st'); 4 | }); 5 | 6 | $('#forceSetInterval').on('click', function myClickHandler() { 7 | setInterval(throwAnError, 100, 'si'); 8 | }); 9 | 10 | function throwAnError(code) { 11 | throw new Error('This is intended for testing purposes: ' + code); 12 | } 13 | })(); 14 | -------------------------------------------------------------------------------- /lib/__mocks__/performance.ts: -------------------------------------------------------------------------------- 1 | // a wrapper around win.performance for cross-browser support 2 | export const performance = { 3 | now() { 4 | return Date.now(); 5 | } 6 | }; 7 | 8 | export let isPerformanceObserverAvailable = true; 9 | export function setPerformanceObserverAvailable(available: boolean) { 10 | isPerformanceObserverAvailable = available; 11 | } 12 | 13 | export function reset() { 14 | setPerformanceObserverAvailable(true); 15 | } 16 | -------------------------------------------------------------------------------- /test/e2e/00_pageLoad/customPage.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | pageLoad test 6 | 7 | 8 | Hello World! 9 | 10 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /test/e2e/00_pageLoad/apiKey.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | pageLoad test 6 | 7 | 8 | Hello World! 9 | 10 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /test/e2e/00_pageLoad/apiKeyViaKey.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | pageLoad test 6 | 7 | 8 | Hello World! 9 | 10 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /lib/types/globals.d.ts: -------------------------------------------------------------------------------- 1 | // Ensure this is treated as a module. 2 | export { }; 3 | 4 | declare global { 5 | declare const DEBUG: boolean; 6 | 7 | interface Window { 8 | Zone?: any; 9 | } 10 | 11 | type ErrorLike = { 12 | message: string; 13 | stack?: string; 14 | 15 | // to permit ['foo'] based access that doesn't break 16 | // when pushed through the Closure compiler 17 | [key: string]: number | string | null | undefined; 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /test/e2e/00_pageLoad/backendTraceId.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | pageLoad test 6 | 7 | 8 | Hello World! 9 | 10 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /lib/__mocks__/util.ts: -------------------------------------------------------------------------------- 1 | let generateUniqueIdCalls = 0; 2 | const dummyIds = ['a', 'b', 'c', 'd', 'e', 'f']; 3 | export function generateUniqueId() { 4 | return dummyIds[generateUniqueIdCalls++]; 5 | } 6 | 7 | let currentNow = 100; 8 | export function setNow(time: number) { 9 | currentNow = time; 10 | } 11 | 12 | export function now() { 13 | return currentNow; 14 | } 15 | 16 | export function reset() { 17 | currentNow = 100; 18 | generateUniqueIdCalls = 0; 19 | } 20 | 21 | reset(); 22 | -------------------------------------------------------------------------------- /lib/resources/consts.ts: -------------------------------------------------------------------------------- 1 | export const urlMaxLength = 255; 2 | 3 | export const initiatorTypes = { 4 | 'other': 0, 5 | 6 | 'img': 1, 7 | // IMAGE element inside a SVG 8 | 'image': 1, 9 | 10 | 'link': 2, 11 | 'script': 3, 12 | 'css': 4, 13 | 14 | 'xmlhttprequest': 5, 15 | 'fetch': 5, 16 | 'beacon': 5, 17 | 18 | 'html': 6, 19 | 'navigation': 6 20 | }; 21 | 22 | export const cachingTypes = { 23 | unknown: 0, 24 | cached: 1, 25 | validated: 2, 26 | fullLoad: 3 27 | }; 28 | -------------------------------------------------------------------------------- /test/e2e/00_pageLoad/ignoredWindowLocation.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | pageLoad test 6 | 7 | 8 | Hello World! 9 | 10 | 11 | 12 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /test/e2e/00_pageLoad/resourceStripSecrets.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | pageLoad test 6 | 7 | 8 | 9 | resource timings strip secrets 10 | 11 | 12 | 13 | 14 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /test/e2e/00_pageLoad/pageLoadStripSecrets.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | pageLoad test 6 | 7 | 8 | 9 | Strip 'secret' and 'account' from query string 10 | 11 | 12 | 13 | 14 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /test/e2e/00_pageLoad/pageLoadRedactFragment.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | pageLoad test 6 | 7 | 8 | 9 | Strip fragment from url 10 | 11 | 12 | 13 | 14 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /protractor.local.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | const chromeArgs = [ 4 | /*'--disable-web-security'*/ 5 | ]; 6 | const proxy = process.env.HTTP_PROXY || process.env.http_proxy || process.env.HTTPS_PROXY || process.env.https_proxy; 7 | if (proxy) { 8 | chromeArgs.push(`--proxy-server=${proxy}`); 9 | } 10 | 11 | exports.config = { 12 | seleniumAddress: 'http://localhost:4444/wd/hub', 13 | specs: ['test/e2e/**/*.spec.js'], 14 | capabilities: { 15 | 'browserName': 'chrome', 16 | 'goog:chromeOptions': { 17 | 'args': chromeArgs 18 | } 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /test/e2e/00_pageLoad/queryTrackedDomainList/www.example1.com.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | pageLoad test 6 | 7 | 8 | 9 | Test url domain in queryTrackedDomainList 10 | 11 | 12 | 13 | 14 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /secureWebVitalsLoader.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | const fs = require('fs'); 4 | 5 | module.exports = () => ({ 6 | name: 'secureWebVitalsLoader', 7 | load(id) { 8 | if (id.endsWith('web-vitals/dist/web-vitals.js')) { 9 | const content = fs.readFileSync(id, {encoding: 'utf8'}); 10 | const parts = content.split('export{'); 11 | if (parts.length !== 2) { 12 | throw new Error('web-vitals module must have changed. Cannot auto-wrap it with try/catch.'); 13 | } 14 | return `try {${parts[0]}} catch (e) {}export{${parts[1]}`; 15 | } 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /test/e2e/00_pageLoad/queryTrackedDomainList/www.example2.com.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | pageLoad test 6 | 7 | 8 | 9 | Test url domain not in queryTrackedDomainList 10 | 11 | 12 | 13 | 14 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /lib/performance.ts: -------------------------------------------------------------------------------- 1 | import {win} from './browser'; 2 | 3 | // a wrapper around win.performance for cross-browser support 4 | export const performance = 5 | win.performance || 6 | (win as any).webkitPerformance || 7 | (win as any).msPerformance || 8 | (win as any).mozPerformance; 9 | 10 | export const isTimingAvailable = !!(performance && performance.timing); 11 | export const isResourceTimingAvailable = !!(performance && performance.getEntriesByType); 12 | export const isPerformanceObserverAvailable = 13 | performance && typeof (win as any)['PerformanceObserver'] === 'function' && typeof performance['now'] === 'function'; 14 | -------------------------------------------------------------------------------- /test/e2e/00_pageLoad/resourceTimings.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | pageLoad test 6 | 7 | 8 | 9 | 10 | 11 | 12 | resource timings 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /lib/states/waitForPageLoad.ts: -------------------------------------------------------------------------------- 1 | import {pageLoad as pageLoadPhase} from '../phases'; 2 | import onLoadEvent from '../events/onLoad'; 3 | import type {State} from '../types'; 4 | import {transitionTo} from '../fsm'; 5 | import {on} from '../eventBus'; 6 | import vars from '../vars'; 7 | 8 | const state: State = { 9 | onEnter() { 10 | on(onLoadEvent.name, onLoad); 11 | onLoadEvent.initialize(); 12 | }, 13 | 14 | getActiveTraceId() { 15 | return vars.pageLoadTraceId; 16 | }, 17 | 18 | getActivePhase() { 19 | return pageLoadPhase; 20 | } 21 | }; 22 | export default state; 23 | 24 | function onLoad() { 25 | transitionTo('pageLoaded'); 26 | } 27 | -------------------------------------------------------------------------------- /test/e2e/00_pageLoad/multiComplicatedMeta.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | pageLoad test 6 | 7 | 8 | Hello World! 9 | 10 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /lib/utilWrap.ts: -------------------------------------------------------------------------------- 1 | // Define a specific type for the functions 2 | type GenericFunction = (...args: unknown[]) => unknown; 3 | 4 | // Define the params that are be added to the shimmer wrapped function 5 | export interface ShimmerWrap extends GenericFunction { 6 | __wrapped: boolean; 7 | __unwrap: GenericFunction; 8 | __original: GenericFunction; 9 | } 10 | 11 | export function isWrapped(funk: unknown): funk is ShimmerWrap { 12 | return ( 13 | typeof funk === 'function' && 14 | typeof (funk as ShimmerWrap).__original === 'function' && 15 | typeof (funk as ShimmerWrap).__unwrap === 'function' && 16 | (funk as ShimmerWrap).__wrapped === true 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /test/e2e/05_fetch/graphql_apollo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql_apollo", 3 | "version": "1.0.0", 4 | "description": "Local example app to verify common GraphQL Apollo instrumentations", 5 | "main": "dist/main.js", 6 | "scripts": { 7 | "build": "webpack --mode production" 8 | }, 9 | "author": "Ben Blackmore", 10 | "license": "MIT", 11 | "dependencies": { 12 | "apollo-cache-inmemory": "^1.6.6", 13 | "apollo-client": "^2.6.10", 14 | "apollo-link-http": "^1.5.17", 15 | "graphql": "^15.0.0", 16 | "graphql-tag": "^2.10.3" 17 | }, 18 | "devDependencies": { 19 | "webpack": "^4.43.0", 20 | "webpack-cli": "^3.3.11" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /test/e2e/00_pageLoad/tooMuchMeta.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | pageLoad test 6 | 7 | 8 | Hello World! 9 | 10 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /test/e2e/00_pageLoad/queryTrackedDomainList/www.exampleWithRedactionCase.com.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | pageLoad test 6 | 7 | 8 | 9 | Test url in queryTrackedDomainList and with redacted secret and fragment. 10 | 11 | 12 | 13 | 14 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /lib/events/onLoad.ts: -------------------------------------------------------------------------------- 1 | import {addEventListener, now} from '../util'; 2 | import {setTimeout} from '../timers'; 3 | import type {Event} from '../types'; 4 | import {emit} from '../eventBus'; 5 | import {win} from '../browser'; 6 | 7 | const event: Event = { 8 | name: 'e:onLoad', 9 | time: null, 10 | initialize() { 11 | if (document.readyState === 'complete') { 12 | return onReady(); 13 | } 14 | addEventListener(win, 'load', function () { 15 | // we want to get timing data for loadEventEnd, 16 | // so asynchronously process this 17 | setTimeout(onReady, 0); 18 | }); 19 | } 20 | }; 21 | 22 | function onReady() { 23 | event.time = now(); 24 | emit(event.name, event.time); 25 | } 26 | 27 | export default event; 28 | -------------------------------------------------------------------------------- /lib/localStorage.ts: -------------------------------------------------------------------------------- 1 | // localStorage API re-exposed to allow testing. 2 | 3 | import { localStorage } from './browser'; 4 | 5 | export const isSupported = 6 | localStorage != null && typeof localStorage.getItem === 'function' && typeof localStorage.setItem === 'function'; 7 | 8 | export function getItem(k: string): string | null | undefined { 9 | if (isSupported && localStorage) { 10 | return localStorage.getItem(k); 11 | } 12 | return null; 13 | } 14 | 15 | export function setItem(k: string, v: string): void { 16 | if (isSupported && localStorage) { 17 | localStorage.setItem(k, v); 18 | } 19 | } 20 | 21 | export function removeItem(k: string): void { 22 | if (isSupported && localStorage) { 23 | localStorage.removeItem(k); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.concourse/scripts/check-update-weasel-version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | set -o pipefail 4 | set -xb 5 | 6 | # Check if weasel package.json contains valid version 7 | if ! grep -q '"version": "[0-9.]*"' package.json; then 8 | echo "Error: 'version' field not found or not valid in package.json" 9 | exit 1 10 | else 11 | echo "Version field is present in package.json and valid" 12 | fi 13 | 14 | # Extract the weasel version from package.json 15 | VERSION=$(grep -o '"version": *"[^"]*"' package.json | grep -o '"[^"]*"$' | tr -d '"') 16 | 17 | # Replace agentVersion variable in weasel 18 | sed -i "s|agentVersion: '[0-9.]*'|agentVersion: '$VERSION'|g" lib/vars.ts 19 | echo "Updated version from package.json in lib/vars.ts:" 20 | grep "agentVersion: '[0-9.]*'" lib/vars.ts 21 | -------------------------------------------------------------------------------- /test/e2e/00_pageLoad/ignoredResources.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | pageLoad test 6 | 7 | 8 | 9 | 10 | 11 | 12 | resource timings 13 | 14 | 15 | 16 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /test/server/__snapshots__/lineEncodingParser.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`lineEncodingParser must decode beacons 1`] = ` 4 | Array [ 5 | Object { 6 | "id": "42", 7 | "key": "abcdefg", 8 | "page": "landing", 9 | "pageVisible": "true", 10 | }, 11 | Object { 12 | "id": "43", 13 | "key": "abcdefg", 14 | "m_spec": "{\\"signedIn\\":true}", 15 | "page": "product-details", 16 | "pageVisible": "false", 17 | }, 18 | Object { 19 | "key with tabstop": "b", 20 | "key 21 | with 22 | new 23 | line": "a", 24 | "key\\\\with 25 | all reserved\\\\ncharacters": "value\\\\ with 26 | all reserved\\\\ncharacter", 27 | "value with new line": "a 28 | b 29 | c", 30 | "value with tabstop": "a b c", 31 | }, 32 | ] 33 | `; 34 | -------------------------------------------------------------------------------- /test/server/graphql.js: -------------------------------------------------------------------------------- 1 | const { ApolloServer, gql } = require('apollo-server-express'); 2 | 3 | const typeDefs = gql` 4 | type Book { 5 | title: String 6 | author: String 7 | } 8 | 9 | type Query { 10 | book(title: String!): Book, 11 | books: [Book] 12 | } 13 | 14 | type Mutation { 15 | borrow: Book 16 | } 17 | `; 18 | 19 | const books = [ 20 | { 21 | title: 'Harry Potter and the Chamber of Secrets', 22 | author: 'J.K. Rowling', 23 | }, 24 | { 25 | title: 'Jurassic Park', 26 | author: 'Michael Crichton', 27 | }, 28 | ]; 29 | 30 | const resolvers = { 31 | Query: { 32 | book: () => books[0], 33 | books: () => books, 34 | }, 35 | Mutation: { 36 | borrow: () => books[0] 37 | } 38 | }; 39 | 40 | module.exports = exports = new ApolloServer({typeDefs, resolvers}); 41 | -------------------------------------------------------------------------------- /test/e2e/05_fetch/graphql_apollo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | GraphQL Apollo Test 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /test/unit/allowedOrigins.test.js: -------------------------------------------------------------------------------- 1 | import {expect} from 'chai'; 2 | 3 | import {isAllowedOrigin} from '../../lib/allowedOrigins'; 4 | import vars from '../../lib/vars'; 5 | 6 | describe('allowed origins', () => { 7 | afterEach(() => { 8 | vars.allowedOrigins = []; 9 | }); 10 | 11 | it('must not allow origin when the allow list is empty', () => { 12 | expect(isAllowedOrigin('http://example.com')).to.equal(false); 13 | }); 14 | 15 | it('must identify origins to allow', () => { 16 | vars.allowedOrigins = [/example.com$/]; 17 | 18 | expect(isAllowedOrigin('http://example.com')).to.equal(true); 19 | expect(isAllowedOrigin('http://shop.example.com')).to.equal(true); 20 | expect(isAllowedOrigin('http://example.comm')).to.equal(false); 21 | expect(isAllowedOrigin('http://google.com')).to.equal(false); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /lib/fsm.ts: -------------------------------------------------------------------------------- 1 | import type {State} from './types'; 2 | import {info} from './debug'; 3 | 4 | const states: {[name: string]: State} = {}; 5 | let currentStateName: string | null; 6 | 7 | export function registerState(name: string, impl: State) { 8 | states[name] = impl; 9 | } 10 | 11 | export function transitionTo(nextStateName: string) { 12 | if (DEBUG) { 13 | info('Transitioning from %s to %s', currentStateName || '', nextStateName); 14 | } 15 | 16 | currentStateName = nextStateName; 17 | states[nextStateName].onEnter(); 18 | } 19 | 20 | export function getActiveTraceId(): string | null | undefined { 21 | return currentStateName ? states[currentStateName].getActiveTraceId() : null; 22 | } 23 | 24 | export function getActivePhase(): string | null | undefined { 25 | return currentStateName ? states[currentStateName].getActivePhase() : null; 26 | } 27 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | plugins: ['@typescript-eslint'], 4 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], 5 | env: { 6 | browser: true 7 | }, 8 | parserOptions: { 9 | sourceType: 'module' 10 | }, 11 | globals: { 12 | DEBUG: false, 13 | EumState: false, 14 | EumEvent: false, 15 | ErrorLike: false 16 | }, 17 | rules: { 18 | '@typescript-eslint/no-explicit-any': 'off', 19 | '@typescript-eslint/no-unused-vars': 'off', 20 | indent: ['error', 2, {SwitchCase: 1}], 21 | 'linebreak-style': ['error', 'unix'], 22 | quotes: ['error', 'single'], 23 | semi: ['error', 'always'] 24 | }, 25 | overrides: [ 26 | { 27 | files: ['**/*.js'], 28 | rules: { 29 | '@typescript-eslint/no-var-requires': 'off' 30 | } 31 | } 32 | ] 33 | }; 34 | -------------------------------------------------------------------------------- /lib/events/onLastChance.ts: -------------------------------------------------------------------------------- 1 | import { addEventListener } from '../util'; 2 | import { doc, win } from '../browser'; 3 | 4 | let isUnloading = false; 5 | 6 | export function onLastChance(fn: () => void) { 7 | if (isUnloading) { 8 | fn(); 9 | } 10 | 11 | addEventListener(doc, 'visibilitychange', function() { 12 | if (doc.visibilityState !== 'visible') { 13 | fn(); 14 | } 15 | }); 16 | 17 | addEventListener(win, 'pagehide', function() { 18 | isUnloading = true; 19 | fn(); 20 | }); 21 | 22 | // According to the spec visibilitychange should be a replacement for 23 | // beforeunload, but the reality is different (as of 2019-04-17). Chrome will 24 | // close tabs without firing visibilitychange. beforeunload on the other hand 25 | // is fired. 26 | addEventListener(win, 'beforeunload', function() { 27 | isUnloading = true; 28 | fn(); 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /test/unit/transmission/__snapshots__/lineEncoding.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`transmission/lineEncoding must encode just one beacon 1`] = ` 4 | "page landing 5 | key abcdefg 6 | id 42 7 | pageVisible true" 8 | `; 9 | 10 | exports[`transmission/lineEncoding must encode multiple beacons 1`] = ` 11 | "page landing 12 | key abcdefg 13 | id 42 14 | pageVisible true 15 | 16 | page product-details 17 | key abcdefg 18 | id 43 19 | pageVisible false 20 | m_spec {\\"signedIn\\":true}" 21 | `; 22 | 23 | exports[`transmission/lineEncoding must handle reserved characters in keys and values 1`] = ` 24 | "key\\\\nwith\\\\nnew\\\\nline a 25 | key\\\\twith\\\\ttabstop b 26 | value with new line a\\\\nb\\\\nc 27 | value with tabstop a\\\\tb\\\\tc 28 | key\\\\\\\\with\\\\nall\\\\treserved\\\\\\\\ncharacters value\\\\\\\\ with\\\\nall\\\\treserved\\\\\\\\ncharacter" 29 | `; 30 | -------------------------------------------------------------------------------- /test/e2e/15_agentVersion_check/agentVersion_check.spec.js: -------------------------------------------------------------------------------- 1 | const {registerTestServerHooks, getE2ETestBaseUrl, getBeacons} = require('../../server/controls'); 2 | const {registerBaseHooks} = require('../base'); 3 | const util = require('../../util'); 4 | 5 | const cexpect = require('chai').expect; 6 | 7 | describe('15_agentVersion_check', () => { 8 | registerTestServerHooks(); 9 | registerBaseHooks(); 10 | 11 | beforeEach(() => { 12 | browser.get(getE2ETestBaseUrl('15_agentVersion_check/agentVersion_check')); 13 | }); 14 | 15 | it('must check if agentVersion is available in transmitted beacon', () => { 16 | return util.retry(() => { 17 | return getBeacons() 18 | .then(beacons => { 19 | beacons.forEach(beacon => { 20 | cexpect(beacon.agv).to.not.equal(undefined); 21 | cexpect(beacon.agv).to.equal('0.0.0'); 22 | }); 23 | }); 24 | }); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /lib/eventBus.ts: -------------------------------------------------------------------------------- 1 | let bus: Record> = {}; 2 | 3 | type Listener = (arg0: unknown) => unknown; 4 | 5 | export function on(name: string, fn: Listener) { 6 | const listeners = bus[name] = bus[name] || []; 7 | listeners.push(fn); 8 | } 9 | 10 | export function off(name: string, fn: Listener) { 11 | const listeners = bus[name]; 12 | if (!listeners) { 13 | return; 14 | } 15 | 16 | for (let i = listeners.length - 1; i >= 0; i--) { 17 | if (listeners[i] === fn) { 18 | listeners.splice(i, 1); 19 | } 20 | } 21 | 22 | if (listeners.length === 0) { 23 | delete bus[name]; 24 | } 25 | } 26 | 27 | export function emit(name: string, value: unknown) { 28 | const listeners = bus[name]; 29 | if (!listeners) { 30 | return; 31 | } 32 | for (let i = 0, length = listeners.length; i < length; i++) { 33 | listeners[i](value); 34 | } 35 | } 36 | 37 | export function reset() { 38 | bus = {}; 39 | } 40 | -------------------------------------------------------------------------------- /lib/pageTransitionData.ts: -------------------------------------------------------------------------------- 1 | import vars from './vars'; 2 | 3 | // Define the structure for page transition data 4 | export interface PageTransitionData { 5 | d?: number; // Duration for the beacon 6 | } 7 | 8 | // Add page transition data to the global vars object 9 | declare module './vars' { 10 | interface DefaultVars { 11 | pageTransitionData?: PageTransitionData; 12 | } 13 | } 14 | 15 | // Initialize the pageTransitionData property in vars 16 | vars.pageTransitionData = {}; 17 | 18 | // Function to set page transition data 19 | export function setPageTransitionData(data: PageTransitionData): void { 20 | vars.pageTransitionData = data; 21 | } 22 | 23 | // Function to get page transition data 24 | export function getPageTransitionData(): PageTransitionData { 25 | return vars.pageTransitionData || {}; 26 | } 27 | 28 | // Function to clear page transition data 29 | export function clearPageTransitionData(): void { 30 | vars.pageTransitionData = {}; 31 | } 32 | -------------------------------------------------------------------------------- /lib/serverTiming.ts: -------------------------------------------------------------------------------- 1 | import {performance, isResourceTimingAvailable} from './performance'; 2 | import {info} from './debug'; 3 | import vars from './vars'; 4 | 5 | export function getPageLoadBackendTraceId() { 6 | if (!isResourceTimingAvailable) { 7 | return null; 8 | } 9 | 10 | const entries = performance.getEntriesByType('navigation'); 11 | for (let i = 0; i < entries.length; i++) { 12 | const entry = entries[i]; 13 | 14 | if (entry['serverTiming'] != null) { 15 | for (let j = 0; j < entry['serverTiming'].length; j++) { 16 | const serverTiming = entry['serverTiming'][j]; 17 | if (serverTiming['name'] === vars.serverTimingBackendTraceIdEntryName) { 18 | if (DEBUG) { 19 | info('Found page load backend trace ID %s in Server-Timing header.', serverTiming['description']); 20 | } 21 | return serverTiming['description']; 22 | } 23 | } 24 | } 25 | } 26 | 27 | return null; 28 | } 29 | -------------------------------------------------------------------------------- /test/e2e/02_error/manualWithErrorString.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | error test 6 | 7 | 8 | 9 | 12 | 13 | 14 | 15 | 16 | 17 | manual error reporting 18 | 19 | 20 | -------------------------------------------------------------------------------- /lib/excessiveUsageIdentification.ts: -------------------------------------------------------------------------------- 1 | import {setInterval} from './timers'; 2 | 3 | export function createExcessiveUsageIdentifier(opts: { 4 | maxCalls?: number, 5 | maxCallsPerTenMinutes: number, 6 | maxCallsPerTenSeconds: number 7 | }) { 8 | const maxCalls = opts.maxCalls || 4096; 9 | const maxCallsPerTenMinutes = opts.maxCallsPerTenMinutes || 128; 10 | const maxCallsPerTenSeconds = opts.maxCallsPerTenSeconds || 32; 11 | 12 | let totalCalls = 0; 13 | let totalCallsInLastTenMinutes = 0; 14 | let totalCallsInLastTenSeconds = 0; 15 | 16 | setInterval(function() { 17 | totalCallsInLastTenMinutes = 0; 18 | }, 1000 * 60 * 10); 19 | 20 | setInterval(function() { 21 | totalCallsInLastTenSeconds = 0; 22 | }, 1000 * 10); 23 | 24 | return function isExcessiveUsage() { 25 | return ( 26 | ++totalCalls > maxCalls || 27 | ++totalCallsInLastTenMinutes > maxCallsPerTenMinutes || 28 | ++totalCallsInLastTenSeconds > maxCallsPerTenSeconds 29 | ); 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /test/server/lineEncodingParser.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | import {encode} from '../../lib/transmission/lineEncoding'; 4 | import {decode} from './lineEncodingParser'; 5 | 6 | describe('lineEncodingParser', () => { 7 | it('must decode beacons', () => { 8 | const beacons = [ 9 | { 10 | page: 'landing', 11 | key: 'abcdefg', 12 | id: 42, 13 | pageVisible: true 14 | }, 15 | { 16 | page: 'product-details', 17 | key: 'abcdefg', 18 | id: 43, 19 | pageVisible: false, 20 | m_spec: JSON.stringify({signedIn: true}) 21 | }, 22 | { 23 | 'key\nwith\nnew\nline': 'a', 24 | 'key\twith\ttabstop': 'b', 25 | 'value with new line': 'a\nb\nc', 26 | 'value with tabstop': 'a\tb\tc', 27 | 'key\\with\nall\treserved\\ncharacters': 'value\\ with\nall\treserved\\ncharacter', 28 | } 29 | ]; 30 | 31 | expect(decode(encode(beacons))).toMatchSnapshot(); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /test/e2e/00_pageLoad/meta.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | pageLoad test 6 | 7 | 8 | Hello World! 9 | 10 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /lib/transmission/util.ts: -------------------------------------------------------------------------------- 1 | import type { ReportingBackend } from '../types'; 2 | import vars from '../vars'; 3 | 4 | export function isTransmitionRequest(url: string) { 5 | const lowerCaseUrl = url.toLowerCase(); 6 | 7 | if (vars.reportingBackends && vars.reportingBackends.length > 0) { 8 | for (let i = 0, len = vars.reportingBackends.length; i < len; i++) { 9 | const reportingBackend: ReportingBackend = vars.reportingBackends[i]; 10 | if (reportingBackend['reportingUrl'] && reportingBackend['reportingUrl'].length > 0) { 11 | const lowerCaseReportingUrl = reportingBackend['reportingUrl'].toLowerCase(); 12 | if (lowerCaseUrl === lowerCaseReportingUrl || lowerCaseUrl === lowerCaseReportingUrl + '/') { 13 | return true; 14 | } 15 | } 16 | } 17 | } else if (vars.reportingUrl) { 18 | const lowerCaseReportingUrl = vars.reportingUrl.toLowerCase(); 19 | return lowerCaseUrl === lowerCaseReportingUrl || lowerCaseUrl === lowerCaseReportingUrl + '/'; 20 | } 21 | return false; 22 | } 23 | -------------------------------------------------------------------------------- /.github/workflows/verify.yml: -------------------------------------------------------------------------------- 1 | name: Verify 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Setup Node.js 15 | uses: actions/setup-node@v2 16 | with: 17 | node-version: '16.14.2' 18 | cache: 'yarn' 19 | - uses: actions/setup-java@v2 20 | with: 21 | distribution: 'zulu' 22 | java-version: '11' 23 | - uses: saucelabs/sauce-connect-action@v2 24 | with: 25 | username: ${{ secrets.SAUCE_USERNAME }} 26 | accessKey: ${{ secrets.SAUCE_ACCESS_KEY }} 27 | tunnelIdentifier: github-action-tunnel 28 | - run: npm install -g yarn@berry 29 | - run: yarn 30 | - run: yarn run build 31 | - run: yarn run test:quick 32 | - run: yarn run test:e2e:saucelabs 33 | env: 34 | SAUCE_USERNAME: ${{ secrets.SAUCE_USERNAME }} 35 | SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }} 36 | -------------------------------------------------------------------------------- /test/e2e/02_error/manualWithPageChange.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | error test 6 | 7 | 8 | 9 | 14 | 15 | 16 | 17 | 18 | 19 | manual error reporting with page change 20 | 21 | 22 | -------------------------------------------------------------------------------- /test/e2e/05_fetch/beforePageLoad.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | fetch test 6 | 7 | 8 | 9 | fetch before page load 10 | 11 |
12 | 13 | 14 | 15 | 16 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /test/e2e/00_pageLoad/navigationTimings.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | pageLoad test 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | navigation timings 16 | 17 | 18 | -------------------------------------------------------------------------------- /test/server/lineEncodingParser.js: -------------------------------------------------------------------------------- 1 | exports.decode = str => { 2 | const beacons = []; 3 | const lines = str.split('\n'); 4 | 5 | let beacon = {}; 6 | beacons.push(beacon); 7 | lines.forEach(line => { 8 | if (line.length === 0) { 9 | beacon = {}; 10 | beacons.push(beacon); 11 | } else { 12 | const [key, value] = line.split('\t', 2); 13 | beacon[decodePart(key)] = decodePart(value); 14 | } 15 | }); 16 | 17 | return beacons; 18 | }; 19 | 20 | function decodePart(part) { 21 | let result = ''; 22 | 23 | for (let i = 0; i < part.length; i++) { 24 | const char = part.charAt(i); 25 | if (char === '\\') { 26 | const nextChar = part.charAt(i + 1); 27 | if (nextChar === 't') { 28 | result += '\t'; 29 | } else if (nextChar === 'n') { 30 | result += '\n'; 31 | } else if (nextChar === '\\') { 32 | result += '\\'; 33 | } else { 34 | result += '\\' + nextChar; 35 | } 36 | i++; 37 | } else { 38 | result += char; 39 | } 40 | } 41 | 42 | return result; 43 | } 44 | -------------------------------------------------------------------------------- /test/e2e/05_fetch/fetchStripSecrets.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | fetch test 6 | 7 | 8 | 9 | fetch before page load 10 | 11 |
12 | 13 | 14 | 15 | 16 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /test/e2e/10_pageChanges/pageChangesDuringOnLoad.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | page changes test 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /lib/debug.ts: -------------------------------------------------------------------------------- 1 | import {noop} from './util'; 2 | 3 | type Logger = (...args: any[]) => void; 4 | 5 | export const log: Logger = DEBUG ? createLogger('log') : noop; 6 | export const info: Logger = DEBUG ? createLogger('info') : noop; 7 | export const warn: Logger = DEBUG ? createLogger('warn') : noop; 8 | export const error: Logger = DEBUG ? createLogger('error') : noop; 9 | export const debug: Logger = DEBUG ? createLogger('debug') : noop; 10 | 11 | function createLogger(method: Extract): Logger { 12 | if (typeof console === 'undefined' || typeof console.log !== 'function' || typeof console.log.apply !== 'function') { 13 | return noop; 14 | } 15 | 16 | if (console[method] && typeof console[method].apply === 'function') { 17 | return function () { 18 | // eslint-disable-next-line prefer-rest-params, prefer-spread 19 | console[method].apply(console, arguments as any); 20 | }; 21 | } 22 | 23 | return function () { 24 | // eslint-disable-next-line prefer-rest-params, prefer-spread 25 | console.log.apply(console, arguments as any); 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /test/e2e/07_wrapEventHandlers/sameOriginErrors.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | error test 6 | 7 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2020 Instana Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/e2e/02_error/manualWithErrorObject.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | error test 6 | 7 | 8 | 9 | 20 | 21 | 22 | 23 | 24 | 25 | manual error reporting 26 | 27 | 28 | -------------------------------------------------------------------------------- /test/e2e/03_transmission/transmission.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | transmission test 6 | 7 | 8 | 9 | 35 | 36 | 37 | ajax after page load 38 | 39 |
40 | 41 | 42 | -------------------------------------------------------------------------------- /lib/browser.ts: -------------------------------------------------------------------------------- 1 | // aliasing globals for improved minifications 2 | 3 | export const win: typeof window = window; 4 | export const doc: typeof win.document = win.document; 5 | export const nav: typeof navigator = navigator; 6 | export const inEncodeURIComponent: (arg: string) => string = win.encodeURIComponent; 7 | export const XMLHttpRequest = win.XMLHttpRequest; 8 | export const originalFetch = win.fetch; 9 | export const localStorage: Storage | null = (function () { 10 | try { 11 | return win.localStorage; 12 | } catch (e) { 13 | // localStorage access is not permitted in certain security modes, e.g. 14 | // when cookies are completely disabled in web browsers. 15 | return null; 16 | } 17 | })(); 18 | 19 | /** 20 | * Leverage's browser behavior to load image sources. Exposed via this module 21 | * to enable testing. 22 | */ 23 | export function executeImageRequest(url: string) { 24 | const image = new Image(); 25 | image.src = url; 26 | } 27 | 28 | /** 29 | * Exposed via this module to enable testing. 30 | */ 31 | export function sendBeacon(url: string, data: string): boolean { 32 | return nav.sendBeacon(url, data); 33 | } 34 | -------------------------------------------------------------------------------- /test/e2e/01_xhr/xhrBeforePageLoad.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | xhr test 6 | 7 | 8 | 9 | 10 | 11 | 12 | ajax before page load 13 | 14 |
15 | 16 | 17 | 18 | 19 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /lib/hooks/unhandledRejection.ts: -------------------------------------------------------------------------------- 1 | import {reportError} from './unhandledError'; 2 | import {win} from '../browser'; 3 | 4 | const messagePrefix = 'Unhandled promise rejection: '; 5 | const stackUnavailableMessage = ''; 6 | 7 | export function hookIntoGlobalUnhandledRejectionEvent() { 8 | if (typeof win.addEventListener === 'function') { 9 | win.addEventListener('unhandledrejection', onUnhandledRejection); 10 | } 11 | } 12 | 13 | export function onUnhandledRejection(event: PromiseRejectionEvent) { 14 | if (event.reason == null) { 15 | reportError({ 16 | message: messagePrefix + '', 17 | stack: stackUnavailableMessage 18 | }); 19 | } else if (typeof event.reason.message === 'string') { 20 | reportError({ 21 | message: messagePrefix + event.reason.message, 22 | stack: typeof event.reason.stack === 'string' ? event.reason.stack : stackUnavailableMessage 23 | }); 24 | } else if (typeof event.reason !== 'object') { 25 | reportError({ 26 | message: messagePrefix + event.reason, 27 | stack: stackUnavailableMessage 28 | }); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /test/e2e/05_fetch/fetchWithFormData.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | fetch test 6 | 7 | 8 | 9 | 10 | 11 | Fetch with form data 12 |
13 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /test/e2e/05_fetch/error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | fetch test 6 | 7 | 8 | 9 | 10 | 11 | fetch error 12 | 13 |
14 | 15 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Local development environment 4 | 5 | ```sh 6 | git clone https://github.com/instana/weasel.git 7 | cd weasel 8 | yarn 9 | # Does this fail for you? 10 | yarn webdriver-manager update 11 | yarn webdriver:start 12 | yarn build 13 | yarn test 14 | ``` 15 | 16 | ## Executing a Single E2E Test 17 | To execute a single E2E test, change the `describe` or `it` function to `fdescribe` or `fit` respectively. 18 | 19 | ## Executing local tests against specific browsers on Saucelabs 20 | You will need [sauce connect](https://docs.saucelabs.com/secure-connections/sauce-connect/setup-configuration/basic-setup/). 21 | 22 | ```shell 23 | export SAUCE_USERNAME="…" 24 | export SAUCE_ACCESS_KEY="…" 25 | export SAUCE_IDENTIFIER="manual_tests" 26 | 27 | # Start the sauce connect proxy in one terminal window: 28 | sc -u "$SAUCE_USERNAME" -k "$SAUCE_ACCESS_KEY" -P 4445 -i "$SAUCE_IDENTIFIER" 29 | 30 | # In another window, start the tests 31 | IS_TEST=true TRAVIS=true TRAVIS_JOB_NUMBER="$SAUCE_IDENTIFIER" yarn run test:e2e 32 | ``` 33 | 34 | ## Configure Travis CI 35 | 36 | ``` 37 | $ travis encrypt --add env.global SAUCE_USERNAME=… 38 | $ travis encrypt --add env.global SAUCE_ACCESS_KEY=… 39 | ``` 40 | -------------------------------------------------------------------------------- /test/e2e/01_xhr/xhrBeforePageLoadSynchronous.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | xhr test 6 | 7 | 8 | 9 | 10 | 11 | 12 | 01_xhrBeforePageLoadSynchronous 13 | 14 |
15 | 16 | 17 | 18 | 19 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /lib/transmission/lineEncoding.ts: -------------------------------------------------------------------------------- 1 | import type { Beacon } from '../types'; 2 | import {hasOwnProperty} from '../util'; 3 | 4 | // We know that must values are plain key/value pairs. We therefore choose a format that is 5 | // very easy to parse in a streaming fashion on the server-side. This format is a basic 6 | // line-based encoding of key/value pairs. Each line contains a key/value pair. 7 | // 8 | // In contrast to form encoding, this encoding handles JSON much better. 9 | export function encode(beacons: Array>): string { 10 | let str = ''; 11 | 12 | for (let i = 0; i < beacons.length; i++) { 13 | const beacon = beacons[i]; 14 | 15 | // Multiple beacons are separated by an empty line 16 | str += '\n'; 17 | 18 | for (const key in beacon) { 19 | if (hasOwnProperty(beacon, key)) { 20 | const value = beacon[key]; 21 | if (value != null) { 22 | str += '\n' + encodePart(key) + '\t' + encodePart(value); 23 | } 24 | } 25 | } 26 | } 27 | 28 | return str.substring(2); 29 | } 30 | 31 | function encodePart(part: any) { 32 | return String(part) 33 | .replace(/\\/g, '\\\\') 34 | .replace(/\n/g, '\\n') 35 | .replace(/\t/g, '\\t'); 36 | } 37 | -------------------------------------------------------------------------------- /test/e2e/10_pageChanges/pageChanges.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | page changes test 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /test/e2e/01_xhr/xhrStripSecrets.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | xhr test 6 | 7 | 8 | 9 | 10 | 11 | 12 | ajax strip secret 13 | 14 |
15 | 16 | 17 | 18 | 19 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /test/e2e/05_fetch/fetchWithRequestObjectAndFormData.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | fetch test 6 | 7 | 8 | 9 | 10 | 11 | Fetch with Request object and form data 12 |
13 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /test/e2e/06_wrapTimers/sameOriginErrors.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | error test 6 | 7 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 23 | 24 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /test/e2e/01_xhr/xhrAfterPageLoad.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | xhr test 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | ajax after page load 15 | 16 |
17 | 18 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /test/e2e/07_wrapEventHandlers/crossOriginErrors.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | error test 6 | 7 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 23 | 24 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /test/e2e/05_fetch/afterPageLoad.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | fetch test 6 | 7 | 8 | 9 | 10 | 11 | fetch after page load 12 | 13 |
14 | 15 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /test/e2e/04_serverTiming/serverTiming.spec.js: -------------------------------------------------------------------------------- 1 | const {registerTestServerHooks, getE2ETestBaseUrl, getBeacons} = require('../../server/controls'); 2 | const {registerBaseHooks, getCapabilities} = require('../base'); 3 | const util = require('../../util'); 4 | 5 | const cexpect = require('chai').expect; 6 | 7 | describe('04_serverTiming', () => { 8 | registerTestServerHooks(); 9 | registerBaseHooks(); 10 | 11 | beforeEach(() => { 12 | browser.get(getE2ETestBaseUrl('04_serverTiming/serverTiming')); 13 | }); 14 | 15 | it('must read backend trace ID when available from server timing header', () => { 16 | return getCapabilities().then(capabilities => { 17 | if (!hasServerTimingSupport(capabilities)) { 18 | return; 19 | } 20 | 21 | return util.retry(() => { 22 | return getBeacons() 23 | .then(beacons => { 24 | cexpect(beacons).to.have.lengthOf(1); 25 | cexpect(beacons[0].bt).to.equal('aFakeBackendTraceIdForTests'); 26 | }); 27 | }); 28 | }); 29 | }); 30 | 31 | function hasServerTimingSupport(capabilities) { 32 | const version = Number(capabilities.version); 33 | return capabilities.browserName === 'chrome' && (capabilities.version == null || version >= 65); 34 | } 35 | }); 36 | -------------------------------------------------------------------------------- /test/e2e/05_fetch/requestObjectAndInitObjectWithoutHeaders.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | fetch test 6 | 7 | 8 | 9 | 10 | 11 | fetch with request object 12 | 13 |
14 | 15 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /test/e2e/08_unhandledRejections/unhandledRejection.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | unhandled rejection 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 18 | 19 |
No unhandled rejection handled
20 | 21 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /test/e2e/10_pageChanges/changingBackToPreviousPage.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | page changes returning back to previous page test 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /test/e2e/06_wrapTimers/crossOriginErrors.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | error test 6 | 7 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 23 | 24 | 28 | 29 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /test/e2e/01_xhr/xhrTimeout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | xhr test 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | ajax timeout 15 | 16 |
17 | 18 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /test/e2e/05_fetch/captureHeaders.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Caputure header test 6 | 7 | 8 | 9 | 10 | 11 | Caputure HTTP headers in fetch request. 12 | 13 |
14 | 15 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /test/e2e/01_xhr/xhrError.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | xhr test 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | ajax error 15 | 16 |
17 | 18 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /test/util.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | const Promise = require('bluebird'); 4 | 5 | exports.retry = function retry(fn, time, until) { 6 | if (time == null) { 7 | time = exports.getTestTimeout() / 2; 8 | } 9 | 10 | if (until == null) { 11 | until = Date.now() + time; 12 | } 13 | 14 | if (Date.now() > until) { 15 | return fn(); 16 | } 17 | 18 | return Promise.delay(time / 20) 19 | .then(fn) 20 | .catch(function() { 21 | return retry(fn, time, until); 22 | }); 23 | }; 24 | 25 | 26 | exports.expectOneMatching = function expectOneMatching(arr, fn) { 27 | if (!arr || arr.length === 0) { 28 | throw new Error('Could not find an item which matches all the criteria. Got 0 items.'); 29 | } 30 | 31 | var error; 32 | 33 | for (var i = 0; i < arr.length; i++) { 34 | var item = arr[i]; 35 | 36 | try { 37 | fn(item); 38 | return item; 39 | } catch (e) { 40 | error = e; 41 | } 42 | } 43 | 44 | if (error) { 45 | throw new Error('Could not find an item which matches all the criteria. Got ' + arr.length + 46 | ' items. Last error: ' + error.message + '. All Items:\n' + JSON.stringify(arr, 0, 2) + 47 | '. Error stack trace: ' + error.stack); 48 | } 49 | }; 50 | 51 | 52 | exports.getTestTimeout = function() { 53 | if (process.env.CI) { 54 | return 30000; 55 | } 56 | return 20000; 57 | }; 58 | -------------------------------------------------------------------------------- /test/e2e/09_customEvents/simpleEventReporting.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | error test 6 | 7 | 8 | 9 | 27 | 28 | 29 | 30 | 31 | 32 | simple event reporting 33 | 34 | 35 | -------------------------------------------------------------------------------- /test/unit/transmission/lineEncoding.test.js: -------------------------------------------------------------------------------- 1 | import {encode} from '../../../lib/transmission/lineEncoding'; 2 | 3 | describe('transmission/lineEncoding', () => { 4 | it('must encode just one beacon', () => { 5 | const beacons = [ 6 | { 7 | page: 'landing', 8 | key: 'abcdefg', 9 | id: 42, 10 | pageVisible: true 11 | } 12 | ]; 13 | 14 | expect(encode(beacons)).toMatchSnapshot(); 15 | }); 16 | 17 | it('must encode multiple beacons', () => { 18 | const beacons = [ 19 | { 20 | page: 'landing', 21 | key: 'abcdefg', 22 | id: 42, 23 | pageVisible: true 24 | }, 25 | { 26 | page: 'product-details', 27 | key: 'abcdefg', 28 | id: 43, 29 | pageVisible: false, 30 | m_spec: JSON.stringify({signedIn: true}) 31 | } 32 | ]; 33 | 34 | expect(encode(beacons)).toMatchSnapshot(); 35 | }); 36 | 37 | it('must handle reserved characters in keys and values', () => { 38 | const beacons = [ 39 | { 40 | 'key\nwith\nnew\nline': 'a', 41 | 'key\twith\ttabstop': 'b', 42 | 'value with new line': 'a\nb\nc', 43 | 'value with tabstop': 'a\tb\tc', 44 | 'key\\with\nall\treserved\\ncharacters': 'value\\ with\nall\treserved\\ncharacter', 45 | } 46 | ]; 47 | expect(encode(beacons)).toMatchSnapshot(); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /test/e2e/05_fetch/requestObjectAndInitObject.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | fetch test 6 | 7 | 8 | 9 | 10 | 11 | fetch with request object 12 | 13 |
14 | 15 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /test/e2e/01_xhr/ignoredXhr.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | xhr test 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | ignored ajax call 15 | 16 |
17 | 18 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /test/e2e/01_xhr/xhrCaptureHeaders.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Capture headers in xhr request test 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | Capture HTTP headers in ajax request. 15 | 16 |
17 | 18 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /test/e2e/07_wrapEventHandlers/eventListenerRemoval.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | eventListenerRemoval test 6 | 7 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 23 | 24 |
25 | 26 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /test/unit/eventBus.test.js: -------------------------------------------------------------------------------- 1 | import {expect} from 'chai'; 2 | import sinon from 'sinon'; 3 | 4 | import {on, off, emit, reset} from '../../lib/eventBus'; 5 | 6 | describe('eventBus', () => { 7 | let listener; 8 | 9 | beforeEach(() => { 10 | reset(); 11 | listener = sinon.stub(); 12 | }); 13 | 14 | afterEach(reset); 15 | 16 | it('must push events to listeners', () => { 17 | on('foo', listener); 18 | emit('foo', 42); 19 | expect(listener.callCount).to.equal(1); 20 | expect(listener.getCall(0).args[0]).to.equal(42); 21 | }); 22 | 23 | it('must stop pushing events to listeners', () => { 24 | on('foo', listener); 25 | off('foo', listener); 26 | emit('foo', 42); 27 | expect(listener.callCount).to.equal(0); 28 | }); 29 | 30 | it('must not fail when there are no listeners to remove', () => { 31 | off('blub', () => { 32 | // This function is intentionally empty 33 | }); 34 | }); 35 | 36 | it('must continue to emit to other listeners once one unsubscribes', () => { 37 | const listener2 = sinon.stub(); 38 | on('foo', listener); 39 | on('foo', listener2); 40 | off('foo', listener2); 41 | emit('foo', 42); 42 | expect(listener.callCount).to.equal(1); 43 | expect(listener2.callCount).to.equal(0); 44 | expect(listener.getCall(0).args[0]).to.equal(42); 45 | }); 46 | 47 | it('must not fail when there are no listeners to emit for', () => { 48 | emit('foo', 42); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /test/e2e/08_unhandledRejections/unhandledRejections.spec.js: -------------------------------------------------------------------------------- 1 | const {registerTestServerHooks, getE2ETestBaseUrl, getBeacons} = require('../../server/controls'); 2 | const {registerBaseHooks, exportCapabilities} = require('../base'); 3 | const {retry, expectOneMatching} = require('../../util'); 4 | 5 | const cexpect = require('chai').expect; 6 | 7 | describe('08_unhandledRejections', () => { 8 | registerTestServerHooks(); 9 | registerBaseHooks(); 10 | 11 | let capabilities; 12 | exportCapabilities(_capabilities => capabilities = _capabilities); 13 | 14 | beforeEach(() => { 15 | browser.get(getE2ETestBaseUrl('08_unhandledRejections/unhandledRejection')); 16 | }); 17 | 18 | it('must send error beacon for unhandled rejection', async () => { 19 | if (!/Chrome/i.test(capabilities.browserName) || 20 | Number(capabilities.version) < 49) { 21 | return; 22 | } 23 | 24 | await element(by.id('clickError')).click(); 25 | 26 | await retry(async () => { 27 | const beacons = await getBeacons(); 28 | cexpect(beacons.filter(b => b.ty === 'err').length).to.equal(1); 29 | 30 | expectOneMatching(beacons, beacon => { 31 | cexpect(beacon.ty).to.equal('err'); 32 | cexpect(beacon.e).to.have.a.string('Unrejected on purpose'); 33 | }); 34 | }); 35 | 36 | const output = await element(by.id('output')).getText(); 37 | cexpect(output).to.equal('Custom unhandledrejection listener called.'); 38 | }); 39 | 40 | }); 41 | -------------------------------------------------------------------------------- /lib/hooks/normalizeUrl.ts: -------------------------------------------------------------------------------- 1 | import {debug} from '../debug'; 2 | 3 | const maximumHttpRequestUrlLength = 4096; 4 | 5 | // Asynchronously created a tag. 6 | let urlAnalysisElement: HTMLAnchorElement | null = null; 7 | 8 | try { 9 | urlAnalysisElement = document.createElement('a'); 10 | } catch (e) { 11 | if (DEBUG) { 12 | debug('Failed to create URL analysis element. Will not be able to normalize URLs.', e); 13 | } 14 | } 15 | 16 | export function normalizeUrl(url: string, includeHash?: boolean): string { 17 | if (urlAnalysisElement) { 18 | try { 19 | // "a"-elements normalize the URL when setting a relative URL or URLs 20 | // that are missing a scheme 21 | urlAnalysisElement.href = url; 22 | url = urlAnalysisElement.href; 23 | } catch (e) { 24 | if (DEBUG) { 25 | debug('Failed to normalize URL' + url); 26 | } 27 | } 28 | } 29 | 30 | // in case of view detection, we still user a chance to extract useful information from hash strings 31 | if (!includeHash) { 32 | // Hashes are never transmitted to the server and they are also not included in resource 33 | // timings. Do not include them in the normalized URL. 34 | const hashIndex = url.indexOf('#'); 35 | if (hashIndex >= 0) { 36 | url = url.substring(0, hashIndex); 37 | } 38 | } 39 | 40 | if (url.length > maximumHttpRequestUrlLength) { 41 | url = url.substring(0, maximumHttpRequestUrlLength); 42 | } 43 | 44 | return url; 45 | } 46 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | const resolve = require ('@rollup/plugin-node-resolve'); 4 | const replace = require('rollup-plugin-replace'); 5 | const babel = require('rollup-plugin-babel'); 6 | const path = require('path'); 7 | const fs = require('fs'); 8 | 9 | import commonjs from '@rollup/plugin-commonjs'; 10 | 11 | const extensions = ['.js', '.ts']; 12 | 13 | const secureWebVitalsLoader = require('./secureWebVitalsLoader'); 14 | 15 | const isDebugBuild = process.env.NODE_ENV !== 'production'; 16 | 17 | export default { 18 | input: 'lib/index.ts', 19 | output: { 20 | file: `target/${process.env.FILENAME}.js`, 21 | format: 'iife' 22 | }, 23 | plugins: [ 24 | babel(Object.assign(getBabelrc(), { 25 | babelrc: false, 26 | exclude: 'node_modules/**', 27 | extensions 28 | })), 29 | commonjs(), 30 | secureWebVitalsLoader(), 31 | replace({ 32 | DEBUG: JSON.stringify(isDebugBuild) 33 | }), 34 | resolve({ 35 | browser: true, 36 | extensions 37 | }) 38 | ] 39 | }; 40 | 41 | 42 | function getBabelrc() { 43 | const content = fs.readFileSync(path.join(__dirname, '.babelrc'), {encoding: 'utf8'}); 44 | const config = JSON.parse(content); 45 | config.plugins = config.plugins 46 | // Rollup works with commonjs module. If we would transpile them, then rollup 47 | // could not do its job. 48 | .filter(plugin => plugin !== '@babel/plugin-transform-modules-commonjs'); 49 | return config; 50 | } 51 | -------------------------------------------------------------------------------- /test/e2e/05_fetch/withPolyfill.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | fetch polyfill test 6 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | fetch with a polyfill 18 | 19 |
20 | 21 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /lib/queryTrackedDomainList.ts: -------------------------------------------------------------------------------- 1 | //import { isTransmitionRequest } from './transmission/util'; 2 | import {matchesAny} from './util'; 3 | import vars from './vars'; 4 | 5 | //const dataUrlPrefix = 'data:'; 6 | //const ignorePingsRegex = /.*\/ping(\/?$|\?.*)/i; 7 | 8 | export function isQueryTracked(url?: string | number | null): boolean { 9 | if (!url) { 10 | return true; 11 | } 12 | 13 | if (url === null) { 14 | return true; 15 | } 16 | 17 | url = String(url); 18 | if (!url) { 19 | return true; 20 | } 21 | 22 | // Track entire url including parameters if queryTrackedDomainList is empty 23 | if(vars.queryTrackedDomainList.length === 0){ 24 | return true; 25 | } 26 | 27 | // queryTrackedDomainList contains list of url whose query parameters and fragment string should not be excluded 28 | // from tracking 29 | return matchesAny(vars.queryTrackedDomainList, url); 30 | } 31 | 32 | export function removeQueryAndFragmentFromUrl(url?: string | number | null): string { 33 | if (!url) { 34 | return ''; 35 | } 36 | 37 | if (url === null) { 38 | return ''; 39 | } 40 | 41 | // Force string conversion. During runtime we have seen that some URLs passed into this code path aren't actually 42 | // strings. Reason currently unknown. 43 | 44 | url = String(url); 45 | if (!url) { 46 | return ''; 47 | } 48 | 49 | const parsedUrl = new URL(url); 50 | parsedUrl.search = ''; 51 | parsedUrl.hash = ''; 52 | return parsedUrl.toString(); 53 | } 54 | 55 | -------------------------------------------------------------------------------- /.secrets.baseline: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": { 3 | "files": "test|^.secrets.baseline$", 4 | "lines": null 5 | }, 6 | "generated_at": "2025-05-19T17:53:25Z", 7 | "plugins_used": [ 8 | { 9 | "name": "AWSKeyDetector" 10 | }, 11 | { 12 | "name": "ArtifactoryDetector" 13 | }, 14 | { 15 | "name": "AzureStorageKeyDetector" 16 | }, 17 | { 18 | "name": "BasicAuthDetector" 19 | }, 20 | { 21 | "name": "BoxDetector" 22 | }, 23 | { 24 | "name": "CloudantDetector" 25 | }, 26 | { 27 | "ghe_instance": "github.ibm.com", 28 | "name": "GheDetector" 29 | }, 30 | { 31 | "name": "GitHubTokenDetector" 32 | }, 33 | { 34 | "name": "IbmCloudIamDetector" 35 | }, 36 | { 37 | "name": "IbmCosHmacDetector" 38 | }, 39 | { 40 | "name": "JwtTokenDetector" 41 | }, 42 | { 43 | "name": "MailchimpDetector" 44 | }, 45 | { 46 | "name": "NpmDetector" 47 | }, 48 | { 49 | "name": "PrivateKeyDetector" 50 | }, 51 | { 52 | "name": "SlackDetector" 53 | }, 54 | { 55 | "name": "SoftlayerDetector" 56 | }, 57 | { 58 | "name": "SquareOAuthDetector" 59 | }, 60 | { 61 | "name": "StripeDetector" 62 | }, 63 | { 64 | "name": "TwilioKeyDetector" 65 | } 66 | ], 67 | "results": {}, 68 | "version": "0.13.1+ibm.62.dss", 69 | "word_list": { 70 | "file": null, 71 | "hash": null 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /test/e2e/14_backendTraceId/backendTraceId_check.spec.js: -------------------------------------------------------------------------------- 1 | const {registerTestServerHooks, getE2ETestBaseUrl, getBeacons} = require('../../server/controls'); 2 | const {registerBaseHooks, getCapabilities} = require('../base'); 3 | const util = require('../../util'); 4 | 5 | const cexpect = require('chai').expect; 6 | 7 | describe('14_backendTraceId', () => { 8 | registerTestServerHooks(); 9 | registerBaseHooks(); 10 | 11 | beforeEach(() => { 12 | browser.get(getE2ETestBaseUrl('14_backendTraceId/backendTraceId_check')); 13 | }); 14 | it('must check if backend trace ID is available in transmitted beacon', () => { 15 | return getCapabilities().then(capabilities => { 16 | if (!hasServerTimingSupport(capabilities)) { 17 | return; 18 | } 19 | 20 | return util.retry(() => { 21 | return getBeacons() 22 | .then(beacons => { 23 | cexpect(beacons).to.have.lengthOf(33); 24 | const xhrBeacons = beacons.filter(beacon => beacon.ty === 'xhr'); 25 | xhrBeacons.forEach(xhrBeacon => { 26 | cexpect(xhrBeacon.bt).to.not.equal(undefined); 27 | cexpect(xhrBeacon.bt).to.equal('aFakeBackendTraceIdForTests'); 28 | }); 29 | }); 30 | }); 31 | }); 32 | }); 33 | 34 | function hasServerTimingSupport(capabilities) { 35 | const version = Number(capabilities.version); 36 | return capabilities.browserName === 'chrome' && (capabilities.version == null || version >= 65); 37 | } 38 | }); 39 | -------------------------------------------------------------------------------- /test/unit/hooks/normalizeUrl.test.js: -------------------------------------------------------------------------------- 1 | import {expect} from 'chai'; 2 | 3 | import {normalizeUrl} from '@lib/hooks/normalizeUrl'; 4 | 5 | describe('normalizeUrl', () => { 6 | it('must remove hash part in normalized url', () => { 7 | expect(normalizeUrl('abc/def?a=2&b=2#section1/subsec')).to.equal('http://localhost/abc/def?a=2&b=2'); 8 | expect(normalizeUrl('https://a.com/abc/def?a=2&b=2#section1/subsec')).to.equal('https://a.com/abc/def?a=2&b=2'); 9 | }); 10 | 11 | it('must normalize relative path', () => { 12 | expect(normalizeUrl('/abc/path1/path2/../../f/1.html')).to.equal('http://localhost/abc/f/1.html'); 13 | expect(normalizeUrl('abc/path1/path2/../../f/1.html')).to.equal('http://localhost/abc/f/1.html'); 14 | expect(normalizeUrl('path1/path2/../abc/def?a=2&b=2#section1/subsec')).to.equal( 15 | 'http://localhost/path1/abc/def?a=2&b=2' 16 | ); 17 | }); 18 | 19 | it('must include hash part in normalized url', () => { 20 | expect(normalizeUrl('abc/def?a=2&b=2#section1/subsec', true)).to.equal( 21 | 'http://localhost/abc/def?a=2&b=2#section1/subsec' 22 | ); 23 | }); 24 | 25 | it('must truncate long urls', () => { 26 | const longUrl = 'https://company.com' + '/very-long-url-pattern'.repeat(210); 27 | const normalizedUrl = normalizeUrl(longUrl); 28 | expect(normalizedUrl.length).to.equal(4096); 29 | expect(longUrl.length).to.equal('https://company.com'.length + '/very-long-url-pattern'.length * 210); 30 | expect(longUrl.startsWith(normalizedUrl)).to.equal(true); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /test/e2e/05_fetch/requestObject.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | fetch test 6 | 7 | 8 | 9 | 10 | 11 | fetch with request object 12 | 13 |
14 | 15 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.8.1 4 | - Maximum number of allowed metadata keys in a beacon can now be user configured. 5 | 6 | ## 1.8.0 7 | - Page transition duration for page change in SPA. 8 | 9 | ## 1.7.5 10 | - Do not rate limit pageLoad beacon 11 | - Added W3C supported headers with the requests 12 | 13 | ## 1.7.4 14 | 15 | - Added support to collect all parameters for only the URLs that match a specified list of regular expressions provided by the user. The parameters of the excluded URLs are not tracked. 16 | 17 | ## 1.7.3 18 | 19 | - Upgrade dev packages to address security issues 20 | - Refactored code for autoPageDetection 21 | - Fix autoPageDetection to set page name for initial page load 22 | - Remove backendTraceId for cached responses 23 | 24 | ## 1.7.2 25 | 26 | - Fix autoPageDetection command error caused by closure-compiler. 27 | 28 | ## 1.7.1 29 | 30 | - Auto detect page transitions on route change. 31 | 32 | ## 1.7.0 33 | 34 | - Migrate the project to typescript. 35 | 36 | ## 1.6.6 37 | 38 | - Read version from package.json to update agentVersion variable. 39 | 40 | ## 1.6.5 41 | 42 | - Added fragment as user configurable. Support for case when user wants to redact sensitive information in url fragment. 43 | 44 | ## 1.6.4 45 | 46 | - Added maxToleranceForResourceTimingsMillis(default 3000ms) as user configurable. Support for case where Backend Trace Id is not available in xhr beacons. 47 | 48 | ## 1.6.3 49 | 50 | - Support user defined metric in custom event. 51 | 52 | ## 1.6.2 53 | 54 | - Upgrade web-vitals to 3.5.2, support new metric Interaction to Next Paint. 55 | -------------------------------------------------------------------------------- /test/e2e/05_fetch/fetchNoZoneImpact.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | fetch no zone impact test 6 | 7 | 8 | 9 | 10 | 11 | 12 | fetch after page load 13 | 14 |
15 | 16 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /lib/webVitals.ts: -------------------------------------------------------------------------------- 1 | import {onCLS, onLCP, onFID, onINP} from 'web-vitals'; 2 | import type {PageLoadBeacon} from './types'; 3 | import {pageLoadStartTimestamp} from './timings'; 4 | import {reportCustomEvent} from './customEvents'; 5 | import vars from './vars'; 6 | 7 | interface Metric { 8 | name: 'CLS' | 'FID' | 'LCP' | 'INP'; 9 | 10 | value: number; 11 | id?: string; 12 | } 13 | 14 | function reportExtraMetrics(metric: Metric) { 15 | if (!vars.webvitalsInCustomEvent) { 16 | return; 17 | } 18 | 19 | // We have to write it this way because of the Closure compiler advanced mode. 20 | reportCustomEvent('instana-webvitals-' + metric.name, { 21 | 'timestamp': pageLoadStartTimestamp + Math.round(metric.value), 22 | 'duration': Math.round(metric.value), 23 | 'meta': { 24 | 'id': metric.id, 25 | 'v': metric.value 26 | } 27 | }); 28 | } 29 | 30 | export function addWebVitals(beacon: Partial) { 31 | if (onLCP) { 32 | onLCP(onMetric, { reportAllChanges: true }); 33 | } 34 | if (onFID) { 35 | onFID(onMetric, { reportAllChanges: true }); 36 | } 37 | if (onINP) { 38 | onINP(onMetric, { reportAllChanges: true }); 39 | } 40 | if (onCLS) { 41 | onCLS(onMetricWithoutRounding, { reportAllChanges: true }); 42 | } 43 | 44 | function onMetric(metric: Metric) { 45 | beacon['t_' + metric.name.toLocaleLowerCase()] = Math.round(metric.value); 46 | reportExtraMetrics(metric); 47 | } 48 | 49 | function onMetricWithoutRounding(metric: Metric) { 50 | beacon['t_' + metric.name.toLocaleLowerCase()] = metric.value; 51 | reportExtraMetrics(metric); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /test/e2e/11_userTiming/userTiming.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | error test 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | reporting of user timing data as custom events 15 | 16 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /test/unit/__snapshots__/resources.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`resources/timingSerializer serializeEntry must identify cache validation 1`] = ` 4 | Object { 5 | "appcache": "", 6 | "backendTraceId": "", 7 | "cachingType": "validated", 8 | "decodedBodySize": "16786", 9 | "dns": "", 10 | "duration": "232", 11 | "encodedBodySize": "16786", 12 | "initiator": "img", 13 | "redirect": "", 14 | "request": "187", 15 | "response": "39", 16 | "ssl": "", 17 | "tcp": "", 18 | "time": "1137", 19 | "transferSize": "4320", 20 | "ttfb": "192", 21 | } 22 | `; 23 | 24 | exports[`resources/timingSerializer serializeEntry must identify full asset retrieval 1`] = ` 25 | Object { 26 | "appcache": "", 27 | "backendTraceId": "3748eabc1876cdaa", 28 | "cachingType": "fullLoad", 29 | "decodedBodySize": "16786", 30 | "dns": "", 31 | "duration": "232", 32 | "encodedBodySize": "16786", 33 | "initiator": "img", 34 | "redirect": "", 35 | "request": "187", 36 | "response": "39", 37 | "ssl": "", 38 | "tcp": "", 39 | "time": "1137", 40 | "transferSize": "16835", 41 | "ttfb": "192", 42 | } 43 | `; 44 | 45 | exports[`resources/timingSerializer serializeEntry must identify retrieval from cache 1`] = ` 46 | Object { 47 | "appcache": "", 48 | "backendTraceId": "", 49 | "cachingType": "cached", 50 | "decodedBodySize": "16786", 51 | "dns": "", 52 | "duration": "4", 53 | "encodedBodySize": "16786", 54 | "initiator": "img", 55 | "redirect": "", 56 | "request": "0", 57 | "response": "1", 58 | "ssl": "", 59 | "tcp": "", 60 | "time": "435", 61 | "transferSize": "0", 62 | "ttfb": "3", 63 | } 64 | `; 65 | -------------------------------------------------------------------------------- /lib/stripSecrets.ts: -------------------------------------------------------------------------------- 1 | import { debug } from './debug'; 2 | import { matchesAny } from './util'; 3 | import vars from './vars'; 4 | 5 | let urlAnalysisElement: HTMLAnchorElement | null = null; 6 | 7 | try { 8 | urlAnalysisElement = document.createElement('a'); 9 | } catch (e) { 10 | if (DEBUG) { 11 | debug('Failed to create URL analysis element. Will not be able to normalize URLs.', e); 12 | } 13 | } 14 | 15 | export function stripSecrets(url: string) { 16 | if (!url || url === '') { 17 | return url; 18 | } 19 | 20 | try { 21 | if (urlAnalysisElement) { 22 | urlAnalysisElement.href = url; 23 | url = urlAnalysisElement.href; 24 | } 25 | const queryIndex = url.indexOf('?'); 26 | const hashIndex = url.indexOf('#'); 27 | let fragString = null; 28 | 29 | if (hashIndex >= 0) { 30 | fragString = url 31 | .substring(hashIndex); 32 | if (url && matchesAny(vars.fragment, url)) { 33 | fragString = '#'; 34 | } 35 | url = url.substring(0, hashIndex); 36 | } 37 | 38 | if (queryIndex >= 0) { 39 | const queryString = url 40 | .substring(queryIndex) 41 | .split('&') 42 | .map(function (param) { 43 | const key = param.split('=')[0]; 44 | if (key && matchesAny(vars.secrets, key)) { 45 | return key + '='; 46 | } 47 | return param; 48 | }).join('&'); 49 | 50 | url = url.substring(0, queryIndex) + queryString + (fragString ? fragString : ''); 51 | } 52 | } catch (e) { 53 | if (DEBUG) { 54 | debug('Failed to strip secret from ' + url); 55 | } 56 | } 57 | 58 | return url; 59 | } 60 | -------------------------------------------------------------------------------- /test/e2e/05_fetch/ignoredFetch.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | fetch test 6 | 7 | 8 | 9 | 10 | 11 | ignored fetch call 12 | 13 |
14 | 15 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /lib/sop.ts: -------------------------------------------------------------------------------- 1 | import { debug } from './debug'; 2 | import { win } from './browser'; 3 | 4 | // Asynchronously created a tag. 5 | // document.createElement('a') 6 | let urlAnalysisElement: HTMLAnchorElement | null = null; 7 | let documentOriginAnalysisElement: HTMLAnchorElement | null = null; 8 | try { 9 | urlAnalysisElement = document.createElement('a'); 10 | documentOriginAnalysisElement = document.createElement('a'); 11 | documentOriginAnalysisElement.href = win.location.href; 12 | } catch (e) { 13 | if (DEBUG) { 14 | debug( 15 | 'Failed to create URL analysis elements. Will not be able to execute same-origin check, i.e. all same-origin checks will fail.', 16 | e 17 | ); 18 | } 19 | } 20 | 21 | export function isSameOrigin(url: string): boolean { 22 | if (!urlAnalysisElement || !documentOriginAnalysisElement) { 23 | return false; 24 | } 25 | 26 | try { 27 | urlAnalysisElement.href = url; 28 | 29 | return ( 30 | // Most browsers support this fallback logic out of the box. Not so the Internet explorer. 31 | // To make it work in Internet explorer, we need to add the fallback manually. 32 | // IE 9 uses a colon as the protocol when no protocol is defined 33 | (urlAnalysisElement.protocol && urlAnalysisElement.protocol !== ':' 34 | ? urlAnalysisElement.protocol 35 | : documentOriginAnalysisElement.protocol) === documentOriginAnalysisElement.protocol && 36 | (urlAnalysisElement.hostname || documentOriginAnalysisElement.hostname) === documentOriginAnalysisElement.hostname && 37 | (urlAnalysisElement.port || documentOriginAnalysisElement.port) === documentOriginAnalysisElement.port 38 | ); 39 | } catch (e) { 40 | return false; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /lib/ignoreRules.ts: -------------------------------------------------------------------------------- 1 | import { isTransmitionRequest } from './transmission/util'; 2 | import {matchesAny} from './util'; 3 | import vars from './vars'; 4 | 5 | const dataUrlPrefix = 'data:'; 6 | const ignorePingsRegex = /.*\/ping(\/?$|\?.*)/i; 7 | 8 | export function isUrlIgnored(url?: string | number): boolean { 9 | if (!url) { 10 | return true; 11 | } 12 | 13 | // Force string conversion. During runtime we have seen that some URLs passed into this code path aren't actually 14 | // strings. Reason currently unknown. 15 | url = String(url); 16 | if (!url) { 17 | return true; 18 | } 19 | 20 | // We never want to track data URLs. Instead of matching these via regular expressions (which might be expensive), 21 | // we are explicitly doing a startsWith ignore case check 22 | // https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs 23 | if (url.substring == null || url.substring(0, dataUrlPrefix.length).toLowerCase() === dataUrlPrefix) { 24 | return true; 25 | } 26 | 27 | if (vars.ignorePings && ignorePingsRegex.test(url)) { 28 | return true; 29 | } 30 | 31 | // Disable monitoring of data transmission requests. The data transmission strategy already ensures 32 | // that data transmission requests are not picked up internally. However we have seen some users 33 | // leverage custom (broken) XMLHttpRequest instrumentations to implement application code which 34 | // then break the detection of data transmission requests. 35 | if (isTransmitionRequest(url)) { 36 | return true; 37 | } 38 | 39 | return matchesAny(vars.ignoreUrls, url); 40 | } 41 | 42 | export function isErrorMessageIgnored(message?: string) { 43 | return !message || matchesAny(vars.ignoreErrorMessages, message); 44 | } 45 | -------------------------------------------------------------------------------- /.concourse/tasks/build-and-test.yml: -------------------------------------------------------------------------------- 1 | platform: linux 2 | image_resource: 3 | type: registry-image 4 | source: 5 | repository: delivery.instana.io/int-docker-private-virtual/node 6 | tag: 18.18.0 7 | username: ((delivery-instana-io-internal-project-artifact-read-writer-creds.username)) 8 | password: ((delivery-instana-io-internal-project-artifact-read-writer-creds.password)) 9 | 10 | inputs: 11 | - name: source 12 | outputs: 13 | - name: sonarqube-analysis-input 14 | 15 | run: 16 | dir: source 17 | path: bash 18 | args: 19 | - -cx 20 | - | 21 | set -e 22 | export BUILD_PATH=$(pwd) 23 | export ARTIFACTORY_USERNAME="((delivery-instana-io-internal-project-artifact-read-writer-creds.username))" 24 | export ARTIFACTORY_PASSWORD="((delivery-instana-io-internal-project-artifact-read-writer-creds.password))" 25 | 26 | # Replace variables 27 | sed -i "s|'EumObject'|'InstanaEumObject'|g" lib/vars.ts 28 | sed -i "s|reportingUrl: null|reportingUrl: 'https://eum.instana.io'|g" lib/vars.ts 29 | sed -i "s|__weaselOriginalFunctions__|__instanaOriginalFunctions__|g" lib/vars.ts 30 | sed -i "s|__weaselSecretData_|__instanaSecretData__|g" lib/vars.ts 31 | sed -i "s|beaconBatchingTime: 500|beaconBatchingTime: 2000|g" lib/vars.ts 32 | sed -i "s|sessionStorageKey: 'session'|sessionStorageKey: 'in-session'|g" lib/vars.ts 33 | 34 | chmod -R 777 .concourse/scripts/check-update-weasel-version.sh 35 | .concourse/scripts/check-update-weasel-version.sh 36 | 37 | echo "Building Weasel Agent" 38 | apt-get update 39 | apt-get install -y xxd 40 | yarn 41 | yarn build 42 | yarn test:quick 43 | ls -lt target/ 44 | 45 | echo "Build finished" 46 | -------------------------------------------------------------------------------- /lib/hooks/timers.ts: -------------------------------------------------------------------------------- 1 | import {reportError, ignoreNextOnErrorEvent} from './unhandledError'; 2 | import {isRunningZoneJs} from '../timers'; 3 | import {win} from '../browser'; 4 | import {warn} from '../debug'; 5 | import vars from '../vars'; 6 | 7 | export function wrapTimers() { 8 | if (vars.wrapTimers) { 9 | if (isRunningZoneJs) { 10 | if (DEBUG) { 11 | warn( 12 | 'We discovered a usage of Zone.js. In order to avoid any incompatibility issues timer wrapping is not going to be enabled.' 13 | ); 14 | } 15 | return; 16 | } 17 | wrapTimer('setTimeout'); 18 | wrapTimer('setInterval'); 19 | } 20 | } 21 | 22 | function wrapTimer(name: 'setTimeout' | 'setInterval') { 23 | const original = win[name]; 24 | if (typeof original !== 'function') { 25 | // cannot wrap because fn is not a function – should actually never happen 26 | return; 27 | } 28 | 29 | (win as any)[name] = function wrappedTimerSetter(fn: TimerHandler): ReturnType<(typeof win)[typeof name]> { 30 | // non-deopt arguments copy 31 | const args = new Array(arguments.length); 32 | for (let i = 0; i < arguments.length; i++) { 33 | // eslint-disable-next-line prefer-rest-params 34 | args[i] = arguments[i]; 35 | } 36 | args[0] = wrap(fn); 37 | return original.apply(this, args as any); 38 | }; 39 | } 40 | 41 | function wrap(fn: TimerHandler) { 42 | if (typeof fn !== 'function') { 43 | // cannot wrap because fn is not a function 44 | return fn; 45 | } 46 | 47 | return function wrappedTimerHandler(this: any) { 48 | try { 49 | // eslint-disable-next-line prefer-rest-params 50 | return fn.apply(this, arguments); 51 | } catch (e) { 52 | reportError(e as any); 53 | ignoreNextOnErrorEvent(); 54 | throw e; 55 | } 56 | }; 57 | } 58 | -------------------------------------------------------------------------------- /test/unit/stripSecrets.test.js: -------------------------------------------------------------------------------- 1 | jest.mock('../../lib/vars'); 2 | 3 | describe('stripSecrets', () => { 4 | let varsMock; 5 | let stripSecrets; 6 | let url; 7 | let urlRedacted; 8 | 9 | beforeEach(() => { 10 | self.DEBUG = false; 11 | const stripSecretsMock = require('../../lib/stripSecrets'); 12 | stripSecrets = stripSecretsMock.stripSecrets; 13 | varsMock = require('../../lib/vars').default; 14 | varsMock.secrets = [/account/i, /pass/i]; 15 | global.DEBUG = false; 16 | }); 17 | 18 | afterEach(() => { 19 | delete global.DEBUG; 20 | }); 21 | 22 | it('strip secret from url with multiple query parameters', () => { 23 | url = 'http://example.com/search?accountno=user01&pass=password&phoneno=999'; 24 | urlRedacted = 'http://example.com/search?accountno=&pass=&phoneno=999'; 25 | expect(stripSecrets(url)).toEqual(urlRedacted); 26 | }); 27 | 28 | it('strip secret from url with single query parameter', () => { 29 | url = 'http://example.com/search?accountno=user01'; 30 | urlRedacted = 'http://example.com/search?accountno='; 31 | expect(stripSecrets(url)).toEqual(urlRedacted); 32 | url = 'http://example.com/search?DATA=[{"T":"Info", "FID":"CI", "Name":"HasRR","Text":"1"}]'; 33 | urlRedacted = 34 | 'http://example.com/search?DATA=[{%22T%22:%22Info%22,%20%22FID%22:%22CI%22,%20%22Name%22:%22HasRR%22,%22Text%22:%221%22}]'; 35 | expect(stripSecrets(url)).toEqual(urlRedacted); 36 | }); 37 | 38 | it('strip secret from url with no query parameter', () => { 39 | url = 'http://example.com/search'; 40 | expect(stripSecrets(url)).toEqual(url); 41 | }); 42 | 43 | it('strip secret from invalid url', () => { 44 | expect(stripSecrets(null)).toEqual(null); 45 | url = 'invalid://example.com/search?phoneno=999'; 46 | expect(stripSecrets(url)).toEqual(url); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /test/unit/transmission/formEncoded.test.js: -------------------------------------------------------------------------------- 1 | jest.mock('../../../lib/browser'); 2 | jest.mock('../../../lib/vars'); 3 | 4 | describe('transmission/formEncoded', () => { 5 | let browserMock; 6 | let varsMock; 7 | let sendBeacon; 8 | 9 | beforeEach(() => { 10 | browserMock = require('../../../lib/browser'); 11 | browserMock.reset(); 12 | sendBeacon = require('../../../lib/transmission/formEncoded').sendBeacon; 13 | varsMock = require('../../../lib/vars').default; 14 | varsMock.reportingUrl = 'http://eum.example.com'; 15 | varsMock.reportingBackends = [{reportingUrl: 'http://eum.example.com', key: 'key'}]; 16 | }); 17 | 18 | it('must do nothing when there is nothing to transmit', () => { 19 | sendBeacon({}); 20 | assertMatchesSnapshot(); 21 | }); 22 | 23 | it('must only transmit own props', () => { 24 | const parentObject = {}; 25 | parentObject.shouldNotBeIncluded = 'secr3t!'; 26 | const beacon = Object.create(parentObject); 27 | beacon.page = 'cart'; 28 | sendBeacon(beacon); 29 | assertMatchesSnapshot(); 30 | }); 31 | 32 | it('must transmit small payloads via image request', () => { 33 | sendBeacon({ 34 | page: 'cart', 35 | id: 42, 36 | m_featureFlags: JSON.stringify({developerMode: false}) 37 | }); 38 | assertMatchesSnapshot(); 39 | }); 40 | 41 | it('must transmit larger payloads via XHR request', () => { 42 | const beacon = { 43 | page: 'cart', 44 | id: 42, 45 | m_featureFlags: JSON.stringify({developerMode: false}), 46 | longKey: Array(2001).fill('a').join('') 47 | }; 48 | 49 | sendBeacon(beacon); 50 | assertMatchesSnapshot(); 51 | }); 52 | 53 | function assertMatchesSnapshot() { 54 | expect({ 55 | imageRequests: browserMock.imageRequests, 56 | xhrRequests: browserMock.xhrRequests 57 | }).toMatchSnapshot(); 58 | } 59 | }); 60 | -------------------------------------------------------------------------------- /test/e2e/05_fetch/graphql_apollo/src/index.js: -------------------------------------------------------------------------------- 1 | import { InMemoryCache } from 'apollo-cache-inmemory'; 2 | import { HttpLink } from 'apollo-link-http'; 3 | import ApolloClient from 'apollo-client'; 4 | import gql from 'graphql-tag'; 5 | 6 | const client = new ApolloClient({ 7 | link: new HttpLink({ 8 | uri: `/graphql` 9 | }), 10 | cache: new InMemoryCache() 11 | }); 12 | 13 | window.getBook = () => { 14 | return client.query({ 15 | query: gql` 16 | query Book($title: String!) { 17 | book(title: $title) { 18 | title 19 | author 20 | } 21 | } 22 | `, 23 | variables: { 24 | title: 'Harry Potter and the Chamber of Secrets' 25 | } 26 | }); 27 | }; 28 | 29 | 30 | 31 | window.getBooks = () => { 32 | return client.query({ 33 | query: gql` 34 | query Books { 35 | books { 36 | title 37 | author 38 | } 39 | } 40 | `, 41 | variables: { 42 | unused: 'not relevant' 43 | } 44 | }); 45 | }; 46 | 47 | window.getBooksWithoutOperationname = () => { 48 | return client.query({ 49 | query: gql` 50 | query{ 51 | books { 52 | title 53 | author 54 | } 55 | } 56 | `, 57 | variables: { 58 | unused: 'not relevant' 59 | } 60 | }); 61 | }; 62 | 63 | window.borrowBook = () => { 64 | return client.mutate({ 65 | mutation: gql` 66 | mutation Borrow{ 67 | borrow { 68 | title 69 | author 70 | } 71 | } 72 | `, 73 | variables: { 74 | bookId: 42 75 | } 76 | }); 77 | }; 78 | 79 | window.borrowBookWithoutOperationName = () => { 80 | return client.mutate({ 81 | mutation: gql` 82 | mutation { 83 | borrow { 84 | title 85 | author 86 | } 87 | } 88 | `, 89 | variables: { 90 | bookId: 42 91 | } 92 | }); 93 | }; 94 | -------------------------------------------------------------------------------- /test/unit/hooks/XMLHttpRequest.test.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import vars from '../../../lib/vars'; 3 | 4 | describe('captureXhrHeaders', ()=> { 5 | let captureHttpHeaders; 6 | 7 | beforeEach(() => { 8 | self.DEBUG = false; 9 | const XMLHttpRequest = require('../../../lib/hooks/XMLHttpRequest'); 10 | captureHttpHeaders = XMLHttpRequest.captureHttpHeaders; 11 | global.DEBUG = false; 12 | }); 13 | 14 | afterEach(() => { 15 | delete global.DEBUG; 16 | }); 17 | 18 | it('must capture specified http headers', () => { 19 | const responseHeaderString = 'date: Fri, 08 Dec 2017 21:04:30 GMT\r\n' 20 | + 'content-encoding: gzip\r\n' 21 | + 'x-content-type-options: nosniff\r\n' 22 | + 'server: meinheld/0.6.1\r\n' 23 | + 'x-frame-options: DENY\r\n' 24 | + 'content-type: text/html; charset=utf-8\r\n' 25 | + 'connection: keep-alive\r\n' 26 | + 'strict-transport-security: max-age=63072000\r\n' 27 | + 'vary: Cookie, Accept-Encoding\r\n' 28 | + 'content-length: 6502\r\n' 29 | + 'x-xss-protection: 1; mode=block\r\n'; 30 | 31 | const expectedResponseHeaders = { 32 | 'date': 'Fri, 08 Dec 2017 21:04:30 GMT', 33 | 'content-encoding': 'gzip', 34 | 'x-content-type-options': 'nosniff', 35 | 'server': 'meinheld/0.6.1', 36 | 'x-frame-options': 'DENY', 37 | 'content-type': 'text/html; charset=utf-8', 38 | 'connection': 'keep-alive', 39 | 'strict-transport-security': 'max-age=63072000', 40 | 'vary': 'Cookie, Accept-Encoding', 41 | 'content-length': '6502', 42 | 'x-xss-protection': '1; mode=block', 43 | }; 44 | 45 | vars.headersToCapture = [/content-type/i]; 46 | let beacon = {}; 47 | captureHttpHeaders(beacon, responseHeaderString); 48 | expect(beacon['h_content-type']).to.equal(expectedResponseHeaders['content-type']); 49 | 50 | }); 51 | }); 52 | 53 | -------------------------------------------------------------------------------- /test/unit/transmission/util.test.js: -------------------------------------------------------------------------------- 1 | import {isTransmitionRequest} from '../../../lib/transmission/util'; 2 | 3 | jest.mock('../../../lib/vars'); 4 | 5 | describe('transmission/util', () => { 6 | let varsMock; 7 | 8 | beforeEach(() => { 9 | varsMock = require('../../../lib/vars').default; 10 | varsMock.reportingUrl = 'http://eum1.example.com'; 11 | varsMock.reportingBackends = []; 12 | }); 13 | 14 | it('Check transmission request', () => { 15 | expect(isTransmitionRequest('http://eum1.example.com')).toBeTruthy(); 16 | expect(isTransmitionRequest('http://eum1.example.com/')).toBeTruthy(); 17 | expect(isTransmitionRequest('http://eum1.example.com/eum.min.js')).not.toBeTruthy(); 18 | expect(isTransmitionRequest('http://eum2.example.com')).not.toBeTruthy(); 19 | expect(isTransmitionRequest('http://eum2.example.com/')).not.toBeTruthy(); 20 | expect(isTransmitionRequest('http://eum.example.com')).not.toBeTruthy(); 21 | }); 22 | 23 | it('Check transmission request with multiple backends', () => { 24 | varsMock.reportingBackends = [ 25 | { reportingUrl: 'http://eum2.example.com', key: 'key' }, 26 | { reportingUrl: 'http://eum3.example.com', key: 'key' } 27 | ]; 28 | expect(isTransmitionRequest('http://eum1.example.com')).not.toBeTruthy(); 29 | expect(isTransmitionRequest('http://eum1.example.com/')).not.toBeTruthy(); 30 | expect(isTransmitionRequest('http://eum2.example.com')).toBeTruthy(); 31 | expect(isTransmitionRequest('http://eum2.example.com/')).toBeTruthy(); 32 | expect(isTransmitionRequest('http://eum2.example.com/eum.min.js')).not.toBeTruthy(); 33 | expect(isTransmitionRequest('http://eum3.example.com')).toBeTruthy(); 34 | expect(isTransmitionRequest('http://eum3.example.com/')).toBeTruthy(); 35 | expect(isTransmitionRequest('http://eum3.example.com/eum.min.js')).not.toBeTruthy(); 36 | expect(isTransmitionRequest('http://eum.example.com')).not.toBeTruthy(); 37 | }); 38 | 39 | }); 40 | -------------------------------------------------------------------------------- /test/server/controls.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jasmine */ 2 | 3 | const request = require('request-promise'); 4 | const spawn = require('child_process').spawn; 5 | const path = require('path'); 6 | const qs = require('qs'); 7 | 8 | const util = require('../util'); 9 | 10 | // See this wiki entry for port selection details 11 | // https://wiki.saucelabs.com/display/DOCS/Sauce+Connect+Proxy+FAQS 12 | const ports = [8000, 8001]; 13 | let serverProcess; 14 | 15 | exports.registerTestServerHooks = () => { 16 | beforeEach(() => { 17 | var env = Object.create(process.env); 18 | env.BEACON_SERVER_PORTS = ports.join(','); 19 | 20 | serverProcess = spawn('node', [path.join(__dirname, 'server.js')], { 21 | stdio: [process.stdin, process.stdout, process.stderr], 22 | env: env 23 | }); 24 | 25 | return waitUntilServerIsUp(); 26 | }); 27 | 28 | afterEach(() => { 29 | serverProcess.kill(); 30 | }); 31 | }; 32 | 33 | function waitUntilServerIsUp() { 34 | return util.retry(() => { 35 | return request({ 36 | method: 'GET', 37 | url: `http://127.0.0.1:${ports[0]}` 38 | }); 39 | }); 40 | } 41 | 42 | exports.getServerBaseUrl = () => `http://127.0.0.1:${ports[0]}`; 43 | exports.getE2ETestBaseUrl = (file, query={}) => { 44 | query.ports = ports.join(','); 45 | return `http://127.0.0.1:${ports[0]}/e2e/${file}.html?${qs.stringify(query)}`; 46 | }; 47 | 48 | exports.getBeacons = () => { 49 | return request({ 50 | method: 'GET', 51 | url: `http://127.0.0.1:${ports[0]}/transmittedBeacons` 52 | }) 53 | .then(parseWithDevelopmentInsights); 54 | }; 55 | 56 | exports.getAjaxRequests = () => { 57 | return request({ 58 | method: 'GET', 59 | url: `http://127.0.0.1:${ports[0]}/ajaxRequests` 60 | }) 61 | .then(parseWithDevelopmentInsights); 62 | }; 63 | 64 | function parseWithDevelopmentInsights(s) { 65 | try { 66 | return JSON.parse(s); 67 | } catch (e) { 68 | console.error('Failed to JSON parse', e, 'got: ', s); 69 | throw e; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /lib/__mocks__/browser.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | export const inEncodeURIComponent = global.encodeURIComponent; 4 | export const win = global.window; 5 | export const doc = win.document; 6 | export const nav = global.navigator; 7 | 8 | export const xhrRequests: Array = []; 9 | export const XMLHttpRequest = function () { 10 | const requestFunctions: { 11 | open?: (...args: any[]) => void; 12 | send?: (...args: any[]) => void; 13 | setRequestHeader?: (...args: any[]) => void; 14 | } = {}; 15 | // Using the prototype chain to avoid adding mock functions to the jest snapshots. 16 | const request = Object.create(requestFunctions); 17 | request.requestHeader = []; 18 | requestFunctions.open = (...args: any[]) => { 19 | request.open = args; 20 | }; 21 | requestFunctions.send = (...args: any[]) => { 22 | request.send = args; 23 | }; 24 | requestFunctions.setRequestHeader = (...args: any[]) => request.requestHeader.push(args); 25 | xhrRequests.push(request); 26 | return request; 27 | }; 28 | 29 | export const imageRequests: Array = []; 30 | export function executeImageRequest(path: string) { 31 | imageRequests.push(path); 32 | } 33 | 34 | export const beaconRequests: Array<{ 35 | url: string; 36 | data: string; 37 | }> = []; 38 | export function sendBeacon(url: string, data: string) { 39 | beaconRequests.push({url, data}); 40 | } 41 | 42 | let localStorageStore: Record = {}; 43 | export const localStorage: { 44 | getItem?: (k: string) => string; 45 | setItem?: (k: string, v: string) => void; 46 | removeItem?: (k: string) => void; 47 | } = {}; 48 | 49 | export function reset() { 50 | xhrRequests.length = 0; 51 | imageRequests.length = 0; 52 | beaconRequests.length = 0; 53 | 54 | localStorageStore = {}; 55 | localStorage.getItem = k => localStorageStore[k]; 56 | localStorage.setItem = (k, v) => { 57 | localStorageStore[k] = v; 58 | }; 59 | localStorage.removeItem = k => delete localStorageStore[k]; 60 | } 61 | 62 | reset(); 63 | -------------------------------------------------------------------------------- /lib/states/pageLoaded.ts: -------------------------------------------------------------------------------- 1 | import {addCommonBeaconProperties} from '../commonBeaconProperties'; 2 | import {addResourceTimings} from '../resources/resources'; 3 | import {addTimingToPageLoadBeacon} from '../timings'; 4 | import {pageLoad as pageLoadPhase} from '../phases'; 5 | import type {State, PageLoadBeacon} from '../types'; 6 | import {onLastChance} from '../events/onLastChance'; 7 | import {sendBeacon} from '../transmission/index'; 8 | import {setTimeout} from '../timers'; 9 | import {stripSecrets} from '../stripSecrets'; 10 | import {win, doc} from '../browser'; 11 | import {info} from '../debug'; 12 | import vars from '../vars'; 13 | 14 | // Export 15 | // Find a way to define all properties beforehand so that flow doesn't complain about missing props. 16 | export const beacon: Partial = { 17 | 'ty': 'pl' 18 | }; 19 | 20 | const state: State = { 21 | onEnter() { 22 | addCommonBeaconProperties(beacon); 23 | 24 | beacon['t'] = vars.pageLoadTraceId; 25 | beacon['bt'] = vars.pageLoadBackendTraceId; 26 | beacon['u'] = stripSecrets(win.location.href); 27 | beacon['ph'] = pageLoadPhase; 28 | 29 | addTimingToPageLoadBeacon(beacon); 30 | addResourceTimings(beacon); 31 | 32 | let beaconSent = false; 33 | if (doc.visibilityState !== 'visible') { 34 | if (DEBUG) { 35 | info( 36 | 'Will not wait for additional page load beacon data because document.visibilityState is', 37 | doc.visibilityState 38 | ); 39 | } 40 | sendPageLoadBeacon(); 41 | return; 42 | } 43 | 44 | setTimeout(sendPageLoadBeacon, vars.maxMaitForPageLoadMetricsMillis); 45 | onLastChance(sendPageLoadBeacon); 46 | 47 | function sendPageLoadBeacon() { 48 | if (!beaconSent) { 49 | beaconSent = true; 50 | sendBeacon(beacon); 51 | } 52 | } 53 | }, 54 | 55 | getActiveTraceId() { 56 | return null; 57 | }, 58 | 59 | getActivePhase() { 60 | return undefined; 61 | } 62 | }; 63 | export default state; 64 | -------------------------------------------------------------------------------- /test/e2e/09_customEvents/simpleEventReportingWithoutBackendTraceId.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | error test 6 | 7 | 8 | 9 | 47 | 48 | 49 | 50 | 51 | 52 | simple event reporting 53 | 54 | 55 | -------------------------------------------------------------------------------- /test/e2e/16_autoPageDetection/autoPageDetection.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | AutoPageDetectionTest 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 |

AutoCaptureScreenNameTest

18 | 25 |
26 |
27 |
28 |

Welcome to the Home Page

29 |

Hope you have a nice day!

30 |
31 |
32 |

About Us

33 |

Learn m Add click event listeners to navigation links 34 | var navLinks = document.querySelectorAll('#nav-links a'); // get all the elemnts with id : navlink within the in html 35 | console.log('Nav links ',navLinks) 36 |

37 |
38 |
39 |

Contact Us

40 |

Contact us using the information provided.

41 |
42 |
43 | 44 | 45 | -------------------------------------------------------------------------------- /test/e2e/14_backendTraceId/backendTraceId_check.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | xhr test 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | generating ajax requests after page load 15 | 16 |
17 | 18 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /test/e2e/16_autoPageDetection/autoPageDetectionTitleAsPageName.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | AutoPageDetectionTest 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 |

AutoCaptureScreenNameTest

20 |
27 |
28 |
29 |
30 |

Welcome to the Home Page

31 |

Hope you have a nice day!

32 |
33 |
34 |

About Us

35 |

Learn m Add click event listeners to navigation links 36 | var navLinks = document.querySelectorAll('#nav-links a'); // get all the elemnts with id : navlink within the in html 37 | console.log('Nav links ',navLinks) 38 |

39 |
40 |
41 |

Contact Us

42 |

Contact us using the information provided.

43 |
44 |
45 | 46 | 47 | -------------------------------------------------------------------------------- /test/e2e/02_error/automatic.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | error test 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | throw error on button click 15 | 16 |
17 | 21 | 22 | 26 | 27 | 31 | 32 | 36 |
37 | 38 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /test/e2e/base.js: -------------------------------------------------------------------------------- 1 | const setupBrowser = () => { 2 | // we are not using Angular.js 3 | browser.waitForAngularEnabled(false); 4 | }; 5 | 6 | exports.restartBrowser = () => { 7 | browser.restartSync(); 8 | setupBrowser(); 9 | }; 10 | 11 | exports.registerBaseHooks = () => { 12 | beforeEach(() => { 13 | setupBrowser(); 14 | }); 15 | 16 | afterEach(async () => { 17 | setupBrowser(); 18 | 19 | // Wait until the page is disposed to ensure that we don't have any 20 | // more beacons that are in flight before the next test starts. 21 | await browser.get('about:blank'); 22 | }); 23 | }; 24 | 25 | exports.getCapabilities = () => browser.getProcessedConfig().then(config => config.capabilities); 26 | 27 | exports.exportCapabilities = exporter => { 28 | beforeEach(() => browser.getProcessedConfig().then(config => exporter(config.capabilities))); 29 | }; 30 | 31 | exports.whenConfigMatches = (predicate, fn) => { 32 | return browser.getProcessedConfig().then(config => { 33 | if (predicate(config)) { 34 | return fn(config); 35 | } 36 | 37 | return true; 38 | }); 39 | }; 40 | 41 | exports.hasResourceTimingSupport = capabilities => { 42 | const version = Number(capabilities.version); 43 | return ( 44 | (capabilities.browserName !== 'internet explorer' && capabilities.browserName !== 'safari') || 45 | (capabilities.browserName === 'internet explorer' && version >= 10) 46 | ); 47 | }; 48 | 49 | exports.hasPerformanceObserverSupport = capabilities => { 50 | // Locally no browser version is defined. We abuse this fact to only execute the performance observer 51 | // validations locally. Unfortunately the CI system, i.e. Saucelabs, is under too much load and therefore 52 | // the performance observer task queue, which is a low priority one, is not drained. This makes the 53 | // E2E tests highly unreliable. 54 | // Instead of hunting for weird test failures, we only execute these locally. To account for the lack of 55 | // E2E tests we have an increased number of unit tests for this feature. 56 | return capabilities.browserName === 'chrome' && !capabilities.version; 57 | }; 58 | -------------------------------------------------------------------------------- /lib/pageChange.ts: -------------------------------------------------------------------------------- 1 | import { createExcessiveUsageIdentifier } from './excessiveUsageIdentification'; 2 | import { addCommonBeaconProperties, addInternalMetaDataToBeacon } from './commonBeaconProperties'; 3 | import { pageLoad as pageLoadPhase } from './phases'; 4 | import { sendBeacon } from './transmission/index'; 5 | import type { PageChangeBeacon } from './types'; 6 | import { getActivePhase } from './fsm'; 7 | import { info } from './debug'; 8 | import { now } from './util'; 9 | import vars from './vars'; 10 | import { getPageTransitionData, clearPageTransitionData } from './pageTransitionData'; 11 | 12 | type InternalMetaKey = 'view.title' | 'view.url'; 13 | export type InternalMeta = Partial>; 14 | 15 | const isExcessiveUsage = createExcessiveUsageIdentifier({ 16 | maxCallsPerTenMinutes: 128, 17 | maxCallsPerTenSeconds: 32 18 | }); 19 | 20 | export function setPage(page?: string, internalMeta?: InternalMeta): void { 21 | const previousPage = vars.page; 22 | vars.page = page; 23 | 24 | const isInitialPageDefinition = getActivePhase() === pageLoadPhase && previousPage == null; 25 | if (!isInitialPageDefinition && previousPage !== page) { 26 | if (isExcessiveUsage()) { 27 | if (DEBUG) { 28 | info('Reached the maximum number of page changes to monitor.'); 29 | } 30 | } else { 31 | reportPageChange(internalMeta); 32 | } 33 | } 34 | } 35 | 36 | function reportPageChange(internalMeta?: InternalMeta): void { 37 | // Some properties deliberately left our for js file size reasons. 38 | const beacon: Partial = { 39 | 'ty': 'pc', 40 | 'ts': now() 41 | }; 42 | 43 | // Add page transition data to the beacon if available 44 | const transitionData = getPageTransitionData(); 45 | 46 | if (transitionData.d !== undefined) { 47 | beacon['d'] = transitionData.d; 48 | } 49 | 50 | addCommonBeaconProperties(beacon); 51 | if (internalMeta) { 52 | addInternalMetaDataToBeacon(beacon, internalMeta); 53 | } 54 | 55 | // Clear the transition data after using it 56 | clearPageTransitionData(); 57 | 58 | sendBeacon(beacon); 59 | } 60 | -------------------------------------------------------------------------------- /test/experiments/errors/unhandledErrorHandler.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | (function() { 4 | window.onerror = function onErrorTracker(message, fileName, lineNumber, columnNumber, error) { 5 | console.log('Got an error:'); 6 | logValue('message', message); 7 | logValue('fileName', fileName); 8 | logValue('lineNumber', lineNumber); 9 | logValue('columnNumber', columnNumber); 10 | 11 | logValue('String(error)', String(error)); 12 | logValue('typeof error', typeof error); 13 | if (error) { 14 | logValue('error.name', error.name); 15 | logValue('error.message', error.message); 16 | logValue('error.stack', error.stack); 17 | } 18 | 19 | logValue('artificial backtrace', backtrace()); 20 | }; 21 | 22 | function logValue(k, v) { 23 | console.log('\t\t%s (type %s):', k, typeof v, v); 24 | } 25 | 26 | // Overwrite to see whether browsers actually go through console.error to display 27 | // uncaught errors within the console. They don't: They just render them within 28 | // the console view, but they don't go through the console APIs. 29 | console.error = function overwrittenOnError(e) { 30 | console.log('Retrieved a console error log attempt with arguments', e); 31 | console.info.apply(console, arguments); 32 | }; 33 | 34 | window.tryCatchAndReport = function tryCatchAndReport(fn) { 35 | try { 36 | fn(); 37 | } catch (e) { 38 | window.onerror(0, 0, 0, 0, e); 39 | } 40 | }; 41 | 42 | var originalSetTimeout = window.setTimeout; 43 | window.setTimeout = function(fn) { 44 | var args = Array.prototype.slice.apply(arguments); 45 | args[0] = function customSetTimeoutInitiator() { 46 | try { 47 | fn(); 48 | } catch (e) { 49 | window.onerror(0, 0, 0, 0, e); 50 | } 51 | }; 52 | return originalSetTimeout.apply(window, args); 53 | }; 54 | 55 | 56 | function backtrace() { 57 | var curr = arguments.callee.caller; 58 | var output = ''; 59 | while (curr != null) { 60 | output += curr.name + '\n'; 61 | curr = curr.caller; 62 | } 63 | return output; 64 | } 65 | })(); 66 | -------------------------------------------------------------------------------- /test/e2e/16_autoPageDetection/autoPageDetectionMappingRuleCheck.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | AutoPageDetectionTest 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 |

AutoCaptureScreenNameTest

20 |
27 |
28 |
29 |
30 |

Welcome to the Home Page

31 |

Hope you have a nice day!

32 |
33 |
34 |

About Us

35 |

Learn m Add click event listeners to navigation links 36 | var navLinks = document.querySelectorAll('#nav-links a'); // get all the elemnts with id : navlink within the in html 37 | console.log('Nav links ',navLinks) 38 |

39 |
40 |
41 |

Contact Us

42 |

Contact us using the information provided.

43 |
44 |
45 | 46 | 47 | -------------------------------------------------------------------------------- /test/e2e/initializer.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | (function() { 4 | window.config = getGlobalConfigObject(); 5 | 6 | (function(win, longGlobalName, shortGlobalName, globalApi) { 7 | if (win[longGlobalName]) { 8 | return; 9 | } 10 | 11 | win[longGlobalName] = shortGlobalName; 12 | globalApi = win[shortGlobalName] = function() { 13 | globalApi['q'].push(arguments); 14 | }; 15 | globalApi['q'] = []; 16 | globalApi['l'] = 1 * new Date(); 17 | globalApi['v'] = 2; 18 | })(window, 'EumObject', 'eum'); 19 | 20 | eum('reportingUrl', '/beacon'); 21 | eum('ignoreUrls', [/.*pleaseIgnoreThis.*/]); 22 | eum('ignoreErrorMessages', [/.*pleaseIgnoreThisError.*/]); 23 | 24 | // Set a rather low batching / waiting times as the tests will otherwise take ages. 25 | eum('beaconBatchingTime', 100); 26 | eum('maxWaitForResourceTimingsMillis', 1000); 27 | eum('maxMaitForPageLoadMetricsMillis', 100); 28 | 29 | // most existing tests does not expect extra custom events 30 | eum('webvitalsInCustomEvent', false); 31 | 32 | if (window.onEumLoad) { 33 | window.onEumLoad(eum); 34 | } 35 | 36 | function getGlobalConfigObject() { 37 | var ownPort = parseInt(window.location.port || '80', 10); 38 | var ports = [ownPort]; 39 | var otherPort = 'noCrossOriginPortDefinedViaPortsQueryString'; 40 | 41 | var portsMatch = window.location.href.match(/(\?|\&)ports=([^$&#]+)/i); 42 | if (portsMatch) { 43 | var portsRaw = decodeURIComponent(portsMatch[2]).split(','); 44 | for (var i = 0; i < portsRaw.length; i++) { 45 | ports[i] = parseInt(portsRaw[i], 10); 46 | if (ports[i] !== ownPort) { 47 | otherPort = ports[i]; 48 | } 49 | } 50 | } 51 | 52 | return { 53 | sameOrigin: 'http://' + window.location.hostname + ':' + ownPort, 54 | crossOrigin: 'http://' + window.location.hostname + ':' + otherPort 55 | }; 56 | } 57 | 58 | window.addCrossOriginScript = function addCrossOriginScript(absolutePath) { 59 | var script = document.createElement('script'); 60 | script.src = window.config.crossOrigin + absolutePath; 61 | document.body.appendChild(script); 62 | }; 63 | })(); 64 | -------------------------------------------------------------------------------- /lib/transmission/index.ts: -------------------------------------------------------------------------------- 1 | import { sendBeacon as sendBatchedBeacon, isEnabled as isBatchingEnabled } from './batched'; 2 | import { isQueryTracked, removeQueryAndFragmentFromUrl } from '../queryTrackedDomainList'; 3 | import { createExcessiveUsageIdentifier } from '../excessiveUsageIdentification'; 4 | import { sendBeacon as sendFormEncodedBeacon } from './formEncoded'; 5 | import { isUrlIgnored } from '../ignoreRules'; 6 | import type { Beacon } from '../types'; 7 | import { info, error } from '../debug'; 8 | 9 | const isExcessiveUsage = createExcessiveUsageIdentifier({ 10 | maxCalls: 8096, 11 | maxCallsPerTenMinutes: 4096, 12 | maxCallsPerTenSeconds: 128 13 | }); 14 | 15 | export function sendBeacon(data: Partial) { 16 | if (isUrlIgnored(data['l'])) { 17 | // data['l'] is a standardized property across all beacons to ensure that we do not accidentally transmit data 18 | // about a page such as this. 19 | 20 | if (DEBUG) { 21 | info('Skipping transmission of beacon because document URL associated to the beacon is ignored by ignore rule.', data); 22 | } 23 | return; 24 | } 25 | 26 | if (!isQueryTracked(data['l'])) { 27 | // data['l'] is a standardized property across all beacons to ensure that we do not accidentally transmit data 28 | // about a page such as this. 29 | data['l'] = removeQueryAndFragmentFromUrl(data['l']); 30 | if (DEBUG) { 31 | info('Tracking location url excluding query parameters and fragment strings ', data['l']); 32 | } 33 | } 34 | 35 | if (!isQueryTracked(data['u'])) { 36 | data['u'] = removeQueryAndFragmentFromUrl(data['u']); 37 | if (DEBUG) { 38 | info('Tracking request url excluding query parameters and fragment strings', data['u']); 39 | } 40 | } 41 | 42 | if (DEBUG) { 43 | info('Transmitting beacon', data); 44 | } 45 | 46 | if (isExcessiveUsage() && data['ty'] != 'pl') { 47 | if (DEBUG) { 48 | info('Reached the maximum number of beacons to transmit.'); 49 | } 50 | return; 51 | } 52 | 53 | try { 54 | if (isBatchingEnabled()) { 55 | sendBatchedBeacon(data); 56 | } else { 57 | sendFormEncodedBeacon(data); 58 | } 59 | } catch (e) { 60 | if (DEBUG) { 61 | error('Failed to transmit beacon', e); 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /lib/util.ts: -------------------------------------------------------------------------------- 1 | import {win} from './browser'; 2 | 3 | // aliasing the global function for improved minification and 4 | // protection against hasOwnProperty overrides. 5 | const globalHasOwnProperty = Object.prototype.hasOwnProperty; 6 | export function hasOwnProperty(obj: Record, key: string) { 7 | return globalHasOwnProperty.call(obj, key); 8 | } 9 | 10 | export function now(): number { 11 | return new Date().getTime(); 12 | } 13 | 14 | export function noop() { 15 | // This function is intentionally empty. 16 | } 17 | 18 | // We are trying to stay close to common tracing architectures and use 19 | // a hex encoded 64 bit random ID. 20 | const validIdCharacters: string[] = '0123456789abcdef'.split(''); 21 | let generateUniqueIdImpl: () => string = function generateUniqueIdViaRandom(): string { 22 | let result = ''; 23 | for (let i = 0; i < 16; i++) { 24 | result += validIdCharacters[Math.round(Math.random() * 15)]; 25 | } 26 | return result; 27 | }; 28 | 29 | if (win.crypto && win.crypto.getRandomValues && win.Uint32Array) { 30 | generateUniqueIdImpl = function generateUniqueIdViaCrypto(): string { 31 | const array = new win.Uint32Array(2); 32 | win.crypto.getRandomValues(array); 33 | return array[0].toString(16).padStart(8,'0') + array[1].toString(16).padStart(8,'0'); 34 | }; 35 | } 36 | 37 | export const generateUniqueId = generateUniqueIdImpl; 38 | 39 | export function addEventListener(target: EventTarget, eventType: string, callback: (arg: Event) => unknown) { 40 | if (target.addEventListener) { 41 | target.addEventListener(eventType, callback, false); 42 | } else if ((target as any).attachEvent) { 43 | (target as any).attachEvent('on' + eventType, callback); 44 | } 45 | } 46 | 47 | export function removeEventListener(target: EventTarget, eventType: string, callback: () => unknown) { 48 | if (target.removeEventListener) { 49 | target.removeEventListener(eventType, callback, false); 50 | } else if ((target as any).detachEvent) { 51 | (target as any).detachEvent('on' + eventType, callback); 52 | } 53 | } 54 | 55 | export function matchesAny(regexp: RegExp[], s: string) { 56 | for (let i = 0, len = regexp.length; i < len; i++) { 57 | if (regexp[i].test(s)) { 58 | return true; 59 | } 60 | } 61 | 62 | return false; 63 | } 64 | -------------------------------------------------------------------------------- /test/e2e/13_repeatedInjection/repeatedInjection.spec.js: -------------------------------------------------------------------------------- 1 | const {registerTestServerHooks, getE2ETestBaseUrl, getBeacons} = require('../../server/controls'); 2 | const {registerBaseHooks} = require('../base'); 3 | const {retry, expectOneMatching} = require('../../util'); 4 | 5 | const cexpect = require('chai').expect; 6 | 7 | describe('13_repeatedInjection', () => { 8 | registerTestServerHooks(); 9 | registerBaseHooks(); 10 | 11 | describe('repeatedInjection', () => { 12 | beforeEach(() => { 13 | browser.get(getE2ETestBaseUrl('13_repeatedInjection/repeatedInjection')); 14 | }); 15 | 16 | it('must report beacons with different key', () => { 17 | return retry(() => { 18 | return getBeacons().then(beacons => { 19 | const pageLoadBeacon = expectOneMatching(beacons, beacon => { 20 | cexpect(beacon.ty).to.equal('pl'); 21 | cexpect(beacon.k).to.equal('key2'); 22 | }); 23 | 24 | expectOneMatching(beacons, beacon => { 25 | cexpect(beacon.ty).to.equal('cus'); 26 | cexpect(beacon.ts).to.be.a('string'); 27 | cexpect(beacon.n).to.equal('myTestEvent1'); 28 | cexpect(beacon.k).to.equal('key1'); 29 | cexpect(beacon.pl).to.equal(pageLoadBeacon.t); 30 | }); 31 | 32 | expectOneMatching(beacons, beacon => { 33 | cexpect(beacon.ty).to.equal('cus'); 34 | cexpect(beacon.ts).to.be.a('string'); 35 | cexpect(beacon.n).to.equal('myTestEvent2'); 36 | cexpect(beacon.k).to.equal('key2'); 37 | cexpect(beacon.pl).to.equal(pageLoadBeacon.t); 38 | }); 39 | 40 | expectOneMatching(beacons, beacon => { 41 | cexpect(beacon.ty).to.equal('xhr'); 42 | cexpect(beacon.u).to.match(/^http:\/\/127\.0\.0\.1:8000\/ajax\?r=11&cacheBust=\d+$/); 43 | cexpect(beacon.k).to.equal('key1'); 44 | cexpect(beacon.pl).to.equal(pageLoadBeacon.t); 45 | }); 46 | 47 | expectOneMatching(beacons, beacon => { 48 | cexpect(beacon.ty).to.equal('xhr'); 49 | cexpect(beacon.u).to.match(/^http:\/\/127\.0\.0\.1:8000\/ajax\?r=22&cacheBust=\d+$/); 50 | cexpect(beacon.k).to.equal('key2'); 51 | cexpect(beacon.pl).to.equal(pageLoadBeacon.t); 52 | }); 53 | }); 54 | }); 55 | }); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /lib/transmission/formEncoded.ts: -------------------------------------------------------------------------------- 1 | import {XMLHttpRequest, inEncodeURIComponent, executeImageRequest} from '../browser'; 2 | import {disableMonitoringForXMLHttpRequest} from '../hooks/xhrHelpers'; 3 | import {hasOwnProperty} from '../util'; 4 | import type {Beacon, ReportingBackend} from '../types'; 5 | import vars from '../vars'; 6 | 7 | const maxLengthForImgRequest = 2000; 8 | 9 | export function sendBeacon(data: Partial) { 10 | if (vars.reportingBackends && vars.reportingBackends.length > 0) { 11 | for (let i = 0, len = vars.reportingBackends.length; i < len; i++) { 12 | const reportingBackend: ReportingBackend = vars.reportingBackends[i]; 13 | if (i > 0) { 14 | data['k'] = reportingBackend['key']; 15 | } 16 | const str = stringify(data); 17 | if (str.length != 0) { 18 | transmit(str, reportingBackend['reportingUrl']); 19 | } 20 | } 21 | } else { 22 | const str = stringify(data); 23 | if (str.length != 0) { 24 | transmit(str, String(vars.reportingUrl)); 25 | } 26 | } 27 | } 28 | 29 | function transmit(str: string, reportingUrl: string) { 30 | if (XMLHttpRequest && str.length > maxLengthForImgRequest) { 31 | const xhr = new XMLHttpRequest(); 32 | disableMonitoringForXMLHttpRequest(xhr); 33 | xhr.open('POST', String(reportingUrl), true); 34 | xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded;charset=UTF-8'); 35 | // Ensure that browsers do not try to automatically parse the response. 36 | xhr.responseType = 'text'; 37 | xhr.timeout = vars.xhrTransmissionTimeout; 38 | xhr.send(str); 39 | } else { 40 | // Older browsers do not support the XMLHttpRequest API. This sucks and may 41 | // result in a variety of issues, e.g. URL length restrictions. "Luckily", older 42 | // browsers also lack support for advanced features such as resource timing. 43 | // This should make this transmission via a GET request possible. 44 | executeImageRequest(String(reportingUrl) + '?' + str); 45 | } 46 | } 47 | 48 | function stringify(data: Partial) { 49 | let str = ''; 50 | 51 | for (const key in data) { 52 | if (hasOwnProperty(data, key)) { 53 | const value = data[key]; 54 | if (value != null) { 55 | str += '&' + inEncodeURIComponent(key) + '=' + inEncodeURIComponent(String(data[key])); 56 | } 57 | } 58 | } 59 | 60 | return str.substring(1); 61 | } 62 | -------------------------------------------------------------------------------- /test/unit/excessiveUsageIdentification.test.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | jest.useFakeTimers(); 4 | 5 | describe('excessiveUsageIdentification', () => { 6 | let createExcessiveUsageIdentifier; 7 | let isExcessiveUsage; 8 | 9 | beforeEach(() => { 10 | jest.resetModules(); 11 | self.DEBUG = false; 12 | createExcessiveUsageIdentifier = require('../../lib/excessiveUsageIdentification').createExcessiveUsageIdentifier; 13 | isExcessiveUsage = null; 14 | }); 15 | 16 | it('must identify excessive usage once the maximum call count is eached', () => { 17 | const maxCalls = 8; 18 | isExcessiveUsage = createExcessiveUsageIdentifier({ 19 | maxCalls, 20 | maxCallsPerTenMinutes: Number.MAX_VALUE, 21 | maxCallsPerTenSeconds: Number.MAX_VALUE 22 | }); 23 | 24 | for (let i = 0; i < maxCalls; i++) { 25 | expect(isExcessiveUsage()).to.equal(false); 26 | } 27 | expect(isExcessiveUsage()).to.equal(true); 28 | jest.runOnlyPendingTimers(); 29 | expect(isExcessiveUsage()).to.equal(true); 30 | }); 31 | 32 | it('must identify excessive usage per ten minute window', () => { 33 | const maxCallsPerTenMinutes = 8; 34 | isExcessiveUsage = createExcessiveUsageIdentifier({ 35 | maxCalls: Number.MAX_VALUE, 36 | maxCallsPerTenMinutes, 37 | maxCallsPerTenSeconds: Number.MAX_VALUE 38 | }); 39 | 40 | for (let i = 0; i < maxCallsPerTenMinutes; i++) { 41 | expect(isExcessiveUsage()).to.equal(false); 42 | } 43 | expect(isExcessiveUsage()).to.equal(true); 44 | 45 | jest.runOnlyPendingTimers(); 46 | for (let i = 0; i < maxCallsPerTenMinutes; i++) { 47 | expect(isExcessiveUsage()).to.equal(false); 48 | } 49 | expect(isExcessiveUsage()).to.equal(true); 50 | }); 51 | 52 | it('must identify excessive usage per ten second window', () => { 53 | const maxCallsPerTenSeconds = 8; 54 | isExcessiveUsage = createExcessiveUsageIdentifier({ 55 | maxCalls: Number.MAX_VALUE, 56 | maxCallsPerTenMinutes: Number.MAX_VALUE, 57 | maxCallsPerTenSeconds 58 | }); 59 | 60 | for (let i = 0; i < maxCallsPerTenSeconds; i++) { 61 | expect(isExcessiveUsage()).to.equal(false); 62 | } 63 | expect(isExcessiveUsage()).to.equal(true); 64 | 65 | jest.runOnlyPendingTimers(); 66 | for (let i = 0; i < maxCallsPerTenSeconds; i++) { 67 | expect(isExcessiveUsage()).to.equal(false); 68 | } 69 | expect(isExcessiveUsage()).to.equal(true); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Why 2 | 3 | 8 | 9 | # What 10 | 11 | 16 | 17 | # References 18 | 19 | 20 | 21 | - [Story / Card](http://example.com) 22 | - [Support Case](http://example.com) 23 | - [Backend Card or PR](http://example.com) 24 | - [Backend PR to pick up weasel](http://example.com) 25 | - [UI-Client Card or PR](http://example.com) 26 | - [Release Notes task or PR](http://example.com) 27 | - [Documentation card or PR](https://example.com) 28 | - [Loadgenerator card or PR](http://example.com) 29 | 30 | # Checklist 31 | 32 | 33 | 34 | - A new version of weasel? 35 | - [ ] Add this feature into CHANGELOG.md 36 | - [ ] Increase version in package.json 37 | - [ ] Tag main branch with new version after the PR is merged 38 | - API change? 39 | - [ ] API Documentation is updated in JavaScript Agent API page 40 | - Test is needed? 41 | - [ ] Unit test case is applied to cover code change 42 | - [ ] End to end test case is applied to cover code change 43 | - User facing change? 44 | - [ ] A card or PR for backend is created and linked in references 45 | - [ ] A card or PR for Ui-Client is created and linked in references 46 | - [ ] A sub-task or PR for Release Notes is created and linked in references 47 | - [ ] A card or PR for loadgenerator is created and linked in reference 48 | - [ ] An item is added in release testing card 49 | - Documentation needs an update? 50 | - [ ] Docs PR is created and linked in references 51 | 52 | # Merge Guidance 53 | 54 | ❗ Please use a **squash merge** unless there is an explicit reason you need to use a different merge strategy (e.g. you are bringing in changes from a previous release branch, or you have specific changes in your branch that you would like to retain). This keeps our commit history clean, makes changes more atomic, and makes it easier to revert changes. 55 | 56 | Screen Shot 2023-06-14 at 10 14 56 AM 57 | 58 | -------------------------------------------------------------------------------- /test/e2e/09_customEvents/simpleEventReportingWithoutCustomMetric.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | error test 6 | 7 | 8 | 9 | 62 | 63 | 64 | 65 | 66 | 67 | simple event reporting without proper CustomMetric 68 | 69 | 70 | -------------------------------------------------------------------------------- /lib/hooks/xhrHelpers.ts: -------------------------------------------------------------------------------- 1 | import {serializeEntryToArray} from '../resources/timingSerializer'; 2 | import type {XhrBeacon} from '../types'; 3 | import vars from '../vars'; 4 | 5 | /* 6 | * This file exists to resolve circular dependencies between 7 | * lib/transmission/index.js -> lib/transmission/batched.js -> lib/hooks/XMLHttpRequest.js -> lib/transmission/index.js 8 | */ 9 | 10 | export function disableMonitoringForXMLHttpRequest(xhr: XMLHttpRequest): void { 11 | const state = (xhr as any)[vars.secretPropertyKey] = (xhr as any)[vars.secretPropertyKey] || {}; 12 | state.ignored = true; 13 | } 14 | 15 | export function addResourceTiming(beacon: Partial, resource: PerformanceResourceTiming) { 16 | const timings = serializeEntryToArray(resource); 17 | 18 | beacon['s_ty'] = getTimingValue(timings[3]); 19 | beacon['s_eb'] = getTimingValue(timings[4]); 20 | beacon['s_db'] = getTimingValue(timings[5]); 21 | beacon['s_ts'] = getTimingValue(timings[6]); 22 | beacon['t_red'] = getTimingValue(timings[7]); 23 | beacon['t_apc'] = getTimingValue(timings[8]); 24 | beacon['t_dns'] = getTimingValue(timings[9]); 25 | beacon['t_tcp'] = getTimingValue(timings[10]); 26 | beacon['t_ssl'] = getTimingValue(timings[11]); 27 | beacon['t_req'] = getTimingValue(timings[12]); 28 | beacon['t_rsp'] = getTimingValue(timings[13]); 29 | if (timings[14]) { 30 | beacon['bt'] = timings[14]; 31 | beacon['bc'] = 1; 32 | } 33 | beacon['t_ttfb'] = getTimingValue(timings[15]); 34 | } 35 | 36 | function getTimingValue(timing: any) { 37 | if (typeof timing === 'number') { 38 | return timing; 39 | } 40 | return undefined; 41 | } 42 | 43 | export function addCorrelationHttpHeaders(fn: (name: string, value: string) => void, ctx: any, traceId: string) { 44 | fn.call(ctx, 'X-INSTANA-T', traceId); 45 | fn.call(ctx, 'X-INSTANA-S', traceId); 46 | fn.call(ctx, 'X-INSTANA-L', '1,correlationType=web;correlationId=' + traceId); 47 | /** 48 | * The following code supports W3C trace context headers, ensuring compatibility with OpenTelemetry (OTel). 49 | * The "03" flag at the end indicates that the trace was randomly generated and is not sampled from the frontend. 50 | * If the trace generation method changes in the future, remove the "03" flag from the end. 51 | * 52 | * References: 53 | * https://www.w3.org/TR/trace-context-2/#trace-flags 54 | * https://www.w3.org/TR/trace-context-2/#random-trace-id-flag 55 | */ 56 | if(vars.enableW3CHeaders){ 57 | fn.call(ctx, 'traceparent','00-0000000000000000'+traceId+'-'+traceId+'-03'); 58 | fn.call(ctx, 'tracestate', traceId); 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /test/e2e/05_fetch/fetchWithCsrfToken.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | fetch test 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /lib/customEvents.ts: -------------------------------------------------------------------------------- 1 | import {addCommonBeaconProperties, addMetaDataToBeacon} from './commonBeaconProperties'; 2 | import {createExcessiveUsageIdentifier} from './excessiveUsageIdentification'; 3 | import type {CustomEventBeacon, CustomEventOptions} from './types'; 4 | import {shortenStackTrace} from './hooks/unhandledError'; 5 | import {sendBeacon} from './transmission/index'; 6 | import {now, generateUniqueId} from './util'; 7 | import {getActiveTraceId} from './fsm'; 8 | import {info} from './debug'; 9 | 10 | const isExcessiveUsage = createExcessiveUsageIdentifier({ 11 | maxCallsPerTenMinutes: 128, 12 | maxCallsPerTenSeconds: 32 13 | }); 14 | 15 | export function reportCustomEvent(eventName: string, opts?: Partial): void { 16 | if (isExcessiveUsage()) { 17 | if (DEBUG) { 18 | info('Reached the maximum number of custom events to monitor'); 19 | } 20 | return; 21 | } 22 | 23 | let traceId = getActiveTraceId(); 24 | const spanId = generateUniqueId(); 25 | if (!traceId) { 26 | traceId = spanId; 27 | } 28 | 29 | // Some properties deliberately left our for js file size reasons. 30 | const beacon: Partial = { 31 | 'ty': 'cus', 32 | 's': spanId, 33 | 't': traceId, 34 | 'ts': now(), 35 | 'n': eventName 36 | }; 37 | 38 | addCommonBeaconProperties(beacon); 39 | 40 | if (opts) { 41 | enrich(beacon as CustomEventBeacon, opts); 42 | } 43 | 44 | sendBeacon(beacon as CustomEventBeacon); 45 | } 46 | 47 | function enrich(beacon: CustomEventBeacon, opts: Partial) { 48 | if (opts['meta']) { 49 | addMetaDataToBeacon(beacon, opts['meta']); 50 | } 51 | 52 | if (typeof opts['duration'] === 'number' && !isNaN(opts['duration'])) { 53 | beacon['d'] = opts['duration']; 54 | // add Math.round since duration could be a float 55 | // We know that both properties are numbers. Flow thinks they are strings because we access them via […]. 56 | beacon['ts'] = Math.round(beacon['ts'] - opts['duration']); 57 | } 58 | 59 | if (typeof opts['timestamp'] === 'number' && !isNaN(opts['timestamp'])) { 60 | beacon['ts'] = opts['timestamp']; 61 | } 62 | 63 | if ( 64 | typeof opts['backendTraceId'] === 'string' && 65 | (opts['backendTraceId'].length === 16 || opts['backendTraceId'].length === 32) 66 | ) { 67 | beacon['bt'] = opts['backendTraceId']; 68 | } 69 | 70 | if (opts['error']) { 71 | beacon['e'] = String(opts['error']['message']).substring(0, 300); 72 | beacon['st'] = shortenStackTrace(opts['error']['stack']); 73 | } 74 | 75 | if (typeof opts['componentStack'] === 'string') { 76 | beacon['cs'] = opts['componentStack'].substring(0, 4096); 77 | } 78 | 79 | if (typeof opts['customMetric'] === 'number') { 80 | beacon['cm'] = opts['customMetric']; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /lib/trie.ts: -------------------------------------------------------------------------------- 1 | import { hasOwnProperty } from './util'; 2 | 3 | const INTERNAL_END_MARKER = ''; 4 | 5 | type TrieValue = string | number; 6 | type TrieNode = { 7 | [INTERNAL_END_MARKER]?: Array; 8 | [key: string]: TrieNode | Array | undefined; 9 | }; 10 | type TrieResult = { 11 | '$'?: Array; 12 | [key: string]: TrieResult | undefined; 13 | } | Array; 14 | 15 | interface Trie { 16 | root: TrieNode; 17 | addItem(key: string, value: TrieValue): Trie; 18 | insertItem(node: TrieNode, keyCharacters: Array, keyCharacterIndex: number, value: TrieValue): void; 19 | toJs(node?: TrieNode): TrieResult; 20 | } 21 | 22 | export function createTrie() { 23 | return new Trie(); 24 | } 25 | 26 | const Trie = function (this: Trie) { 27 | this.root = {}; 28 | } as any as { new(): Trie; }; 29 | 30 | Trie.prototype.addItem = function addItem(key: string, value: TrieValue) { 31 | this.insertItem(this.root, key.split(''), 0, value); 32 | return this; 33 | }; 34 | 35 | Trie.prototype.insertItem = function insertItem(node: TrieNode, keyCharacters: Array, keyCharacterIndex: number, value: TrieValue) { 36 | const character = keyCharacters[keyCharacterIndex]; 37 | // Characters exhausted, add value to node 38 | if (character == null) { 39 | const values = node[INTERNAL_END_MARKER] = node[INTERNAL_END_MARKER] || []; 40 | values.push(value); 41 | return; 42 | } 43 | 44 | const nextNode = node[character] = node[character] || {}; 45 | this.insertItem(nextNode, keyCharacters, keyCharacterIndex + 1, value); 46 | }; 47 | 48 | Trie.prototype.toJs = function toJs(node?: TrieNode): TrieResult { 49 | node = node || this.root as TrieNode; 50 | 51 | const keys = getKeys(node); 52 | if (keys.length === 1 && keys[0] === INTERNAL_END_MARKER) { 53 | return (node[INTERNAL_END_MARKER] as Array).slice(); 54 | } 55 | 56 | const result: TrieResult = {}; 57 | 58 | for (let i = 0, length = keys.length; i < length; i++) { 59 | const key = keys[i]; 60 | if (key === INTERNAL_END_MARKER) { 61 | result['$'] = (node[INTERNAL_END_MARKER] as Array).slice(); 62 | continue; 63 | } 64 | 65 | let combinedKeys = key; 66 | let child = node[key] as TrieNode; 67 | let childKeys = getKeys(child); 68 | while (childKeys.length === 1 && childKeys[0] !== INTERNAL_END_MARKER) { 69 | combinedKeys += childKeys[0]; 70 | child = child[childKeys[0]] as TrieNode; 71 | childKeys = getKeys(child); 72 | } 73 | 74 | result[combinedKeys] = this.toJs(child); 75 | } 76 | 77 | return result; 78 | }; 79 | 80 | function getKeys(obj: TrieNode) { 81 | const result = []; 82 | 83 | for (const key in obj) { 84 | if (hasOwnProperty(obj, key)) { 85 | result.push(key); 86 | } 87 | } 88 | 89 | return result; 90 | } 91 | -------------------------------------------------------------------------------- /lib/hooks/eventHandlers.ts: -------------------------------------------------------------------------------- 1 | import {addWrappedDomEventListener, popWrappedDomEventListener} from '../asyncFunctionWrapping'; 2 | import type {EventListenerOptionsOrUseCapture} from '../asyncFunctionWrapping'; 3 | import {reportError, ignoreNextOnErrorEvent} from './unhandledError'; 4 | import {win} from '../browser'; 5 | import vars from '../vars'; 6 | 7 | export function wrapEventHandlers() { 8 | if (vars.wrapEventHandlers) { 9 | wrapEventTarget(win.EventTarget); 10 | } 11 | } 12 | 13 | function wrapEventTarget(EventTarget: typeof win.EventTarget) { 14 | if ( 15 | !EventTarget || 16 | typeof EventTarget.prototype.addEventListener !== 'function' || 17 | typeof EventTarget.prototype.removeEventListener !== 'function' 18 | ) { 19 | return; 20 | } 21 | 22 | const originalAddEventListener = EventTarget.prototype.addEventListener; 23 | const originalRemoveEventListener = EventTarget.prototype.removeEventListener; 24 | 25 | EventTarget.prototype.addEventListener = function wrappedAddEventListener( 26 | eventName: string, 27 | fn: EventListenerOrEventListenerObject | null, 28 | optionsOrCapture?: EventListenerOptionsOrUseCapture 29 | ) { 30 | if (typeof fn !== 'function') { 31 | // eslint-disable-next-line prefer-rest-params 32 | return originalAddEventListener.apply(this, arguments as any); 33 | } 34 | 35 | // non-deopt arguments copy 36 | const args = new Array(arguments.length); 37 | for (let i = 0; i < arguments.length; i++) { 38 | // eslint-disable-next-line prefer-rest-params 39 | args[i] = arguments[i]; 40 | } 41 | 42 | args[1] = function wrappedEventListener() { 43 | try { 44 | // eslint-disable-next-line prefer-rest-params 45 | return fn.apply(this, arguments as any); 46 | } catch (e) { 47 | reportError(e as any); 48 | ignoreNextOnErrorEvent(); 49 | throw e; 50 | } 51 | }; 52 | 53 | args[1] = addWrappedDomEventListener(this, args[1], eventName, fn, optionsOrCapture); 54 | 55 | return originalAddEventListener.apply(this, args as any); 56 | }; 57 | 58 | EventTarget.prototype.removeEventListener = function wrappedRemoveEventListener( 59 | eventName: string, 60 | fn: EventListenerOrEventListenerObject | null, 61 | optionsOrCapture?: EventListenerOptionsOrUseCapture 62 | ) { 63 | if (typeof fn !== 'function') { 64 | // eslint-disable-next-line prefer-rest-params 65 | return originalRemoveEventListener.apply(this, arguments as any); 66 | } 67 | 68 | // non-deopt arguments copy 69 | const args = new Array(arguments.length); 70 | for (let i = 0; i < arguments.length; i++) { 71 | // eslint-disable-next-line prefer-rest-params 72 | args[i] = arguments[i]; 73 | } 74 | 75 | args[1] = popWrappedDomEventListener(this, eventName, fn, optionsOrCapture, fn); 76 | 77 | return originalRemoveEventListener.apply(this, args as any); 78 | }; 79 | } 80 | -------------------------------------------------------------------------------- /test/e2e/11_userTiming/userTiming.spec.js: -------------------------------------------------------------------------------- 1 | const {registerTestServerHooks, getE2ETestBaseUrl, getBeacons} = require('../../server/controls'); 2 | const {registerBaseHooks, getCapabilities, hasPerformanceObserverSupport} = require('../base'); 3 | const {retry, expectOneMatching} = require('../../util'); 4 | 5 | const cexpect = require('chai').expect; 6 | 7 | describe('11_userTiming', () => { 8 | registerTestServerHooks(); 9 | registerBaseHooks(); 10 | 11 | describe('various user timings', () => { 12 | beforeEach(() => { 13 | browser.get(getE2ETestBaseUrl('11_userTiming/userTiming')); 14 | }); 15 | 16 | it('must report user timing data as custom events', async () => { 17 | const capabilities = await getCapabilities(); 18 | if (!hasPerformanceObserverSupport(capabilities)) { 19 | return; 20 | } 21 | 22 | await retry(async () => { 23 | const beacons = await getBeacons(); 24 | const pageLoadBeacon = expectOneMatching(beacons, beacon => { 25 | cexpect(beacon.ty).to.equal('pl'); 26 | }); 27 | 28 | expectOneMatching(beacons, beacon => { 29 | cexpect(beacon.ty).to.equal('cus'); 30 | cexpect(beacon.n).to.equal('domComplete'); 31 | cexpect(beacon.ts).to.be.a('string'); 32 | cexpect(Number(beacon.d)).to.be.at.most(Number(pageLoadBeacon.d)); 33 | cexpect(beacon.l).to.be.a('string'); 34 | cexpect(beacon.pl).to.equal(pageLoadBeacon.t); 35 | cexpect(beacon.m_userTimingType).to.equal('measure'); 36 | }); 37 | 38 | const startWorkBeacon = expectOneMatching(beacons, beacon => { 39 | cexpect(beacon.ty).to.equal('cus'); 40 | cexpect(beacon.n).to.equal('startWork'); 41 | cexpect(beacon.d).not.to.equal('0'); 42 | cexpect(beacon.m_userTimingType).to.equal('mark'); 43 | }); 44 | 45 | expectOneMatching(beacons, beacon => { 46 | cexpect(beacon.ty).to.equal('cus'); 47 | cexpect(beacon.n).to.equal('endWork'); 48 | cexpect(Number(beacon.ts)).to.be.at.least(Number(startWorkBeacon.ts)); 49 | cexpect(beacon.d).not.to.equal('0'); 50 | cexpect(beacon.m_userTimingType).to.equal('mark'); 51 | }); 52 | 53 | expectOneMatching(beacons, beacon => { 54 | cexpect(beacon.ty).to.equal('cus'); 55 | cexpect(beacon.n).to.equal('work'); 56 | cexpect(Number(beacon.ts)).to.equal(Number(startWorkBeacon.ts) + Number(startWorkBeacon.d)); 57 | cexpect(beacon.d).not.to.equal('0'); 58 | cexpect(beacon.m_userTimingType).to.equal('measure'); 59 | }); 60 | 61 | beacons.forEach(beacon => { 62 | if (beacon.ty === 'cus') { 63 | cexpect(beacon.n).not.to.have.string('⚛'); 64 | cexpect(beacon.n).not.to.have.string('⛔'); 65 | cexpect(beacon.n).not.to.have.string('Zone'); 66 | } 67 | }); 68 | }); 69 | }); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /lib/hooks/userTiming.ts: -------------------------------------------------------------------------------- 1 | import {performance, isResourceTimingAvailable, isPerformanceObserverAvailable} from '../performance'; 2 | import {pageLoadStartTimestamp} from '../timings'; 3 | import {reportCustomEvent} from '../customEvents'; 4 | import {matchesAny} from '../util'; 5 | import {win} from '../browser'; 6 | import {info} from '../debug'; 7 | import vars from '../vars'; 8 | 9 | export function hookIntoUserTimings() { 10 | if (performance && performance['timeOrigin'] && isResourceTimingAvailable) { 11 | drainExistingPerformanceEntries(); 12 | observeNewUserTimings(); 13 | } 14 | } 15 | 16 | function drainExistingPerformanceEntries() { 17 | onUserTimings(performance.getEntriesByType('mark')); 18 | onUserTimings(performance.getEntriesByType('measure')); 19 | } 20 | 21 | function onUserTimings(performanceEntries: PerformanceEntry[]) { 22 | for (let i = 0; i < performanceEntries.length; i++) { 23 | onUserTiming(performanceEntries[i]); 24 | } 25 | } 26 | 27 | function onUserTiming(performanceEntry: PerformanceEntry) { 28 | if (matchesAny(vars.ignoreUserTimings, performanceEntry.name)) { 29 | if (DEBUG) { 30 | info('Ignoring user timing "%s" because it is ignored via the configuration.', performanceEntry.name); 31 | } 32 | return; 33 | } 34 | 35 | let duration; 36 | if (performanceEntry.entryType !== 'mark') { 37 | duration = Math.round(performanceEntry.duration); 38 | } else { 39 | // timestamp for mark also equals to "performance['timeOrigin'] + performanceEntry.startTime" 40 | // otherwise we'll see all UserTiming cus events starting at 0 offset to timeOrigin 41 | // which will cause confusion while UI ordering beacons by timestamp. 42 | // 43 | // see also: https://github.com/instana/weasel/pull/91/files#diff-6bfdd81c3c734033fa8c5709e4faee07476683733f76c3d254fc03841a125d27R44 44 | // basically we keep the duration change in this PR but revert the timestamp 45 | duration = Math.round(performanceEntry.startTime); 46 | } 47 | 48 | // We have to write it this way because of the Closure compiler advanced mode. 49 | reportCustomEvent(performanceEntry.name, { 50 | // Do not allow the timestamp to be before our Notion of page load start. 51 | 'timestamp': Math.max(pageLoadStartTimestamp, Math.round(performance['timeOrigin'] + performanceEntry.startTime)), 52 | 'duration': duration, 53 | 'meta': { 54 | 'userTimingType': performanceEntry.entryType 55 | } 56 | }); 57 | } 58 | 59 | function observeNewUserTimings() { 60 | if (isPerformanceObserverAvailable) { 61 | try { 62 | const observer = new win['PerformanceObserver'](onObservedPerformanceEntries); 63 | observer['observe']({'entryTypes': ['mark', 'measure']}); 64 | } catch (e) { 65 | // Some browsers may not support the passed entryTypes and decide to throw an error. 66 | // This would then result in an error with a message like: 67 | // 68 | // entryTypes only contained unsupported types 69 | // 70 | // Swallow and ignore the error. Treat it like unavailable performance observer data. 71 | } 72 | } 73 | } 74 | 75 | function onObservedPerformanceEntries(list: PerformanceObserverEntryList) { 76 | onUserTimings(list.getEntries()); 77 | } 78 | -------------------------------------------------------------------------------- /lib/resources/resources.ts: -------------------------------------------------------------------------------- 1 | import { isQueryTracked, removeQueryAndFragmentFromUrl } from '../queryTrackedDomainList'; 2 | import {performance, isResourceTimingAvailable} from '../performance'; 3 | import { isTransmitionRequest } from '../transmission/util'; 4 | import type {BeaconWithResourceTiming} from '../types'; 5 | import {serializeEntry} from './timingSerializer'; 6 | import {stripSecrets} from '../stripSecrets'; 7 | import {isUrlIgnored} from '../ignoreRules'; 8 | import {urlMaxLength} from './consts'; 9 | import {createTrie} from '../trie'; 10 | import {win} from '../browser'; 11 | import {info} from '../debug'; 12 | import vars from '../vars'; 13 | 14 | // See https://w3c.github.io/resource-timing/ 15 | // See https://www.w3.org/TR/hr-time/ 16 | 17 | export function addResourceTimings(beacon: Partial, minStartTime?: number) { 18 | if (!!isResourceTimingAvailable && win.JSON) { 19 | const entries = getEntriesTransferFormat(performance.getEntriesByType('resource'), minStartTime); 20 | beacon['res'] = win.JSON.stringify(entries); 21 | } else if (DEBUG) { 22 | info('Resource timing not supported.'); 23 | } 24 | } 25 | 26 | function getEntriesTransferFormat(performanceEntries: PerformanceEntryList, minStartTime?: number) { 27 | const trie = createTrie(); 28 | 29 | for (let i = 0, len = performanceEntries.length; i < len; i++) { 30 | const entry = performanceEntries[i] as PerformanceResourceTiming; 31 | if (minStartTime != null && 32 | (entry['startTime'] - vars.highResTimestampReference + vars.referenceTimestamp) < minStartTime) { 33 | continue; 34 | } else if (entry['duration'] < 0) { 35 | // Some old browsers do not properly implement resource timing. They report negative durations. 36 | // Ignore instead of reporting these, as the data isn't usable. 37 | continue; 38 | } 39 | 40 | let url = entry.name; 41 | if (isUrlIgnored(url)) { 42 | if (DEBUG) { 43 | info('Will not include data about resource because resource URL is ignored via ignore rules.', entry); 44 | } 45 | continue; 46 | } 47 | 48 | if (!isQueryTracked(url)) { 49 | url = removeQueryAndFragmentFromUrl(url); 50 | if (DEBUG) { 51 | info('Tracking url excluding query parameters and fragment strings', url); 52 | } 53 | } 54 | 55 | const lowerCaseUrl = url.toLowerCase(); 56 | const initiatorType = entry['initiatorType']; 57 | if (lowerCaseUrl === 'about:blank' || lowerCaseUrl.indexOf('javascript:') === 0 || // some iframe cases 58 | // Data transmission can be visible as a resource. Do not report it. 59 | isTransmitionRequest(url)) { 60 | continue; 61 | } 62 | 63 | if (url.length > urlMaxLength) { 64 | url = url.substring(0, urlMaxLength); 65 | } 66 | 67 | // We provide more detailed XHR insights via our XHR instrumentation. 68 | // The XHR instrumentation is available once the initialization was executed 69 | // (which is completely synchronous). 70 | if ((initiatorType !== 'xmlhttprequest' && initiatorType !== 'fetch') || entry['startTime'] < vars.highResTimestampReference) { 71 | trie.addItem(stripSecrets(url), serializeEntry(entry)); 72 | } 73 | } 74 | 75 | return trie.toJs(); 76 | } 77 | -------------------------------------------------------------------------------- /test/unit/hooks/unhandledRejection.test.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import { onUnhandledRejection, hookIntoGlobalUnhandledRejectionEvent } from '../../../lib/hooks/unhandledRejection'; 4 | import { getReportedErrors, clearReportedErrors } from '../../../lib/hooks/unhandledError'; 5 | 6 | jest.mock('../../../lib/hooks/unhandledError'); 7 | 8 | describe('asyncFunctionWrapping', () => { 9 | beforeEach(clearReportedErrors); 10 | 11 | describe('registration', () => { 12 | it('must observe unhandled rejections', () => { 13 | hookIntoGlobalUnhandledRejectionEvent(); 14 | // window.PromiseRejectionEvent is unsupported by our current version of jsdom. As a result, 15 | // we are simulating it. 16 | // https://html.spec.whatwg.org/multipage/webappapis.html#promiserejectioneventinit 17 | const reason = new Error('foo'); 18 | const event = new window.Event('unhandledrejection'); 19 | event.reason = reason; 20 | window.dispatchEvent(event); 21 | expect(getReportedErrors()).to.have.lengthOf(1); 22 | expect(getReportedErrors()[0].message).to.have.a.string(reason.message); 23 | expect(getReportedErrors()[0].stack).to.equal(reason.stack); 24 | }); 25 | }); 26 | 27 | describe('reporting', () => { 28 | it('must support undefined reasons', () => { 29 | onUnhandledRejection({}); 30 | expect(getReportedErrors()).to.have.lengthOf(1); 31 | expect(getReportedErrors()[0].message).to.match(/no reason defined/); 32 | expect(getReportedErrors()[0].stack).to.match(/unavailable/); 33 | }); 34 | 35 | it('must support errors as reasons', () => { 36 | const error = new Error('Test error handling'); 37 | onUnhandledRejection({ 38 | reason: error 39 | }); 40 | expect(getReportedErrors()).to.have.lengthOf(1); 41 | expect(getReportedErrors()[0].message).to.have.a.string(error.message); 42 | expect(getReportedErrors()[0].stack).to.equal(error.stack); 43 | }); 44 | 45 | it('must support error like objects as reasons', () => { 46 | const error = { 47 | message: 'Test error handling' 48 | }; 49 | onUnhandledRejection({ 50 | reason: error 51 | }); 52 | expect(getReportedErrors()).to.have.lengthOf(1); 53 | expect(getReportedErrors()[0].message).to.have.a.string(error.message); 54 | expect(getReportedErrors()[0].stack).to.match(/unavailable/); 55 | }); 56 | 57 | it('must support error like objects with a stack as reasons', () => { 58 | const error = { 59 | message: 'Test error handling', 60 | stack: 'At foo:32' 61 | }; 62 | onUnhandledRejection({ 63 | reason: error 64 | }); 65 | expect(getReportedErrors()).to.have.lengthOf(1); 66 | expect(getReportedErrors()[0].message).to.have.a.string(error.message); 67 | expect(getReportedErrors()[0].stack).to.equal(error.stack); 68 | }); 69 | 70 | it('must support rejection with primitives', () => { 71 | const reason = 'Permutation fun'; 72 | onUnhandledRejection({ 73 | reason 74 | }); 75 | expect(getReportedErrors()).to.have.lengthOf(1); 76 | expect(getReportedErrors()[0].message).to.have.a.string(reason); 77 | expect(getReportedErrors()[0].stack).to.match(/unavailable/); 78 | }); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /test/unit/ignoreRules.test.js: -------------------------------------------------------------------------------- 1 | import {expect} from 'chai'; 2 | 3 | import {isUrlIgnored, isErrorMessageIgnored} from '../../lib/ignoreRules'; 4 | import vars from '../../lib/vars'; 5 | 6 | describe('ignoreRules', () => { 7 | afterEach(() => { 8 | vars.ignoreUrls = []; 9 | vars.ignorePings = true; 10 | vars.ignoreErrorMessages = []; 11 | vars.reportingUrl = 'https://ingress.example.com'; 12 | vars.reportingBackends = [{reportingUrl: 'https://ingress.example.com', key: 'key'}]; 13 | }); 14 | 15 | describe('isUrlIgnored', () => { 16 | it('must not ignore URLs when the ignore list is empty', () => { 17 | expect(isUrlIgnored('http://example.com')).to.equal(false); 18 | }); 19 | 20 | it('must identify URLs to ignore', () => { 21 | vars.ignoreUrls = [/example.com$/]; 22 | 23 | expect(isUrlIgnored('http://example.com')).to.equal(true); 24 | expect(isUrlIgnored('http://shop.example.com')).to.equal(true); 25 | expect(isUrlIgnored('http://example.comm')).to.equal(false); 26 | expect(isUrlIgnored('http://google.com')).to.equal(false); 27 | }); 28 | 29 | it('must ignore pings by default', () => { 30 | expect(isUrlIgnored('http://example.com/ping')).to.equal(true); 31 | expect(isUrlIgnored('http://example.com/ping/')).to.equal(true); 32 | expect(isUrlIgnored('http://example.com/dsadsadad/ping/')).to.equal(true); 33 | expect(isUrlIgnored('http://example.com/ping?foo=bar')).to.equal(true); 34 | expect(isUrlIgnored('http://ping.com/shop')).to.equal(false); 35 | expect(isUrlIgnored('http://example.com/ping/example')).to.equal(false); 36 | expect(isUrlIgnored('http://example.com/pingalistic')).to.equal(false); 37 | }); 38 | 39 | it('must allow pings when the user knows what they are doing', () => { 40 | vars.ignorePings = false; 41 | 42 | expect(isUrlIgnored('http://example.com/ping')).to.equal(false); 43 | expect(isUrlIgnored('http://example.com/ping/')).to.equal(false); 44 | expect(isUrlIgnored('http://example.com/dsadsadad/ping/')).to.equal(false); 45 | expect(isUrlIgnored('http://ping.com/shop')).to.equal(false); 46 | expect(isUrlIgnored('http://example.com/ping?foo=bar')).to.equal(false); 47 | }); 48 | 49 | it('must ignore data URLs by default', () => { 50 | expect(isUrlIgnored('data:text/html,%3Ch1%3EHello%2C%20World!%3C%2Fh1%3E')).to.equal(true); 51 | }); 52 | 53 | it('must ignore data transmission requests', () => { 54 | expect(isUrlIgnored(vars.reportingUrl)).to.equal(true); 55 | expect(isUrlIgnored(vars.reportingUrl + '/')).to.equal(true); 56 | expect(isUrlIgnored(vars.reportingUrl + '/eum.min.js')).to.equal(false); 57 | }); 58 | }); 59 | 60 | describe('isErrorMessageIgnored', () => { 61 | it('must not ignore error messages when the ignore list is empty', () => { 62 | expect(isErrorMessageIgnored('Script error.')).to.equal(false); 63 | expect(isErrorMessageIgnored('Script error')).to.equal(false); 64 | }); 65 | 66 | it('must respect ignore rules', () => { 67 | vars.ignoreErrorMessages = [/script error/i]; 68 | expect(isErrorMessageIgnored('Foobar: Script error.')).to.equal(true); 69 | expect(isErrorMessageIgnored('Something: Script error')).to.equal(true); 70 | expect(isErrorMessageIgnored('Foobar')).to.equal(false); 71 | }); 72 | }); 73 | }); 74 | --------------------------------------------------------------------------------