@@ -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.
156163const ThrottleWindowMs = 10_000 ;
157164const ThrottleLimitPerName = Math . max ( 1 , PostHogMaxEventsPerSecond * 2 ) ;
165+ const ExceptionThrottleLimitPerSignature = Math . max ( 1 , ThrottleLimitPerName ) ;
158166const ThrottleCounters = new Map < string , { Count : number ; ResetAt : number } > ( ) ;
159167const 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
161174const 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+
179225const 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