@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/src/index.ts CHANGED
@@ -1,26 +1,37 @@
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
 
29
+ import type { AsyncLocalStorage } from "node:async_hooks";
22
30
  import { ATTR } from "./attrs.ts";
23
31
 
32
+ export type AttributeValue = string | number | boolean;
33
+ export type Attributes = Record<string, AttributeValue>;
34
+
24
35
  export interface StradaOptions {
25
36
  /** Strada project identifier. Blank disables sending. */
26
37
  projectId: string;
@@ -42,10 +53,17 @@ export interface StradaOptions {
42
53
  releaseBranch?: string;
43
54
  /** deployment.id resource attribute. Defaults to releaseCommit. */
44
55
  deploymentId?: string;
45
- /** Current user id, sent as user.id on every event and error. */
56
+ /** Drop errors whose message matches any of these patterns */
57
+ ignoreErrors?: Array<string | RegExp>;
58
+ /** Drop errors whose stack trace matches any of these patterns */
59
+ denyUrls?: Array<string | RegExp>;
60
+ /** Return null to drop an error before it is sent */
61
+ beforeSend?: (error: Error) => Error | null;
62
+ /** Current user id, sent as user.id on every event, log, error, and span. */
46
63
  userId?: string | (() => string | undefined);
47
- /** Same shape as the full SDK. Only log batching applies here. */
64
+ /** Same shape as the full SDK. Only batching delay and size apply here. */
48
65
  telemetry?: {
66
+ traces?: { scheduledDelayMillis?: number; maxExportBatchSize?: number };
49
67
  logs?: { scheduledDelayMillis?: number; maxExportBatchSize?: number };
50
68
  };
51
69
  }
@@ -82,7 +100,57 @@ export interface CaptureExceptionOptions {
82
100
  fingerprint?: string[];
83
101
  }
84
102
 
85
- type AttributeValue = string | number | boolean;
103
+ export interface TrackPageviewOptions {
104
+ /** Page pathname, e.g. "/pricing". Required. */
105
+ path: string;
106
+ /** Full page URL, e.g. "https://acme.com/pricing?plan=pro". */
107
+ url?: string;
108
+ /** Query string, e.g. "?plan=pro". Derived from url if not set. */
109
+ query?: string;
110
+ /** Referrer URL or domain. */
111
+ referrer?: string;
112
+ /** Session ID. Falls back to an ephemeral server id. */
113
+ sessionId?: string;
114
+ /** User ID. Falls back to the userId init option. */
115
+ userId?: string;
116
+ /** Extra span attributes to set on the pageview span. */
117
+ attributes?: Record<string, string>;
118
+ }
119
+
120
+ export interface StartSpanOptions {
121
+ name: string;
122
+ attributes?: Attributes;
123
+ }
124
+
125
+ /** Same values as OTel SpanStatusCode. */
126
+ export const SpanStatusCode = { UNSET: 0, OK: 1, ERROR: 2 } as const;
127
+
128
+ /** Subset of the OTel Span interface, so full SDK spans fit wherever a light span is expected. */
129
+ export interface Span {
130
+ spanContext(): { traceId: string; spanId: string; traceFlags: number };
131
+ setAttribute(key: string, value: AttributeValue): this;
132
+ setAttributes(attributes: Attributes): this;
133
+ addEvent(name: string, attributes?: Attributes): this;
134
+ setStatus(status: { code: number; message?: string }): this;
135
+ updateName(name: string): this;
136
+ recordException(exception: Error | string): void;
137
+ isRecording(): boolean;
138
+ end(): void;
139
+ }
140
+
141
+ export type DisposableSpan = Span & Disposable;
142
+
143
+ type LogMethod = (...args: unknown[]) => void;
144
+
145
+ /** Console-style logger. The full SDK logger also has OTel `emit()`, light does not. */
146
+ export interface StradaLogger {
147
+ trace: LogMethod;
148
+ debug: LogMethod;
149
+ info: LogMethod;
150
+ warn: LogMethod;
151
+ error: LogMethod;
152
+ fatal: LogMethod;
153
+ }
86
154
 
87
155
  type OtlpAnyValue =
88
156
  | { stringValue: string }
@@ -98,24 +166,53 @@ type OtlpLogRecord = {
98
166
  severityNumber: number;
99
167
  severityText: string;
100
168
  body: { stringValue: string };
101
- eventName: string;
169
+ eventName?: string;
170
+ traceId?: string;
171
+ spanId?: string;
172
+ attributes: OtlpKeyValue[];
173
+ };
174
+
175
+ type OtlpSpan = {
176
+ traceId: string;
177
+ spanId: string;
178
+ parentSpanId?: string;
179
+ name: string;
180
+ kind: number;
181
+ startTimeUnixNano: string;
182
+ endTimeUnixNano: string;
102
183
  attributes: OtlpKeyValue[];
184
+ events: Array<{ timeUnixNano: string; name: string; attributes: OtlpKeyValue[] }>;
185
+ status: { code: number; message?: string };
103
186
  };
104
187
 
188
+ type ActiveSpan = { traceId: string; spanId: string };
189
+
190
+ /** A queued log record plus its instrumentation scope (the getLogger() name). */
191
+ type QueuedLog = { scope: string; record: OtlpLogRecord };
192
+
105
193
  type LightState = {
106
194
  options: StradaOptions;
107
195
  endpoint: string;
108
196
  exporting: boolean;
109
197
  resource: OtlpKeyValue[];
110
- queue: OtlpLogRecord[];
198
+ logs: QueuedLog[];
199
+ spans: OtlpSpan[];
111
200
  inflight: Promise<Error | undefined>;
112
201
  timer: ReturnType<typeof setInterval> | undefined;
113
202
  };
114
203
 
115
204
  // OTel SeverityNumber values, inlined to avoid importing @opentelemetry/api-logs.
116
- const INFO_SEVERITY = 9;
117
- const ERROR_SEVERITY = 17;
205
+ const SEVERITY = {
206
+ trace: [1, "TRACE"],
207
+ debug: [5, "DEBUG"],
208
+ info: [9, "INFO"],
209
+ warn: [13, "WARN"],
210
+ error: [17, "ERROR"],
211
+ fatal: [21, "FATAL"],
212
+ } as const;
213
+ const SPAN_KIND_INTERNAL = 1;
118
214
  const MAX_QUEUE_SIZE = 2048;
215
+ const MAX_LOG_STRING_LENGTH = 16_384;
119
216
 
120
217
  let state: LightState | undefined;
121
218
  let tags: Record<string, string> = {};
@@ -140,6 +237,52 @@ function isDevMode(): boolean {
140
237
  }
141
238
  }
