Skip to content

Commit f601c0a

Browse files
feat(Sky): Add exception signature throttling to PostHog bridge
Exceptions share the `$exception` event-name slot in posthog-js, so a burst of CSP refusals/console.error/unhandledrejection callbacks during workbench boot would blow through the 10/10s rate limit. Add a secondary throttle layer keyed by the first 200 chars of the error message. Unique exceptions still reach PostHog while bursts of the same error collapse into one. Both regular and exception drop counts are now reported in the `land:sky:throttle-dropped` event for visibility. This follows the previous throttling commit (24e76da) which added the basic per-event-name throttle for non-exception events.
1 parent a297520 commit f601c0a

1 file changed

Lines changed: 64 additions & 4 deletions

File tree

Source/Workbench/Electron/PostHogBridge.ts

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -153,10 +153,23 @@ interface BufferedMark {
153153
// Dropping here instead means the warning never fires AND the
154154
// PostHog dashboard gets a single "dropped=N" reporting event
155155
// per window so volume loss is visible.
156+
//
157+
// Exceptions share the posthog-js `$exception` event-name slot, so a
158+
// single workbench boot's worth of CSP refusals / console.error /
159+
// unhandledrejection callbacks used to blow straight through the
160+
// 10 / 10 s cap. Throttle them by a stable signature (first 200 chars
161+
// of the message) so unique exceptions still reach PostHog while
162+
// bursts of the *same* exception collapse into one.
156163
const ThrottleWindowMs = 10_000;
157164
const ThrottleLimitPerName = Math.max(1, PostHogMaxEventsPerSecond * 2);
165+
const ExceptionThrottleLimitPerSignature = Math.max(1, ThrottleLimitPerName);
158166
const ThrottleCounters = new Map<string, { Count: number; ResetAt: number }>();
159167
const ThrottleDropped = new Map<string, number>();
168+
const ExceptionCounters = new Map<
169+
string,
170+
{ Count: number; ResetAt: number }
171+
>();
172+
const ExceptionDropped = new Map<string, number>();
160173

161174
const ShouldThrottle = (Name: string): boolean => {
162175
const Now = Date.now();
@@ -176,19 +189,58 @@ const ShouldThrottle = (Name: string): boolean => {
176189
return false;
177190
};
178191

192+
// Build a stable signature for an exception. Uses the first 200 chars of the
193+
// message (or the `String(Error)` fallback) so identical errors from
194+
// different call sites still collapse onto one counter. Avoids stack-trace
195+
// fingerprinting because addresses / minified names drift between builds.
196+
const ExceptionSignature = (Error: unknown): string => {
197+
if (Error && typeof Error === "object" && "message" in Error) {
198+
const Message = String((Error as { message: unknown }).message ?? "");
199+
return Message.slice(0, 200) || "unknown";
200+
}
201+
return String(Error).slice(0, 200) || "unknown";
202+
};
203+
204+
const ShouldThrottleException = (Signature: string): boolean => {
205+
const Now = Date.now();
206+
const Entry = ExceptionCounters.get(Signature);
207+
if (!Entry || Entry.ResetAt <= Now) {
208+
ExceptionCounters.set(Signature, {
209+
Count: 1,
210+
ResetAt: Now + ThrottleWindowMs,
211+
});
212+
return false;
213+
}
214+
Entry.Count += 1;
215+
if (Entry.Count > ExceptionThrottleLimitPerSignature) {
216+
ExceptionDropped.set(
217+
Signature,
218+
(ExceptionDropped.get(Signature) ?? 0) + 1,
219+
);
220+
return true;
221+
}
222+
return false;
223+
};
224+
179225
const DrainThrottleMetrics = (PH: any): void => {
180-
if (ThrottleDropped.size === 0) return;
226+
if (ThrottleDropped.size === 0 && ExceptionDropped.size === 0) return;
181227
const Summary: Record<string, number> = {};
182228
for (const [Name, Count] of ThrottleDropped.entries()) {
183229
Summary[Name] = Count;
184230
}
185231
ThrottleDropped.clear();
232+
const ExceptionSummary: Record<string, number> = {};
233+
for (const [Signature, Count] of ExceptionDropped.entries()) {
234+
ExceptionSummary[Signature] = Count;
235+
}
236+
ExceptionDropped.clear();
186237
// Single event per window - counted as one against the
187238
// throttle itself, so always safe under the limit.
188239
try {
189240
PH.capture?.("land:sky:throttle-dropped", {
190241
$component: "sky",
191242
dropped: Summary,
243+
dropped_exceptions: ExceptionSummary,
192244
window_ms: ThrottleWindowMs,
193245
});
194246
} catch {}
@@ -211,9 +263,17 @@ const Initialize = async (): Promise<void> => {
211263
Error: unknown,
212264
Properties?: Record<string, unknown>,
213265
) => {
214-
// Errors pass through unthrottled - they're already rare
215-
// and signal-rich, and the dashboard deduplicates by
216-
// $exception_type anyway.
266+
// Exceptions share the posthog-js `$exception` event-name
267+
// slot for the purposes of its internal limiter, so a
268+
// bursty stream (workbench boot produces dozens of
269+
// duplicate CSP refusals, hook callbacks, etc.) used to
270+
// blow through the 10/10 s cap and surface as
271+
// "ignored due to client rate limiting" in the webview
272+
// console. Collapse by message signature here so unique
273+
// exceptions still reach PostHog and bursts of the same
274+
// error show up as a single `throttle-dropped` summary.
275+
const Signature = ExceptionSignature(Error);
276+
if (ShouldThrottleException(Signature)) return;
217277
return Raw.captureException(Error, Properties);
218278
},
219279
};

0 commit comments

Comments
 (0)