@strada.sh/light 0.1.0 → 0.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/dist/index.js CHANGED
@@ -1,28 +1,45 @@
1
1
  /**
2
- * `@strada.sh/light`: a zero-dependency, drop-in subset of `@strada.sh/sdk`.
2
+ * `@strada.sh/light`: a zero-dependency, explicit-only subset of `@strada.sh/sdk`.
3
3
  *
4
4
  * Switch by changing the import path. Same function names, same option
5
- * names, same `otel_logs` rows (`event.name`, `custom.*`, `exception.*`,
6
- * `strada.user.identify`), so every Strada query works unchanged. What the
7
- * light build does not support is not exported or not accepted, so a switch
8
- * that relies on it fails at compile time instead of silently dropping data.
9
- * `index.test.ts` type-checks this file against the full SDK to keep both in
10
- * sync.
5
+ * names, same rows in `otel_logs` / `otel_traces` (`event.name`, `custom.*`,
6
+ * `exception.*`, `strada.user.identify`, `pageview`), so every Strada query
7
+ * works unchanged. What the light build does not support is not exported or
8
+ * not accepted, so a switch that relies on it fails at compile time instead
9
+ * of silently dropping data. `index.test.ts` type-checks every export against
10
+ * the full SDK to keep both in sync.
11
11
  *
12
- * It builds OTLP JSON log records by hand and POSTs them with `fetch` to
13
- * `/v1/logs`. `initStrada()` installs nothing global: no OTel providers, no
14
- * process handlers, no resource detectors (no hostname, OS username, or
15
- * command args). Only what you pass is sent. The flush timer is unref'd, so
16
- * call `flush()` before a short-lived process exits.
12
+ * Explicit only: nothing is captured unless you call it. `initStrada()`
13
+ * installs nothing global: no OTel providers, no process or window error
14
+ * handlers, no fetch patching, no resource detectors (no hostname, OS
15
+ * username, or command args). Records are built as OTLP JSON by hand and
16
+ * POSTed with `fetch` to `/v1/logs` and `/v1/traces`. The flush timer is
17
+ * unref'd, so call `flush()` before a short-lived process exits.
18
+ *
19
+ * Span parenting across `await` uses AsyncLocalStorage when the runtime
20
+ * exposes `process.getBuiltinModule` (Node 20.16+, Bun, Workers with
21
+ * nodejs_compat). Elsewhere (browsers) only synchronous nesting works, the
22
+ * same limitation as the full browser SDK.
17
23
  *
18
24
  * Every function follows "telemetry never throws": failures are returned as
19
- * values and logged once with console.warn.
25
+ * values and logged once with console.warn. `startSpan()` rethrows the
26
+ * callback's own error, like the full SDK.
20
27
  */
21
28
  import { ATTR } from "./attrs.js";
29
+ /** Same values as OTel SpanStatusCode. */
30
+ export const SpanStatusCode = { UNSET: 0, OK: 1, ERROR: 2 };
22
31
  // OTel SeverityNumber values, inlined to avoid importing @opentelemetry/api-logs.
23
- const INFO_SEVERITY = 9;
24
- const ERROR_SEVERITY = 17;
32
+ const SEVERITY = {
33
+ trace: [1, "TRACE"],
34
+ debug: [5, "DEBUG"],
35
+ info: [9, "INFO"],
36
+ warn: [13, "WARN"],
37
+ error: [17, "ERROR"],
38
+ fatal: [21, "FATAL"],
39
+ };
40
+ const SPAN_KIND_INTERNAL = 1;
25
41
  const MAX_QUEUE_SIZE = 2048;
42
+ const MAX_LOG_STRING_LENGTH = 16_384;
26
43
  let state;
27
44
  let tags = {};
28
45
  const warned = new Set();
@@ -44,6 +61,46 @@ function isDevMode() {
44
61
  return false;
45
62
  }
46
63
  }
