@tallyrow/safesignal 1.0.1 → 1.2.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.
package/README.md CHANGED
@@ -43,8 +43,9 @@ log.info('checkout opened', { cartItems: 3 });
43
43
 
44
44
  - Ship an HTTP/beacon transport in the default entry — use the
45
45
  `./transport-beacon` subpath for the first-party body-only HTTPS
46
- transport, or implement `Transport` yourself for a custom
47
- delivery primitive.
46
+ transport, the `./transport-otlp` subpath to ship OTLP/HTTP+JSON
47
+ to any OTLP backend, or implement `Transport` yourself for a
48
+ custom delivery primitive.
48
49
  - Read `process.env.NODE_ENV`, `import.meta.env`, `location`, or
49
50
  `document.cookie` — pass `environment` explicitly.
50
51
  - Install global listeners or singletons (RUM-style automatic
@@ -104,6 +105,95 @@ The transport:
104
105
  `transport_shutdown_failed`. Wire the hook to **both** layers
105
106
  above for full coverage.
106
107
 
108
+ ## Ship logs to OTLP — `./transport-otlp` subpath
109
+
110
+ To deliver SafeSignal's events to **any OTLP-compatible backend**
111
+ (Datadog, Honeycomb, Grafana, an OpenTelemetry Collector,
112
+ ClickHouse, …), import `createOtlpTransport` from the
113
+ `./transport-otlp` subpath. It emits standard **OTLP/HTTP+JSON**
114
+ logs — vendor-neutral, with zero new runtime dependencies and no
115
+ `@opentelemetry/*` in the bundle.
116
+
117
+ ```ts
118
+ import { configureLogging, getRootLogger } from '@tallyrow/safesignal';
119
+ import { createOtlpTransport } from '@tallyrow/safesignal/transport-otlp';
120
+
121
+ configureLogging({
122
+ application: { name: 'checkout-web', version: '4.2.0' },
123
+ environment: 'production',
124
+ transports: [
125
+ createOtlpTransport({
126
+ endpoint: 'https://otlp.example.com/v1/logs', // full OTLP logs URL, HTTPS
127
+ headers: { 'x-api-key': process.env.OTLP_API_KEY! }, // sent only on the wire
128
+ batching: { maxBatchSize: 20, maxBatchAgeMs: 5000 },
129
+ }),
130
+ ],
131
+ });
132
+
133
+ getRootLogger().info('checkout.started', { cartId: 'c_123', itemCount: 3 });
134
+ ```
135
+
136
+ What it guarantees:
137
+
138
+ - **OTLP/HTTP+JSON** `LogRecord`s with your application/module/
139
+ environment identity mapped to the OTLP `Resource`
140
+ (`service.name`, `service.version`, `deployment.environment`;
141
+ `module.*` per-record). Levels map to OTLP severity
142
+ (`debug`→5, `info`→9, `warn`→13, `error`→17).
143
+ - **Fail-safe**: `fetch` with `keepalive` delivery, **no retry** —
144
+ a down/slow/erroring backend never throws into your code and
145
+ never breaks the page. Failed batches are dropped with one
146
+ rate-limited `onInternalError` notice per failure class
147
+ (`oversized_event`, `buffer_overflow`, `delivery_unavailable`,
148
+ `send_failed`, `partial_rejection`, `serialize_failed`,
149
+ `shutdown_failed`).
150
+ - **Secure**: events are already redacted before the transport
151
+ sees them; auth headers are sent only on the request and never
152
+ appear in payloads, diagnostics, or the bundle. HTTPS-only
153
+ (loopback `http://` requires explicit `allowInsecureLoopback`).
154
+ - **Lightweight & federated**: the transport is configured once at
155
+ the runtime level; the host owns it, federated modules do not
156
+ replace it, and duplicate package copies are **isolated**. Local
157
+ collectors: `endpoint: 'http://localhost:4318/v1/logs'` with
158
+ `allowInsecureLoopback: true`.
159
+
160
+ ## Correlate logs with traces — W3C trace context
161
+
162
+ Supply a **W3C Trace Context** and SafeSignal carries `trace_id` / `span_id`
163
+ on every event; when shipped via `./transport-otlp`, they populate the OTLP
164
+ `LogRecord`'s standard `traceId` / `spanId` / `flags` fields, so any backend
165
+ joins each log to its trace. SafeSignal is **carry-only** — it never mints ids.
166
+
167
+ ```ts
168
+ import { configureLogging, getRootLogger, parseTraceparent } from '@tallyrow/safesignal';
169
+
170
+ // From a header string the app already holds (e.g. SSR-injected):
171
+ const trace = parseTraceparent('00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01');
172
+
173
+ configureLogging({
174
+ application: { name: 'checkout-web' },
175
+ environment: 'production',
176
+ context: trace ? { trace } : {},
177
+ // …or dynamically, per emit, from your tracer's active span:
178
+ correlation: () => {
179
+ const s = myTracer.activeSpan();
180
+ return s ? { trace: { traceId: s.traceId, spanId: s.spanId, traceFlags: 1 } } : {};
181
+ },
182
+ });
183
+
184
+ getRootLogger().info('payment.authorized', { amount: 4200 });
185
+ ```
186
+
187
+ - **Carry-only / fail-safe**: no supplied context ⇒ no trace fields; a malformed
188
+ `traceparent`, wrong-length/all-zero id, or oversized `tracestate` is dropped
189
+ fail-closed — the event still ships, no throw.
190
+ - **Secure**: trace ids are identifiers, not secrets; `tracestate` is bounded;
191
+ existing redaction is unaffected.
192
+ - **Vendor-neutral**: pure W3C — works with any tracer; the `./transport-otlp`
193
+ bundle stays `@opentelemetry`-free. Trace context layers through the same
194
+ context-merge precedence (root → logger chain → `correlation()`); host and
195
+ federated modules each contribute without per-`Logger` cost.
196
+
107
197
  ## Level configuration
108
198
 
109
199
  In `production`, `debug` and `info` are dropped by default. Raise the
@@ -293,9 +383,11 @@ The following are forward-looking items (not shipping today):
293
383
 
294
384
  - **Trace-context propagation** — W3C Trace Context (`traceparent`,
295
385
  `tracestate`) for correlating frontend logs with backend traces.
296
- - **`./transport-otlp` subpath** OTel-formatted events; ships to
297
- any OTLP-compatible backend (Datadog, Honeycomb, Grafana
298
- Tempo + Loki, self-hosted ClickHouse, etc.).
386
+ - **OTLP/HTTP+protobuf encoding** for the
387
+ [`./transport-otlp`](#ship-logs-to-otlp--transport-otlp-subpath)
388
+ subpath the subpath ships **JSON** today behind an internal
389
+ encoding seam; a protobuf encoder is an additive follow-up (no
390
+ public-API change).
299
391
  - **RUM features** — Web Vitals, automatic error capture, view
300
392
  tracking, network instrumentation (planned as opt-in subpaths
301
393
  under `./rum-*`).
package/dist/index.cjs CHANGED
@@ -20,12 +20,16 @@ function mergeContexts(...sources) {
20
20
  src.attributes
21
21
  );
22
22
  }
23
+ if (src.trace !== void 0) {
24
+ merged.trace = src.trace;
25
+ }
23
26
  }
