From feb6d5cae84be01bcb71f7f714aa9aab3643f3c4 Mon Sep 17 00:00:00 2001
From: jomcarvajal <jose.carvajal@edify.cr>
Date: Wed, 19 Mar 2025 16:01:11 -0600
Subject: [PATCH 1/5] CORE-764: new sentry logger

---
 src/components/ToggleButtonGroup.spec.tsx     |   3 +-
 .../ToggleButtonGroup.spec.tsx.snap           | 139 +++++++++++++++++-
 src/sentryLogger/sentryLog.spec.ts            |  70 +++++++++
 src/sentryLogger/sentryLog.ts                 |  38 +++++
 4 files changed, 247 insertions(+), 3 deletions(-)
 create mode 100644 src/sentryLogger/sentryLog.spec.ts
 create mode 100644 src/sentryLogger/sentryLog.ts

diff --git a/src/components/ToggleButtonGroup.spec.tsx b/src/components/ToggleButtonGroup.spec.tsx
index 35878f4e8..c1b42476d 100644
--- a/src/components/ToggleButtonGroup.spec.tsx
+++ b/src/components/ToggleButtonGroup.spec.tsx
@@ -30,7 +30,8 @@ describe('ToggleButtonGroup', () => {
         selectionMode 
         ${'multiple'}
         ${'single'}
-    `(`matches snapshot with selectionMode #selectionMode`, ({selectionMode}) => {
+        ${undefined}
+    `(`matches snapshot with selectionMode $selectionMode`, ({selectionMode}) => {
         const tree = renderer.create(
             <ToggleButtonGroup 
                 selectionMode={selectionMode}
diff --git a/src/components/__snapshots__/ToggleButtonGroup.spec.tsx.snap b/src/components/__snapshots__/ToggleButtonGroup.spec.tsx.snap
index 120a80caa..da5421322 100644
--- a/src/components/__snapshots__/ToggleButtonGroup.spec.tsx.snap
+++ b/src/components/__snapshots__/ToggleButtonGroup.spec.tsx.snap
@@ -1,6 +1,6 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`ToggleButtonGroup matches snapshot with selectionMode #selectionMode 1`] = `
+exports[`ToggleButtonGroup matches snapshot with selectionMode multiple 1`] = `
 <div
   aria-orientation="horizontal"
   className="sc-gsnTZi hmfeGJ"
@@ -130,7 +130,142 @@ exports[`ToggleButtonGroup matches snapshot with selectionMode #selectionMode 1`
 </div>
 `;
 
-exports[`ToggleButtonGroup matches snapshot with selectionMode #selectionMode 2`] = `
+exports[`ToggleButtonGroup matches snapshot with selectionMode single 1`] = `
+<div
+  aria-orientation="horizontal"
+  className="sc-gsnTZi hmfeGJ"
+  data-orientation="horizontal"
+  data-rac=""
+  onBlurCapture={[Function]}
+  onFocusCapture={[Function]}
+  onKeyDownCapture={[Function]}
+  role="radiogroup"
+>
+  <button
+    aria-checked={true}
+    className="sc-dkzDqf jDeGjH"
+    data-rac=""
+    data-selected={true}
+    data-testid="red-testid"
+    disabled={false}
+    onBlur={[Function]}
+    onClick={[Function]}
+    onDragStart={[Function]}
+    onFocus={[Function]}
+    onKeyDown={[Function]}
+    onMouseDown={[Function]}
+    onMouseEnter={[Function]}
+    onMouseLeave={[Function]}
+    onMouseUp={[Function]}
+    onTouchCancel={[Function]}
+    onTouchEnd={[Function]}
+    onTouchMove={[Function]}
+    onTouchStart={[Function]}
+    role="radio"
+    type="button"
+  >
+    Red
+  </button>
+  <button
+    aria-checked={false}
+    className="sc-dkzDqf jDeGjH"
+    data-rac=""
+    data-testid="green-testid"
+    disabled={false}
+    onBlur={[Function]}
+    onClick={[Function]}
+    onDragStart={[Function]}
+    onFocus={[Function]}
+    onKeyDown={[Function]}
+    onMouseDown={[Function]}
+    onMouseEnter={[Function]}
+    onMouseLeave={[Function]}
+    onMouseUp={[Function]}
+    onTouchCancel={[Function]}
+    onTouchEnd={[Function]}
+    onTouchMove={[Function]}
+    onTouchStart={[Function]}
+    role="radio"
+    type="button"
+  >
+    Green
+  </button>
+  <button
+    aria-checked={false}
+    className="sc-dkzDqf jDeGjH"
+    data-rac=""
+    data-testid="blue-testid"
+    disabled={false}
+    onBlur={[Function]}
+    onClick={[Function]}
+    onDragStart={[Function]}
+    onFocus={[Function]}
+    onKeyDown={[Function]}
+    onMouseDown={[Function]}
+    onMouseEnter={[Function]}
+    onMouseLeave={[Function]}
+    onMouseUp={[Function]}
+    onTouchCancel={[Function]}
+    onTouchEnd={[Function]}
+    onTouchMove={[Function]}
+    onTouchStart={[Function]}
+    role="radio"
+    type="button"
+  >
+    Blue
+  </button>
+  <button
+    aria-checked={false}
+    className="sc-dkzDqf jDeGjH"
+    data-rac=""
+    data-testid="yellow-testid"
+    disabled={false}
+    onBlur={[Function]}
+    onClick={[Function]}
+    onDragStart={[Function]}
+    onFocus={[Function]}
+    onKeyDown={[Function]}
+    onMouseDown={[Function]}
+    onMouseEnter={[Function]}
+    onMouseLeave={[Function]}
+    onMouseUp={[Function]}
+    onTouchCancel={[Function]}
+    onTouchEnd={[Function]}
+    onTouchMove={[Function]}
+    onTouchStart={[Function]}
+    role="radio"
+    type="button"
+  >
+    Yellow
+  </button>
+  <button
+    aria-checked={false}
+    className="sc-dkzDqf jDeGjH"
+    data-rac=""
+    data-testid="orange-testid"
+    disabled={false}
+    onBlur={[Function]}
+    onClick={[Function]}
+    onDragStart={[Function]}
+    onFocus={[Function]}
+    onKeyDown={[Function]}
+    onMouseDown={[Function]}
+    onMouseEnter={[Function]}
+    onMouseLeave={[Function]}
+    onMouseUp={[Function]}
+    onTouchCancel={[Function]}
+    onTouchEnd={[Function]}
+    onTouchMove={[Function]}
+    onTouchStart={[Function]}
+    role="radio"
+    type="button"
+  >
+    Orange
+  </button>
+</div>
+`;
+
+exports[`ToggleButtonGroup matches snapshot with selectionMode undefined 1`] = `
 <div
   aria-orientation="horizontal"
   className="sc-gsnTZi hmfeGJ"
diff --git a/src/sentryLogger/sentryLog.spec.ts b/src/sentryLogger/sentryLog.spec.ts
new file mode 100644
index 000000000..d93001e46
--- /dev/null
+++ b/src/sentryLogger/sentryLog.spec.ts
@@ -0,0 +1,70 @@
+import { createSentryLogger } from './sentryLog';
+import * as Sentry from "@sentry/react";
+import { Level, Logger } from '@openstax/ts-utils/services/logger';
+
+describe('createConsoleLogger', () => {
+  let logFn: jest.SpyInstance;
+  let logger: Logger;
+
+  beforeEach(() => {
+    logger = createSentryLogger();
+    logFn = jest.spyOn(Sentry, 'addBreadcrumb').mockImplementation(() => null);
+  });
+
+  afterEach(() => {
+    logFn.mockReset();
+  });
+
+  it('logs all fields', () => {
+    logger.setContext({ extra: 'context' });
+    logger.logEvent(
+      Level.Info,
+      {
+        "timestamp": "2016-04-20T20:55:53.847Z",
+        "type": "navigation",
+        "event_id": "any-id",
+        "category": "any-category",
+        "message": "This is a test",
+        "data": {
+          "from": "/login",
+          "to": "/dashboard"
+        }
+      }
+    );
+
+    expect(logFn).toHaveBeenCalledWith(
+      {
+        "timestamp": "2016-04-20T20:55:53.847Z",
+        "type": "navigation",
+        "level": "info",
+        "message": "This is a test",
+        "event_id": "any-id",
+        "category": "any-category",
+        "data": {
+          "from": "/login",
+          "to": "/dashboard"
+        }
+      }
+    );
+  });
+
+  it('logs with missing fields', () => {
+    logger.setContext({ extra: 'context' });
+    logger.logEvent(
+      Level.Warn,
+      {},
+    );
+
+    expect(logFn).toHaveBeenCalledWith(
+      {
+        "timestamp": undefined,
+        "category": "",
+        "event_id": "",
+        "type": "",
+        "message": "",
+        "level": "warning",
+        "data": {}
+      }
+    );
+  });
+});
diff --git a/src/sentryLogger/sentryLog.ts b/src/sentryLogger/sentryLog.ts
new file mode 100644
index 000000000..425dfc182
--- /dev/null
+++ b/src/sentryLogger/sentryLog.ts
@@ -0,0 +1,38 @@
+import { addBreadcrumb, SeverityLevel } from "@sentry/react";
+import { createCoreLogger, Level } from '@openstax/ts-utils/services/logger';
+import { JsonCompatibleStruct } from "@openstax/ts-utils/routing";
+
+/**
+ * Creates a logger that creates breadcrumbs using Sentry.
+ * 
+ * More info: https://develop.sentry.dev/sdk/data-model/event-payloads/breadcrumbs/
+ */
+
+const serializeLevel = (level: Level): string =>
+  level === Level.Warn ? 'warning' : level;
+
+const serializeBreadcrumb = (level: Level, breadcrumb: JsonCompatibleStruct): {
+  type?: string;
+  level?: SeverityLevel;
+  event_id?: string;
+  category?: string;
+  message?: string;
+  timestamp?: number;
+  data?: { [key: string]: any };
+} => {
+  const { type, event_id, category, message, timestamp, data } = breadcrumb;
+  // Casting values to remove JsonCompatibleStruct type from event
+  return {
+    type: (type?? '') as string,
+    level: serializeLevel(level) as SeverityLevel,
+    "event_id": (event_id ?? '') as string,
+    category: (category ?? '') as string,
+    message: (message ?? '') as string,
+    timestamp: timestamp as number ?? undefined,
+    data: (data ?? {}) as { [key: string]: any },
+  }
+};
+
+export const createSentryLogger = () => createCoreLogger((level, breadcrumb) =>
+  addBreadcrumb(serializeBreadcrumb(level, breadcrumb))
+);
\ No newline at end of file

From 05eb4785c47d7989113ccedd85af75debe9902b4 Mon Sep 17 00:00:00 2001
From: jomcarvajal <jose.carvajal@edify.cr>
Date: Thu, 20 Mar 2025 10:43:11 -0600
Subject: [PATCH 2/5] CORE-764: fix casting issues, append rest of data

---
 src/sentryLogger/sentryLog.spec.ts | 16 ++++++------
 src/sentryLogger/sentryLog.ts      | 40 +++++++++++++++++++++---------
 2 files changed, 37 insertions(+), 19 deletions(-)

diff --git a/src/sentryLogger/sentryLog.spec.ts b/src/sentryLogger/sentryLog.spec.ts
index d93001e46..795c05122 100644
--- a/src/sentryLogger/sentryLog.spec.ts
+++ b/src/sentryLogger/sentryLog.spec.ts
@@ -25,9 +25,11 @@ describe('createConsoleLogger', () => {
         "event_id": "any-id",
         "category": "any-category",
         "message": "This is a test",
-        "data": {
-          "from": "/login",
-          "to": "/dashboard"
+        "from": "/login",
+        "to": "/dashboard",
+        "profile": {},
+        "address": {
+          "city": "New York"
         }
       }
     );
@@ -38,18 +40,19 @@ describe('createConsoleLogger', () => {
         "type": "navigation",
         "level": "info",
         "message": "This is a test",
-        "event_id": "any-id",
         "category": "any-category",
         "data": {
           "from": "/login",
-          "to": "/dashboard"
+          "to": "/dashboard",
+          "event_id": "any-id",
+          "extra": "context",
+          "city": "New York",
         }
       }
     );
   });
 
   it('logs with missing fields', () => {
-    logger.setContext({ extra: 'context' });
     logger.logEvent(
       Level.Warn,
       {},
@@ -59,7 +62,6 @@ describe('createConsoleLogger', () => {
       {
         "timestamp": undefined,
         "category": "",
-        "event_id": "",
         "type": "",
         "message": "",
         "level": "warning",
diff --git a/src/sentryLogger/sentryLog.ts b/src/sentryLogger/sentryLog.ts
index 425dfc182..e02bf9627 100644
--- a/src/sentryLogger/sentryLog.ts
+++ b/src/sentryLogger/sentryLog.ts
@@ -1,6 +1,6 @@
 import { addBreadcrumb, SeverityLevel } from "@sentry/react";
 import { createCoreLogger, Level } from '@openstax/ts-utils/services/logger';
-import { JsonCompatibleStruct } from "@openstax/ts-utils/routing";
+import { JsonCompatibleArray, JsonCompatibleStruct, JsonCompatibleValue } from "@openstax/ts-utils/routing";
 
 /**
  * Creates a logger that creates breadcrumbs using Sentry.
@@ -8,28 +8,44 @@ import { JsonCompatibleStruct } from "@openstax/ts-utils/routing";
  * More info: https://develop.sentry.dev/sdk/data-model/event-payloads/breadcrumbs/
  */
 
-const serializeLevel = (level: Level): string =>
+const checkTypeOf = (
+  value: JsonCompatibleStruct | JsonCompatibleValue | JsonCompatibleArray,
+  typeValue: string,
+  defaultValue: any,
+) =>
+  value && typeof value === typeValue ? value : defaultValue
+  ;
+
+const flattenObj = (obj: { [x: string]: any; }) =>
+  Object.keys(obj).reduce((acc: { [x: string]: any; }, curKey) => {
+    if (typeof obj[curKey] === 'object') {
+      acc = { ...acc, ...flattenObj(obj[curKey]) }
+    } else {
+      acc[curKey] = obj[curKey]
+    }
+    return acc
+  }, {});
+
+const serializeLevel = (level: Level): SeverityLevel =>
   level === Level.Warn ? 'warning' : level;
 
 const serializeBreadcrumb = (level: Level, breadcrumb: JsonCompatibleStruct): {
   type?: string;
   level?: SeverityLevel;
-  event_id?: string;
   category?: string;
   message?: string;
   timestamp?: number;
   data?: { [key: string]: any };
 } => {
-  const { type, event_id, category, message, timestamp, data } = breadcrumb;
-  // Casting values to remove JsonCompatibleStruct type from event
+  const { type, category, message, timestamp, ...rest } = breadcrumb;
+
   return {
-    type: (type?? '') as string,
-    level: serializeLevel(level) as SeverityLevel,
-    "event_id": (event_id ?? '') as string,
-    category: (category ?? '') as string,
-    message: (message ?? '') as string,
-    timestamp: timestamp as number ?? undefined,
-    data: (data ?? {}) as { [key: string]: any },
+    type: checkTypeOf(type, 'string', ''),
+    level: serializeLevel(level),
+    category: checkTypeOf(category, 'string', ''),
+    message: checkTypeOf(message, 'string', ''),
+    timestamp: checkTypeOf(timestamp, 'string', undefined),
+    data: flattenObj(rest),
   }
 };
 

From 5762d214ce36d013ab7547095d042c70f693e8ef Mon Sep 17 00:00:00 2001
From: jomcarvajal <jose.carvajal@edify.cr>
Date: Thu, 20 Mar 2025 17:03:51 -0600
Subject: [PATCH 3/5] CORE-764: add export to index

---
 src/index.ts | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/index.ts b/src/index.ts
index 11b9091c3..8c93e40d8 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -31,4 +31,5 @@ export * from './contexts';
 export * from './hooks';
 export * from './theme';
 export * from './types';
+export * from './sentryLogger/sentryLog';
 export type { Key } from 'react-aria-components';

From 5168453aab58b081ae09f4f2b3eaa5dee9ae3ac7 Mon Sep 17 00:00:00 2001
From: jomcarvajal <jose.carvajal@edify.cr>
Date: Fri, 21 Mar 2025 09:28:49 -0600
Subject: [PATCH 4/5] CORE-764: validate timestamp as number

---
 src/sentryLogger/sentryLog.spec.ts | 4 ++--
 src/sentryLogger/sentryLog.ts      | 2 +-
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/src/sentryLogger/sentryLog.spec.ts b/src/sentryLogger/sentryLog.spec.ts
index 795c05122..b2825843f 100644
--- a/src/sentryLogger/sentryLog.spec.ts
+++ b/src/sentryLogger/sentryLog.spec.ts
@@ -20,7 +20,7 @@ describe('createConsoleLogger', () => {
     logger.logEvent(
       Level.Info,
       {
-        "timestamp": "2016-04-20T20:55:53.847Z",
+        "timestamp": 1461165894000,
         "type": "navigation",
         "event_id": "any-id",
         "category": "any-category",
@@ -36,7 +36,7 @@ describe('createConsoleLogger', () => {
 
     expect(logFn).toHaveBeenCalledWith(
       {
-        "timestamp": "2016-04-20T20:55:53.847Z",
+        "timestamp": 1461165894000,
         "type": "navigation",
         "level": "info",
         "message": "This is a test",
diff --git a/src/sentryLogger/sentryLog.ts b/src/sentryLogger/sentryLog.ts
index e02bf9627..c8e890328 100644
--- a/src/sentryLogger/sentryLog.ts
+++ b/src/sentryLogger/sentryLog.ts
@@ -44,7 +44,7 @@ const serializeBreadcrumb = (level: Level, breadcrumb: JsonCompatibleStruct): {
     level: serializeLevel(level),
     category: checkTypeOf(category, 'string', ''),
     message: checkTypeOf(message, 'string', ''),
-    timestamp: checkTypeOf(timestamp, 'string', undefined),
+    timestamp: checkTypeOf(timestamp, 'number', undefined),
     data: flattenObj(rest),
   }
 };

From 6f3266c24f06cc5d35e2e7a133832f842ab14e0c Mon Sep 17 00:00:00 2001
From: jomcarvajal <jose.carvajal@edify.cr>
Date: Fri, 21 Mar 2025 10:38:07 -0600
Subject: [PATCH 5/5] CORE-764: simplify typeof check, remove type key and set
 category as log

---
 src/sentryLogger/sentryLog.spec.ts |  8 ++------
 src/sentryLogger/sentryLog.ts      | 27 ++++++++++-----------------
 2 files changed, 12 insertions(+), 23 deletions(-)

diff --git a/src/sentryLogger/sentryLog.spec.ts b/src/sentryLogger/sentryLog.spec.ts
index b2825843f..d77ed9b47 100644
--- a/src/sentryLogger/sentryLog.spec.ts
+++ b/src/sentryLogger/sentryLog.spec.ts
@@ -21,9 +21,7 @@ describe('createConsoleLogger', () => {
       Level.Info,
       {
         "timestamp": 1461165894000,
-        "type": "navigation",
         "event_id": "any-id",
-        "category": "any-category",
         "message": "This is a test",
         "from": "/login",
         "to": "/dashboard",
@@ -37,10 +35,9 @@ describe('createConsoleLogger', () => {
     expect(logFn).toHaveBeenCalledWith(
       {
         "timestamp": 1461165894000,
-        "type": "navigation",
         "level": "info",
         "message": "This is a test",
-        "category": "any-category",
+        "category": "log",
         "data": {
           "from": "/login",
           "to": "/dashboard",
@@ -61,8 +58,7 @@ describe('createConsoleLogger', () => {
     expect(logFn).toHaveBeenCalledWith(
       {
         "timestamp": undefined,
-        "category": "",
-        "type": "",
+        "category": "log",
         "message": "",
         "level": "warning",
         "data": {}
diff --git a/src/sentryLogger/sentryLog.ts b/src/sentryLogger/sentryLog.ts
index c8e890328..5284fb466 100644
--- a/src/sentryLogger/sentryLog.ts
+++ b/src/sentryLogger/sentryLog.ts
@@ -1,21 +1,12 @@
 import { addBreadcrumb, SeverityLevel } from "@sentry/react";
 import { createCoreLogger, Level } from '@openstax/ts-utils/services/logger';
-import { JsonCompatibleArray, JsonCompatibleStruct, JsonCompatibleValue } from "@openstax/ts-utils/routing";
+import { JsonCompatibleStruct } from "@openstax/ts-utils/routing";
 
 /**
- * Creates a logger that creates breadcrumbs using Sentry.
- * 
- * More info: https://develop.sentry.dev/sdk/data-model/event-payloads/breadcrumbs/
+ * Flatten nested objects
+ * @see https://stackoverflow.com/a/70377608
  */
 
-const checkTypeOf = (
-  value: JsonCompatibleStruct | JsonCompatibleValue | JsonCompatibleArray,
-  typeValue: string,
-  defaultValue: any,
-) =>
-  value && typeof value === typeValue ? value : defaultValue
-  ;
-
 const flattenObj = (obj: { [x: string]: any; }) =>
   Object.keys(obj).reduce((acc: { [x: string]: any; }, curKey) => {
     if (typeof obj[curKey] === 'object') {
@@ -30,7 +21,6 @@ const serializeLevel = (level: Level): SeverityLevel =>
   level === Level.Warn ? 'warning' : level;
 
 const serializeBreadcrumb = (level: Level, breadcrumb: JsonCompatibleStruct): {
-  type?: string;
   level?: SeverityLevel;
   category?: string;
   message?: string;
@@ -40,15 +30,18 @@ const serializeBreadcrumb = (level: Level, breadcrumb: JsonCompatibleStruct): {
   const { type, category, message, timestamp, ...rest } = breadcrumb;
 
   return {
-    type: checkTypeOf(type, 'string', ''),
     level: serializeLevel(level),
-    category: checkTypeOf(category, 'string', ''),
-    message: checkTypeOf(message, 'string', ''),
-    timestamp: checkTypeOf(timestamp, 'number', undefined),
+    category: 'log',
+    message: typeof message === 'string' ? message : '',
+    timestamp: typeof timestamp === 'number' ? timestamp : undefined,
     data: flattenObj(rest),
   }
 };
 
+/**
+ * Creates a logger that creates breadcrumbs using Sentry.
+ * @see https://develop.sentry.dev/sdk/data-model/event-payloads/breadcrumbs/
+ */
 export const createSentryLogger = () => createCoreLogger((level, breadcrumb) =>
   addBreadcrumb(serializeBreadcrumb(level, breadcrumb))
 );
\ No newline at end of file