64
+ // ---------------------------------------------------------------------------
65
+ // Active span context
66
+ // ---------------------------------------------------------------------------
67
+ const asyncStorage = (() => {
68
+ try {
69
+ const hooks = globalThis.process?.getBuiltinModule?.("node:async_hooks");
70
+ return hooks ? new hooks.AsyncLocalStorage() : undefined;
71
+ }
72
+ catch {
73
+ return undefined;
74
+ }
75
+ })();
76
+ let syncActiveSpan;
77
+ function getActiveSpan() {
78
+ return asyncStorage ? asyncStorage.getStore() : syncActiveSpan;
79
+ }
80
+ function runWithActiveSpan(active, fn) {
81
+ if (asyncStorage)
82
+ return asyncStorage.run(active, fn);
83
+ const previous = syncActiveSpan;
84
+ syncActiveSpan = active;
85
+ try {
86
+ return fn();
87
+ }
88
+ finally {
89
+ syncActiveSpan = previous;
90
+ }
91
+ }
92
+ // ---------------------------------------------------------------------------
93
+ // Encoding helpers
94
+ // ---------------------------------------------------------------------------
95
+ function randomHex(bytes) {
96
+ const values = crypto.getRandomValues(new Uint8Array(bytes));
97
+ return Array.from(values, (value) => {
98
+ return value.toString(16).padStart(2, "0");
99
+ }).join("");
100
+ }
101
+ function nowUnixNano() {
102
+ return `${BigInt(Date.now()) * 1000000n}`;
103
+ }
47
104
  function toAnyValue(value) {
48
105
  if (typeof value === "string")
49
106
  return { stringValue: value };
@@ -60,6 +117,60 @@ function toKeyValues(record) {
60
117
  return [{ key, value: toAnyValue(value) }];
61
118
  });
62
119
  }
120
+ function truncate(value) {
121
+ if (value.length <= MAX_LOG_STRING_LENGTH)
122
+ return value;
123
+ return `${value.slice(0, MAX_LOG_STRING_LENGTH)}… [truncated ${value.length - MAX_LOG_STRING_LENGTH} chars]`;
124
+ }
125
+ function formatLogValue(value) {
126
+ if (typeof value === "string")
127
+ return truncate(value);
128
+ if (value instanceof Error)
129
+ return truncate(value.stack || value.message);
130
+ if (value === undefined)
131
+ return "undefined";
132
+ if (typeof value === "bigint" || typeof value === "symbol" || typeof value === "function")
133
+ return String(value);
134
+ try {
135
+ const seen = new WeakSet();
136
+ const json = JSON.stringify(value, (_key, nested) => {
137
+ if (typeof nested === "bigint")
138
+ return nested.toString();
139
+ if (typeof nested === "object" && nested !== null) {
140
+ if (seen.has(nested))
141
+ return "[Circular]";
142
+ seen.add(nested);
143
+ }
144
+ return nested;
145
+ });
146
+ return truncate(json ?? String(value));
147
+ }
148
+ catch {
149
+ return truncate(String(value));
150
+ }
151
+ }
152
+ function isPlainObject(value) {
153
+ if (value === null || typeof value !== "object")
154
+ return false;
155
+ const proto = Object.getPrototypeOf(value);
156
+ return proto === Object.prototype || proto === null;
157
+ }
158
+ /** One plain object = structured log (fields become attributes). Anything else = console-style body. */
159
+ function normalizeLogInput(args) {
160
+ const [first] = args;
161
+ if (args.length === 1 && isPlainObject(first)) {
162
+ const attributes = Object.fromEntries(Object.entries(first).flatMap(([key, value]) => {
163
+ if (value == null)
164
+ return [];
165
+ if (typeof value === "number" || typeof value === "boolean")
166
+ return [[key, value]];
167
+ return [[key, formatLogValue(value)]];
168
+ }));
169
+ const message = attributes.message;
170
+ return { body: typeof message === "string" ? message : formatLogValue(first), attributes };
171
+ }
172
+ return { body: args.map(formatLogValue).join(" "), attributes: {} };
173
+ }
63
174
  function normalizeError(value) {
64
175
  if (value instanceof Error)
65
176
  return value;
@@ -73,67 +184,122 @@ function normalizeError(value) {
73
184
  return new Error("Unknown error");
74
185
  }
75
186
  }
