@crimson-education/browser-logger 5.0.0-beta.19 → 5.0.0-beta.20

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.
@@ -0,0 +1,486 @@
1
+ import { identifyUser, record } from '@aws-amplify/analytics';
2
+ // import { Endpoint, FinalizeRequestMiddleware } from '@aws-sdk/types';
3
+ // import { HttpRequest } from '@aws-sdk/protocol-http';
4
+ // import { PinpointClient, ServiceInputTypes, ServiceOutputTypes } from '@aws-sdk/client-pinpoint';
5
+
6
+ import {
7
+ IReporter,
8
+ Metadata,
9
+ ReporterBreadcrumb,
10
+ ReporterConfigBase,
11
+ ReporterEvent,
12
+ ReportUser,
13
+ ServiceInfo,
14
+ } from '../types';
15
+ import { logger } from '../logger';
16
+ // Note: fetchAuthSession was previously imported but unused; removing to satisfy linter
17
+ import { Amplify } from 'aws-amplify';
18
+
19
+ /* eslint-disable @typescript-eslint/no-unused-vars */
20
+ type AttributeMap = Record<string, string[] | string | null>;
21
+
22
+ type AmplifyAutoTrackSource = 'pageView' | 'event' | 'session';
23
+
24
+ // Auto-tracking implementation for Gen2
25
+ class AmplifyAutoTracker {
26
+ private config: AmplifyReporterConfig;
27
+ private allMetadata: AttributeMap;
28
+ private currentUser: ReportUser | null = null;
29
+ private sessionStartTime: number = Date.now();
30
+ private pageViewCount: number = 0;
31
+ private lastPageUrl: string = window.location.href;
32
+ private isPageVisible: boolean = !document.hidden;
33
+
34
+ constructor(config: AmplifyReporterConfig, allMetadata: AttributeMap) {
35
+ this.config = config;
36
+ this.allMetadata = allMetadata;
37
+ this.setupEventListeners();
38
+ }
39
+
40
+ private setupEventListeners() {
41
+ // Page visibility tracking
42
+ document.addEventListener('visibilitychange', () => {
43
+ this.isPageVisible = !document.hidden;
44
+ if (this.config.autoTrackSessions) {
45
+ this.trackSessionState();
46
+ }
47
+ });
48
+
49
+ // Page view tracking
50
+ if (this.config.autoTrackPageViews) {
51
+ // Delay initial page view to ensure Amplify is fully configured
52
+ // Using setTimeout(0) to defer to the next event loop tick
53
+ setTimeout(() => {
54
+ this.trackPageView();
55
+ }, 0);
56
+
57
+ // Track route changes for SPAs
58
+ let currentUrl = window.location.href;
59
+ const observer = new MutationObserver(() => {
60
+ if (window.location.href !== currentUrl) {
61
+ currentUrl = window.location.href;
62
+ this.trackPageView();
63
+ }
64
+ });
65
+ observer.observe(document.body, { childList: true, subtree: true });
66
+ }
67
+
68
+ // User interaction tracking
69
+ if (this.config.autoTrackEvents) {
70
+ this.setupInteractionTracking();
71
+ }
72
+ }
73
+
74
+ private trackPageView() {
75
+ this.pageViewCount++;
76
+ const pageViewEvent = {
77
+ message: 'Page View',
78
+ metadata: {
79
+ url: window.location.href,
80
+ title: document.title,
81
+ referrer: document.referrer,
82
+ pageViewCount: this.pageViewCount,
83
+ ...this.getAutoTrackMetadata('pageView'),
84
+ },
85
+ };
86
+
87
+ try {
88
+ record({
89
+ name: pageViewEvent.message,
90
+ attributes: asAttributeMap(
91
+ {
92
+ ...this.allMetadata,
93
+ ...pageViewEvent.metadata,
94
+ },
95
+ false,
96
+ ) as Record<string, string>,
97
+ });
98
+ } catch (error) {
99
+ logger.warn('Failed to record page view event', { error });
100
+ }
101
+ }
102
+
103
+ private trackSessionState() {
104
+ const sessionDuration = Date.now() - this.sessionStartTime;
105
+ const sessionEvent = {
106
+ message: this.isPageVisible ? 'Session Active' : 'Session Inactive',
107
+ metadata: {
108
+ sessionDuration,
109
+ isVisible: this.isPageVisible,
110
+ ...this.getAutoTrackMetadata('session'),
111
+ },
112
+ };
113
+
114
+ try {
115
+ record({
116
+ name: sessionEvent.message,
117
+ attributes: asAttributeMap(
118
+ {
119
+ ...this.allMetadata,
120
+ ...sessionEvent.metadata,
121
+ },
122
+ false,
123
+ ) as Record<string, string>,
124
+ });
125
+ } catch (error) {
126
+ logger.warn('Failed to record session state event', { error });
127
+ }
128
+ }
129
+
130
+ private setupInteractionTracking() {
131
+ const selectorPrefix = this.config.selectorPrefix ?? 'data-analytics-';
132
+
133
+ document.addEventListener('click', (event) => {
134
+ const target = event.target as HTMLElement;
135
+ if (!target) return;
136
+
137
+ // Find the closest element with analytics attributes
138
+ const analyticsElement = target.closest(`[${selectorPrefix}name]`);
139
+ if (!analyticsElement) return;
140
+
141
+ const analyticsName = analyticsElement.getAttribute(`${selectorPrefix}name`);
142
+ if (!analyticsName) return;
143
+
144
+ const interactionEvent = {
145
+ message: 'User Interaction',
146
+ metadata: {
147
+ elementName: analyticsName,
148
+ elementType: target.tagName.toLowerCase(),
149
+ elementText: target.textContent?.slice(0, 100),
150
+ interactionType: 'click',
151
+ ...this.getAutoTrackMetadata('event'),
152
+ },
153
+ };
154
+
155
+ try {
156
+ record({
157
+ name: interactionEvent.message,
158
+ attributes: asAttributeMap(
159
+ {
160
+ ...this.allMetadata,
161
+ ...interactionEvent.metadata,
162
+ },
163
+ false,
164
+ ) as Record<string, string>,
165
+ });
166
+ } catch (error) {
167
+ logger.warn('Failed to record interaction event', { error });
168
+ }
169
+ });
170
+ }
171
+
172
+ private getAutoTrackMetadata(source: AmplifyAutoTrackSource): Metadata {
173
+ const { beforeAutoTrack } = this.config;
174
+ return typeof beforeAutoTrack === 'function' ? (beforeAutoTrack(source) ?? {}) : {};
175
+ }
176
+
177
+ public setUser(user: ReportUser | null) {
178
+ this.currentUser = user;
179
+ }
180
+
181
+ public updateMetadata(metadata: AttributeMap) {
182
+ Object.assign(this.allMetadata, metadata);
183
+ }
184
+ }
185
+
186
+ export interface AmplifyReporterConfig extends ReporterConfigBase {
187
+ /**
188
+ * AWS Region for Amplify.
189
+ */
190
+ region: string;
191
+ /**
192
+ * The Identity Pool Id to use for reporting, if set to false, Auth.configure is not called.
193
+ * This must be called manually for the reporter to work.
194
+ */
195
+ identityPoolId: string;
196
+ /**
197
+ * The Pinpoint App Id to report to.
198
+ */
199
+ analyticsAppId: string;
200
+ /** Optional proxy URL */
201
+ proxyUrl?: string;
202
+ /**
203
+ * The Cognito User Pool to configure in Auth.configure.
204
+ * If you are using Cognito, it is better to set identityPoolId to false and configure Auth manually.
205
+ */
206
+ userPoolId?: string;
207
+ /**
208
+ * The Cognito Web Client Id to configure in Auth.configure.
209
+ * If you are using Cognito, it is better to set identityPoolId to false and configure Auth manually.
210
+ */
211
+ userPoolWebClientId?: string;
212
+
213
+ /**
214
+ * If you want to track which page/url in your webapp is the most frequently viewed one, you can use this feature.
215
+ * It will automatically send events containing url information when the page is visited.
216
+ */
217
+ autoTrackPageViews?: boolean;
218
+ /**
219
+ * If you want to track user interactions with elements on the page, you can use this feature.
220
+ * All you need to do is attach the specified selectors to your dom element and turn on the auto tracking.
221
+ */
222
+ autoTrackEvents?: boolean;
223
+ /**
224
+ * A web session can be defined in different ways.
225
+ * To keep it simple we define that the web session is active when the page is not hidden and inactive when the page is hidden.
226
+ */
227
+ autoTrackSessions?: boolean;
228
+ /**
229
+ * Optional function to run before autotracked analytics events are sent out.
230
+ * The returned metadata is attached to the event.
231
+ */
232
+ beforeAutoTrack?(source: AmplifyAutoTrackSource): Metadata | undefined;
233
+
234
+ /**
235
+ * The data tag prefix to use for attributing HTML elements. Defaults to data-analytics-
236
+ */
237
+ selectorPrefix?: string;
238
+
239
+ /**
240
+ * Modify how the reporter sends events to Amplify.
241
+ */
242
+ buffering?: AmplifyReporterBufferingConfig;
243
+ }
244
+
245
+ /**
246
+ * Configuration options for the buffering behavior of Pinpoint's event tracker.
247
+ *
248
+ * @see https://docs.amplify.aws/lib/analytics/getting-started/q/platform/js/#set-up-existing-analytics-backend
249
+ */
250
+ type AmplifyReporterBufferingConfig = {
251
+ /** Number of items to buffer for sending. */
252
+ bufferSize?: number;
253
+ /** Number of events sent each time Pinpoint flushes. */
254
+ flushSize?: number;
255
+ /** Interval Pinpoint flushes analytics events. Measured in milliseconds. */
256
+ flushInterval?: number;
257
+ /** The maximum number of times Pinpoint will retry to send an event. */
258
+ resendLimit?: number;
259
+ };
260
+
261
+ export function amplifyReporter(info: ServiceInfo, config: AmplifyReporterConfig): IReporter {
262
+ Amplify.configure({
263
+ Auth: {
264
+ Cognito: {
265
+ identityPoolId: config.identityPoolId,
266
+ allowGuestAccess: true,
267
+ },
268
+ },
269
+ Analytics: {
270
+ Pinpoint: {
271
+ appId: config.analyticsAppId,
272
+ region: config.region,
273
+ ...config.buffering,
274
+ },
275
+ },
276
+ });
277
+
278
+ const allMetadata = asAttributeMap({
279
+ appName: info.service,
280
+ service: info.service,
281
+ domain: window.location.host,
282
+ environment: info.environment,
283
+ version: info.version,
284
+ });
285
+
286
+ // Initialize auto-tracker for Gen2
287
+ const autoTracker = new AmplifyAutoTracker(config, allMetadata);
288
+
289
+ if (config.proxyUrl) {
290
+ // installPinpointProxy(new URL(config.proxyUrl));
291
+ }
292
+
293
+ const reporter: IReporter = {
294
+ trackEvent: function (event: ReporterEvent): void {
295
+ try {
296
+ record({
297
+ name: event.message,
298
+ attributes: asAttributeMap(
299
+ {
300
+ ...allMetadata,
301
+ ...event.metadata,
302
+ ...event.tags,
303
+ },
304
+ false,
305
+ ) as Record<string, string>,
306
+ metrics: event.metrics,
307
+ });
308
+ } catch (error) {
309
+ logger.warn('Failed to record event', { error, eventName: event.message });
310
+ }
311
+ },
312
+ addBreadcrumb: function (breadcrumb: ReporterBreadcrumb): void {
313
+ reporter.trackEvent({
314
+ message: breadcrumb.message,
315
+ metadata: {
316
+ category: breadcrumb.category,
317
+ ...breadcrumb.metadata,
318
+ },
319
+ });
320
+ },
321
+ addMetadata: function (metadata: Metadata): void {
322
+ Object.assign(allMetadata, asAttributeMap(metadata, true));
323
+ autoTracker.updateMetadata(asAttributeMap(metadata, true));
324
+ // Note: updateEndpoint is not available in Amplify v7
325
+ // Metadata updates would need to be handled differently
326
+ },
327
+ setUser: function (user: ReportUser | null): void {
328
+ const userMetadata = user
329
+ ? asAttributeMap({
330
+ userId: user.id,
331
+ })
332
+ : {};
333
+ Object.assign(allMetadata, asAttributeMap(userMetadata, true));
334
+ autoTracker.setUser(user);
335
+ // Only call identifyUser when we have a valid user ID
336
+ // Calling with empty string can cause issues with Pinpoint USER_ID
337
+ if (user?.id) {
338
+ try {
339
+ identifyUser({
340
+ userId: user.id,
341
+ userProfile: {
342
+ email: user.email,
343
+ },
344
+ });
345
+ } catch (error) {
346
+ // Credentials may not be ready yet, log warning and continue
347
+ // The userId is still stored in allMetadata for event tracking
348
+ logger.warn('Failed to identify user in Pinpoint', { error });
349
+ }
350
+ }
351
+ },
352
+ setRouteName: function (routeName: string): void {
353
+ reporter.addMetadata({ routeName });
354
+ },
355
+ setPageName: function (pageName: string): void {
356
+ reporter.addMetadata({ pageName });
357
+ },
358
+ };
359
+
360
+ return reporter;
361
+ }
362
+
363
+ /**
364
+ * Pinpoint has strict attribute name and value length limits
365
+ */
366
+ export function asAttributeMap(values: Record<string, unknown>, groupValues = true): AttributeMap {
367
+ const attributeMap = buildAttributeMap(values, undefined, groupValues);
368
+
369
+ const checkedEntries = Object.entries(attributeMap).map(([key, value]) => {
370
+ const truncatedKey = key.length > 50 ? `___${key.slice(-47)}` : key;
371
+ const truncatedValue = Array.isArray(value)
372
+ ? (value?.map((val) => val.slice(0, 100)) ?? null)
373
+ : (value?.slice(0, 100) ?? null);
374
+
375
+ return [truncatedKey, truncatedValue];
376
+ });
377
+
378
+ // Pinpoint only accepts 40 attributes
379
+ if (checkedEntries.length > 40) {
380
+ logger.error(`Amplify only allows 40 attributes per event, truncating to 40 attributes`, {
381
+ attributes: checkedEntries,
382
+ });
383
+ checkedEntries.length = 40;
384
+ }
385
+
386
+ return Object.fromEntries(checkedEntries);
387
+ }
388
+
389
+ /**
390
+ * Pinpoint expects `endpoint.attributes` and `endpoint.userAttributes` to have
391
+ * values which are string arrays. This function takes in an object and ensures
392
+ * all of its values are of type `string[]` to appease Pinpoint.
393
+ */
394
+ export function buildAttributeMap(
395
+ values: Record<string, any>,
396
+ parentKey: string | undefined = undefined,
397
+ groupValues = true,
398
+ ): AttributeMap {
399
+ const valuesWithStringArrays: AttributeMap = {};
400
+
401
+ Object.entries(values).forEach(([key, value]) => {
402
+ const combinedKey = parentKey ? [parentKey, key].join('.') : key;
403
+
404
+ // Only treat undefined or null as empty; avoid converting falsy values like ''/0/false to null
405
+ if (value === undefined || value === null) {
406
+ // For event attributes (groupValues === false), Pinpoint rejects null values.
407
+ // In that case we omit the key entirely to avoid "Event attribute value can not be null".
408
+ if (groupValues) {
409
+ valuesWithStringArrays[combinedKey] = null;
410
+ }
411
+ } else if (groupValues && Array.isArray(value)) {
412
+ valuesWithStringArrays[combinedKey] = value.map((element) =>
413
+ typeof element === 'string' ? element : JSON.stringify(element),
414
+ );
415
+ } else if (typeof value === 'object') {
416
+ const flattenedAttribute = buildAttributeMap(value, combinedKey, groupValues);
417
+ Object.assign(valuesWithStringArrays, flattenedAttribute);
418
+ } else {
419
+ const stringValue = typeof value === 'string' ? value : JSON.stringify(value);
420
+ valuesWithStringArrays[combinedKey] = groupValues ? [stringValue] : stringValue;
421
+ }
422
+ });
423
+
424
+ return valuesWithStringArrays;
425
+ }
426
+
427
+ // function installPinpointProxy(proxyUrl: URL) {
428
+ // // No public API for overriding where the Pinpoint client sends events to... 🤮
429
+ // // In theory you can pass in an `endpoint` to the Pinpoint client's constructor like any other AWS
430
+ // // client, but Amplify's analytics doesn't expose anything we can use to get an endpoint threaded
431
+ // // down to the Pinpoint client's constructor.
432
+ // //
433
+ // // The Pinpoint client _also_ isn't available synchronously because it is instantiated when events
434
+ // // get sent out, and then reconfigured whenever the API credentials change. We need to hook `_initClients`
435
+ // // to ensure that the Pinpoint client being used is always patched with our custom endpoint.
436
+ // const provider = (globalThis as any).Analytics?.getPluggable?.('AWSPinpoint') as any;
437
+ // if (!provider || typeof provider._initClients !== 'function') {
438
+ // logger.error(
439
+ // 'Installation of the Pinpoint proxy failed. This likely means the internals of the @aws-amplify/analytics package have changed.',
440
+ // );
441
+ // return;
442
+ // }
443
+
444
+ // const originalInitClients = provider._initClients;
445
+ // const requestMiddleware: FinalizeRequestMiddleware<ServiceInputTypes, ServiceOutputTypes> =
446
+ // (next) => async (args) => {
447
+ // const pinpointClient = provider.pinpointClient as PinpointClient | undefined;
448
+
449
+ // if (pinpointClient && proxyUrl.pathname !== '/' && HttpRequest.isInstance(args.request)) {
450
+ // // Add proxyUrl.pathname to final request url if it was provided
451
+ // const shouldStripSlash = proxyUrl.pathname.endsWith('/');
452
+ // args.request.path = `${proxyUrl.pathname}${args.request.path.slice(shouldStripSlash ? 1 : 0)}`;
453
+
454
+ // // Wrap request body so the proxy has signing info
455
+ // if (typeof args.request.body === 'string') {
456
+ // const credentials = await pinpointClient.config.credentials();
457
+ // args.request.body = JSON.stringify({
458
+ // credentials,
459
+ // data: JSON.parse(args.request.body),
460
+ // });
461
+ // }
462
+ // }
463
+
464
+ // return next(args);
465
+ // };
466
+
467
+ // provider._initClients = async (credentials: unknown) => {
468
+ // const result = await originalInitClients.call(provider, credentials);
469
+ // const pinpointClient = provider.pinpointClient as PinpointClient | undefined;
470
+
471
+ // if (pinpointClient) {
472
+ // pinpointClient.config.endpoint = (): Promise<Endpoint> =>
473
+ // Promise.resolve({
474
+ // hostname: proxyUrl.hostname,
475
+ // // Passing proxyUrl.pathname here doesn't work; it gets overridden
476
+ // path: '/',
477
+ // port: undefined,
478
+ // protocol: proxyUrl.protocol,
479
+ // });
480
+
481
+ // pinpointClient.middlewareStack.remove(requestMiddleware);
482
+ // pinpointClient.middlewareStack.add(requestMiddleware, { step: 'finalizeRequest' });
483
+ // }
484
+ // return result;
485
+ // };
486
+ // }