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