142
239
 
240
+ // ---------------------------------------------------------------------------
241
+ // Active span context
242
+ // ---------------------------------------------------------------------------
243
+
244
+ const asyncStorage: AsyncLocalStorage<ActiveSpan> | undefined = (() => {
245
+ try {
246
+ const hooks = globalThis.process?.getBuiltinModule?.("node:async_hooks") as
247
+ | typeof import("node:async_hooks")
248
+ | undefined;
249
+ return hooks ? new hooks.AsyncLocalStorage<ActiveSpan>() : undefined;
250
+ } catch {
251
+ return undefined;
252
+ }
253
+ })();
254
+ let syncActiveSpan: ActiveSpan | undefined;
255
+
256
+ function getActiveSpan(): ActiveSpan | undefined {
257
+ return asyncStorage ? asyncStorage.getStore() : syncActiveSpan;
258
+ }
259
+
260
+ function runWithActiveSpan<T>(active: ActiveSpan, fn: () => T): T {
261
+ if (asyncStorage) return asyncStorage.run(active, fn);
262
+ const previous = syncActiveSpan;
263
+ syncActiveSpan = active;
264
+ try {
265
+ return fn();
266
+ } finally {
267
+ syncActiveSpan = previous;
268
+ }
269
+ }
270
+
271
+ // ---------------------------------------------------------------------------
272
+ // Encoding helpers
273
+ // ---------------------------------------------------------------------------
274
+
275
+ function randomHex(bytes: number): string {
276
+ const values = crypto.getRandomValues(new Uint8Array(bytes));
277
+ return Array.from(values, (value) => {
278
+ return value.toString(16).padStart(2, "0");
279
+ }).join("");
280
+ }
281
+
282
+ function nowUnixNano(): string {
283
+ return `${BigInt(Date.now()) * 1_000_000n}`;
284
+ }
285
+
143
286
  function toAnyValue(value: AttributeValue): OtlpAnyValue {
144
287
  if (typeof value === "string") return { stringValue: value };
145
288
  if (typeof value === "boolean") return { boolValue: value };
@@ -154,6 +297,55 @@ function toKeyValues(record: Record<string, AttributeValue | undefined>): OtlpKe
154
297
  });
