@pendo/agent 2.337.2 → 2.338.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -430,6 +430,12 @@ function getPolicy(pendo) {
430
430
  return policy;
431
431
  }
432
432
 
433
+ function escapeRegExp(string) {
434
+ if (!string)
435
+ return;
436
+ return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
437
+ }
438
+
433
439
  var STAGING_SERVER_HASHES = 'stagingServerHashes';
434
440
  var STAGING_DOMAINS = 'stagingDomains';
435
441
  var pendo$1;
@@ -515,7 +521,9 @@ function urlMatchesPatterns(url, patterns = [], hashes = []) {
515
521
  }
516
522
  }
517
523
  if (typeof pattern === 'string') {
518
- pattern = new RegExp('^' + pattern + '$');
524
+ // String entries are literal origins/hosts, not patterns. Escape
525
+ // them so a '.' can't act as a wildcard and match a look-alike domain.
526
+ pattern = new RegExp('^' + escapeRegExp(pattern) + '$');
519
527
  }
520
528
  if (pattern instanceof RegExp && pattern.test(url)) {
521
529
  return true;
@@ -3916,7 +3924,13 @@ function isMobileUserAgent() {
3916
3924
  return (/Android|webOS|iPhone|iPod|BlackBerry|IEMobile|Opera Mini/i).test(getUA());
3917
3925
  }
3918
3926
  function isSafari(userAgent = getUA()) {
3919
- return userAgent.indexOf('Safari') >= 0 && !userAgent.indexOf('Chrome') >= 0;
3927
+ return userAgent.indexOf('Safari') >= 0 && !isChromium(userAgent);
3928
+ }
3929
+ // Every Chromium UA carries a trailing Safari/ token, so Chrome, Edge, Opera,
3930
+ // Brave and Electron all have to be excluded from isSafari(). Chromium builds
3931
+ // report Chromium/ without a Chrome/ token, hence both checks.
3932
+ function isChromium(userAgent = getUA()) {
3933
+ return userAgent.indexOf('Chrome') >= 0 || userAgent.indexOf('Chromium') >= 0;
3920
3934
  }
3921
3935
  const sniffer = {
3922
3936
  supportsHistoryApi: shouldWrapNativeHistory,
@@ -4029,8 +4043,8 @@ let SERVER = '';
4029
4043
  let ASSET_HOST = '';
4030
4044
  let ASSET_PATH = '';
4031
4045
  let DESIGNER_SERVER = '';
4032
- let VERSION = '2.337.2_';
4033
- let PACKAGE_VERSION = '2.337.2';
4046
+ let VERSION = '2.338.0_';
4047
+ let PACKAGE_VERSION = '2.338.0';
4034
4048
  let LOADER = 'xhr';
4035
4049
  /* eslint-enable web-sdk-eslint-rules/no-gulp-env-references */
4036
4050
  /**
@@ -4447,12 +4461,6 @@ class LogEmitter extends EventTarget {
4447
4461
  }
4448
4462
  const log = new LogEmitter();
4449
4463
 
4450
- function escapeRegExp(string) {
4451
- if (!string)
4452
- return;
4453
- return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
4454
- }
4455
-
4456
4464
  const allowKeys = function () {
4457
4465
  var allowKeys = ConfigReader.get('storage.allowKeys') || '*';
4458
4466
  return _.isArray(allowKeys) ? _.indexBy(allowKeys) : allowKeys;
@@ -10079,6 +10087,26 @@ var writeMessage = function (msg) {
10079
10087
  });
10080
10088
  return q.resolve(writeImgTag(url)).catch(_.noop);
10081
10089
  };
10090
+ /**
10091
+ * Releases a fetch response body we are never going to read. An undrained body
10092
+ * holds its stream, the underlying data pipe and the socket open until the
10093
+ * {Response} is collected, which may never happen in a long-lived embedded
10094
+ * renderer. Cancelling does not affect keepalive delivery.
10095
+ *
10096
+ * @access private
10097
+ * @param {Response} response to discard the body of
10098
+ * @returns {Response} the same response, for chaining
10099
+ */
10100
+ function cancelResponseBody(response) {
10101
+ try {
10102
+ if (response && response.body && _.isFunction(response.body.cancel)) {
10103
+ response.body.cancel().catch(_.noop);
10104
+ }
10105
+ }
10106
+ catch (e) {
10107
+ }
10108
+ return response;
10109
+ }
10082
10110
  /**
10083
10111
  * @access private
10084
10112
  * @param {string} msg to send
@@ -10107,7 +10135,9 @@ function writeErrorPOST(msg) {
10107
10135
  else {
10108
10136
  promise = ajax.postJSON(url, errorData);
10109
10137
  }
10110
- return promise.then(function () { log.info('successfully wrote error'); }, function (err) { log.info('error writing error:' + err); });
10138
+ return promise.then(function (response) {
10139
+ cancelResponseBody(response);
10140
+ }, function (err) { log.debug('error writing error:' + err); });
10111
10141
  }
10112
10142
  catch (e) {
10113
10143
  log.info('Failed to write error to server using POST endpoint: ' + e);
@@ -10129,6 +10159,10 @@ function writeMetricsPOST(payload) {
10129
10159
  keepalive: true,
10130
10160
  body: JSON.stringify(payload),
10131
10161
  headers: { 'Content-Type': 'application/json' }
10162
+ })
10163
+ .then(cancelResponseBody)
10164
+ .catch(function (err) {
10165
+ log.debug(`Failed to write metrics payload to server: ${err}`);
10132
10166
  });
10133
10167
  }
10134
10168
  else {
@@ -10156,13 +10190,23 @@ function writeImgTag(src) {
10156
10190
  image.src = src;
10157
10191
  return deferred.promise;
10158
10192
  }
10193
+ /**
10194
+ * Sends a fire-and-forget beacon request. The response body is cancelled before
10195
+ * the promise resolves, so callers get {Response.status}, {Response.ok} and the
10196
+ * headers but cannot read the body. Use {fetch} directly if you need to read one.
10197
+ *
10198
+ * @access private
10199
+ * @param {string} url to request
10200
+ * @param {Object} options passed to {fetch}, defaulting to a keepalive GET
10201
+ * @returns {Promise<Response>} with its body already cancelled
10202
+ */
10159
10203
  function fetchKeepalive(url, options = {}) {
10160
10204
  const defaults = {
10161
10205
  method: 'GET',
10162
10206
  keepalive: true
10163
10207
  };
10164
10208
  const fetchOptions = _.extend({}, defaults, options);
10165
- return fetch(url, fetchOptions);
10209
+ return fetch(url, fetchOptions).then(cancelResponseBody);
10166
10210
  }
10167
10211
  fetchKeepalive.supported = function () {
10168
10212
  try {
@@ -11788,6 +11832,13 @@ _.extend(dom, {
11788
11832
  });
11789
11833
  _.extend(dom.prototype, DomEvent.$, DomQuery.$);
11790
11834
 
11835
+ // Domains that Pendo owns, will be swapped in by build patches
11836
+ const pendoDomainPatterns = [
11837
+ /^https:\/\/((adopt|oem)\.)?((us1)\.)?(app|via|adopt|cdn|oem|novus-api|novus)(\.(au|eu|gov|hsbc|jpn|tv))?\.pendo\.io$/,
11838
+ /^https:\/\/([0-9]{8}t[0-9]{4}-dot-)(pendo-au|pendo-eu|pendo-govramp|pendo-hsbc|pendo-io|pendo-jp-prod|pendo-tv|pendo-us1)\.appspot\.com$/,
11839
+ /^https:\/\/hotfix-(ops|app)-([0-9]+-dot-)(pendo-au|pendo-eu|pendo-govramp|pendo-hsbc|pendo-io|pendo-jp-prod|pendo-tv|pendo-us1)\.appspot\.com$/,
11840
+ /^https:\/\/(pendo-au|pendo-eu|pendo-govramp|pendo-hsbc|pendo-io|pendo-jp-prod|pendo-tv|pendo-us1)-static\.storage\.googleapis\.com$/
11841
+ ]; // eslint-disable-line web-sdk-eslint-rules/no-gulp-env-references
11791
11842
  function isTrustedOrigin(host) {
11792
11843
  if (!host)
11793
11844
  return false;
@@ -11801,17 +11852,6 @@ function isTrustedOrigin(host) {
11801
11852
  return true;
11802
11853
  if (host === getContentHost())
11803
11854
  return true;
11804
- // Domains that Pendo owns, will be swapped in by build patches
11805
- const patterns = [
11806
- /^https:\/\/((adopt|oem)\.)?((us1)\.)?(app|via|adopt|cdn|oem|novus-api|novus)(\.(au|eu|gov|hsbc|jpn|tv))?\.pendo\.io$/,
11807
- /^https:\/\/([0-9]{8}t[0-9]{4}-dot-)(pendo-au|pendo-eu|pendo-govramp|pendo-hsbc|pendo-io|pendo-jp-prod|pendo-tv|pendo-us1)\.appspot\.com$/,
11808
- /^https:\/\/hotfix-(ops|app)-([0-9]+-dot-)(pendo-au|pendo-eu|pendo-govramp|pendo-hsbc|pendo-io|pendo-jp-prod|pendo-tv|pendo-us1)\.appspot\.com$/,
11809
- /^https:\/\/(pendo-au|pendo-eu|pendo-govramp|pendo-hsbc|pendo-io|pendo-jp-prod|pendo-tv|pendo-us1)-static\.storage\.googleapis\.com$/,
11810
- /^https:\/\/([a-zA-Z0-9-]+\.)*pendo-dev\.com$/,
11811
- /^https:\/\/([a-zA-Z0-9-]+-dot-)?(pendo-apollo|pendo-armada|pendo-atlas|pendo-batman|pendo-calypso|pendo-dap|pendo-dev|pendo-dr-us-east1|pendo-eu-dev|pendo-freeze|pendo-helix|pendo-link|pendo-magic|pendo-mango|pendo-mcfly|pendo-ml|pendo-mobile-fbi|pendo-mobile-guides|pendo-mobile-hummus|pendo-mobile-plat|pendo-perfserf|pendo-voc|pendo-wildlings|pendo-ionchef|pendo-scrum-ops|pendo-security|pendo-test|pendo-uat|pendo-au|pendo-eu|pendo-govramp|pendo-hsbc|pendo-io|pendo-jp-prod|pendo-tv|pendo-us1)\.appspot\.com$/,
11812
- /^https:\/\/(via|adopt|local)\.pendo\.(local|io):\d{4}$/,
11813
- /^https:\/\/(pendo-apollo|pendo-armada|pendo-atlas|pendo-batman|pendo-calypso|pendo-dap|pendo-dev|pendo-dr-us-east1|pendo-eu-dev|pendo-freeze|pendo-helix|pendo-link|pendo-magic|pendo-mango|pendo-mcfly|pendo-ml|pendo-mobile-fbi|pendo-mobile-guides|pendo-mobile-hummus|pendo-mobile-plat|pendo-perfserf|pendo-voc|pendo-wildlings|pendo-ionchef|pendo-scrum-ops|pendo-security|pendo-test|pendo-uat)-static\.storage\.googleapis\.com$/
11814
- ]; // eslint-disable-line web-sdk-eslint-rules/no-gulp-env-references
11815
11855
  var adoptHost = ConfigReader.get('adoptHost');
11816
11856
  if (adoptHost) {
11817
11857
  var fullHostname = 'https://' + adoptHost;
@@ -11821,10 +11861,17 @@ function isTrustedOrigin(host) {
11821
11861
  }
11822
11862
  if (urlMatchesPatterns(host, ConfigReader.get('allowedOriginServers', []), ConfigReader.get('allowedOriginServerHashes', [])))
11823
11863
  return true;
11824
- return _.any(patterns, function (pattern) {
11864
+ return _.any(pendoDomainPatterns, function (pattern) {
11825
11865
  return pattern.test(host);
11826
11866
  });
11827
11867
  }
11868
+ function isPendoDomain(origin) {
11869
+ if (!origin)
11870
+ return false;
11871
+ return _.any(pendoDomainPatterns, function (pattern) {
11872
+ return pattern.test(origin);
11873
+ });
11874
+ }
11828
11875
  function hasAllowedOriginServers() {
11829
11876
  const allowedOriginServers = ConfigReader.get('allowedOriginServers', []);
11830
11877
  return allowedOriginServers && allowedOriginServers.length > 0;
@@ -13162,6 +13209,7 @@ function sendWithImg(apiKey, silo) {
13162
13209
  return writeImgTag(buildGetRequestUrl(apiKey, silo));
13163
13210
  }
13164
13211
  function handleFetchResponse(response) {
13212
+ cancelResponseBody(response);
13165
13213
  if (response.status < 200 || response.status >= 300) {
13166
13214
  return q.reject(new Error(`received status code ${response.status}: ${response.statusText}`));
13167
13215
  }
@@ -26805,10 +26853,23 @@ var DesignerV2 = (function () {
26805
26853
  }
26806
26854
  return validTarget;
26807
26855
  }
26856
+ function hasSameOriginOpener() {
26857
+ if (!window.opener)
26858
+ return false;
26859
+ try {
26860
+ return window.opener.location.origin === window.location.origin;
26861
+ }
26862
+ catch (e) {
26863
+ return false;
26864
+ }
26865
+ }
26808
26866
  function launchOnToken(url) {
26809
26867
  if (isDesignerFrame(window.name)) {
26810
26868
  return;
26811
26869
  }
26870
+ if (isPendoDomain(window.location.origin) && !hasSameOriginOpener()) {
26871
+ return;
26872
+ }
26812
26873
  var tokenParamTest = /pendo-designer=([A-Za-z0-9-]+)/;
26813
26874
  if (!tokenParamTest.test(url))
26814
26875
  return;
@@ -26821,7 +26882,7 @@ var DesignerV2 = (function () {
26821
26882
  // clear stored designer state if we're launching from a token
26822
26883
  window.localStorage.removeItem('pendo-navigation-state');
26823
26884
  window.localStorage.removeItem('pendo-designer-mode');
26824
- launchInAppDesigner({
26885
+ DesignerV2.launchInAppDesigner({
26825
26886
  target: validTarget,
26826
26887
  host: validAssetHost,
26827
26888
  preloader: false,
@@ -29738,7 +29799,11 @@ var BuildingBlockGuides = (function () {
29738
29799
  // position and calculate its height before making it visible.
29739
29800
  // In the designer, changing visibility like this causes the guide to "flicker"
29740
29801
  // between re-renders
29741
- guideContainer.css({ visibility: 'hidden' });
29802
+ // Only hide the first match. Resource center module previews nest the module's
29803
+ // pendo-g- inside the home view's pendo-g- under the same id (via
29804
+ // correctGuideContainerId), and imageNodeHandler only un-hides the first match,
29805
+ // so hiding every match left the module content hidden forever.
29806
+ dom(guideContainer[0]).css({ visibility: 'hidden' });
29742
29807
  guideToAppend.css({ visibility: 'hidden' });
29743
29808
  const embedConfig = _.get(guide, 'attributes.embedConfig', null);
29744
29809
  var watermark = BuildingBlockWatermark.buildWatermark({
@@ -50698,6 +50763,7 @@ const RECORDING_CONFIG_ON_RECORDING_STOP = 'recording.onRecordingStop';
50698
50763
  const RECORDING_CONFIG_WORKER_OVERRIDE = 'recording.workerOverride';
50699
50764
  const RECORDING_CONFIG_TREAT_IFRAME_AS_ROOT = 'recording.treatIframeAsRoot';
50700
50765
  const RECORDING_CONFIG_DISABLE_UNLOAD = 'recording.disableUnload';
50766
+ const RECORDING_CONFIG_RECORD_CROSS_ORIGIN_IFRAMES = 'recording.recordCrossOriginIframes';
50701
50767
  const RESOURCE_CACHING = 'resourceCaching';
50702
50768
  const ONE_DAY_IN_MILLISECONDS = 24 * 60 * 60 * 1000;
50703
50769
  const ONE_MINUTE_IN_MILLISECONDS = 60 * 1000;
@@ -50809,6 +50875,26 @@ class SessionRecorder {
50809
50875
  * @type {boolean}
50810
50876
  */
50811
50877
  cf.addOption(RECORDING_CONFIG_TREAT_IFRAME_AS_ROOT, [cf.sources.SNIPPET_SRC], undefined);
50878
+ /**
50879
+ * Controls whether content embedded from another domain is included in replay. When enabled, the
50880
+ * embedded content shows up as part of the page. When disabled, it appears as a blank box.
50881
+ *
50882
+ * This only applies to embedded content that also has Pendo installed. Embeds from other companies
50883
+ * (Stripe, YouTube) are never captured either way — turning this off just stops Pendo from
50884
+ * messaging those frames at all.
50885
+ *
50886
+ * To capture embedded content as its own separate replay instead of part of the page it sits in,
50887
+ * see recording.treatIframeAsRoot.
50888
+ *
50889
+ * @access public
50890
+ * @category Config/Replay
50891
+ * @name recording.recordCrossOriginIframes
50892
+ * @default true
50893
+ * @type {boolean}
50894
+ */
50895
+ // Like recording.enabled, this defaults to undefined so that it only overrides the
50896
+ // value returned by the backend when it is explicitly set to true/false
50897
+ cf.addOption(RECORDING_CONFIG_RECORD_CROSS_ORIGIN_IFRAMES, [cf.sources.SNIPPET_SRC], undefined);
50812
50898
  cf.addOption(RESOURCE_CACHING, [cf.sources.PENDO_CONFIG_SRC], undefined);
50813
50899
  }
50814
50900
  initialize(pendo, PluginAPI) {
@@ -51028,6 +51114,9 @@ class SessionRecorder {
51028
51114
  });
51029
51115
  const blockedSelectors = ['.pendo-ignore', '.pendo-sr-ignore'].concat(recordingOptions.blockedSelectors || []);
51030
51116
  const hiddenSelectors = ['.pendo-sr-hide'].concat(recordingOptions.hiddenSelectors || []);
51117
+ // The snippet config value should take precedence over the value from the visitor config
51118
+ const snippetValue = this.api.ConfigReader.get(RECORDING_CONFIG_RECORD_CROSS_ORIGIN_IFRAMES);
51119
+ const recordCrossOriginIframes = this.pendo._.isBoolean(snippetValue) ? snippetValue : recordingOptions.recordCrossOriginIframes;
51031
51120
  const config = {
51032
51121
  maskInputFn: this.pendo._.partial(maskInput, maskOptions),
51033
51122
  maskTextFn: this.pendo._.partial(maskText, maskOptions),
@@ -51038,7 +51127,7 @@ class SessionRecorder {
51038
51127
  // we need to concat hidden selectors to re-use block selector logic
51039
51128
  blockSelector: blockedSelectors.concat(hiddenSelectors).join(','),
51040
51129
  hideSelector: hiddenSelectors.join(','),
51041
- recordCrossOriginIframes: recordingOptions.recordCrossOriginIframes != null ? recordingOptions.recordCrossOriginIframes : true,
51130
+ recordCrossOriginIframes: recordCrossOriginIframes != null ? recordCrossOriginIframes : true,
51042
51131
  emitFromIframe: !!this.api.ConfigReader.get(RECORDING_CONFIG_TREAT_IFRAME_AS_ROOT)
51043
51132
  };
51044
51133
  return config;
@@ -51794,6 +51883,20 @@ var WorkerFactory = /*#__PURE__*/createInlineWorkerFactory(/* rollup-plugin-web-
51794
51883
  }
51795
51884
 
51796
51885
  const ONE_HUNDRED_MB_IN_BYTES = 100 * 1024 * 1024;
51886
+ // An undrained response body holds its stream, the underlying data pipe and the
51887
+ // socket open until the response is collected, which in a worker that lives for
51888
+ // the whole session may never happen. Deliberately duplicated from
51889
+ // cancelResponseBody in src/transmit.js instead of imported: this is its own
51890
+ // bundle, and importing transmit would pull the rest of its dependencies in.
51891
+ function cancelResponseBody(response) {
51892
+ try {
51893
+ if (response && response.body && typeof response.body.cancel === 'function') {
51894
+ response.body.cancel().catch(function () { });
51895
+ }
51896
+ }
51897
+ catch (e) {
51898
+ }
51899
+ }
51797
51900
  function matchHostedResources(recordingEvent, stringifiedRecordingPayload) {
51798
51901
  const fontURLRegex = /https:\/\/[^"\\\s]+?\.(woff2?|ttf|otf|eot)(\?[^"\\\s]*)?\b/g;
51799
51902
  const matches = stringifiedRecordingPayload.match(fontURLRegex);
@@ -51868,6 +51971,7 @@ var WorkerFactory = /*#__PURE__*/createInlineWorkerFactory(/* rollup-plugin-web-
51868
51971
  method: 'POST',
51869
51972
  body
51870
51973
  }).then(function (response) {
51974
+ cancelResponseBody(response);
51871
51975
  if (response.status < 200 || response.status >= 300) {
51872
51976
  postMessage({
51873
51977
  error: new Error(`received status code ${response.status}: ${response.statusText}`),
@@ -52627,7 +52731,10 @@ function ConsoleCapture() {
52627
52731
  let transport;
52628
52732
  let isPtmPaused;
52629
52733
  let isCapturingConsoleLogs = false;
52734
+ let alwaysCaptureErrors = false;
52735
+ let isRecordingActive = false;
52630
52736
  const CAPTURE_CONSOLE_CONFIG = 'captureConsoleLogs';
52737
+ const ALWAYS_CAPTURE_ERRORS_CONFIG = 'alwaysCaptureDevLogErrors';
52631
52738
  const CONSOLE_METHODS = ['log', 'warn', 'error', 'info'];
52632
52739
  const DEV_LOG_SUB_TYPE = 'console';
52633
52740
  // deduplicate logs
@@ -52666,9 +52773,11 @@ function ConsoleCapture() {
52666
52773
  pluginAPI = PluginAPI;
52667
52774
  const { ConfigReader } = pluginAPI;
52668
52775
  ConfigReader.addOption(CAPTURE_CONSOLE_CONFIG, [ConfigReader.sources.PENDO_CONFIG_SRC], false);
52776
+ ConfigReader.addOption(ALWAYS_CAPTURE_ERRORS_CONFIG, [ConfigReader.sources.PENDO_CONFIG_SRC], false);
52669
52777
  const captureConsoleEnabled = ConfigReader.get(CAPTURE_CONSOLE_CONFIG);
52670
52778
  if (!captureConsoleEnabled)
52671
52779
  return;
52780
+ alwaysCaptureErrors = !!ConfigReader.get(ALWAYS_CAPTURE_ERRORS_CONFIG);
52672
52781
  globalPendo = pendo;
52673
52782
  buffer = new DevlogBuffer(pendo, pluginAPI);
52674
52783
  transport = new DevlogTransport(pendo, pluginAPI);
@@ -52712,11 +52821,19 @@ function ConsoleCapture() {
52712
52821
  pluginAPI.log.info(`[ConsoleCapture] Console log capture ${shouldCapture ? 'started' : 'stopped'}${reason ? `: ${reason}` : ''}`);
52713
52822
  }
52714
52823
  function recordingStarted() {
52824
+ isRecordingActive = true;
52715
52825
  setCaptureState({ shouldCapture: true, reason: 'recording started' });
52716
52826
  }
52717
52827
  function recordingStopped() {
52828
+ isRecordingActive = false;
52829
+ if (alwaysCaptureErrors)
52830
+ return; // capture isn't tied to the recording lifecycle
52718
52831
  setCaptureState({ shouldCapture: false, reason: 'recording stopped' });
52719
52832
  }
52833
+ // ingestion only keeps error logs when there's no recording, so don't send the rest
52834
+ function isErrorOnlyCapture() {
52835
+ return alwaysCaptureErrors && !isRecordingActive;
52836
+ }
52720
52837
  function readyHandler() {
52721
52838
  try {
52722
52839
  addIntercepts();
@@ -52725,6 +52842,10 @@ function ConsoleCapture() {
52725
52842
  catch (error) {
52726
52843
  pluginAPI.log.error('Error setting up console capture intercepts: ', error);
52727
52844
  teardown();
52845
+ return;
52846
+ }
52847
+ if (alwaysCaptureErrors) {
52848
+ setCaptureState({ shouldCapture: true, reason: 'always capture dev log errors' });
52728
52849
  }
52729
52850
  }
52730
52851
  function addIntercepts() {
@@ -52760,6 +52881,8 @@ function ConsoleCapture() {
52760
52881
  function createConsoleEvent(args, methodName, { skipStackTrace = false, skipScrubPII = false } = {}) {
52761
52882
  if (!isCapturingConsoleLogs || !args || args.length === 0 || !_.contains(CONSOLE_METHODS, methodName))
52762
52883
  return;
52884
+ if (isErrorOnlyCapture() && methodName !== 'error')
52885
+ return;
52763
52886
  // stringify args
52764
52887
  let message = _.compact(_.map(args, arg => {
52765
52888
  let stringifiedArg;
@@ -52873,8 +52996,11 @@ function NetworkCapture() {
52873
52996
  let responseBodyCb;
52874
52997
  let pendoDevlogBaseUrl;
52875
52998
  let isCapturingNetworkLogs = false;
52999
+ let alwaysCaptureErrors = false;
53000
+ let isRecordingActive = false;
52876
53001
  let excludeRequestUrls = [];
52877
53002
  const CAPTURE_NETWORK_CONFIG = 'captureNetworkRequests';
53003
+ const ALWAYS_CAPTURE_ERRORS_CONFIG = 'alwaysCaptureDevLogErrors';
52878
53004
  const NETWORK_SUB_TYPE = 'network';
52879
53005
  const NETWORK_LOGS_CONFIG = 'networkLogs';
52880
53006
  const NETWORK_LOGS_CONFIG_ALLOWED_REQUEST_HEADERS = 'networkLogs.allowedRequestHeaders';
@@ -52950,9 +53076,11 @@ function NetworkCapture() {
52950
53076
  globalPendo = pendo;
52951
53077
  const { ConfigReader } = pluginAPI;
52952
53078
  ConfigReader.addOption(CAPTURE_NETWORK_CONFIG, [ConfigReader.sources.PENDO_CONFIG_SRC], false);
53079
+ ConfigReader.addOption(ALWAYS_CAPTURE_ERRORS_CONFIG, [ConfigReader.sources.PENDO_CONFIG_SRC], false);
52953
53080
  const captureNetworkEnabled = ConfigReader.get(CAPTURE_NETWORK_CONFIG);
52954
53081
  if (!captureNetworkEnabled)
52955
53082
  return;
53083
+ alwaysCaptureErrors = !!ConfigReader.get(ALWAYS_CAPTURE_ERRORS_CONFIG);
52956
53084
  buffer = new DevlogBuffer(pendo, pluginAPI);
52957
53085
  transport = new DevlogTransport(pendo, pluginAPI);
52958
53086
  sendQueue = new pluginAPI.SendQueue(transport.sendRequest.bind(transport));
@@ -53012,7 +53140,9 @@ function NetworkCapture() {
53012
53140
  */
53013
53141
  ConfigReader.addOption(NETWORK_LOGS_CONFIG_CAPTURE_REQUEST_BODY, [ConfigReader.sources.SNIPPET_SRC], undefined);
53014
53142
  /**
53015
- * Callback function to process/modify response bodies before capturing them. If not provided, the response body will not be captured.
53143
+ * Callback function to process/modify response bodies before capturing them. If not provided, the response body
53144
+ * will not be captured, unless `alwaysCaptureDevLogErrors` is enabled: error response bodies are then captured
53145
+ * as-is until a callback is provided.
53016
53146
  *
53017
53147
  * @access public
53018
53148
  * @category Config/Network Logs
@@ -53057,6 +53187,14 @@ function NetworkCapture() {
53057
53187
  responseBodyCb = config.captureResponseBody;
53058
53188
  }
53059
53189
  }
53190
+ // error bodies are captured by default here; processBody still masks and truncates them
53191
+ function getResponseBodyCb(response) {
53192
+ if (responseBodyCb)
53193
+ return responseBodyCb;
53194
+ if (alwaysCaptureErrors && response.status >= 400)
53195
+ return (body) => body;
53196
+ return undefined;
53197
+ }
53060
53198
  function buildExcludeRequestUrls() {
53061
53199
  const requestUrls = pluginAPI.ConfigReader.get(NETWORK_LOGS_CONFIG_EXCLUDE_REQUEST_URLS);
53062
53200
  if (!requestUrls || requestUrls.length === 0)
@@ -53104,13 +53242,24 @@ function NetworkCapture() {
53104
53242
  pluginAPI.log.info(`[NetworkCapture] Network request capture ${shouldCapture ? 'started' : 'stopped'}${reason ? `: ${reason}` : ''}`);
53105
53243
  }
53106
53244
  function recordingStarted() {
53245
+ isRecordingActive = true;
53107
53246
  return setCaptureState({ shouldCapture: true, reason: 'recording started' });
53108
53247
  }
53109
53248
  function recordingStopped() {
53249
+ isRecordingActive = false;
53250
+ if (alwaysCaptureErrors)
53251
+ return; // capture isn't tied to the recording lifecycle
53110
53252
  return setCaptureState({ shouldCapture: false, reason: 'recording stopped' });
53111
53253
  }
53254
+ // ingestion only keeps error responses when there's no recording, so don't send the rest
53255
+ function isErrorOnlyCapture() {
53256
+ return alwaysCaptureErrors && !isRecordingActive;
53257
+ }
53112
53258
  function startCapture() {
53113
53259
  pluginAPI.NetworkRequest.on({ request: handleRequest, response: handleResponse, error: handleError });
53260
+ if (alwaysCaptureErrors) {
53261
+ setCaptureState({ shouldCapture: true, reason: 'always capture dev log errors' });
53262
+ }
53114
53263
  }
53115
53264
  function teardown() {
53116
53265
  if (sendInterval) {
@@ -53158,6 +53307,10 @@ function NetworkCapture() {
53158
53307
  delete requestMap[response.requestId];
53159
53308
  return;
53160
53309
  }
53310
+ if (isErrorOnlyCapture() && !(response.status >= 400)) {
53311
+ delete requestMap[response.requestId];
53312
+ return;
53313
+ }
53161
53314
  if (!buffer.hasTokenAvailable()) {
53162
53315
  delete requestMap[response.requestId];
53163
53316
  return;
@@ -53216,11 +53369,11 @@ function NetworkCapture() {
53216
53369
  return '[Failed to process request body]';
53217
53370
  }
53218
53371
  }
53219
- function processResponseBody({ response }) {
53220
- if (!response || !response.body || !responseBodyCb)
53372
+ function processResponseBody({ response, bodyCb = responseBodyCb }) {
53373
+ if (!response || !response.body || !bodyCb)
53221
53374
  return '';
53222
53375
  try {
53223
- const body = responseBodyCb(response.body, { response });
53376
+ const body = bodyCb(response.body, { response });
53224
53377
  const contentType = globalPendo._.get(response, 'headers.content-type', '');
53225
53378
  return processBody(body, contentType);
53226
53379
  }
@@ -53237,8 +53390,9 @@ function NetworkCapture() {
53237
53390
  if (requestBodyCb) {
53238
53391
  networkEvent.devLogRequestBody = processRequestBody({ request });
53239
53392
  }
53240
- if (responseBodyCb) {
53241
- networkEvent.devLogResponseBody = processResponseBody({ response });
53393
+ const effectiveResponseBodyCb = getResponseBodyCb(response);
53394
+ if (effectiveResponseBodyCb) {
53395
+ networkEvent.devLogResponseBody = processResponseBody({ response, bodyCb: effectiveResponseBodyCb });
53242
53396
  }
53243
53397
  return networkEvent;
53244
53398
  }
@@ -55319,12 +55473,18 @@ const WIDGET_SETTINGS_API = '/external/widget/settings';
55319
55473
  class PredictApiService {
55320
55474
  constructor({ baseUrl, token }) {
55321
55475
  this.performRequest = (url, options) => __awaiter(this, void 0, void 0, function* () {
55476
+ var _a;
55322
55477
  const response = yield fetch(url, options);
55323
- const body = response.ok ? yield response.json() : null;
55324
- return { ok: response.ok, status: response.status, body };
55478
+ if (!response.ok) {
55479
+ // nothing reads an error body, and leaving it undrained holds its
55480
+ // stream and socket open until the response is collected
55481
+ (_a = response.body) === null || _a === void 0 ? void 0 : _a.cancel().catch(() => { });
55482
+ return { ok: false, status: response.status, body: null };
55483
+ }
55484
+ return { ok: true, status: response.status, body: yield response.json() };
55325
55485
  });
55326
55486
  this.getWidgetSettingsById = (id, log) => __awaiter(this, void 0, void 0, function* () {
55327
- var _a, _b;
55487
+ var _b, _c;
55328
55488
  if (!id) {
55329
55489
  log('[predict] no id provided');
55330
55490
  return null;
@@ -55339,11 +55499,11 @@ class PredictApiService {
55339
55499
  return null;
55340
55500
  }
55341
55501
  const data = body;
55342
- if ((_a = data === null || data === void 0 ? void 0 : data.errors) === null || _a === void 0 ? void 0 : _a.length) {
55502
+ if ((_b = data === null || data === void 0 ? void 0 : data.errors) === null || _b === void 0 ? void 0 : _b.length) {
55343
55503
  log(`[predict] error getting widget settings by id: ${data.errors}`);
55344
55504
  return null;
55345
55505
  }
55346
- return (_b = data === null || data === void 0 ? void 0 : data.data) !== null && _b !== void 0 ? _b : null;
55506
+ return (_c = data === null || data === void 0 ? void 0 : data.data) !== null && _c !== void 0 ? _c : null;
55347
55507
  }
55348
55508
  catch (error) {
55349
55509
  log(`[predict] error getting widget settings by id: ${error}`);