@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/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 +534 -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,151 @@ 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
|
-
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(
|
|
388
|
+
return failure(`Strada ingest ${path} request failed`, cause);
|
|
189
389
|
}
|
|
190
390
|
}
|
|
191
391
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
body,
|
|
197
|
-
severity,
|
|
198
|
-
attributes,
|
|
392
|
+
async function send({
|
|
393
|
+
current,
|
|
394
|
+
logs,
|
|
395
|
+
spans,
|
|
199
396
|
}: {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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
|
-
});
|
|
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
|
-
},
|
|
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
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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?:
|
|
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
|
|
550
|
+
return emitLog({
|
|
286
551
|
operation: "track()",
|
|
287
|
-
name,
|
|
288
552
|
body: name,
|
|
289
|
-
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
|
|
566
|
+
return emitLog({
|
|
302
567
|
operation: "identifyUser()",
|
|
303
|
-
name,
|
|
304
568
|
body: name,
|
|
305
|
-
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
|
-
|
|
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
|
|
619
|
+
return emitLog({
|
|
345
620
|
operation: "captureException()",
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
621
|
+
body: prepared.message,
|
|
622
|
+
severity: "error",
|
|
623
|
+
eventName: "exception",
|
|
349
624
|
attributes: {
|
|
350
|
-
[ATTR["exception.type"]]:
|
|
351
|
-
[ATTR["exception.message"]]:
|
|
352
|
-
[ATTR["exception.stacktrace"]]:
|
|
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.
|
|
369
|
-
const
|
|
370
|
-
|
|
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,
|
|
818
|
+
return send({ current, logs, spans });
|
|
374
819
|
});
|
|
375
820
|
return current.inflight;
|
|
376
821
|
}
|