@crimson-education/browser-logger 5.0.3 → 5.0.5

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.
@@ -14,6 +14,7 @@ import { datadogLogs, HandlerType, LogsInitConfiguration } from '@datadog/browse
14
14
  import { datadogRum, DefaultPrivacyLevel, RumInitConfiguration } from '@datadog/browser-rum';
15
15
  import { logTransports } from '../logger';
16
16
  import { DatadogLogTransportConfig, datadogTransport } from '../logger/datadogTransport';
17
+ import { shouldSuppressAnalyticsEvent } from './analyticsNoise';
17
18
 
18
19
  // User frustration detection for Datadog Gen2
19
20
  class DatadogFrustrationDetector {
@@ -105,6 +106,16 @@ class DatadogFrustrationDetector {
105
106
  }
106
107
 
107
108
  export interface DatadogReporterConfig extends ReporterConfigBase {
109
+ /**
110
+ * Enable Datadog Browser Logs initialization and log transport wiring.
111
+ * Defaults to true.
112
+ */
113
+ logsEnabled?: boolean;
114
+ /**
115
+ * Enable Datadog Browser RUM initialization.
116
+ * Defaults to true.
117
+ */
118
+ rumEnabled?: boolean;
108
119
  /** The RUM application ID. */
109
120
  applicationId: string;
110
121
  /** A Datadog client token (Generated in the RUM Page) */
@@ -241,6 +252,8 @@ export interface DatadogReporterConfig extends ReporterConfigBase {
241
252
 
242
253
  export function datadogReporter(info: ServiceInfo, config: DatadogReporterConfig): IReporter {
243
254
  const isLocalhost = window.location.hostname === 'localhost';
255
+ const logsEnabled = config.logsEnabled ?? true;
256
+ const rumEnabled = config.rumEnabled ?? true;
244
257
 
245
258
  // Don't forward error logs by default, do enable the log transport by default
246
259
  // forwardErrorsToLogs incorrectly is called forwardConsoleLogs, this is for backwards compatibility
@@ -248,7 +261,7 @@ export function datadogReporter(info: ServiceInfo, config: DatadogReporterConfig
248
261
  const enableLogTransport = config.logTransport !== false;
249
262
 
250
263
  // Only init datadog logs if something is using it.
251
- if (forwardErrorsToLogs === true || enableLogTransport === true) {
264
+ if (logsEnabled && (forwardErrorsToLogs === true || enableLogTransport === true)) {
252
265
  datadogLogs.init({
253
266
  site: config.site as any,
254
267
  clientToken: config.clientToken,
@@ -270,45 +283,67 @@ export function datadogReporter(info: ServiceInfo, config: DatadogReporterConfig
270
283
  }
271
284
 
272
285
  // Add the datadog log transport
273
- if (enableLogTransport) {
286
+ if (logsEnabled && enableLogTransport) {
274
287
  logTransports.push(datadogTransport(typeof config.logTransport === 'boolean' ? {} : config.logTransport));
275
288
  }
276
289
 
277
- datadogRum.init({
278
- enableExperimentalFeatures: ['feature_flags'],
279
- site: config.site as any,
280
- clientToken: config.clientToken,
281
- applicationId: config.applicationId,
282
- service: info.service,
283
- env: info.environment,
284
- version: config.version ?? info.version,
285
-
286
- usePartitionedCrossSiteSessionCookie: config.usePartitionedCrossSiteSessionCookie,
287
- // Use correct Datadog v6 SDK options
288
- sessionSampleRate: config.sessionSampleRate ?? config.rumSampleRate ?? config.sampleRate ?? 100,
289
- sessionReplaySampleRate: config.sessionReplaySampleRate?? config.replaySampleRate ?? 100,
290
-
291
- // Track interactions (Note: trackFrustrations is not available in Datadog v6)
292
- trackUserInteractions: config.trackUserInteractions ?? config.trackInteractions ?? false,
293
-
294
- useSecureSessionCookie: config.useSecureSessionCookie ?? !isLocalhost,
295
- // Note: useCrossSiteSessionCookie is not available in newer Datadog versions
296
- trackSessionAcrossSubdomains: config.trackSessionAcrossSubdomains,
297
- trackViewsManually: config.trackViewsManually ?? false,
298
- actionNameAttribute: config.actionNameAttribute ?? 'data-analytics-name',
299
- beforeSend: config.beforeSend,
300
-
301
- defaultPrivacyLevel: config.defaultPrivacyLevel ?? 'mask-user-input',
302
- allowedTracingUrls: config.allowedTracingUrls ?? config.allowedTrackingOrigins,
303
-
304
- excludedActivityUrls: config.excludedActivityUrls,
305
- });
306
-
307
- // Initialize frustration detector
308
- new DatadogFrustrationDetector(config);
290
+ if (rumEnabled) {
291
+ datadogRum.init({
292
+ enableExperimentalFeatures: ['feature_flags'],
293
+ site: config.site as any,
294
+ clientToken: config.clientToken,
295
+ applicationId: config.applicationId,
296
+ service: info.service,
297
+ env: info.environment,
298
+ version: config.version ?? info.version,
299
+
300
+ usePartitionedCrossSiteSessionCookie: config.usePartitionedCrossSiteSessionCookie,
301
+ // Use correct Datadog v6 SDK options
302
+ sessionSampleRate: config.sessionSampleRate ?? config.rumSampleRate ?? config.sampleRate ?? 100,
303
+ sessionReplaySampleRate: config.sessionReplaySampleRate ?? config.replaySampleRate ?? 100,
304
+
305
+ // Track interactions (Note: trackFrustrations is not available in Datadog v6)
306
+ trackUserInteractions: config.trackUserInteractions ?? config.trackInteractions ?? false,
307
+
308
+ useSecureSessionCookie: config.useSecureSessionCookie ?? !isLocalhost,
309
+ // Note: useCrossSiteSessionCookie is not available in newer Datadog versions
310
+ trackSessionAcrossSubdomains: config.trackSessionAcrossSubdomains,
311
+ trackViewsManually: config.trackViewsManually ?? false,
312
+ actionNameAttribute: config.actionNameAttribute ?? 'data-analytics-name',
313
+ beforeSend: config.beforeSend,
314
+
315
+ defaultPrivacyLevel: config.defaultPrivacyLevel ?? 'mask-user-input',
316
+ allowedTracingUrls: config.allowedTracingUrls ?? config.allowedTrackingOrigins,
317
+
318
+ excludedActivityUrls: config.excludedActivityUrls,
319
+ });
320
+
321
+ // Initialize frustration detector
322
+ new DatadogFrustrationDetector(config);
323
+ }
324
+
325
+ function normalizeErrorForLogs(error: ReportError) {
326
+ if (error instanceof Error) {
327
+ return {
328
+ message: error.message,
329
+ name: error.name,
330
+ stack: error.stack,
331
+ };
332
+ }
333
+
334
+ return {
335
+ message: String(error),
336
+ };
337
+ }
309
338
 
310
339
  const reporter: IReporter = {
311
340
  trackEvent: function (event: ReporterEvent): void {
341
+ if (!rumEnabled) {
342
+ return;
343
+ }
344
+ if (shouldSuppressAnalyticsEvent(event.message, config.ignoreTrackEventPatterns)) {
345
+ return;
346
+ }
312
347
  datadogRum.addAction(event.message, {
313
348
  level: event.level,
314
349
  ...event.metadata,
@@ -317,6 +352,9 @@ export function datadogReporter(info: ServiceInfo, config: DatadogReporterConfig
317
352
  });
318
353
  },
319
354
  addBreadcrumb: function (breadcrumb: ReporterBreadcrumb): void {
355
+ if (!rumEnabled) {
356
+ return;
357
+ }
320
358
  datadogRum.addAction(breadcrumb.message, {
321
359
  ...breadcrumb.metadata,
322
360
  category: breadcrumb.category,
@@ -325,20 +363,29 @@ export function datadogReporter(info: ServiceInfo, config: DatadogReporterConfig
325
363
  addMetadata: function (metadata: Metadata): void {
326
364
  for (const [key, value] of Object.entries(metadata)) {
327
365
  if (value !== null) {
328
- datadogRum.setGlobalContextProperty(key, value);
329
-
330
- // Note, this will add duplicate context data in logs.
331
- // But this is valuable for logs ingested outside of the browser logger.
332
- datadogLogs.setGlobalContextProperty(key, value);
366
+ if (rumEnabled) {
367
+ datadogRum.setGlobalContextProperty(key, value);
368
+ }
369
+ if (logsEnabled) {
370
+ // Note, this will add duplicate context data in logs.
371
+ // But this is valuable for logs ingested outside of the browser logger.
372
+ datadogLogs.setGlobalContextProperty(key, value);
373
+ }
333
374
  } else {
334
- datadogRum.removeGlobalContextProperty(key);
335
-
336
- // But this is valuable for logs ingested outside of the browser logger.
337
- datadogLogs.removeGlobalContextProperty(key);
375
+ if (rumEnabled) {
376
+ datadogRum.removeGlobalContextProperty(key);
377
+ }
378
+ if (logsEnabled) {
379
+ // But this is valuable for logs ingested outside of the browser logger.
380
+ datadogLogs.removeGlobalContextProperty(key);
381
+ }
338
382
  }
339
383
  }
340
384
  },
341
385
  setUser: function (user: ReportUser | null): void {
386
+ if (!rumEnabled) {
387
+ return;
388
+ }
342
389
  if (user) {
343
390
  datadogRum.setUser({
344
391
  ...user,
@@ -351,9 +398,15 @@ export function datadogReporter(info: ServiceInfo, config: DatadogReporterConfig
351
398
  }
352
399
  },
353
400
  setRouteName: function (routeName: string): void {
401
+ if (!rumEnabled) {
402
+ return;
403
+ }
354
404
  reporter.addMetadata({ routeName });
355
405
  },
356
406
  setPageName: function (pageName: string): void {
407
+ if (!rumEnabled) {
408
+ return;
409
+ }
357
410
  if (config.trackViewsManually) {
358
411
  datadogRum.startView(pageName);
359
412
  } else {
@@ -361,15 +414,35 @@ export function datadogReporter(info: ServiceInfo, config: DatadogReporterConfig
361
414
  }
362
415
  },
363
416
  reportError: function (error: ReportError, metadata?: Metadata): void {
364
- datadogRum.addError(error, metadata);
417
+ if (rumEnabled) {
418
+ datadogRum.addError(error, metadata);
419
+ return;
420
+ }
421
+ if (logsEnabled) {
422
+ const normalized = normalizeErrorForLogs(error);
423
+ datadogLogs.logger.error(normalized.message, {
424
+ ...metadata,
425
+ errorName: normalized.name,
426
+ errorStack: normalized.stack,
427
+ });
428
+ }
365
429
  },
366
430
  reportFeatureFlag: function (flag: ReporterFlag): void {
431
+ if (!rumEnabled) {
432
+ return;
433
+ }
367
434
  datadogRum.addFeatureFlagEvaluation(flag.name, flag.value);
368
435
  },
369
436
  recordSession: function (): void {
437
+ if (!rumEnabled) {
438
+ return;
439
+ }
370
440
  datadogRum.startSessionReplayRecording();
371
441
  },
372
442
  recordSessionStop: function (): void {
443
+ if (!rumEnabled) {
444
+ return;
445
+ }
373
446
  datadogRum.stopSessionReplayRecording();
374
447
  },
375
448
  };
@@ -154,6 +154,64 @@ describe('posthogReporter', () => {
154
154
  expect(posthogMock.stopSessionRecording).toHaveBeenCalled();
155
155
  });
156
156
 
157
+ it('suppresses configured trackEvent messages from PostHog analytics', () => {
158
+ const reporter = posthogReporter(
159
+ {
160
+ service: 'crimson-app-frontend',
161
+ application: 'crimson-app',
162
+ environment: 'production',
163
+ version: '1.2.3',
164
+ },
165
+ {
166
+ apiKey: 'ph_key',
167
+ apiHost: 'https://eu.i.posthog.com',
168
+ ignoreTrackEventPatterns: ['feedback fetch success'],
169
+ },
170
+ );
171
+
172
+ reporter.trackEvent({
173
+ message: 'feedback fetch success',
174
+ level: LogLevel.Info,
175
+ });
176
+
177
+ expect(posthogMock.capture).not.toHaveBeenCalled();
178
+
179
+ reporter.trackEvent({
180
+ message: 'message-sent',
181
+ level: LogLevel.Info,
182
+ });
183
+
184
+ expect(posthogMock.capture).toHaveBeenCalledWith('message-sent', {
185
+ message: 'message-sent',
186
+ level: LogLevel.Info,
187
+ });
188
+ });
189
+
190
+ it('does not suppress trackEvent messages unless configured', () => {
191
+ const reporter = posthogReporter(
192
+ {
193
+ service: 'crimson-app-frontend',
194
+ application: 'crimson-app',
195
+ environment: 'production',
196
+ version: '1.2.3',
197
+ },
198
+ {
199
+ apiKey: 'ph_key',
200
+ apiHost: 'https://eu.i.posthog.com',
201
+ },
202
+ );
203
+
204
+ reporter.trackEvent({
205
+ message: 'feedback fetch success',
206
+ level: LogLevel.Info,
207
+ });
208
+
209
+ expect(posthogMock.capture).toHaveBeenCalledWith('feedback fetch success', {
210
+ message: 'feedback fetch success',
211
+ level: LogLevel.Info,
212
+ });
213
+ });
214
+
157
215
  it('adds compatibility properties in before_send', () => {
158
216
  const beforeSend = jest.fn((captureResult) => captureResult);
159
217
 
@@ -198,6 +256,102 @@ describe('posthogReporter', () => {
198
256
  expect(beforeSend).toHaveBeenCalledWith(captureResult);
199
257
  });
200
258
 
259
+ it('uses the current URL for automatic pageviews instead of stale page metadata', () => {
260
+ const reporter = posthogReporter(
261
+ {
262
+ service: 'crimson-app-frontend',
263
+ application: 'crimson-app',
264
+ environment: 'production',
265
+ version: '1.2.3',
266
+ },
267
+ {
268
+ apiKey: 'ph_key',
269
+ apiHost: 'https://us.i.posthog.com',
270
+ },
271
+ );
272
+
273
+ reporter.setPageName('/calendar/schedule');
274
+ reporter.setRouteName('/calendar');
275
+
276
+ const initConfig = posthogMock.init.mock.calls[0]?.[1];
277
+ const captureResult = initConfig.before_send({
278
+ uuid: 'event-uuid',
279
+ event: '$pageview',
280
+ properties: {
281
+ $current_url: 'https://app.crimsoneducation.org/messages',
282
+ },
283
+ });
284
+
285
+ expect(captureResult.properties).toEqual(
286
+ expect.objectContaining({
287
+ url_path: '/messages',
288
+ url_path_group: '/messages',
289
+ view_name: '/messages',
290
+ }),
291
+ );
292
+ });
293
+
294
+ it('keeps a matching route template as the automatic pageview path group', () => {
295
+ const reporter = posthogReporter(
296
+ {
297
+ service: 'crimson-app-frontend',
298
+ application: 'crimson-app',
299
+ environment: 'production',
300
+ version: '1.2.3',
301
+ },
302
+ {
303
+ apiKey: 'ph_key',
304
+ apiHost: 'https://us.i.posthog.com',
305
+ },
306
+ );
307
+
308
+ reporter.setRouteName('/users/:id/roadmap');
309
+
310
+ const initConfig = posthogMock.init.mock.calls[0]?.[1];
311
+ const captureResult = initConfig.before_send({
312
+ uuid: 'event-uuid',
313
+ event: '$pageview',
314
+ properties: {
315
+ $current_url: 'https://app.crimsoneducation.org/users/123/roadmap',
316
+ },
317
+ });
318
+
319
+ expect(captureResult.properties).toEqual(
320
+ expect.objectContaining({
321
+ url_path: '/users/123/roadmap',
322
+ url_path_group: '/users/:id/roadmap',
323
+ view_name: '/users/123/roadmap',
324
+ }),
325
+ );
326
+ });
327
+
328
+ it('updates page metadata before capturing manual pageviews', () => {
329
+ const reporter = posthogReporter(
330
+ {
331
+ service: 'crimson-app-frontend',
332
+ application: 'crimson-app',
333
+ environment: 'production',
334
+ version: '1.2.3',
335
+ },
336
+ {
337
+ apiKey: 'ph_key',
338
+ apiHost: 'https://us.i.posthog.com',
339
+ trackViewsManually: true,
340
+ },
341
+ );
342
+
343
+ reporter.setRouteName('/users/:id/roadmap');
344
+ reporter.setPageName('/users/123/roadmap');
345
+
346
+ expect(posthogMock.register).toHaveBeenLastCalledWith({
347
+ pageName: '/users/123/roadmap',
348
+ });
349
+ expect(posthogMock.capture).toHaveBeenCalledWith('$pageview', {
350
+ pageName: '/users/123/roadmap',
351
+ routeName: '/users/:id/roadmap',
352
+ });
353
+ });
354
+
201
355
  it('preserves registered metadata when clearing the user', () => {
202
356
  const reporter = posthogReporter(
203
357
  {
@@ -10,6 +10,7 @@ import {
10
10
  ReportUser,
11
11
  ServiceInfo,
12
12
  } from '../types';
13
+ import { shouldSuppressAnalyticsEvent } from './analyticsNoise';
13
14
 
14
15
  type PostHogValue = Properties[string];
15
16
 
@@ -106,6 +107,20 @@ function getCurrentUrl(captureResult: CaptureResult): string | undefined {
106
107
  return undefined;
107
108
  }
108
109
 
110
+ function getStringProperty(properties: Properties | undefined, key: string): string | undefined {
111
+ const value = properties?.[key];
112
+ return typeof value === 'string' && value ? value : undefined;
113
+ }
114
+
115
+ function routeMatchesPath(routeName: string, urlPath: string): boolean {
116
+ const escapedRoute = routeName
117
+ .split('/')
118
+ .map((segment) => (segment.startsWith(':') ? '[^/]+' : segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
119
+ .join('/');
120
+
121
+ return new RegExp(`^${escapedRoute}/?$`).test(urlPath);
122
+ }
123
+
109
124
  function getUserRoleValue(user: CompatibilityUser | null, metadata: Metadata): PostHogValue | undefined {
110
125
  const candidate =
111
126
  user?.userRole ?? user?.role ?? user?.userRoles ?? user?.roles ?? metadata.userRole ?? metadata.userRoles;
@@ -123,7 +138,15 @@ function buildCompatibilityProperties(
123
138
  const baseOrigin = typeof window !== 'undefined' ? window.location.origin : 'https://example.invalid';
124
139
  const urlObject = url ? new URL(url, baseOrigin) : null;
125
140
  const urlPath = urlObject?.pathname;
126
- const pageName = typeof metadata.pageName === 'string' ? metadata.pageName : undefined;
141
+ const isPageview = captureResult.event === '$pageview';
142
+ const capturedPageName = getStringProperty(captureResult.properties, 'pageName');
143
+ const metadataPageName = typeof metadata.pageName === 'string' ? metadata.pageName : undefined;
144
+ const pageName = isPageview ? capturedPageName ?? urlPath : metadataPageName;
145
+ const capturedRouteName = getStringProperty(captureResult.properties, 'routeName');
146
+ const metadataRouteName = typeof metadata.routeName === 'string' ? metadata.routeName : undefined;
147
+ const routeName =
148
+ capturedRouteName ??
149
+ (urlPath && metadataRouteName && routeMatchesPath(metadataRouteName, urlPath) ? metadataRouteName : undefined);
127
150
 
128
151
  return {
129
152
  app_name: String(metadata.application ?? info.application ?? info.service),
@@ -139,6 +162,7 @@ function buildCompatibilityProperties(
139
162
  user_role: getUserRoleValue(user, metadata),
140
163
  url,
141
164
  url_path: urlPath,
165
+ url_path_group: routeName ?? (isPageview ? urlPath : undefined),
142
166
  view_id: posthog.getPageViewId(),
143
167
  view_name: pageName,
144
168
  browser: captureResult.properties?.$browser,
@@ -283,6 +307,9 @@ export function posthogReporter(info: ServiceInfo, config: PostHogReporterConfig
283
307
  const reporter: IReporter = {
284
308
  ...config,
285
309
  trackEvent: function (event: ReporterEvent): void {
310
+ if (shouldSuppressAnalyticsEvent(event.message, config.ignoreTrackEventPatterns)) {
311
+ return;
312
+ }
286
313
  posthog.capture(event.message, {
287
314
  message: event.message,
288
315
  level: event.level,
@@ -342,14 +369,14 @@ export function posthogReporter(info: ServiceInfo, config: PostHogReporterConfig
342
369
  reporter.addMetadata({ routeName });
343
370
  },
344
371
  setPageName: function (pageName: string): void {
372
+ reporter.addMetadata({ pageName });
373
+
345
374
  if (manualPageviews) {
346
375
  posthog.capture('$pageview', {
347
376
  pageName,
377
+ routeName: typeof currentMetadata.routeName === 'string' ? currentMetadata.routeName : undefined,
348
378
  });
349
- return;
350
379
  }
351
-
352
- reporter.addMetadata({ pageName });
353
380
  },
354
381
  reportError: function (error: ReportError, metadata?: Metadata): void {
355
382
  posthog.captureException(error, {
@@ -163,6 +163,12 @@ export interface ReporterConfigBase {
163
163
  * This is useful to prevent data of no use for the reporter, or if the data keys are too long.
164
164
  */
165
165
  ignoreMetadataPatterns?: (string | RegExp)[];
166
+
167
+ /**
168
+ * Ignore specific trackEvent messages from analytics-style reporters.
169
+ * This keeps technical log noise out of product analytics/RUM while preserving log reporter output.
170
+ */
171
+ ignoreTrackEventPatterns?: (string | RegExp)[];
166
172
  }
167
173
 
168
174
  /**