76
- async function send(current, records) {
187
+ function matchesAny(value, patterns) {
188
+ return (patterns ?? []).some((pattern) => {
189
+ return typeof pattern === "string" ? value.includes(pattern) : pattern.test(value);
190
+ });
191
+ }
192
+ function currentUserId() {
193
+ const userId = state?.options.userId;
194
+ return typeof userId === "function" ? userId() : userId;
195
+ }
196
+ // ---------------------------------------------------------------------------
197
+ // Queue and export
198
+ // ---------------------------------------------------------------------------
199
+ async function post({ current, path, body }) {
77
200
  try {
78
- const response = await fetch(`${current.endpoint}/v1/logs`, {
201
+ const response = await fetch(`${current.endpoint}${path}`, {
79
202
  method: "POST",
80
203
  headers: {
81
204
  "content-type": "application/json",
82
205
  ...(current.options.token ? { authorization: `Bearer ${current.options.token}` } : {}),
83
206
  },
84
- body: JSON.stringify({
85
- resourceLogs: [
86
- {
87
- resource: { attributes: current.resource },
88
- scopeLogs: [{ scope: { name: "strada" }, logRecords: records }],
89
- },
90
- ],
91
- }),
207
+ body: JSON.stringify(body),
92
208
  });
93
209
  if (!response.ok)
94
- return failure(`Strada ingest responded ${response.status}`);
210
+ return failure(`Strada ingest ${path} responded ${response.status}`);
95
211
  return undefined;
96
212
  }
97
213
  catch (cause) {
98
- return failure("Strada ingest request failed", cause);
214
+ return failure(`Strada ingest ${path} request failed`, cause);
99
215
  }
100
216
  }
101
- /** Queue one log record. Shared by track, identifyUser, and captureException. */
102
- function emit({ operation, name, body, severity, attributes, }) {
217
+ async function send({ current, logs, spans, }) {
218
+ const scopeNames = [...new Set(logs.map((log) => log.scope))];
219
+ const scopeLogs = scopeNames.map((name) => {
220
+ return {
221
+ scope: { name },
222
+ logRecords: logs.filter((log) => log.scope === name).map((log) => log.record),
223
+ };
224
+ });
225
+ const results = await Promise.all([
226
+ logs.length > 0
227
+ ? post({ current, path: "/v1/logs", body: { resourceLogs: [{ resource: { attributes: current.resource }, scopeLogs }] } })
228
+ : undefined,
229
+ spans.length > 0
230
+ ? post({
231
+ current,
232
+ path: "/v1/traces",
233
+ body: {
234
+ resourceSpans: [{ resource: { attributes: current.resource }, scopeSpans: [{ scope: { name: "strada" }, spans }] }],
235
+ },
236
+ })
237
+ : undefined,
238
+ ]);
239
+ return results.find((result) => {
240
+ return result instanceof Error;
241
+ });
242
+ }
243
+ /** Returns the live state only when exporting, so callers can skip building records. */
244
+ function exportingState(operation) {
103
245
  if (!state) {
104
246
  warnOnce(`${operation} called before initStrada(). Nothing was sent.`);
105
247
  return undefined;
106
248
  }
107
- const current = state;
108
- if (!current.exporting)
109
- return undefined;
110
- if (current.queue.length >= MAX_QUEUE_SIZE)
111
- return failure("Queue full, dropping telemetry");
112
- const userId = typeof current.options.userId === "function" ? current.options.userId() : current.options.userId;
113
- const time = `${BigInt(Date.now()) * 1000000n}`;
114
- current.queue.push({
115
- timeUnixNano: time,
116
- observedTimeUnixNano: time,
117
- severityNumber: severity,
118
- severityText: severity === ERROR_SEVERITY ? "ERROR" : "INFO",
119
- body: { stringValue: body },
120
- eventName: name,
121
- attributes: toKeyValues({ [ATTR["user.id"]]: userId, ...attributes }),
122
- });
249
+ return state.exporting ? state : undefined;
250
+ }
251
+ function scheduleFlush(current) {
123
252
  if (!current.timer) {
253
+ const delay = Math.min(current.options.telemetry?.logs?.scheduledDelayMillis ?? 5000, current.options.telemetry?.traces?.scheduledDelayMillis ?? 5000);
124
254
  current.timer = setInterval(() => {
125
255
  void flush();
126
- }, current.options.telemetry?.logs?.scheduledDelayMillis ?? 5000);
256
+ }, delay);
127
257
  // Never keep a CLI or daemon alive just to send telemetry.
128
258
  if (typeof current.timer === "object" && typeof current.timer.unref === "function") {
129
259
  current.timer.unref();
130
260
  }
131
261
  }
132
- if (current.queue.length >= (current.options.telemetry?.logs?.maxExportBatchSize ?? 512)) {
262
+ const logsFull = current.logs.length >= (current.options.telemetry?.logs?.maxExportBatchSize ?? 512);
263
+ const spansFull = current.spans.length >= (current.options.telemetry?.traces?.maxExportBatchSize ?? 512);
264
+ if (logsFull || spansFull)
133
265
  void flush();
134
- }
266
+ }
267
+ function emitLog({ operation, scope = "strada", body, severity, eventName, attributes, }) {
268
+ const current = exportingState(operation);
269
+ if (!current)
270
+ return undefined;
271
+ if (current.logs.length >= MAX_QUEUE_SIZE)
272
+ return failure("Log queue full, dropping telemetry");
273
+ const [severityNumber, severityText] = SEVERITY[severity];
274
+ const active = getActiveSpan();
275
+ const time = nowUnixNano();
276
+ current.logs.push({ scope, record: {
277
+ timeUnixNano: time,
278
+ observedTimeUnixNano: time,
279
+ severityNumber,
280
+ severityText,
281
+ body: { stringValue: body },
282
+ ...(eventName ? { eventName } : {}),
283
+ ...(active ? { traceId: active.traceId, spanId: active.spanId } : {}),
284
+ attributes: toKeyValues({ [ATTR["user.id"]]: currentUserId(), ...attributes }),
285
+ } });
286
+ scheduleFlush(current);
135
287
  return undefined;
136
288
  }
289
+ function emitSpan(span) {
290
+ const current = exportingState("span.end()");
291
+ if (!current)
292
+ return;
293
+ if (current.spans.length >= MAX_QUEUE_SIZE) {
294
+ warnOnce("Span queue full, dropping telemetry");
295
+ return;
296
+ }
297
+ current.spans.push(span);
298
+ scheduleFlush(current);
299
+ }
300
+ // ---------------------------------------------------------------------------
301
+ // Public API
302
+ // ---------------------------------------------------------------------------
137
303
  export function initStrada(options) {
138
304
  try {
139
305
  if (state) {
@@ -160,7 +326,8 @@ export function initStrada(options) {
160
326
  [ATTR["vcs.ref.head.name"]]: options.releaseBranch,
161
327
  [ATTR["deployment.id"]]: options.deploymentId ?? options.releaseCommit,
162
328
  }),
163
- queue: [],
329
+ logs: [],
330
+ spans: [],
164
331
  inflight: Promise.resolve(undefined),
165
332
  timer: undefined,
166
333
  };
@@ -176,11 +343,11 @@ export function track(name, properties) {
176
343
  const custom = Object.fromEntries(Object.entries(properties ?? {}).map(([key, value]) => {
177
344
  return [`custom.${key}`, value];
178
345
  }));
179
- return emit({
346
+ return emitLog({
180
347
  operation: "track()",
181
- name,
182
348
  body: name,
183
- severity: INFO_SEVERITY,
349
+ severity: "info",
350
+ eventName: name,
184
351
  attributes: { [ATTR["event.name"]]: name, ...custom },
185
352
  });
186
353
  }
@@ -192,11 +359,11 @@ export function track(name, properties) {
192
359
  export function identifyUser(user) {
193
360
  try {
194
361
  const name = ATTR["strada.user.identify"];
195
- return emit({
362
+ return emitLog({
196
363
  operation: "identifyUser()",
197
- name,
198
364
  body: name,
199
- severity: INFO_SEVERITY,
365
+ severity: "info",
366
+ eventName: name,
200
367
  attributes: {
201
368
  [ATTR["event.name"]]: name,
202
369
  [ATTR["user.id"]]: user.id,
@@ -221,27 +388,41 @@ export function identifyUser(user) {
221
388
  export function setTags(next) {
222
389
  tags = { ...tags, ...next };
223
390
  }
224
- /**
225
- * Report a handled error as an issue. No ignoreErrors, denyUrls, or
226
- * beforeSend in the light build; KnownError instances are skipped like in
227
- * the full SDK.
228
- */
391
+ /** Report an error as an issue. Applies KnownError, ignoreErrors, denyUrls, and beforeSend. */
229
392
  export function captureException(error, opts) {
230
393
  try {
231
394
  const normalized = normalizeError(error);
395
+ const options = state?.options;
232
396
  if (normalized.name === "KnownError" || normalized.constructor?.name === "KnownError")
233
397
  return undefined;
234
- const fingerprintValue = Reflect.get(normalized, "fingerprint");
398
+ if (matchesAny(normalized.message || "", options?.ignoreErrors))
399
+ return undefined;
400
+ if (matchesAny(normalized.stack || "", options?.denyUrls))
401
+ return undefined;
402
+ const prepared = (() => {
403
+ if (!options?.beforeSend)
404
+ return normalized;
405
+ try {
406
+ return options.beforeSend(normalized);
407
+ }
408
+ catch (thrown) {
409
+ warnOnce(`beforeSend threw, sending the original error instead: ${normalizeError(thrown).message}`);
410
+ return normalized;
411
+ }
412
+ })();
413
+ if (!prepared)
414
+ return undefined;
415
+ const fingerprintValue = Reflect.get(prepared, "fingerprint");
235
416
  const fingerprint = opts?.fingerprint ?? (Array.isArray(fingerprintValue) ? fingerprintValue : undefined);
236
- return emit({
417
+ return emitLog({
237
418
  operation: "captureException()",
238
- name: "exception",
239
- body: normalized.message,
240
- severity: ERROR_SEVERITY,
419
+ body: prepared.message,
420
+ severity: "error",
421
+ eventName: "exception",
241
422
  attributes: {
242
- [ATTR["exception.type"]]: normalized.name || "Error",
243
- [ATTR["exception.message"]]: normalized.message || "",
244
- [ATTR["exception.stacktrace"]]: normalized.stack ?? "",
423
+ [ATTR["exception.type"]]: prepared.name || "Error",
424
+ [ATTR["exception.message"]]: prepared.message || "",
425
+ [ATTR["exception.stacktrace"]]: prepared.stack ?? "",
245
426
  [ATTR["exception.mechanism.type"]]: opts?.mechanism ?? "generic",
246
427
  [ATTR["exception.mechanism.handled"]]: String(opts?.handled ?? true),
247
428
  [ATTR["exception.fingerprint"]]: fingerprint ? JSON.stringify(fingerprint) : undefined,
@@ -254,17 +435,183 @@ export function captureException(error, opts) {
254
435
  return failure("captureException() failed", cause);
255
436
  }
256
437
  }
438
+ /** Console-style logger that sends to `otel_logs`. Not exception capture: use captureException() for issues. */
439
+ export function getLogger(name = "strada") {
440
+ const method = (severity) => {
441
+ return (...args) => {
442
+ try {
443
+ const { body, attributes } = normalizeLogInput(args);
444
+ void emitLog({ operation: `logger.${severity}()`, scope: name, body, severity, attributes });
445
+ }
446
+ catch (cause) {
447
+ void failure(`logger.${severity}() failed`, cause);
448
+ }
449
+ };
450
+ };
451
+ return {
452
+ trace: method("trace"),
453
+ debug: method("debug"),
454
+ info: method("info"),
455
+ warn: method("warn"),
456
+ error: method("error"),
457
+ fatal: method("fatal"),
458
+ };
459
+ }
460
+ function createSpan(options) {
461
+ const parent = getActiveSpan();
462
+ const traceId = parent?.traceId ?? randomHex(16);
463
+ const spanId = randomHex(8);
464
+ const startTimeUnixNano = nowUnixNano();
465
+ let name = options.name;
466
+ let ended = false;
467
+ let status = { code: SpanStatusCode.UNSET };
468
+ const attributes = {
469
+ [ATTR["user.id"]]: currentUserId(),
470
+ ...options.attributes,
471
+ };
472
+ const events = [];
473
+ const span = {
474
+ spanContext() {
475
+ return { traceId, spanId, traceFlags: 1 };
476
+ },
477
+ setAttribute(key, value) {
478
+ attributes[key] = value;
479
+ return span;
480
+ },
481
+ setAttributes(next) {
482
+ Object.assign(attributes, next);
483
+ return span;
484
+ },
485
+ addEvent(eventName, eventAttributes) {
486
+ events.push({ timeUnixNano: nowUnixNano(), name: eventName, attributes: toKeyValues(eventAttributes ?? {}) });
487
+ return span;
488
+ },
489
+ setStatus(next) {
490
+ status = next;
491
+ return span;
492
+ },
493
+ updateName(next) {
494
+ name = next;
495
+ return span;
496
+ },
497
+ recordException(exception) {
498
+ const error = normalizeError(exception);
499
+ span.addEvent("exception", {
500
+ [ATTR["exception.type"]]: error.name || "Error",
501
+ [ATTR["exception.message"]]: error.message || "",
502
+ [ATTR["exception.stacktrace"]]: error.stack ?? "",
503
+ });
504
+ },
505
+ isRecording() {
506
+ return !ended;
507
+ },
508
+ end() {
509
+ if (ended)
510
+ return;
511
+ ended = true;
512
+ emitSpan({
513
+ traceId,
514
+ spanId,
515
+ ...(parent ? { parentSpanId: parent.spanId } : {}),
516
+ name,
517
+ kind: SPAN_KIND_INTERNAL,
518
+ startTimeUnixNano,
519
+ endTimeUnixNano: nowUnixNano(),
520
+ attributes: toKeyValues(attributes),
521
+ events,
522
+ status,
523
+ });
524
+ },
525
+ [Symbol.dispose]() {
526
+ span.end();
527
+ },
528
+ };
529
+ return span;
530
+ }
531
+ /** Create a detached span. Call `span.end()` or use `using`. It does not parent later spans. */
532
+ export function startInactiveSpan(options) {
533
+ return createSpan(options);
534
+ }
535
+ /**
536
+ * Run `callback` inside an active span that auto-ends. Spans, logs, events,
537
+ * and errors created inside are parented to it. A thrown error is recorded on
538
+ * the span and rethrown.
539
+ */
540
+ export function startSpan(options, callback) {
541
+ const span = createSpan(options);
542
+ const onError = (error) => {
543
+ span.recordException(normalizeError(error));
544
+ span.setStatus({ code: SpanStatusCode.ERROR });
545
+ span.end();
546
+ };
547
+ const result = (() => {
548
+ try {
549
+ return runWithActiveSpan(span.spanContext(), () => {
550
+ return callback(span);
551
+ });
552
+ }
553
+ catch (error) {
554
+ onError(error);
555
+ throw error;
556
+ }
557
+ })();
558
+ if (result instanceof Promise) {
559
+ return result.then((value) => {
560
+ span.end();
561
+ return value;
562
+ }, (error) => {
563
+ onError(error);
564
+ throw error;
565
+ });
566
+ }
567
+ span.end();
568
+ return result;
569
+ }
570
+ /** Server-side pageview span. Feeds the same analytics views as browser pageviews. */
571
+ export function trackPageview(opts) {
572
+ try {
573
+ const url = (() => {
574
+ try {
575
+ return opts.url ? new URL(opts.url) : undefined;
576
+ }
577
+ catch {
578
+ return undefined;
579
+ }
580
+ })();
581
+ const span = createSpan({
582
+ name: "pageview",
583
+ attributes: {
584
+ ...opts.attributes,
585
+ [ATTR["url.path"]]: opts.path || url?.pathname || "/",
586
+ [ATTR["pageview.source"]]: "server",
587
+ // Analytics views require a non-empty session.id.
588
+ [ATTR["session.id"]]: opts.sessionId ?? `server:${crypto.randomUUID()}`,
589
+ ...(url ? { [ATTR["url.full"]]: url.href } : {}),
590
+ ...(opts.query || url?.search ? { [ATTR["url.query"]]: opts.query || url?.search || "" } : {}),
591
+ ...(opts.referrer ? { [ATTR["http.request.header.referer"]]: opts.referrer } : {}),
592
+ ...(opts.userId ? { [ATTR["user.id"]]: opts.userId } : {}),
593
+ },
594
+ });
595
+ span.end();
596
+ return undefined;
597
+ }
598
+ catch (cause) {
599
+ return failure("trackPageview() failed", cause);
600
+ }
601
+ }
257
602
  export function flush() {
258
603
  const current = state;
259
604
  if (!current)
260
605
  return Promise.resolve(undefined);
261
- if (current.queue.length === 0)
606
+ if (current.logs.length === 0 && current.spans.length === 0)
262
607
  return current.inflight;
263
- const records = current.queue;
264
- current.queue = [];
608
+ const logs = current.logs;
609
+ const spans = current.spans;
610
+ current.logs = [];
611
+ current.spans = [];
265
612
  // Chain sends so flush() resolves only after every earlier batch is done.
266
613
  current.inflight = current.inflight.then(() => {
267
- return send(current, records);
614
+ return send({ current, logs, spans });
268
615
  });
269
616
  return current.inflight;
270
617
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@strada.sh/light",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Zero-dependency Strada SDK subset for product events, user profiles, and handled errors. Drop-in for @strada.sh/sdk: same names, same options, no OpenTelemetry.",
5
5
  "keywords": [
6
6
  "strada",
package/src/attrs.ts CHANGED
@@ -22,4 +22,10 @@ export const ATTR = {
22
22
  "exception.mechanism.type": "exception.mechanism.type",
23
23
  "exception.mechanism.handled": "exception.mechanism.handled",
24
24
  "exception.fingerprint": "exception.fingerprint",
25
+ "session.id": "session.id",
26
+ "url.path": "url.path",
27
+ "url.query": "url.query",
28
+ "url.full": "url.full",
29
+ "http.request.header.referer": "http.request.header.referer",
30
+ "pageview.source": "pageview.source",
25
31
  } as const;