@tallyrow/safesignal 1.3.0 → 1.4.0

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.
Files changed (50) hide show
  1. package/README.md +329 -20
  2. package/dist/capture.cjs +77 -0
  3. package/dist/capture.cjs.map +1 -0
  4. package/dist/capture.d.cts +49 -0
  5. package/dist/capture.d.ts +49 -0
  6. package/dist/capture.mjs +75 -0
  7. package/dist/capture.mjs.map +1 -0
  8. package/dist/dev-console.cjs +90 -0
  9. package/dist/dev-console.cjs.map +1 -0
  10. package/dist/dev-console.d.cts +67 -0
  11. package/dist/dev-console.d.ts +67 -0
  12. package/dist/dev-console.mjs +88 -0
  13. package/dist/dev-console.mjs.map +1 -0
  14. package/dist/framework-react.cjs +92 -0
  15. package/dist/framework-react.cjs.map +1 -0
  16. package/dist/framework-react.d.cts +97 -0
  17. package/dist/framework-react.d.ts +97 -0
  18. package/dist/framework-react.mjs +87 -0
  19. package/dist/framework-react.mjs.map +1 -0
  20. package/dist/framework-vue.cjs +88 -0
  21. package/dist/framework-vue.cjs.map +1 -0
  22. package/dist/framework-vue.d.cts +101 -0
  23. package/dist/framework-vue.d.ts +101 -0
  24. package/dist/framework-vue.mjs +82 -0
  25. package/dist/framework-vue.mjs.map +1 -0
  26. package/dist/index.cjs +180 -40
  27. package/dist/index.cjs.map +1 -1
  28. package/dist/index.d.cts +2 -2
  29. package/dist/index.d.ts +2 -2
  30. package/dist/index.mjs +180 -40
  31. package/dist/index.mjs.map +1 -1
  32. package/dist/stacks.cjs +81 -0
  33. package/dist/stacks.cjs.map +1 -0
  34. package/dist/stacks.d.cts +55 -0
  35. package/dist/stacks.d.ts +55 -0
  36. package/dist/stacks.mjs +77 -0
  37. package/dist/stacks.mjs.map +1 -0
  38. package/dist/testing.d.cts +1 -1
  39. package/dist/testing.d.ts +1 -1
  40. package/dist/transport-beacon.cjs.map +1 -1
  41. package/dist/transport-beacon.d.cts +1 -1
  42. package/dist/transport-beacon.d.ts +1 -1
  43. package/dist/transport-beacon.mjs.map +1 -1
  44. package/dist/transport-otlp.cjs.map +1 -1
  45. package/dist/transport-otlp.d.cts +1 -1
  46. package/dist/transport-otlp.d.ts +1 -1
  47. package/dist/transport-otlp.mjs.map +1 -1
  48. package/dist/{types-BiRyHi1e.d.cts → types-CZtSjgq5.d.cts} +53 -1
  49. package/dist/{types-BiRyHi1e.d.ts → types-CZtSjgq5.d.ts} +53 -1
  50. package/package.json +53 -6
package/README.md CHANGED
@@ -2,11 +2,35 @@
2
2
 
3
3
  **SafeSignal** is a browser-first, vendor-neutral structured logging
4
4
  facade and safety boundary for browser applications and federated