155
298
  }
156
299
 
300
+ function truncate(value: string): string {
301
+ if (value.length <= MAX_LOG_STRING_LENGTH) return value;
302
+ return `${value.slice(0, MAX_LOG_STRING_LENGTH)}… [truncated ${value.length - MAX_LOG_STRING_LENGTH} chars]`;
303
+ }
304
+
305
+ function formatLogValue(value: unknown): string {
306
+ if (typeof value === "string") return truncate(value);
307
+ if (value instanceof Error) return truncate(value.stack || value.message);
308
+ if (value === undefined) return "undefined";
309
+ if (typeof value === "bigint" || typeof value === "symbol" || typeof value === "function") return String(value);
310
+ try {
311
+ const seen = new WeakSet<object>();
312
+ const json = JSON.stringify(value, (_key, nested) => {
313
+ if (typeof nested === "bigint") return nested.toString();
314
+ if (typeof nested === "object" && nested !== null) {
315
+ if (seen.has(nested)) return "[Circular]";
316
+ seen.add(nested);
317
+ }
318
+ return nested;
319
+ });
320
+ return truncate(json ?? String(value));
321
+ } catch {
322
+ return truncate(String(value));
323
+ }
324
+ }
325
+
326
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
327
+ if (value === null || typeof value !== "object") return false;
328
+ const proto = Object.getPrototypeOf(value);
329
+ return proto === Object.prototype || proto === null;
330
+ }
331
+
332
+ /** One plain object = structured log (fields become attributes). Anything else = console-style body. */
333
+ function normalizeLogInput(args: unknown[]): { body: string; attributes: Attributes } {
334
+ const [first] = args;
335
+ if (args.length === 1 && isPlainObject(first)) {
336
+ const attributes: Attributes = Object.fromEntries(
337
+ Object.entries(first).flatMap(([key, value]): Array<[string, AttributeValue]> => {
338
+ if (value == null) return [];
339
+ if (typeof value === "number" || typeof value === "boolean") return [[key, value]];
340
+ return [[key, formatLogValue(value)]];
341
+ }),
342
+ );
343
+ const message = attributes.message;
344
+ return { body: typeof message === "string" ? message : formatLogValue(first), attributes };
345
+ }
346
+ return { body: args.map(formatLogValue).join(" "), attributes: {} };
347
+ }
348
+
157
349
  function normalizeError(value: unknown): Error {
158
350
  if (value instanceof Error) return value;
159
351
  if (typeof value === "string") return new Error(value);
@@ -165,79 +357,151 @@ function normalizeError(value: unknown): Error {
165
357
  }
166
358
  }
167
359
 
