@strada.sh/light 0.1.0 → 0.2.1
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/attrs.d.ts +6 -0
- package/dist/attrs.d.ts.map +1 -1
- package/dist/attrs.js +6 -0
- package/dist/index.d.ts +103 -21
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +419 -72
- package/package.json +1 -1
- package/src/attrs.ts +6 -0
- package/src/index.ts +536 -89
package/src/index.ts
CHANGED
|
@@ -1,26 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@strada.sh/light`: a zero-dependency,
|
|
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`
|
|
6
|
-
* `strada.user.identify`), so every Strada query
|
|
7
|
-
* light build does not support is not exported or
|
|
8
|
-
* that relies on it fails at compile time instead
|
|
9
|
-
* `index.test.ts` type-checks
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* command args).
|
|
16
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
117
|
-
|
|
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,153 @@ function normalizeError(value: unknown): Error {
|
|
|
165
357
|
}
|
|
166
358
|
}
|
|
167
359
|
|
|
168
|
-
|
|
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}
|
|
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
|
-
|
|
385
|
+
// Read the body so fetch returns the socket to its keep-alive pool.
|
|
386
|
+
// An unread body pins the socket and every flush opens a new TLS connection.
|
|
387
|
+
await response.arrayBuffer();
|
|
388
|
+
if (!response.ok) return failure(`Strada ingest ${path} responded ${response.status}`);
|
|
186
389
|
return undefined;
|
|
187
390
|
} catch (cause) {
|
|
188
|
-
return failure(
|
|
391
|
+
return failure(`Strada ingest ${path} request failed`, cause);
|
|
189
392
|
}
|
|
190
393
|
}
|
|
191
394
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
body,
|
|
197
|
-
severity,
|
|
198
|
-
attributes,
|
|
395
|
+
async function send({
|
|
396
|
+
current,
|
|
397
|
+
logs,
|
|
398
|
+
spans,
|
|
199
399
|
}: {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
400
|
+
current: LightState;
|
|
401
|
+
logs: QueuedLog[];
|
|
402
|
+
spans: OtlpSpan[];
|
|
403
|
+
}): Promise<Error | undefined> {
|
|
404
|
+
const scopeNames = [...new Set(logs.map((log) => log.scope))];
|
|
405
|
+
const scopeLogs = scopeNames.map((name) => {
|
|
406
|
+
return {
|
|
407
|
+
scope: { name },
|
|
408
|
+
logRecords: logs.filter((log) => log.scope === name).map((log) => log.record),
|
|
409
|
+
};
|
|
410
|
+
});
|
|
411
|
+
// Sequential on purpose: parallel requests to one origin open a second connection.
|
|
412
|
+
const logsError =
|
|
413
|
+
logs.length > 0
|
|
414
|
+
? await post({ current, path: "/v1/logs", body: { resourceLogs: [{ resource: { attributes: current.resource }, scopeLogs }] } })
|
|
415
|
+
: undefined;
|
|
416
|
+
const spansError =
|
|
417
|
+
spans.length > 0
|
|
418
|
+
? await post({
|
|
419
|
+
current,
|
|
420
|
+
path: "/v1/traces",
|
|
421
|
+
body: {
|
|
422
|
+
resourceSpans: [{ resource: { attributes: current.resource }, scopeSpans: [{ scope: { name: "strada" }, spans }] }],
|
|
423
|
+
},
|
|
424
|
+
})
|
|
425
|
+
: undefined;
|
|
426
|
+
return logsError ?? spansError;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Returns the live state only when exporting, so callers can skip building records. */
|
|
430
|
+
function exportingState(operation: string): LightState | undefined {
|
|
206
431
|
if (!state) {
|
|
207
432
|
warnOnce(`${operation} called before initStrada(). Nothing was sent.`);
|
|
208
433
|
return undefined;
|
|
209
434
|
}
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
});
|
|
435
|
+
return state.exporting ? state : undefined;
|
|
436
|
+
}
|
|
225
437
|
|
|
438
|
+
function scheduleFlush(current: LightState): void {
|
|
226
439
|
if (!current.timer) {
|
|
440
|
+
const delay = Math.min(
|
|
441
|
+
current.options.telemetry?.logs?.scheduledDelayMillis ?? 5000,
|
|
442
|
+
current.options.telemetry?.traces?.scheduledDelayMillis ?? 5000,
|
|
443
|
+
);
|
|
227
444
|
current.timer = setInterval(() => {
|
|
228
445
|
void flush();
|
|
229
|
-
},
|
|
446
|
+
}, delay);
|
|
230
447
|
// Never keep a CLI or daemon alive just to send telemetry.
|
|
231
448
|
if (typeof current.timer === "object" && typeof current.timer.unref === "function") {
|
|
232
449
|
current.timer.unref();
|
|
233
450
|
}
|
|
234
451
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
452
|
+
const logsFull = current.logs.length >= (current.options.telemetry?.logs?.maxExportBatchSize ?? 512);
|
|
453
|
+
const spansFull = current.spans.length >= (current.options.telemetry?.traces?.maxExportBatchSize ?? 512);
|
|
454
|
+
if (logsFull || spansFull) void flush();
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function emitLog({
|
|
458
|
+
operation,
|
|
459
|
+
scope = "strada",
|
|
460
|
+
body,
|
|
461
|
+
severity,
|
|
462
|
+
eventName,
|
|
463
|
+
attributes,
|
|
464
|
+
}: {
|
|
465
|
+
operation: string;
|
|
466
|
+
scope?: string;
|
|
467
|
+
body: string;
|
|
468
|
+
severity: keyof typeof SEVERITY;
|
|
469
|
+
eventName?: string;
|
|
470
|
+
attributes: Record<string, AttributeValue | undefined>;
|
|
471
|
+
}): Error | undefined {
|
|
472
|
+
const current = exportingState(operation);
|
|
473
|
+
if (!current) return undefined;
|
|
474
|
+
if (current.logs.length >= MAX_QUEUE_SIZE) return failure("Log queue full, dropping telemetry");
|
|
475
|
+
const [severityNumber, severityText] = SEVERITY[severity];
|
|
476
|
+
const active = getActiveSpan();
|
|
477
|
+
const time = nowUnixNano();
|
|
478
|
+
current.logs.push({ scope, record: {
|
|
479
|
+
timeUnixNano: time,
|
|
480
|
+
observedTimeUnixNano: time,
|
|
481
|
+
severityNumber,
|
|
482
|
+
severityText,
|
|
483
|
+
body: { stringValue: body },
|
|
484
|
+
...(eventName ? { eventName } : {}),
|
|
485
|
+
...(active ? { traceId: active.traceId, spanId: active.spanId } : {}),
|
|
486
|
+
attributes: toKeyValues({ [ATTR["user.id"]]: currentUserId(), ...attributes }),
|
|
487
|
+
} });
|
|
488
|
+
scheduleFlush(current);
|
|
238
489
|
return undefined;
|
|
239
490
|
}
|
|
240
491
|
|
|
492
|
+
function emitSpan(span: OtlpSpan): void {
|
|
493
|
+
const current = exportingState("span.end()");
|
|
494
|
+
if (!current) return;
|
|
495
|
+
if (current.spans.length >= MAX_QUEUE_SIZE) {
|
|
496
|
+
warnOnce("Span queue full, dropping telemetry");
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
current.spans.push(span);
|
|
500
|
+
scheduleFlush(current);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ---------------------------------------------------------------------------
|
|
504
|
+
// Public API
|
|
505
|
+
// ---------------------------------------------------------------------------
|
|
506
|
+
|
|
241
507
|
export function initStrada(options: StradaOptions): Error | undefined {
|
|
242
508
|
try {
|
|
243
509
|
if (state) {
|
|
@@ -264,7 +530,8 @@ export function initStrada(options: StradaOptions): Error | undefined {
|
|
|
264
530
|
[ATTR["vcs.ref.head.name"]]: options.releaseBranch,
|
|
265
531
|
[ATTR["deployment.id"]]: options.deploymentId ?? options.releaseCommit,
|
|
266
532
|
}),
|
|
267
|
-
|
|
533
|
+
logs: [],
|
|
534
|
+
spans: [],
|
|
268
535
|
inflight: Promise.resolve(undefined),
|
|
269
536
|
timer: undefined,
|
|
270
537
|
};
|
|
@@ -275,18 +542,18 @@ export function initStrada(options: StradaOptions): Error | undefined {
|
|
|
275
542
|
}
|
|
276
543
|
|
|
277
544
|
/** Product analytics event. Properties are stored as `custom.*` attributes. */
|
|
278
|
-
export function track(name: string, properties?:
|
|
545
|
+
export function track(name: string, properties?: Attributes): Error | undefined {
|
|
279
546
|
try {
|
|
280
547
|
const custom = Object.fromEntries(
|
|
281
548
|
Object.entries(properties ?? {}).map(([key, value]) => {
|
|
282
549
|
return [`custom.${key}`, value];
|
|
283
550
|
}),
|
|
284
551
|
);
|
|
285
|
-
return
|
|
552
|
+
return emitLog({
|
|
286
553
|
operation: "track()",
|
|
287
|
-
name,
|
|
288
554
|
body: name,
|
|
289
|
-
severity:
|
|
555
|
+
severity: "info",
|
|
556
|
+
eventName: name,
|
|
290
557
|
attributes: { [ATTR["event.name"]]: name, ...custom },
|
|
291
558
|
});
|
|
292
559
|
} catch (cause) {
|
|
@@ -298,11 +565,11 @@ export function track(name: string, properties?: Record<string, AttributeValue>)
|
|
|
298
565
|
export function identifyUser(user: StradaUserIdentity): Error | undefined {
|
|
299
566
|
try {
|
|
300
567
|
const name = ATTR["strada.user.identify"];
|
|
301
|
-
return
|
|
568
|
+
return emitLog({
|
|
302
569
|
operation: "identifyUser()",
|
|
303
|
-
name,
|
|
304
570
|
body: name,
|
|
305
|
-
severity:
|
|
571
|
+
severity: "info",
|
|
572
|
+
eventName: name,
|
|
306
573
|
attributes: {
|
|
307
574
|
[ATTR["event.name"]]: name,
|
|
308
575
|
[ATTR["user.id"]]: user.id,
|
|
@@ -330,26 +597,36 @@ export function setTags(next: Record<string, string>): void {
|
|
|
330
597
|
tags = { ...tags, ...next };
|
|
331
598
|
}
|
|
332
599
|
|
|
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
|
-
*/
|
|
600
|
+
/** Report an error as an issue. Applies KnownError, ignoreErrors, denyUrls, and beforeSend. */
|
|
338
601
|
export function captureException(error: unknown, opts?: CaptureExceptionOptions): Error | undefined {
|
|
339
602
|
try {
|
|
340
603
|
const normalized = normalizeError(error);
|
|
604
|
+
const options = state?.options;
|
|
341
605
|
if (normalized.name === "KnownError" || normalized.constructor?.name === "KnownError") return undefined;
|
|
342
|
-
|
|
606
|
+
if (matchesAny(normalized.message || "", options?.ignoreErrors)) return undefined;
|
|
607
|
+
if (matchesAny(normalized.stack || "", options?.denyUrls)) return undefined;
|
|
608
|
+
const prepared = (() => {
|
|
609
|
+
if (!options?.beforeSend) return normalized;
|
|
610
|
+
try {
|
|
611
|
+
return options.beforeSend(normalized);
|
|
612
|
+
} catch (thrown) {
|
|
613
|
+
warnOnce(`beforeSend threw, sending the original error instead: ${normalizeError(thrown).message}`);
|
|
614
|
+
return normalized;
|
|
615
|
+
}
|
|
616
|
+
})();
|
|
617
|
+
if (!prepared) return undefined;
|
|
618
|
+
|
|
619
|
+
const fingerprintValue = Reflect.get(prepared, "fingerprint");
|
|
343
620
|
const fingerprint = opts?.fingerprint ?? (Array.isArray(fingerprintValue) ? fingerprintValue : undefined);
|
|
344
|
-
return
|
|
621
|
+
return emitLog({
|
|
345
622
|
operation: "captureException()",
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
623
|
+
body: prepared.message,
|
|
624
|
+
severity: "error",
|
|
625
|
+
eventName: "exception",
|
|
349
626
|
attributes: {
|
|
350
|
-
[ATTR["exception.type"]]:
|
|
351
|
-
[ATTR["exception.message"]]:
|
|
352
|
-
[ATTR["exception.stacktrace"]]:
|
|
627
|
+
[ATTR["exception.type"]]: prepared.name || "Error",
|
|
628
|
+
[ATTR["exception.message"]]: prepared.message || "",
|
|
629
|
+
[ATTR["exception.stacktrace"]]: prepared.stack ?? "",
|
|
353
630
|
[ATTR["exception.mechanism.type"]]: opts?.mechanism ?? "generic",
|
|
354
631
|
[ATTR["exception.mechanism.handled"]]: String(opts?.handled ?? true),
|
|
355
632
|
[ATTR["exception.fingerprint"]]: fingerprint ? JSON.stringify(fingerprint) : undefined,
|
|
@@ -362,15 +639,185 @@ export function captureException(error: unknown, opts?: CaptureExceptionOptions)
|
|
|
362
639
|
}
|
|
363
640
|
}
|
|
364
641
|
|
|
642
|
+
/** Console-style logger that sends to `otel_logs`. Not exception capture: use captureException() for issues. */
|
|
643
|
+
export function getLogger(name = "strada"): StradaLogger {
|
|
644
|
+
const method = (severity: keyof typeof SEVERITY): LogMethod => {
|
|
645
|
+
return (...args) => {
|
|
646
|
+
try {
|
|
647
|
+
const { body, attributes } = normalizeLogInput(args);
|
|
648
|
+
void emitLog({ operation: `logger.${severity}()`, scope: name, body, severity, attributes });
|
|
649
|
+
} catch (cause) {
|
|
650
|
+
void failure(`logger.${severity}() failed`, cause);
|
|
651
|
+
}
|
|
652
|
+
};
|
|
653
|
+
};
|
|
654
|
+
return {
|
|
655
|
+
trace: method("trace"),
|
|
656
|
+
debug: method("debug"),
|
|
657
|
+
info: method("info"),
|
|
658
|
+
warn: method("warn"),
|
|
659
|
+
error: method("error"),
|
|
660
|
+
fatal: method("fatal"),
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function createSpan(options: StartSpanOptions): DisposableSpan {
|
|
665
|
+
const parent = getActiveSpan();
|
|
666
|
+
const traceId = parent?.traceId ?? randomHex(16);
|
|
667
|
+
const spanId = randomHex(8);
|
|
668
|
+
const startTimeUnixNano = nowUnixNano();
|
|
669
|
+
let name = options.name;
|
|
670
|
+
let ended = false;
|
|
671
|
+
let status: { code: number; message?: string } = { code: SpanStatusCode.UNSET };
|
|
672
|
+
const attributes: Record<string, AttributeValue | undefined> = {
|
|
673
|
+
[ATTR["user.id"]]: currentUserId(),
|
|
674
|
+
...options.attributes,
|
|
675
|
+
};
|
|
676
|
+
const events: OtlpSpan["events"] = [];
|
|
677
|
+
|
|
678
|
+
const span: DisposableSpan = {
|
|
679
|
+
spanContext() {
|
|
680
|
+
return { traceId, spanId, traceFlags: 1 };
|
|
681
|
+
},
|
|
682
|
+
setAttribute(key, value) {
|
|
683
|
+
attributes[key] = value;
|
|
684
|
+
return span;
|
|
685
|
+
},
|
|
686
|
+
setAttributes(next) {
|
|
687
|
+
Object.assign(attributes, next);
|
|
688
|
+
return span;
|
|
689
|
+
},
|
|
690
|
+
addEvent(eventName, eventAttributes) {
|
|
691
|
+
events.push({ timeUnixNano: nowUnixNano(), name: eventName, attributes: toKeyValues(eventAttributes ?? {}) });
|
|
692
|
+
return span;
|
|
693
|
+
},
|
|
694
|
+
setStatus(next) {
|
|
695
|
+
status = next;
|
|
696
|
+
return span;
|
|
697
|
+
},
|
|
698
|
+
updateName(next) {
|
|
699
|
+
name = next;
|
|
700
|
+
return span;
|
|
701
|
+
},
|
|
702
|
+
recordException(exception) {
|
|
703
|
+
const error = normalizeError(exception);
|
|
704
|
+
span.addEvent("exception", {
|
|
705
|
+
[ATTR["exception.type"]]: error.name || "Error",
|
|
706
|
+
[ATTR["exception.message"]]: error.message || "",
|
|
707
|
+
[ATTR["exception.stacktrace"]]: error.stack ?? "",
|
|
708
|
+
});
|
|
709
|
+
},
|
|
710
|
+
isRecording() {
|
|
711
|
+
return !ended;
|
|
712
|
+
},
|
|
713
|
+
end() {
|
|
714
|
+
if (ended) return;
|
|
715
|
+
ended = true;
|
|
716
|
+
emitSpan({
|
|
717
|
+
traceId,
|
|
718
|
+
spanId,
|
|
719
|
+
...(parent ? { parentSpanId: parent.spanId } : {}),
|
|
720
|
+
name,
|
|
721
|
+
kind: SPAN_KIND_INTERNAL,
|
|
722
|
+
startTimeUnixNano,
|
|
723
|
+
endTimeUnixNano: nowUnixNano(),
|
|
724
|
+
attributes: toKeyValues(attributes),
|
|
725
|
+
events,
|
|
726
|
+
status,
|
|
727
|
+
});
|
|
728
|
+
},
|
|
729
|
+
[Symbol.dispose]() {
|
|
730
|
+
span.end();
|
|
731
|
+
},
|
|
732
|
+
};
|
|
733
|
+
return span;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** Create a detached span. Call `span.end()` or use `using`. It does not parent later spans. */
|
|
737
|
+
export function startInactiveSpan(options: StartSpanOptions): DisposableSpan {
|
|
738
|
+
return createSpan(options);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Run `callback` inside an active span that auto-ends. Spans, logs, events,
|
|
743
|
+
* and errors created inside are parented to it. A thrown error is recorded on
|
|
744
|
+
* the span and rethrown.
|
|
745
|
+
*/
|
|
746
|
+
export function startSpan<T>(options: StartSpanOptions, callback: (span: Span) => T): T {
|
|
747
|
+
const span = createSpan(options);
|
|
748
|
+
const onError = (error: unknown) => {
|
|
749
|
+
span.recordException(normalizeError(error));
|
|
750
|
+
span.setStatus({ code: SpanStatusCode.ERROR });
|
|
751
|
+
span.end();
|
|
752
|
+
};
|
|
753
|
+
const result = (() => {
|
|
754
|
+
try {
|
|
755
|
+
return runWithActiveSpan(span.spanContext(), () => {
|
|
756
|
+
return callback(span);
|
|
757
|
+
});
|
|
758
|
+
} catch (error) {
|
|
759
|
+
onError(error);
|
|
760
|
+
throw error;
|
|
761
|
+
}
|
|
762
|
+
})();
|
|
763
|
+
if (result instanceof Promise) {
|
|
764
|
+
return result.then(
|
|
765
|
+
(value) => {
|
|
766
|
+
span.end();
|
|
767
|
+
return value;
|
|
768
|
+
},
|
|
769
|
+
(error) => {
|
|
770
|
+
onError(error);
|
|
771
|
+
throw error;
|
|
772
|
+
},
|
|
773
|
+
) as T;
|
|
774
|
+
}
|
|
775
|
+
span.end();
|
|
776
|
+
return result;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/** Server-side pageview span. Feeds the same analytics views as browser pageviews. */
|
|
780
|
+
export function trackPageview(opts: TrackPageviewOptions): Error | undefined {
|
|
781
|
+
try {
|
|
782
|
+
const url = (() => {
|
|
783
|
+
try {
|
|
784
|
+
return opts.url ? new URL(opts.url) : undefined;
|
|
785
|
+
} catch {
|
|
786
|
+
return undefined;
|
|
787
|
+
}
|
|
788
|
+
})();
|
|
789
|
+
const span = createSpan({
|
|
790
|
+
name: "pageview",
|
|
791
|
+
attributes: {
|
|
792
|
+
...opts.attributes,
|
|
793
|
+
[ATTR["url.path"]]: opts.path || url?.pathname || "/",
|
|
794
|
+
[ATTR["pageview.source"]]: "server",
|
|
795
|
+
// Analytics views require a non-empty session.id.
|
|
796
|
+
[ATTR["session.id"]]: opts.sessionId ?? `server:${crypto.randomUUID()}`,
|
|
797
|
+
...(url ? { [ATTR["url.full"]]: url.href } : {}),
|
|
798
|
+
...(opts.query || url?.search ? { [ATTR["url.query"]]: opts.query || url?.search || "" } : {}),
|
|
799
|
+
...(opts.referrer ? { [ATTR["http.request.header.referer"]]: opts.referrer } : {}),
|
|
800
|
+
...(opts.userId ? { [ATTR["user.id"]]: opts.userId } : {}),
|
|
801
|
+
},
|
|
802
|
+
});
|
|
803
|
+
span.end();
|
|
804
|
+
return undefined;
|
|
805
|
+
} catch (cause) {
|
|
806
|
+
return failure("trackPageview() failed", cause);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
365
810
|
export function flush(): Promise<Error | undefined> {
|
|
366
811
|
const current = state;
|
|
367
812
|
if (!current) return Promise.resolve(undefined);
|
|
368
|
-
if (current.
|
|
369
|
-
const
|
|
370
|
-
|
|
813
|
+
if (current.logs.length === 0 && current.spans.length === 0) return current.inflight;
|
|
814
|
+
const logs = current.logs;
|
|
815
|
+
const spans = current.spans;
|
|
816
|
+
current.logs = [];
|
|
817
|
+
current.spans = [];
|
|
371
818
|
// Chain sends so flush() resolves only after every earlier batch is done.
|
|
372
819
|
current.inflight = current.inflight.then(() => {
|
|
373
|
-
return send(current,
|
|
820
|
+
return send({ current, logs, spans });
|
|
374
821
|
});
|
|
375
822
|
return current.inflight;
|
|
376
823
|
}
|