5
- frontend modules. Secure-by-default sanitization, URL scrubbing,
6
- key + shape redaction, control-character escaping, and a pluggable
7
- transport boundary all applied before any transport sees an event.
8
- Published on npm as `@tallyrow/safesignal` (TallyRow is the
9
- publishing organization; SafeSignal is the product).
5
+ frontend modules. It catches the errors your users actually hit —
6
+ uncaught exceptions, unhandled rejections, and React/Vue component
7
+ crashesand ships them securely to any backend. Secure-by-default
8
+ sanitization, URL scrubbing, key + shape redaction, control-character
9
+ escaping, and a pluggable transport boundary — all applied before any
10
+ transport sees an event. Published on npm as `@tallyrow/safesignal`
11
+ (TallyRow is the publishing organization; SafeSignal is the product).
12
+
13
+ ## What you get
14
+
15
+ The visible wins — all opt-in subpaths, each routed through the same
16
+ secure pipeline as any log:
17
+
18
+ - ⭐ **[Catch the silent errors](#catch-uncaught-errors--capture-subpath)** —
19
+ the opt-in `./capture` subpath logs the **uncaught exceptions and
20
+ unhandled rejections** that otherwise never reach your transports.
21
+ - **[Dev-mode console rendering](#pretty-dev-logs--dev-console-subpath)** —
22
+ readable, colorized log output in development.
23
+ - **[Error breadcrumbs](#error-breadcrumbs--recent-event-context-on-errors)** —
24
+ bounded recent-event context attached to each error.
25
+ - **[Readable, source-mapped error stacks](#readable-error-stacks--stacks-subpath)** —
26
+ turn a wall of minified frames into trimmed, structured ones.
27
+ - **[React error boundary + hook](#catch-react-errors--framework-react-subpath)** —
28
+ `./framework-react` routes component-tree errors through your `Logger`.
29
+ - **[Vue errorHandler adapter](#catch-vue-errors--framework-vue-subpath)** —
30
+ `./framework-vue` does the same for Vue 3.
31
+
32
+ The core stays tiny and ships nothing you don't import. Here is *why*
33
+ each of those stays safe:
10
34
 
11
35
  ## Why SafeSignal
12
36
 
@@ -14,7 +38,7 @@ publishing organization; SafeSignal is the product).
14
38
  - **Never-throw boundary**: no transport, redactor, or sanitizer failure propagates into your `log.info(...)` call site. Logging cannot break rendering, navigation, or state updates.
15
39
  - **Vendor-neutral transport**: ship to Datadog, Honeycomb, your own ingestion, or the built-in `./transport-beacon` subpath for body-only HTTPS delivery — same API regardless of destination.
16
40
  - **Federated-runtime aware**: host owns the configured runtime; modules import loggers without re-configuring. Hundreds of `Logger` instances per page stay constant-cost.
17
- - **Lightweight**: ~8 KB gzipped default entry; structured events with bounded depth and bounded size; no global listeners, no ambient state reads, no per-instance backend init.
41
+ - **Lightweight**: ~8 KB gzipped default entry; structured events with bounded depth and bounded size; the core installs no global listeners and reads no ambient state (an opt-in host subpath may install one — see the [`./capture` subpath](#catch-uncaught-errors--capture-subpath)), and `Logger` creation does no per-instance backend init.
18
42
 
19
43
  ## Install
20
44
 
@@ -48,9 +72,15 @@ log.info('checkout opened', { cartItems: 3 });
48
72
  custom delivery primitive.
49
73
  - Read `process.env.NODE_ENV`, `import.meta.env`, `location`, or
50
74
  `document.cookie` — pass `environment` explicitly.
51
- - Install global listeners or singletons (RUM-style automatic
52
- error capture, view tracking, web vitals, network
53
- instrumentation are forward-looking; see Roadmap below).
75
+ - Touch globals from the **core** or from `createLogger()` — the
76
+ core installs no global listeners and reads no ambient state. The
77
+ **one opt-in exception** is host-owned: a host may install a
78
+ single global **error** capturer via the
79
+ [`./capture` subpath](#catch-uncaught-errors--capture-subpath) —
80
+ explicit, opt-in, routed through the same secure pipeline;
81
+ federated modules never install it. Web Vitals, view tracking,
82
+ network instrumentation, and a server/monitoring backend remain out
83
+ of scope — SafeSignal is an error-logging library, not a RUM product.
54
84
  - Persist events to IndexedDB or any storage layer.
55
85
  - Batch, sample, or deduplicate events by default (opt-in
56
86
  batching is available via the `./transport-beacon` subpath).
@@ -224,6 +254,286 @@ identical across the batch (and within the 512-char bound).
224
254
  — `navigator.sendBeacon` cannot set custom request headers, so
225
255
  `./transport-beacon` is out of scope.
226
256
 
257
+ ## Catch uncaught errors — `./capture` subpath
258
+
259
+ Uncaught exceptions and unhandled promise rejections normally vanish —
260
+ they never reach your configured transports. The **opt-in** `./capture`
261
+ subpath lets a **host** route them through the same secure pipeline as
262
+ every other log:
263
+
264
+ ```ts
265
+ import { configureLogging, getRootLogger } from '@tallyrow/safesignal';
266
+ import { createBeaconTransport } from '@tallyrow/safesignal/transport-beacon';
267
+ import { installGlobalErrorCapture } from '@tallyrow/safesignal/capture';
268
+
269
+ configureLogging({
270
+ application: { name: 'checkout-web', version: '4.2.0' },
271
+ environment: 'production',
272
+ transports: [createBeaconTransport({ endpoint: 'https://logs.example.com/ingest' })],
273
+ });
274
+
275
+ // Host installs once — returns a disposer.
276
+ const dispose = installGlobalErrorCapture(getRootLogger());
277
+ // …on teardown: dispose();
278
+ ```
279
+
280
+ It emits an `error`-level event (`'Uncaught exception'` /
281
+ `'Unhandled promise rejection'`) carrying the serialized error and a
282
+ `safesignal.source` / `safesignal.errorType` marker, redacted +
283
+ sanitized like any log.
284
+
285
+ - **Host-owned, opt-in** (Principle VIII): it is **never** a side effect
286
+ of `createLogger()`; a **federated module never installs it** — only the
287
+ host that owns the runtime does. Pass it the host's `Logger`
288
+ (`getRootLogger()` or `createLogger({ module })`).
289
+ - **Fail-safe**: it never throws into the page and never breaks
290
+ rendering/navigation; a failing transport is swallowed to
291
+ `onInternalError`.
292
+ - **Additive**: it chains via `addEventListener` — your existing
293
+ `window.onerror`/handlers keep firing; it never `preventDefault()`s.
294
+ - **Errors only** — no view tracking, web vitals, or network
295
+ instrumentation (that is RUM — out of scope). Duplicate package copies are
296
+ **isolated** (each capturer uses the `Logger` from its own copy).
297
+
298
+ ## Catch React errors — `./framework-react` subpath
299
+
300
+ When a React component throws during render, the default outcome is a blank
301
+ screen and an error that never reaches your transports. The **opt-in**
302
+ `./framework-react` subpath is the **no-globals, per-component** counterpart to
303
+ `./capture`: a `<LogErrorBoundary>` and a `useLogError()` hook that route React
304
+ errors through your existing `Logger` and render a graceful fallback. `react` is
305
+ a **peer dependency** (`>=16.8`) — the core and every other subpath stay
306
+ React-free.
307
+
308
+ ```tsx
309
+ import { configureLogging, createLogger } from '@tallyrow/safesignal';
310
+ import { LoggerProvider, LogErrorBoundary, useLogError }
311
+ from '@tallyrow/safesignal/framework-react';
312
+
313
+ configureLogging({ application: { name: 'checkout-web' }, environment: 'production', transports: [/* … */] });
314
+ const log = createLogger({ module: 'checkout' });
315
+
316
+ function App() {
317
+ return (
318
+ <LoggerProvider logger={log}>
319
+ <LogErrorBoundary fallback={<p>Something went wrong.</p>}>
320
+ <Checkout />
321
+ </LogErrorBoundary>
322
+ </LoggerProvider>
323
+ );
324
+ }
325
+ ```
326
+
327
+ A boundary-caught error emits an `error`-level event (`'React render error'`)
328
+ carrying the serialized error and the React **component stack**
329
+ (`safesignal.react.componentStack`), with `safesignal.source:
330
+ 'react-error-boundary'`, redacted + sanitized like any log. For the errors a
331
+ boundary **cannot** catch — event handlers, async/`Promise` callbacks, effects —
332
+ use the hook:
333
+
334
+ ```tsx
335
+ function SaveButton() {
336
+ const logError = useLogError(); // stable callback; resolves the logger from context
337
+ const onClick = async () => {
338
+ try { await save(); } catch (err) { logError(err, { 'safesignal.action': 'save' }); }
339
+ };
340
+ return <button onClick={onClick}>Save</button>;
341
+ }
342
+ ```
343
+
344
+ - **No globals** (Principle VIII): patches nothing, attaches no `window`
345
+ listeners — the explicit contrast with `./capture`'s host-level install. The
346
+ two are complementary and can be used together.
347
+ - **Fail-safe** (Principle III): a logging (or `onError`) failure is swallowed
348
+ and the fallback still renders; React semantics keep it loop-free.
349
+ - **Fail-closed** (Principle V): errors route through the same redaction
350
+ pipeline as any log; if redaction fails the event is dropped.
351
+ - **Explicit logger**: provide it via `<LoggerProvider>` or a `logger` prop /
352
+ `useLogError(logger)` argument. With no logger resolvable, the helpers are a
353
+ **safe no-op** (never throw). `<LogErrorBoundary>` also accepts `onError`,
354
+ `resetKeys`, and a render-prop `fallback={(error, reset) => …}` for recovery.
355
+ Duplicate package copies are **isolated** (each routes through the logger it is
356
+ handed).
357
+
358
+ ## Catch Vue errors — `./framework-vue` subpath
359
+
360
+ The Vue 3 counterpart of `./framework-react`. When a Vue component throws during
361
+ render, a lifecycle hook, a watcher, or a template handler, that error never
362
+ reaches your transports. The **opt-in** `./framework-vue` subpath is the
363
+ **no-globals, per-app** counterpart to `./capture`: an `app.config.errorHandler`
364
+ adapter plus the `useLogError()` and `useErrorCapture()` composables that route
365
+ Vue errors through your existing `Logger`. `vue` is a **peer dependency**
366
+ (`>=3.0`) — the core and every other subpath stay Vue-free.
367
+
368
+ ```ts
369
+ import { createApp } from 'vue';
370
+ import { createLogger } from '@tallyrow/safesignal';
371
+ import { safesignalErrorHandler } from '@tallyrow/safesignal/framework-vue';
372
+
373
+ const log = createLogger({ module: 'checkout' });
374
+
375
+ createApp(App)
376
+ .use(safesignalErrorHandler, { logger: log }) // sets app.config.errorHandler + provides the logger
377
+ .mount('#app');
378
+ ```
379
+
380
+ A framework error emits an `error`-level event (`'Vue error'`) carrying the
381
+ serialized error and best-effort Vue context (`safesignal.vue.info`,
382
+ `safesignal.vue.componentName`), with `safesignal.source: 'vue-error-handler'`,
383
+ redacted + sanitized like any log. Prefer to wire it yourself? The factory is
384
+ side-effect-free: `app.config.errorHandler = createErrorHandler(log)`.
385
+
386
+ For errors Vue's handler can't catch (async/`try`-`catch`, native listeners), use
387
+ `useLogError()`; to contain and recover a subtree, use `useErrorCapture()`:
388
+
389
+ ```ts
390
+ import { ref } from 'vue';
391
+ import { useLogError, useErrorCapture } from '@tallyrow/safesignal/framework-vue';
392
+
393
+ // In a component's setup():
394
+ const logError = useLogError(); // stable callback; resolves the provided logger
395
+ async function onClick() {
396
+ try { await save(); } catch (err) { logError(err, { 'safesignal.action': 'save' }); }
397
+ }
398
+
399
+ // In a wrapper component's setup() — a subtree boundary:
400
+ const failed = ref(false);
401
+ useErrorCapture({ onError: () => { failed.value = true; } });
402
+ // descendant errors log once (safesignal.source: 'vue-error-captured') and, by
403
+ // default, do NOT also reach the app-level handler. Pass { propagate: true } to bubble.
404
+ ```
405
+
406
+ - **No globals** (Principle VIII): patches nothing, attaches no `window`
407
+ listeners — the explicit contrast with `./capture`. Complementary; usable together.
408
+ - **Fail-safe** (Principle III): a logging (or `onError`) failure is swallowed and
409
+ the app keeps running; no error loop.
410
+ - **Fail-closed** (Principle V): errors route through the same redaction pipeline
411
+ as any log; if redaction fails the event is dropped.
412
+ - **Explicit logger**: provide it via the plugin (or `useLogError(logger)` /
413
+ `useErrorCapture({ logger })`). With no logger resolvable, the helpers are a
414
+ **safe no-op** (never throw). Duplicate package copies are **isolated**.
415
+
416
+ ## Readable error stacks — `./stacks` subpath
417
+
418
+ A raw browser error stack is a wall of minified, framework-internal noise. The
419
+ **opt-in** `./stacks` subpath parses an error's stack into **trimmed, structured
420
+ frames** (function / file / line / column), and — when you supply a **synchronous
421
+ source-map resolver** — maps minified production frames back to original source
422
+ positions. **Off by default.**
423
+
424
+ ```ts
425
+ import { configureLogging, getRootLogger } from '@tallyrow/safesignal';
426
+ import { createStackNormalizer } from '@tallyrow/safesignal/stacks';
427
+
428
+ // Optional: a SYNCHRONOUS resolver over source maps you have already loaded.
429
+ const resolver = (f) => mySourceMaps.lookup(f.file, f.line, f.column) ?? null;
430
+
431
+ configureLogging({
432
+ application: { name: 'checkout-web', version: '4.2.0' },
433
+ environment: 'production',
434
+ transports: [/* … */],
435
+ normalizeStack: createStackNormalizer({ resolver, maxFrames: 30 }), // OFF unless set
436
+ });
437
+
438
+ getRootLogger().error('checkout failed', { orderId: 'ord_9f3' }, new Error('boom'));
439
+ ```
440
+
441
+ The delivered **error** event gains `attributes['safesignal.stack']` — an ordered
442
+ array of `{ function?, file?, line?, column?, original? }` (the raw `error.stack`
443
+ string is preserved unchanged). Other events are untouched.
444
+
445
+ - **Trimmed**: `node_modules`, engine-internal, and boilerplate frames are removed
446
+ by default (`includeNodeModules` / `includeInternal` opt back in); bounded to
447
+ `maxFrames` (default 30, max 100).
448
+ - **Source-mapped**: with a **synchronous** `resolver`, resolvable frames carry
449
+ `original`; an unmappable frame is left as-is. SafeSignal does **no** async work
450
+ or `.map` fetching — you load your maps; SafeSignal calls a fast sync lookup.
451
+ - **Safe**: frames ride in `attributes`, so a secret in a frame URL's query is
452
+ scrubbed by the pipeline (whole-value guarantee). Off by default, fail-safe
453
+ (a throwing parser/resolver never breaks the page — the error is always
454
+ delivered), runtime-level (no per-`Logger` cost), and **no new dependency**.
455
+
456
+ ## Error breadcrumbs — recent-event context on errors
457
+
458
+ When an error is logged, the hardest debugging question is "what happened *just
459
+ before* this?" Enable **opt-in error breadcrumbs** and every error log
460
+ automatically carries a bounded trail of the most recent events plus the error's
461
+ cause chain — built only from SafeSignal's own already sanitized + redacted
462
+ events. **Off by default.**
463
+
464
+ ```ts
465
+ import { configureLogging, getRootLogger } from '@tallyrow/safesignal';
466
+
467
+ configureLogging({
468
+ application: { name: 'checkout-web', version: '4.2.0' },
469
+ environment: 'production',
470
+ transports: [/* … */],
471
+ breadcrumbs: true, // or { maxEvents: 30 } — default 20, max 100
472
+ });
473
+
474
+ const log = getRootLogger();
475
+ log.info('checkout opened', { cartItems: 3 });
476
+ log.warn('coupon expired');
477
+ log.error('checkout failed', { orderId: 'ord_9f3' },
478
+ new Error('checkout failed', { cause: new Error('payment processor 5xx') }));
479
+ ```
480
+
481
+ The delivered **error** event gains two documented, machine-parseable attribute
482
+ fields (other events are untouched):
483
+
484
+ - `attributes['safesignal.breadcrumbs']` — the recent events, oldest→newest, each
485
+ `{ ts, level, message, app?, module?, attributes? }` (host vs. federated-module
486
+ origin stays distinguishable).
487
+ - `attributes['safesignal.errorCauses']` — the error's nested cause chain,
488
+ outermost→root, each `{ name, message }`.
489
+
490
+ - **Bounded & cheap**: a single runtime-level ring buffer — constant memory (≤
491
+ `maxEvents`), constant-cost recording, **no** per-`Logger` cost. Duplicate
492
+ package copies are **isolated** (each runtime owns its buffer).
493
+ - **Safe**: breadcrumbs carry only the post-redaction event; the cause chain runs
494
+ through the same redaction. It never mutates other (or already-delivered)
495
+ events, and never throws into the page — an error is always delivered, with or
496
+ without the trail.
497
+
498
+ ## Pretty dev logs — `./dev-console` subpath
499
+
500
+ The built-in `ConsoleTransport` hands devtools the message plus the structured
501
+ event object — correct and safe, but a wall of JSON to scan locally. The
502
+ **opt-in** `./dev-console` subpath ships `DevConsoleTransport`: a pretty,
503
+ **development-only** sibling that renders the *same* already sanitized + redacted
504
+ event as a collapsed, level-styled group (icon/color, message, `app · module ·
505
+ env`, attributes, error, and a trace link). Select it **only** in development so
506
+ your bundler tree-shakes it out of production entirely:
507
+
508
+ ```ts
509
+ import { configureLogging, getRootLogger, ConsoleTransport } from '@tallyrow/safesignal';
510
+ import { DevConsoleTransport } from '@tallyrow/safesignal/dev-console';
511
+
512
+ configureLogging({
513
+ application: { name: 'checkout-web', version: '4.2.0' },
514
+ environment: import.meta.env.DEV ? 'development' : 'production',
515
+ transports: [
516
+ import.meta.env.DEV
517
+ ? DevConsoleTransport({ traceUrl: ({ traceId }) => `https://trace.example/${traceId}` })
518
+ : ConsoleTransport(),
519
+ ],
520
+ });
521
+
522
+ getRootLogger().info('checkout opened', { cartItems: 3 });
523
+ ```
524
+
525
+ - **Genuine zero production cost**: the dev branch is dead-code-eliminated from
526
+ your production build, so the renderer ships **0 bytes** there. SafeSignal's
527
+ default `.` entry (and `ConsoleTransport`) is byte-unchanged.
528
+ - **Runtime-gated + defensive**: it renders pretty only when the event's
529
+ `environment === 'development'`; in any other environment — or where rich
530
+ console features are absent — it behaves exactly like `ConsoleTransport`
531
+ (`console[level](message, event)`), even if mistakenly used in production.
532
+ - **Structured-only & safe**: it renders **only** the post-pipeline event (no
533
+ re-serialization of app objects), attaches **no** globals, reads no ambient
534
+ state, and never throws into the page. The optional `traceUrl` formatter is
535
+ carry-only — built from the event's existing trace ids, no ids minted.
536
+
227
537
  ## Level configuration
228
538
 
229
539
  In `production`, `debug` and `info` are dropped by default. Raise the
@@ -282,7 +592,7 @@ log.info('order placed', { orderId: order.id, total: order.total });
282
592
  enumeration of DO / DON'T patterns, the sanitizer's bounded-input
283
593
  rules, the redactor's default denylist and shape rules,
284
594
  `createRedactor()` composition, `scrubUrl()` usage, the
285
- diagnostics contract, and — per constitution Principle VI — every
595
+ diagnostics contract, and — per constitution Principle VII — every
286
596
  documented behavior that drops, transforms, or otherwise bounds an
287
597
  event before delivery (level-filter drops, fail-closed redactor
288
598
  drops, sanitizer truncation markers, URL-scrubber replacements,
@@ -382,11 +692,12 @@ package copies", and "Vendor neutrality".
382
692
 
383
693
  ## Project resources
384
694
 
385
- [![pipeline status](https://gitlab.com/tallyrow/safesignal/badges/main/pipeline.svg)](https://gitlab.com/tallyrow/safesignal/-/commits/main)
695
+ [![CI](https://github.com/TallyRow/safesignal/actions/workflows/ci.yml/badge.svg)](https://github.com/TallyRow/safesignal/actions/workflows/ci.yml)
696
+ [![npm version](https://img.shields.io/npm/v/@tallyrow/safesignal.svg)](https://www.npmjs.com/package/@tallyrow/safesignal)
386
697
 
387
698
  Community and legal:
388
699
 
389
- - [`CONTRIBUTING.md`](CONTRIBUTING.md) — how to file issues, send MRs, sign commits (DCO)
700
+ - [`CONTRIBUTING.md`](CONTRIBUTING.md) — how to file issues, open PRs, sign commits (DCO)
390
701
  - [`SECURITY.md`](SECURITY.md) — vulnerability disclosure policy
391
702
  - [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md) — Contributor Covenant 2.1
392
703
  - [`GOVERNANCE.md`](GOVERNANCE.md) — how project decisions get made
@@ -416,14 +727,12 @@ The following are forward-looking items (not shipping today):
416
727
  subpath — the subpath ships **JSON** today behind an internal
417
728
  encoding seam; a protobuf encoder is an additive follow-up (no
418
729
  public-API change).
419
- - **RUM features** — Web Vitals, automatic error capture, view
420
- tracking, network instrumentation (planned as opt-in subpaths
421
- under `./rum-*`).
422
-
423
- A separate sibling project, **`safesignal-server`**, is planned as
424
- a self-hostable monitoring backend that consumes SafeSignal's
425
- OTLP-formatted events. SafeSignal stays a small vendor-neutral
426
- SDK; the server lives in its own repo when it ships.
730
+
731
+ SafeSignal is singly focused: it captures your errors — explicit
732
+ `log.error`, framework boundaries, and opt-in global capture — and
733
+ ships them securely to any backend. It is **not** a RUM/monitoring
734
+ product (no Web Vitals, view tracking, network instrumentation, or
735
+ server backend) and is not planned to become one.
427
736
 
428
737
  ## Migration history
429
738
 
@@ -0,0 +1,77 @@
1
+ 'use strict';
2
+
3
+ // src/capture/index.ts
4
+ var SOURCE = "global-error-capture";
5
+ function noop() {
6
+ }
7
+ function safeNotify(hook, cause) {
8
+ if (!hook) return;
9
+ try {
10
+ hook(cause instanceof Error ? cause : new Error(String(cause)));
11
+ } catch {
12
+ }
13
+ }
14
+ function installGlobalErrorCapture(logger, options = {}) {
15
+ const target = options.target ?? globalThis;
16
+ const canListen = typeof target.addEventListener === "function" && typeof target.removeEventListener === "function";
17
+ if (!canListen) return noop;
18
+ let disposed = false;
19
+ let inFlight = false;
20
+ const emit = (message, errorType, errorValue, extra) => {
21
+ if (inFlight) return;
22
+ inFlight = true;
23
+ try {
24
+ const attributes = {
25
+ "safesignal.source": SOURCE,
26
+ "safesignal.errorType": errorType,
27
+ ...extra ?? {}
28
+ };
29
+ logger.error(message, attributes, errorValue);
30
+ } catch (err) {
31
+ safeNotify(options.onInternalError, err);
32
+ } finally {
33
+ inFlight = false;
34
+ }
35
+ };
36
+ const handleError = (event) => {
37
+ try {
38
+ const e = event;
39
+ const hasError = e.error !== void 0 && e.error !== null;
40
+ const extra = {};
41
+ if (!hasError) {
42
+ if (typeof e.filename === "string") extra.filename = e.filename;
43
+ if (typeof e.lineno === "number") extra.lineno = e.lineno;
44
+ if (typeof e.colno === "number") extra.colno = e.colno;
45
+ }
46
+ const errorValue = hasError ? e.error : typeof e.message === "string" ? e.message : "Uncaught exception";
47
+ emit(
48
+ "Uncaught exception",
49
+ "uncaught-exception",
50
+ errorValue,
51
+ Object.keys(extra).length > 0 ? extra : void 0
52
+ );
53
+ } catch (err) {
54
+ safeNotify(options.onInternalError, err);
55
+ }
56
+ };
57
+ const handleRejection = (event) => {
58
+ try {
59
+ const e = event;
60
+ emit("Unhandled promise rejection", "unhandled-rejection", e.reason);
61
+ } catch (err) {
62
+ safeNotify(options.onInternalError, err);
63
+ }
64
+ };
65
+ target.addEventListener("error", handleError);
66
+ target.addEventListener("unhandledrejection", handleRejection);
67
+ return () => {
68
+ if (disposed) return;
69
+ disposed = true;
70
+ target.removeEventListener("error", handleError);
71
+ target.removeEventListener("unhandledrejection", handleRejection);
72
+ };
73
+ }
74
+
75
+ exports.installGlobalErrorCapture = installGlobalErrorCapture;
76
+ //# sourceMappingURL=capture.cjs.map
77
+ //# sourceMappingURL=capture.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/capture/index.ts"],"names":[],"mappings":"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","file":"capture.cjs","sourcesContent":["/**\n * Opt-in global error capture — the `./capture` subpath.\n *\n * A **host-level**, opt-in install that routes **uncaught exceptions** and\n * **unhandled promise rejections** through the host `Logger`'s existing secure\n * pipeline (sanitize → URL-scrub → redact → guard → transport). It is the single\n * explicit host-installed runtime-level global handler Principle VIII v1.5.0\n * sanctions — never a side effect of `createLogger()`, never installed by a\n * federated module.\n *\n * Properties (see `specs/013-global-error-capture/contracts/capture-api.md`):\n * - Fail-closed: emits via `logger.error`, so stacks/messages are redacted +\n * sanitized (drop-on-failure) before any transport sees them.\n * - Fail-safe: never throws/rejects into the page; internal failures are\n * swallowed (routed to `options.onInternalError`).\n * - Additive: attaches via `addEventListener` — never assigns `window.onerror`\n * and never calls `preventDefault()`, so existing handlers keep firing.\n * - Loop-safe: a re-entrancy guard stops an error raised during emit from\n * re-capturing.\n * - Errors only: attaches ONLY `error` + `unhandledrejection` (not RUM).\n *\n * The only `src/` import is **type-only** from `../api/types.js`, so this bundle\n * shares no runtime state with the core (it operates through the passed\n * `Logger`, which closes over the host's configured runtime).\n */\n\nimport type { Attributes, Logger } from '../api/types.js';\n\n/** Options for {@link installGlobalErrorCapture}. */\nexport interface GlobalErrorCaptureOptions {\n /** Where to attach listeners. Default: `globalThis`. */\n target?: EventTarget;\n /**\n * Diagnostics hook for the capturer's OWN failures (event-build/dispatch\n * throw). Invoked fail-safe — its own throw is swallowed. Distinct from the\n * runtime's `onInternalError`.\n */\n onInternalError?: (err: Error) => void;\n}\n\n/** Removes the installed listeners and stops capture. Idempotent. */\nexport type GlobalErrorCaptureDisposer = () => void;\n\nconst SOURCE = 'global-error-capture';\n\nfunction noop(): void {\n /* no-op disposer */\n}\n\nfunction safeNotify(\n hook: ((err: Error) => void) | undefined,\n cause: unknown,\n): void {\n if (!hook) return;\n try {\n hook(cause instanceof Error ? cause : new Error(String(cause)));\n } catch {\n // A diagnostics hook that itself throws must not break capture.\n }\n}\n\n/**\n * Install host-level capture of uncaught exceptions + unhandled promise\n * rejections, routed through `logger`'s configured pipeline. Returns a disposer.\n * Opt-in, host-owned; never a side effect of `createLogger()`. Never throws.\n */\nexport function installGlobalErrorCapture(\n logger: Logger,\n options: GlobalErrorCaptureOptions = {},\n): GlobalErrorCaptureDisposer {\n const target: EventTarget =\n options.target ?? (globalThis as unknown as EventTarget);\n\n // Safe no-op where the target cannot register listeners (SSR / worker).\n const canListen =\n typeof (target as { addEventListener?: unknown }).addEventListener ===\n 'function' &&\n typeof (target as { removeEventListener?: unknown }).removeEventListener ===\n 'function';\n if (!canListen) return noop;\n\n let disposed = false;\n let inFlight = false;\n\n const emit = (\n message: string,\n errorType: 'uncaught-exception' | 'unhandled-rejection',\n errorValue: unknown,\n extra?: Attributes,\n ): void => {\n if (inFlight) return; // loop-safety: drop a capture raised during emit.\n inFlight = true;\n try {\n const attributes: Attributes = {\n 'safesignal.source': SOURCE,\n 'safesignal.errorType': errorType,\n ...(extra ?? {}),\n };\n logger.error(message, attributes, errorValue);\n } catch (err) {\n safeNotify(options.onInternalError, err);\n } finally {\n inFlight = false;\n }\n };\n\n const handleError = (event: Event): void => {\n try {\n const e = event as ErrorEvent;\n const hasError = e.error !== undefined && e.error !== null;\n const extra: Attributes = {};\n if (!hasError) {\n // Cross-origin \"Script error.\" cases carry no error object.\n if (typeof e.filename === 'string') extra.filename = e.filename;\n if (typeof e.lineno === 'number') extra.lineno = e.lineno;\n if (typeof e.colno === 'number') extra.colno = e.colno;\n }\n const errorValue = hasError\n ? e.error\n : typeof e.message === 'string'\n ? e.message\n : 'Uncaught exception';\n emit(\n 'Uncaught exception',\n 'uncaught-exception',\n errorValue,\n Object.keys(extra).length > 0 ? extra : undefined,\n );\n } catch (err) {\n safeNotify(options.onInternalError, err);\n }\n };\n\n const handleRejection = (event: Event): void => {\n try {\n const e = event as PromiseRejectionEvent;\n emit('Unhandled promise rejection', 'unhandled-rejection', e.reason);\n } catch (err) {\n safeNotify(options.onInternalError, err);\n }\n };\n\n target.addEventListener('error', handleError);\n target.addEventListener('unhandledrejection', handleRejection);\n\n return (): void => {\n if (disposed) return;\n disposed = true;\n target.removeEventListener('error', handleError);\n target.removeEventListener('unhandledrejection', handleRejection);\n };\n}\n"]}
@@ -0,0 +1,49 @@
1
+ import { f as Logger } from './types-CZtSjgq5.cjs';
2
+
3
+ /**
4
+ * Opt-in global error capture — the `./capture` subpath.
5
+ *
6
+ * A **host-level**, opt-in install that routes **uncaught exceptions** and
7
+ * **unhandled promise rejections** through the host `Logger`'s existing secure
8
+ * pipeline (sanitize → URL-scrub → redact → guard → transport). It is the single
9
+ * explicit host-installed runtime-level global handler Principle VIII v1.5.0
10
+ * sanctions — never a side effect of `createLogger()`, never installed by a
11
+ * federated module.
12
+ *
13
+ * Properties (see `specs/013-global-error-capture/contracts/capture-api.md`):
14
+ * - Fail-closed: emits via `logger.error`, so stacks/messages are redacted +
15
+ * sanitized (drop-on-failure) before any transport sees them.
16
+ * - Fail-safe: never throws/rejects into the page; internal failures are
17
+ * swallowed (routed to `options.onInternalError`).
18
+ * - Additive: attaches via `addEventListener` — never assigns `window.onerror`
19
+ * and never calls `preventDefault()`, so existing handlers keep firing.
20
+ * - Loop-safe: a re-entrancy guard stops an error raised during emit from
21
+ * re-capturing.
22
+ * - Errors only: attaches ONLY `error` + `unhandledrejection` (not RUM).
23
+ *
24
+ * The only `src/` import is **type-only** from `../api/types.js`, so this bundle
25
+ * shares no runtime state with the core (it operates through the passed
26
+ * `Logger`, which closes over the host's configured runtime).
27
+ */
28
+
29
+ /** Options for {@link installGlobalErrorCapture}. */
30
+ interface GlobalErrorCaptureOptions {
31
+ /** Where to attach listeners. Default: `globalThis`. */
32
+ target?: EventTarget;
33
+ /**
34
+ * Diagnostics hook for the capturer's OWN failures (event-build/dispatch
35
+ * throw). Invoked fail-safe — its own throw is swallowed. Distinct from the
36
+ * runtime's `onInternalError`.
37
+ */
38
+ onInternalError?: (err: Error) => void;
39
+ }
40
+ /** Removes the installed listeners and stops capture. Idempotent. */
41
+ type GlobalErrorCaptureDisposer = () => void;
42
+ /**
43
+ * Install host-level capture of uncaught exceptions + unhandled promise
44
+ * rejections, routed through `logger`'s configured pipeline. Returns a disposer.
45
+ * Opt-in, host-owned; never a side effect of `createLogger()`. Never throws.
46
+ */
47
+ declare function installGlobalErrorCapture(logger: Logger, options?: GlobalErrorCaptureOptions): GlobalErrorCaptureDisposer;
48
+
49
+ export { type GlobalErrorCaptureDisposer, type GlobalErrorCaptureOptions, installGlobalErrorCapture };
@@ -0,0 +1,49 @@
1
+ import { f as Logger } from './types-CZtSjgq5.js';
2
+
3
+ /**
4
+ * Opt-in global error capture — the `./capture` subpath.
5
+ *
6
+ * A **host-level**, opt-in install that routes **uncaught exceptions** and
7
+ * **unhandled promise rejections** through the host `Logger`'s existing secure
8
+ * pipeline (sanitize → URL-scrub → redact → guard → transport). It is the single
9
+ * explicit host-installed runtime-level global handler Principle VIII v1.5.0
10
+ * sanctions — never a side effect of `createLogger()`, never installed by a
11
+ * federated module.
12
+ *
13
+ * Properties (see `specs/013-global-error-capture/contracts/capture-api.md`):
14
+ * - Fail-closed: emits via `logger.error`, so stacks/messages are redacted +
15
+ * sanitized (drop-on-failure) before any transport sees them.
16
+ * - Fail-safe: never throws/rejects into the page; internal failures are
17
+ * swallowed (routed to `options.onInternalError`).
18
+ * - Additive: attaches via `addEventListener` — never assigns `window.onerror`
19
+ * and never calls `preventDefault()`, so existing handlers keep firing.
20
+ * - Loop-safe: a re-entrancy guard stops an error raised during emit from
21
+ * re-capturing.
22
+ * - Errors only: attaches ONLY `error` + `unhandledrejection` (not RUM).
23
+ *
24
+ * The only `src/` import is **type-only** from `../api/types.js`, so this bundle
25
+ * shares no runtime state with the core (it operates through the passed
26
+ * `Logger`, which closes over the host's configured runtime).
27
+ */
28
+
29
+ /** Options for {@link installGlobalErrorCapture}. */
30
+ interface GlobalErrorCaptureOptions {
31
+ /** Where to attach listeners. Default: `globalThis`. */
32
+ target?: EventTarget;
33
+ /**
34
+ * Diagnostics hook for the capturer's OWN failures (event-build/dispatch
35
+ * throw). Invoked fail-safe — its own throw is swallowed. Distinct from the
36
+ * runtime's `onInternalError`.
37
+ */
38
+ onInternalError?: (err: Error) => void;
39
+ }
40
+ /** Removes the installed listeners and stops capture. Idempotent. */
41
+ type GlobalErrorCaptureDisposer = () => void;
42
+ /**
43
+ * Install host-level capture of uncaught exceptions + unhandled promise
44
+ * rejections, routed through `logger`'s configured pipeline. Returns a disposer.
45
+ * Opt-in, host-owned; never a side effect of `createLogger()`. Never throws.
46
+ */
47
+ declare function installGlobalErrorCapture(logger: Logger, options?: GlobalErrorCaptureOptions): GlobalErrorCaptureDisposer;
48
+
49
+ export { type GlobalErrorCaptureDisposer, type GlobalErrorCaptureOptions, installGlobalErrorCapture };