168
- async function send(current: LightState, records: OtlpLogRecord[]): Promise<Error | undefined> {
360
+ function matchesAny(value: string, patterns: Array<string | RegExp> | undefined): boolean {
361
+ return (patterns ?? []).some((pattern) => {
362
+ return typeof pattern === "string" ? value.includes(pattern) : pattern.test(value);
363
+ });
364
+ }
365
+
366
+ function currentUserId(): string | undefined {
367
+ const userId = state?.options.userId;
368
+ return typeof userId === "function" ? userId() : userId;
369
+ }
370
+
371
+ // ---------------------------------------------------------------------------
372
+ // Queue and export
373
+ // ---------------------------------------------------------------------------
374
+
375
+ async function post({ current, path, body }: { current: LightState; path: string; body: object }): Promise<Error | undefined> {
169
376
  try {
170
- const response = await fetch(`${current.endpoint}/v1/logs`, {
377
+ const response = await fetch(`${current.endpoint}${path}`, {
171
378
  method: "POST",
172
379
  headers: {
173
380
  "content-type": "application/json",
174
381
  ...(current.options.token ? { authorization: `Bearer ${current.options.token}` } : {}),
175
382
  },
176
- body: JSON.stringify({
177
- resourceLogs: [
178
- {
179
- resource: { attributes: current.resource },
180
- scopeLogs: [{ scope: { name: "strada" }, logRecords: records }],
181
- },
182
- ],
183
- }),
383
+ body: JSON.stringify(body),
184
384
  });
185
- if (!response.ok) return failure(`Strada ingest responded ${response.status}`);
385
+ if (!response.ok) return failure(`Strada ingest ${path} responded ${response.status}`);
186
386
  return undefined;
187
387
  } catch (cause) {
188
- return failure("Strada ingest request failed", cause);
388
+ return failure(`Strada ingest ${path} request failed`, cause);
189
389
  }
190
390
  }
191
391
 
192
- /** Queue one log record. Shared by track, identifyUser, and captureException. */
193
- function emit({
194
- operation,
195
- name,
196
- body,
197
- severity,
198
- attributes,
392
+ async function send({
393
+ current,
394
+ logs,
395
+ spans,
199
396
  }: {
200
- operation: string;
201
- name: string;
202
- body: string;
203
- severity: number;
204
- attributes: Record<string, AttributeValue | undefined>;
205
- }): Error | undefined {
397
+ current: LightState;
398
+ logs: QueuedLog[];
399
+ spans: OtlpSpan[];
400
+ }): Promise<Error | undefined> {
401
+ const scopeNames = [...new Set(logs.map((log) => log.scope))];
402
+ const scopeLogs = scopeNames.map((name) => {
403
+ return {
404
+ scope: { name },
405
+ logRecords: logs.filter((log) => log.scope === name).map((log) => log.record),
406
+ };
407
+ });
408
+ const results = await Promise.all([
409
+ logs.length > 0
410
+ ? post({ current, path: "/v1/logs", body: { resourceLogs: [{ resource: { attributes: current.resource }, scopeLogs }] } })
411
+ : undefined,
412
+ spans.length > 0
413
+ ? post({
414
+ current,
415
+ path: "/v1/traces",
416
+ body: {
417
+ resourceSpans: [{ resource: { attributes: current.resource }, scopeSpans: [{ scope: { name: "strada" }, spans }] }],
418
+ },
419
+ })
420
+ : undefined,
421
+ ]);
422
+ return results.find((result) => {
423
+ return result instanceof Error;
424
+ });
425
+ }
426
+
427
+ /** Returns the live state only when exporting, so callers can skip building records. */
428
+ function exportingState(operation: string): LightState | undefined {
206
429
  if (!state) {
207
430
  warnOnce(`${operation} called before initStrada(). Nothing was sent.`);
208
431
  return undefined;
209
432
  }
210
- const current = state;
211
- if (!current.exporting) return undefined;
212
- if (current.queue.length >= MAX_QUEUE_SIZE) return failure("Queue full, dropping telemetry");
213
-
214
- const userId = typeof current.options.userId === "function" ? current.options.userId() : current.options.userId;
215
- const time = `${BigInt(Date.now()) * 1_000_000n}`;
216
- current.queue.push({
217
- timeUnixNano: time,
218
- observedTimeUnixNano: time,
219
- severityNumber: severity,
220
- severityText: severity === ERROR_SEVERITY ? "ERROR" : "INFO",
221
- body: { stringValue: body },
222
- eventName: name,
223
- attributes: toKeyValues({ [ATTR["user.id"]]: userId, ...attributes }),
224
- });
433
+ return state.exporting ? state : undefined;
434
+ }
225
435
 
436
+ function scheduleFlush(current: LightState): void {
226
437
  if (!current.timer) {
438
+ const delay = Math.min(
439
+ current.options.telemetry?.logs?.scheduledDelayMillis ?? 5000,
440
+ current.options.telemetry?.traces?.scheduledDelayMillis ?? 5000,
441
+ );
227
442
  current.timer = setInterval(() => {
228
443
  void flush();
229
- }, current.options.telemetry?.logs?.scheduledDelayMillis ?? 5000);
444
+ }, delay);
230
445
  // Never keep a CLI or daemon alive just to send telemetry.
231
446
  if (typeof current.timer === "object" && typeof current.timer.unref === "function") {
232
447
  current.timer.unref();
233
448
  }
234
449
  }
235
- if (current.queue.length >= (current.options.telemetry?.logs?.maxExportBatchSize ?? 512)) {
236
- void flush();
237
- }
450
+ const logsFull = current.logs.length >= (current.options.telemetry?.logs?.maxExportBatchSize ?? 512);
451
+ const spansFull = current.spans.length >= (current.options.telemetry?.traces?.maxExportBatchSize ?? 512);
452
+ if (logsFull || spansFull) void flush();
453
+ }
454
+
455
+ function emitLog({
456
+ operation,
457
+ scope = "strada",
458
+ body,
459
+ severity,
460
+ eventName,
461
+ attributes,
462
+ }: {
463
+ operation: string;
464
+ scope?: string;
465
+ body: string;
466
+ severity: keyof typeof SEVERITY;
467
+ eventName?: string;
468
+ attributes: Record<string, AttributeValue | undefined>;
469
+ }): Error | undefined {
470
+ const current = exportingState(operation);
471
+ if (!current) return undefined;
472
+ if (current.logs.length >= MAX_QUEUE_SIZE) return failure("Log queue full, dropping telemetry");
473
+ const [severityNumber, severityText] = SEVERITY[severity];
474
+ const active = getActiveSpan();
475
+ const time = nowUnixNano();
476
+ current.logs.push({ scope, record: {
477
+ timeUnixNano: time,
478
+ observedTimeUnixNano: time,
479
+ severityNumber,
480
+ severityText,
481
+ body: { stringValue: body },
482
+ ...(eventName ? { eventName } : {}),
483
+ ...(active ? { traceId: active.traceId, spanId: active.spanId } : {}),
484
+ attributes: toKeyValues({ [ATTR["user.id"]]: currentUserId(), ...attributes }),
485
+ } });
486
+ scheduleFlush(current);
238
487
  return undefined;
239
488
  }
240
489
 
490
+ function emitSpan(span: OtlpSpan): void {
491
+ const current = exportingState("span.end()");
492
+ if (!current) return;
493
+ if (current.spans.length >= MAX_QUEUE_SIZE) {
494
+ warnOnce("Span queue full, dropping telemetry");
495
+ return;
496
+ }
497
+ current.spans.push(span);
498
+ scheduleFlush(current);
499
+ }
500
+
501
+ // ---------------------------------------------------------------------------
502
+ // Public API
503
+ // ---------------------------------------------------------------------------
504
+
241
505
  export function initStrada(options: StradaOptions): Error | undefined {
242
506
  try {
243
507
  if (state) {
@@ -264,7 +528,8 @@ export function initStrada(options: StradaOptions): Error | undefined {
264
528
  [ATTR["vcs.ref.head.name"]]: options.releaseBranch,
265
529
  [ATTR["deployment.id"]]: options.deploymentId ?? options.releaseCommit,
266
530
  }),
267
- queue: [],
531
+ logs: [],
532
+ spans: [],
268
533
  inflight: Promise.resolve(undefined),
269
534
  timer: undefined,
270
535
  };
@@ -275,18 +540,18 @@ export function initStrada(options: StradaOptions): Error | undefined {
275
540
  }
276
541
 
277
542
  /** Product analytics event. Properties are stored as `custom.*` attributes. */
278
- export function track(name: string, properties?: Record<string, AttributeValue>): Error | undefined {
543
+ export function track(name: string, properties?: Attributes): Error | undefined {
279
544
  try {
280
545
  const custom = Object.fromEntries(
281
546
  Object.entries(properties ?? {}).map(([key, value]) => {
282
547
  return [`custom.${key}`, value];
283
548
  }),
284
549
  );
285
- return emit({
550
+ return emitLog({
286
551
  operation: "track()",
287
- name,
288
552
  body: name,
289
- severity: INFO_SEVERITY,
553
+ severity: "info",
554
+ eventName: name,
290
555
  attributes: { [ATTR["event.name"]]: name, ...custom },
291
556
  });
292
557
  } catch (cause) {
@@ -298,11 +563,11 @@ export function track(name: string, properties?: Record<string, AttributeValue>)
298
563
  export function identifyUser(user: StradaUserIdentity): Error | undefined {
299
564
  try {
300
565
  const name = ATTR["strada.user.identify"];
301
- return emit({
566
+ return emitLog({
302
567
  operation: "identifyUser()",
303
- name,
304
568
  body: name,
305
- severity: INFO_SEVERITY,
569
+ severity: "info",
570
+ eventName: name,
306
571
  attributes: {
307
572
  [ATTR["event.name"]]: name,
308
573
  [ATTR["user.id"]]: user.id,
@@ -330,26 +595,36 @@ export function setTags(next: Record<string, string>): void {
330
595
  tags = { ...tags, ...next };
331
596
  }
332
597
 
333
- /**
334
- * Report a handled error as an issue. No ignoreErrors, denyUrls, or
335
- * beforeSend in the light build; KnownError instances are skipped like in
336
- * the full SDK.
337
- */
598
+ /** Report an error as an issue. Applies KnownError, ignoreErrors, denyUrls, and beforeSend. */
338
599
  export function captureException(error: unknown, opts?: CaptureExceptionOptions): Error | undefined {
339
600
  try {
340
601
  const normalized = normalizeError(error);
602
+ const options = state?.options;
341
603
  if (normalized.name === "KnownError" || normalized.constructor?.name === "KnownError") return undefined;
342
- const fingerprintValue = Reflect.get(normalized, "fingerprint");
604
+ if (matchesAny(normalized.message || "", options?.ignoreErrors)) return undefined;
605
+ if (matchesAny(normalized.stack || "", options?.denyUrls)) return undefined;
606
+ const prepared = (() => {
607
+ if (!options?.beforeSend) return normalized;
608
+ try {
609
+ return options.beforeSend(normalized);
610
+ } catch (thrown) {
611
+ warnOnce(`beforeSend threw, sending the original error instead: ${normalizeError(thrown).message}`);
612
+ return normalized;
613
+ }
614
+ })();
615
+ if (!prepared) return undefined;
616
+
617
+ const fingerprintValue = Reflect.get(prepared, "fingerprint");
343
618
  const fingerprint = opts?.fingerprint ?? (Array.isArray(fingerprintValue) ? fingerprintValue : undefined);
344
- return emit({
619
+ return emitLog({
345
620
  operation: "captureException()",
346
- name: "exception",
347
- body: normalized.message,
348
- severity: ERROR_SEVERITY,
621
+ body: prepared.message,
622
+ severity: "error",
623
+ eventName: "exception",
349
624
  attributes: {
350
- [ATTR["exception.type"]]: normalized.name || "Error",
351
- [ATTR["exception.message"]]: normalized.message || "",
352
- [ATTR["exception.stacktrace"]]: normalized.stack ?? "",
625
+ [ATTR["exception.type"]]: prepared.name || "Error",
626
+ [ATTR["exception.message"]]: prepared.message || "",
627
+ [ATTR["exception.stacktrace"]]: prepared.stack ?? "",
353
628
  [ATTR["exception.mechanism.type"]]: opts?.mechanism ?? "generic",
354
629
  [ATTR["exception.mechanism.handled"]]: String(opts?.handled ?? true),
355
630
  [ATTR["exception.fingerprint"]]: fingerprint ? JSON.stringify(fingerprint) : undefined,
@@ -362,15 +637,185 @@ export function captureException(error: unknown, opts?: CaptureExceptionOptions)
362
637
  }
363
638
  }
364
639
 
640
+ /** Console-style logger that sends to `otel_logs`. Not exception capture: use captureException() for issues. */
641
+ export function getLogger(name = "strada"): StradaLogger {
642
+ const method = (severity: keyof typeof SEVERITY): LogMethod => {
643
+ return (...args) => {
644
+ try {
645
+ const { body, attributes } = normalizeLogInput(args);
646
+ void emitLog({ operation: `logger.${severity}()`, scope: name, body, severity, attributes });
647
+ } catch (cause) {
648
+ void failure(`logger.${severity}() failed`, cause);
649
+ }
650
+ };
651
+ };
652
+ return {
653
+ trace: method("trace"),
654
+ debug: method("debug"),
655
+ info: method("info"),
656
+ warn: method("warn"),
657
+ error: method("error"),
658
+ fatal: method("fatal"),
659
+ };
660
+ }
661
+
662
+ function createSpan(options: StartSpanOptions): DisposableSpan {
663
+ const parent = getActiveSpan();
664
+ const traceId = parent?.traceId ?? randomHex(16);
665
+ const spanId = randomHex(8);
666
+ const startTimeUnixNano = nowUnixNano();
667
+ let name = options.name;
668
+ let ended = false;
669
+ let status: { code: number; message?: string } = { code: SpanStatusCode.UNSET };
670
+ const attributes: Record<string, AttributeValue | undefined> = {
671
+ [ATTR["user.id"]]: currentUserId(),
672
+ ...options.attributes,
673
+ };
674
+ const events: OtlpSpan["events"] = [];
675
+
676
+ const span: DisposableSpan = {
677
+ spanContext() {
678
+ return { traceId, spanId, traceFlags: 1 };
679
+ },
680
+ setAttribute(key, value) {
681
+ attributes[key] = value;
682
+ return span;
683
+ },
684
+ setAttributes(next) {
685
+ Object.assign(attributes, next);
686
+ return span;
687
+ },
688
+ addEvent(eventName, eventAttributes) {
689
+ events.push({ timeUnixNano: nowUnixNano(), name: eventName, attributes: toKeyValues(eventAttributes ?? {}) });
690
+ return span;
691
+ },
692
+ setStatus(next) {
693
+ status = next;
694
+ return span;
695
+ },
696
+ updateName(next) {
697
+ name = next;
698
+ return span;
699
+ },
700
+ recordException(exception) {
701
+ const error = normalizeError(exception);
702
+ span.addEvent("exception", {
703
+ [ATTR["exception.type"]]: error.name || "Error",
704
+ [ATTR["exception.message"]]: error.message || "",
705
+ [ATTR["exception.stacktrace"]]: error.stack ?? "",
706
+ });
707
+ },
708
+ isRecording() {
709
+ return !ended;
710
+ },
711
+ end() {
712
+ if (ended) return;
713
+ ended = true;
714
+ emitSpan({
715
+ traceId,
716
+ spanId,
717
+ ...(parent ? { parentSpanId: parent.spanId } : {}),
718
+ name,
719
+ kind: SPAN_KIND_INTERNAL,
720
+ startTimeUnixNano,
721
+ endTimeUnixNano: nowUnixNano(),
722
+ attributes: toKeyValues(attributes),
723
+ events,
724
+ status,
725
+ });
726
+ },
727
+ [Symbol.dispose]() {
728
+ span.end();
729
+ },
730
+ };
731
+ return span;
732
+ }
733
+
734
+ /** Create a detached span. Call `span.end()` or use `using`. It does not parent later spans. */
735
+ export function startInactiveSpan(options: StartSpanOptions): DisposableSpan {
736
+ return createSpan(options);
737
+ }
738
+
739
+ /**
740
+ * Run `callback` inside an active span that auto-ends. Spans, logs, events,
741
+ * and errors created inside are parented to it. A thrown error is recorded on
742
+ * the span and rethrown.
743
+ */
744
+ export function startSpan<T>(options: StartSpanOptions, callback: (span: Span) => T): T {
745
+ const span = createSpan(options);
746
+ const onError = (error: unknown) => {
747
+ span.recordException(normalizeError(error));
748
+ span.setStatus({ code: SpanStatusCode.ERROR });
749
+ span.end();
750
+ };
751
+ const result = (() => {
752
+ try {
753
+ return runWithActiveSpan(span.spanContext(), () => {
754
+ return callback(span);
755
+ });
756
+ } catch (error) {
757
+ onError(error);
758
+ throw error;
759
+ }
760
+ })();
761
+ if (result instanceof Promise) {
762
+ return result.then(
763
+ (value) => {
764
+ span.end();
765
+ return value;
766
+ },
767
+ (error) => {
768
+ onError(error);
769
+ throw error;
770
+ },
771
+ ) as T;
772
+ }
773
+ span.end();
774
+ return result;
775
+ }
776
+
777
+ /** Server-side pageview span. Feeds the same analytics views as browser pageviews. */
778
+ export function trackPageview(opts: TrackPageviewOptions): Error | undefined {
779
+ try {
780
+ const url = (() => {
781
+ try {
782
+ return opts.url ? new URL(opts.url) : undefined;
783
+ } catch {
784
+ return undefined;
785
+ }
786
+ })();
787
+ const span = createSpan({
788
+ name: "pageview",
789
+ attributes: {
790
+ ...opts.attributes,
791
+ [ATTR["url.path"]]: opts.path || url?.pathname || "/",
792
+ [ATTR["pageview.source"]]: "server",
793
+ // Analytics views require a non-empty session.id.
794
+ [ATTR["session.id"]]: opts.sessionId ?? `server:${crypto.randomUUID()}`,
795
+ ...(url ? { [ATTR["url.full"]]: url.href } : {}),
796
+ ...(opts.query || url?.search ? { [ATTR["url.query"]]: opts.query || url?.search || "" } : {}),
797
+ ...(opts.referrer ? { [ATTR["http.request.header.referer"]]: opts.referrer } : {}),
798
+ ...(opts.userId ? { [ATTR["user.id"]]: opts.userId } : {}),
799
+ },
800
+ });
801
+ span.end();
802
+ return undefined;
803
+ } catch (cause) {
804
+ return failure("trackPageview() failed", cause);
805
+ }
806
+ }
807
+
365
808
  export function flush(): Promise<Error | undefined> {
366
809
  const current = state;
367
810
  if (!current) return Promise.resolve(undefined);
368
- if (current.queue.length === 0) return current.inflight;
369
- const records = current.queue;
370
- current.queue = [];
811
+ if (current.logs.length === 0 && current.spans.length === 0) return current.inflight;
812
+ const logs = current.logs;
813
+ const spans = current.spans;
814
+ current.logs = [];
815
+ current.spans = [];
371
816
  // Chain sends so flush() resolves only after every earlier batch is done.
372
817
  current.inflight = current.inflight.then(() => {
373
- return send(current, records);
818
+ return send({ current, logs, spans });
374
819
  });
375
820
  return current.inflight;
376
821
  }