Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions src/components/fairway/app-shell/FairwayBottomNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,14 +134,28 @@ export const FairwayBottomNav = memo(function FairwayBottomNav({
: matchActive(item.href, pathname));
const Icon = item.icon;
return (
<li key={item.href} className="flex-1">
<li key={item.href} className="min-w-0 flex-1">
<Link
href={item.href}
aria-current={active ? 'page' : undefined}
aria-label={item.label}
className={cn(
// Full-height column ≥44px tall touch target.
'group relative flex min-h-[56px] flex-col items-center justify-center gap-0.5 px-1 py-1.5',
// Full-height column ≥44px tall touch target. `min-w-0`
// overrides the flex item's default `min-width: auto` floor
// (the browser default lets a flex item refuse to shrink
// below its unbreakable content's natural width even with
// `flex: 1 1 0%` on the parent `<li>`) — without it, a long
// label (e.g. "Development", "Messages", or a mode's
// exposureNoun) can force this column past its 1/5 share of
// a 320/390px bar, overflowing the row by a few px. Tailwind's
// `justify-around` (space-around) falls back to `center` per
// the CSS Box Alignment spec whenever the line's free space
// is negative, and centering an overflowing row shifts its
// start point negative — the exact -2px left overhang on the
// first ("Home") tab this fixes. `min-w-0` here (mirroring
// the `min-w-0` already on the More button below) lets the
// label's own `truncate` class actually engage instead.
'group relative flex min-h-[56px] min-w-0 flex-col items-center justify-center gap-0.5 px-1 py-1.5',
'outline-none transition-colors [transition-duration:var(--fw-dur-fast)] motion-reduce:transition-none',
'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-border-focus',
active ? 'text-accent-700' : 'text-text-tertiary hover:text-text-secondary',
Expand Down Expand Up @@ -178,7 +192,7 @@ export const FairwayBottomNav = memo(function FairwayBottomNav({
);
})}
{onMoreOpen && (
<li className="flex-1">
<li className="min-w-0 flex-1">
<Button
type="button"
variant="ghost"
Expand Down
39 changes: 35 additions & 4 deletions src/instrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,10 +320,16 @@ function allowBridgeProcessWrite(): boolean {
return bridgeProcessWriteCount <= BRIDGE_PROCESS_WRITE_LIMIT;
}

function logProcessErrorToBridge(action: string, error: Error): void {
function logProcessErrorToBridge(action: string, error: Error, metadata?: Record<string, unknown>): void {
if (!allowBridgeProcessWrite()) return;
void import('@/lib/server-error-logger')
.then((m) => m.logServerException(error, { action, source: 'background_job', handled: false }, 'error'))
.then((m) =>
m.logServerException(
error,
{ action, source: 'background_job', handled: false, ...(metadata ? { metadata } : {}) },
'error'
)
)
.catch(() => {});
}

Expand All @@ -332,9 +338,34 @@ function registerProcessErrorHandlers(): void {
processHandlersRegistered = true;

process.on('unhandledRejection', (reason) => {
const error = reason instanceof Error ? reason : new Error(String(reason));
let error: Error;
if (reason instanceof Error) {
error = reason;
} else {
// Non-Error rejection reasons (a promise rejected with a string,
// plain object, number, etc.) used to become `new Error(String(reason))`
// — that Error's OWN stack always points at THIS handler, so Next dev's
// code-frame overlay blamed instrumentation.ts for every such
// rejection instead of the real throw site. console.error the RAW
// reason FIRST (before any synthesis) so the true payload is never
// masked, then build a clearly-labeled synthetic Error: `name` marks
// it as synthetic (not a real thrown Error) rather than masquerading
// as one, the message is PREFIXED with the stringified reason so it
// still reads naturally in Sentry/logs, and the untouched original
// reason is preserved in the Bridge metadata for anyone triaging the
// admin_events row (a stringified reason alone can lose structure —
// e.g. a plain `{ code, message }` rejection collapses to
// "[object Object]").
console.error('[instrumentation] unhandledRejection: non-Error reason', reason);
error = new Error(`${String(reason)} (unhandled promise rejection with a non-Error reason)`);
error.name = 'UnhandledRejection';
}
Sentry.captureException(error);
logProcessErrorToBridge('process.unhandledRejection', error);
logProcessErrorToBridge(
'process.unhandledRejection',
error,
reason instanceof Error ? undefined : { reason }
);
Comment on lines +359 to +368

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Prevent Sentry grouping collapse and Bridge logging failures for object rejections.

When reason is an object, String(reason) evaluates to "[object Object]". Because the stack trace for this synthetic Error always points to this handler, Sentry will incorrectly group all non-Error object rejections into a single issue. Additionally, if the raw reason object contains circular references or BigInts (common in HTTP client rejections), JSON.parse(JSON.stringify(...)) inside logServerException's normalizeContext will synchronously throw, completely dropping the Bridge log.

Safely serialize the object for the error message to ensure distinct Sentry grouping, attach the raw object to Sentry's extra context, and ensure the metadata passed to Bridge is stripped of unserializable values.

🛠️ Proposed fix to safely serialize rejection reasons
       console.error('[instrumentation] unhandledRejection: non-Error reason', reason);
-      error = new Error(`${String(reason)} (unhandled promise rejection with a non-Error reason)`);
+      
+      let prefix = String(reason);
+      if (typeof reason === 'object' && reason !== null) {
+        try {
+          prefix = JSON.stringify(reason);
+        } catch {
+          prefix = '[Unserializable Object]';
+        }
+      }
+      
+      error = new Error(`${prefix} (unhandled promise rejection with a non-Error reason)`);
       error.name = 'UnhandledRejection';
     }
-    Sentry.captureException(error);
+    
+    Sentry.captureException(error, {
+      extra: reason instanceof Error ? undefined : { reason }
+    });
+
+    let safeMetadata: Record<string, unknown> | undefined;
+    if (!(reason instanceof Error)) {
+      try {
+        safeMetadata = { reason: JSON.parse(JSON.stringify(reason)) };
+      } catch {
+        safeMetadata = { reason: String(reason) };
+      }
+    }
+
     logProcessErrorToBridge(
       'process.unhandledRejection',
       error,
-      reason instanceof Error ? undefined : { reason }
+      safeMetadata
     );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
console.error('[instrumentation] unhandledRejection: non-Error reason', reason);
error = new Error(`${String(reason)} (unhandled promise rejection with a non-Error reason)`);
error.name = 'UnhandledRejection';
}
Sentry.captureException(error);
logProcessErrorToBridge('process.unhandledRejection', error);
logProcessErrorToBridge(
'process.unhandledRejection',
error,
reason instanceof Error ? undefined : { reason }
);
console.error('[instrumentation] unhandledRejection: non-Error reason', reason);
let prefix = String(reason);
if (typeof reason === 'object' && reason !== null) {
try {
prefix = JSON.stringify(reason);
} catch {
prefix = '[Unserializable Object]';
}
}
error = new Error(`${prefix} (unhandled promise rejection with a non-Error reason)`);
error.name = 'UnhandledRejection';
}
Sentry.captureException(error, {
extra: reason instanceof Error ? undefined : { reason }
});
let safeMetadata: Record<string, unknown> | undefined;
if (!(reason instanceof Error)) {
try {
safeMetadata = { reason: JSON.parse(JSON.stringify(reason)) };
} catch {
safeMetadata = { reason: String(reason) };
}
}
logProcessErrorToBridge(
'process.unhandledRejection',
error,
safeMetadata
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/instrumentation.ts` around lines 359 - 368, Update the non-Error handling
in the unhandled rejection handler before Sentry.captureException: safely
serialize object reasons with circular-reference and BigInt handling so the
synthetic Error message preserves distinguishing details, attach the raw
rejection reason to Sentry extra context, and pass a sanitized, serializable
metadata value to logProcessErrorToBridge instead of the raw object. Preserve
the existing Error-reason path and UnhandledRejection naming.

});

process.on('uncaughtException', (error) => {
Expand Down
Loading