24
27
  const out = {};
25
28
  if (merged.application !== void 0) out.application = merged.application;
26
29
  if (merged.module !== void 0) out.module = merged.module;
27
30
  if (merged.environment !== void 0) out.environment = merged.environment;
28
31
  if (merged.attributes !== void 0) out.attributes = merged.attributes;
32
+ if (merged.trace !== void 0) out.trace = merged.trace;
29
33
  return out;
30
34
  }
31
35
  function deepMergeAttributes(earlier, later) {
@@ -94,7 +98,7 @@ function escapeControlChars(value) {
94
98
  if (!HAS_CONTROL_CHAR.test(value)) return value;
95
99
  return value.replace(CONTROL_CHAR_GLOBAL, (ch) => {
96
100
  const code = ch.charCodeAt(0);
97
- return "\\u" + code.toString(16).padStart(4, "0");
101
+ return `\\u${code.toString(16).padStart(4, "0")}`;
98
102
  });
99
103
  }
100
104
  var controlCharGuard = (event, _config) => {
@@ -242,7 +246,10 @@ function createRedactor(rules) {
242
246
  let error = event.error;
243
247
  if (event.error !== void 0) {
244
248
  const escName = applyShapeRules(event.error.name, compiled.shapeRules);
245
- const escMessage = applyShapeRules(event.error.message, compiled.shapeRules);
249
+ const escMessage = applyShapeRules(
250
+ event.error.message,
251
+ compiled.shapeRules
252
+ );
246
253
  const stack = event.error.stack;
247
254
  const escStack = stack === void 0 ? void 0 : applyShapeRules(stack, compiled.shapeRules);
248
255
  const errorChanged = escName !== event.error.name || escMessage !== event.error.message || escStack !== stack;
@@ -367,12 +374,13 @@ function isLogEventShape(value) {
367
374
  return false;
368
375
  }
369
376
  const obj = value;
370
- if (typeof obj["timestamp"] !== "string") return false;
371
- if (typeof obj["level"] !== "string") return false;
372
- if (!VALID_LEVELS.has(obj["level"])) return false;
373
- if (typeof obj["message"] !== "string") return false;
374
- if (obj["attributes"] === null || typeof obj["attributes"] !== "object") return false;
375
- if (obj["context"] === null || typeof obj["context"] !== "object") return false;
377
+ if (typeof obj.timestamp !== "string") return false;
378
+ if (typeof obj.level !== "string") return false;
379
+ if (!VALID_LEVELS.has(obj.level)) return false;
380
+ if (typeof obj.message !== "string") return false;
381
+ if (obj.attributes === null || typeof obj.attributes !== "object")
382
+ return false;
383
+ if (obj.context === null || typeof obj.context !== "object") return false;
376
384
  return true;
377
385
  }
378
386
 
@@ -452,7 +460,8 @@ function sanitizeValueImpl(value, depth, ctx) {
452
460
  if (depth > ctx.maxDepth) return "[MaxDepth]";
453
461
  if (value === null) return null;
454
462
  const t = typeof value;
455
- if (t === "string") return truncateString(value, ctx.maxStringLength);
463
+ if (t === "string")
464
+ return truncateString(value, ctx.maxStringLength);
456
465
  if (t === "number") {
457
466
  const n = value;
458
467
  return Number.isFinite(n) ? n : null;
@@ -562,7 +571,7 @@ function sanitizeErrorAsAttribute(err, depth, ctx) {
562
571
  };
563
572
  const stack = safeOptional(() => err.stack);
564
573
  if (stack !== void 0) {
565
- reduced["stack"] = stack;
574
+ reduced.stack = stack;
566
575
  }
567
576
  return sanitizeObject(reduced, depth, ctx);
568
577
  }
@@ -683,7 +692,7 @@ var urlScrub = (event, _config) => {
683
692
  const message = maybeScrubString(event.message);
684
693
  const attributes = walkAttributes2(event.attributes);
685
694
  const context = walkContext3(event.context);
686
- let error = void 0;
695
+ let error;
687
696
  if (event.error !== void 0) {
688
697
  const scrubbedMessage = maybeScrubString(event.error.message);
689
698
  const stack = event.error.stack;
@@ -747,7 +756,7 @@ function scrubHashFragment(parsed, extras) {
747
756
  }
748
757
  }
749
758
  if (!changed) return false;
750
- parsed.hash = "#" + out.join("&");
759
+ parsed.hash = `#${out.join("&")}`;
751
760
  return true;
752
761
  }
753
762
  function isDenied(name, extras) {
@@ -1110,6 +1119,36 @@ function installRuntime(runtime) {
1110
1119
  return previous;
1111
1120
  }
1112
1121
 
1122
+ // src/trace/validate.ts
1123
+ var MAX_TRACESTATE_LEN = 512;
1124
+ var TRACE_ID_RE = /^[0-9a-f]{32}$/;
1125
+ var SPAN_ID_RE = /^[0-9a-f]{16}$/;
1126
+ var ALL_ZERO_TRACE_ID = "0".repeat(32);
1127
+ var ALL_ZERO_SPAN_ID = "0".repeat(16);
1128
+ function isValidTraceId(value) {
1129
+ return typeof value === "string" && TRACE_ID_RE.test(value) && value !== ALL_ZERO_TRACE_ID;
1130
+ }
1131
+ function isValidSpanId(value) {
1132
+ return typeof value === "string" && SPAN_ID_RE.test(value) && value !== ALL_ZERO_SPAN_ID;
1133
+ }
1134
+ function normalizeTraceContext(candidate) {
1135
+ if (typeof candidate !== "object" || candidate === null) {
1136
+ return void 0;
1137
+ }
1138
+ const c = candidate;
1139
+ if (!isValidTraceId(c.traceId) || !isValidSpanId(c.spanId)) {
1140
+ return void 0;
1141
+ }
1142
+ const trace = { traceId: c.traceId, spanId: c.spanId };
1143
+ if (typeof c.traceFlags === "number" && Number.isInteger(c.traceFlags) && c.traceFlags >= 0 && c.traceFlags <= 255) {
1144
+ trace.traceFlags = c.traceFlags;
1145
+ }
1146
+ if (typeof c.traceState === "string" && c.traceState.length > 0 && c.traceState.length <= MAX_TRACESTATE_LEN) {
1147
+ trace.traceState = c.traceState;
1148
+ }
1149
+ return trace;
1150
+ }
1151
+
1113
1152
  // src/api/logger.ts
1114
1153
  var rootLogger;
1115
1154
  function installState(config) {
@@ -1182,6 +1221,14 @@ function makeLogger(options, chainedContexts) {
1182
1221
  ...chainedContexts,
1183
1222
  correlation
1184
1223
  );
1224
+ if (context.trace !== void 0) {
1225
+ const normalized = normalizeTraceContext(context.trace);
1226
+ if (normalized === void 0) {
1227
+ delete context.trace;
1228
+ } else {
1229
+ context.trace = normalized;
1230
+ }
1231
+ }
1185
1232
  const event = buildLogEvent({
1186
1233
  level,
1187
1234
  message,
@@ -1213,6 +1260,27 @@ function makeLogger(options, chainedContexts) {
1213
1260
  };
1214
1261
  }
1215
1262
 
1263
+ // src/trace/traceparent.ts
1264
+ var VERSION_RE = /^[0-9a-f]{2}$/;
1265
+ function parseTraceparent(traceparent, tracestate) {
1266
+ if (typeof traceparent !== "string") return void 0;
1267
+ const parts = traceparent.trim().split("-");
1268
+ if (parts.length < 4) return void 0;
1269
+ const [version, traceId, spanId, flagsHex] = parts;
1270
+ if (version === void 0 || !VERSION_RE.test(version)) return void 0;
1271
+ if (version === "ff") return void 0;
1272
+ if (flagsHex === void 0 || !/^[0-9a-f]{2}$/.test(flagsHex)) {
1273
+ return void 0;
1274
+ }
1275
+ const traceFlags = Number.parseInt(flagsHex, 16);
1276
+ return normalizeTraceContext({
1277
+ traceId,
1278
+ spanId,
1279
+ traceFlags,
1280
+ ...typeof tracestate === "string" ? { traceState: tracestate } : {}
1281
+ });
1282
+ }
1283
+
1216
1284
  // src/transport/console-transport.ts
1217
1285
  function resolveConsoleMethod(level) {
1218
1286
  const slot = console[level];
@@ -1235,6 +1303,7 @@ exports.configureLogging = configureLogging;
1235
1303
  exports.createLogger = createLogger;
1236
1304
  exports.createRedactor = createRedactor;
1237
1305
  exports.getRootLogger = getRootLogger;
1306
+ exports.parseTraceparent = parseTraceparent;
1238
1307
  exports.scrubUrl = scrubUrl;
1239
1308
  //# sourceMappingURL=index.cjs.map
1240
1309
  //# sourceMappingURL=index.cjs.map