@safaricom-mxl/effect-sdk 0.0.14 → 0.0.16

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.
@@ -6,6 +6,325 @@ import { Effect, Layer } from "effect";
6
6
  import * as Tracer from "effect/Tracer";
7
7
  import * as Otel from "@opentelemetry/api";
8
8
  import { SpanKind, SpanStatusCode, context, propagation, trace } from "@opentelemetry/api";
9
+ const RESERVED_EVENT_NAMES = ["session_start", "session_end"];
10
+ const isReservedEventName = (name) => RESERVED_EVENT_NAMES.some((reserved) => reserved === name);
11
+ const coerceTrackProps = (props) => {
12
+ if (props === void 0) return {};
13
+ const attributes = {};
14
+ for (const [key, value] of Object.entries(props)) {
15
+ if (value === void 0) continue;
16
+ attributes[key] = typeof value === "string" ? value : String(value);
17
+ }
18
+ return attributes;
19
+ };
20
+ const toEventTimestamp = (value) => (value instanceof Date ? value : new Date(value ?? Date.now())).toISOString();
21
+ const encodeProductEventLine = (input) => {
22
+ const trimmed = typeof input.name === "string" ? input.name.trim() : "";
23
+ if (trimmed.length === 0 || isReservedEventName(trimmed)) return;
24
+ const options = input.options ?? {};
25
+ return {
26
+ attributes: coerceTrackProps(options.attributes),
27
+ group_id: options.groupId ?? "",
28
+ name: trimmed.slice(0, 80),
29
+ page_path: options.pagePath ?? "",
30
+ service_name: input.serviceName,
31
+ session_id: options.sessionId ?? "",
32
+ source: input.source,
33
+ timestamp: toEventTimestamp(options.timestamp),
34
+ traceparent: options.traceparent,
35
+ url: options.url ?? "",
36
+ user_id: options.userId ?? "",
37
+ visitor_id: options.visitorId ?? ""
38
+ };
39
+ };
40
+ const encodeNdjson = (lines) => `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`;
41
+ //#endregion
42
+ //#region src/shared/identity.ts
43
+ const normalizeIdentity = (input) => {
44
+ if (input === null || input === void 0) return;
45
+ if (typeof input === "string") {
46
+ const id = input.trim();
47
+ return id.length > 0 ? { id } : void 0;
48
+ }
49
+ const id = input.id.trim();
50
+ if (id.length === 0) return;
51
+ return {
52
+ email: input.email,
53
+ groupId: input.groupId,
54
+ groupName: input.groupName,
55
+ id,
56
+ traits: input.traits
57
+ };
58
+ };
59
+ //#endregion
60
+ //#region src/version.ts
61
+ const SDK_NAME = "mxl-effect-sdk";
62
+ const SDK_VERSION = "0.0.15";
63
+ //#endregion
64
+ //#region src/browser/analytics.ts
65
+ const DEFAULT_INGEST_URL = "http://127.0.0.1:8000";
66
+ const MAX_QUEUE = 100;
67
+ let config;
68
+ let identity;
69
+ let consentGranted = true;
70
+ let sessionId = "";
71
+ let queue = [];
72
+ let started = false;
73
+ const randomId = () => {
74
+ if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
75
+ return `mxl_${Math.random().toString(36).slice(2)}_${Date.now()}`;
76
+ };
77
+ const storageGet = (key) => {
78
+ try {
79
+ const value = globalThis.sessionStorage?.getItem(key);
80
+ return value && value.length > 0 ? value : void 0;
81
+ } catch {
82
+ return;
83
+ }
84
+ };
85
+ const storageSet = (key, value) => {
86
+ try {
87
+ globalThis.sessionStorage?.setItem(key, value);
88
+ } catch {}
89
+ };
90
+ const resolveSessionId = () => {
91
+ if (sessionId.length > 0) return sessionId;
92
+ sessionId = storageGet("mxl.session.id") ?? randomId();
93
+ storageSet("mxl.session.id", sessionId);
94
+ return sessionId;
95
+ };
96
+ const ingestOrigin = () => (config?.ingestUrl ?? DEFAULT_INGEST_URL).replace(/\/$/, "");
97
+ const headers = () => {
98
+ const next = {
99
+ "content-type": "application/json",
100
+ "mxl-client-id": config?.clientId ?? "",
101
+ "mxl-sdk-name": SDK_NAME,
102
+ "mxl-sdk-version": SDK_VERSION
103
+ };
104
+ if (config?.apiKey) next["mxl-api-key"] = config.apiKey;
105
+ return next;
106
+ };
107
+ const post = (path, body, contentType, keepalive = false) => {
108
+ if (!config?.clientId) return;
109
+ try {
110
+ fetch(`${ingestOrigin()}${path}`, {
111
+ body,
112
+ headers: {
113
+ ...headers(),
114
+ "content-type": contentType
115
+ },
116
+ keepalive,
117
+ method: "POST"
118
+ });
119
+ } catch {}
120
+ };
121
+ const captureAllowed = () => Boolean(config?.clientId) && consentGranted && started;
122
+ const getCurrentIdentity = () => identity;
123
+ const getCurrentUserId = () => identity?.id;
124
+ const getCurrentSessionId = () => resolveSessionId();
125
+ const configureBrowserAnalytics = (next) => {
126
+ config = next;
127
+ consentGranted = next.privacy?.requireConsent !== true;
128
+ started = true;
129
+ resolveSessionId();
130
+ drainQueue();
131
+ };
132
+ const setConsent = (granted) => {
133
+ consentGranted = granted;
134
+ if (granted) drainQueue();
135
+ };
136
+ const identify = (input) => {
137
+ identity = normalizeIdentity(input);
138
+ if (!captureAllowed() || identity === void 0) return;
139
+ post("/track", JSON.stringify({
140
+ payload: {
141
+ email: identity.email,
142
+ firstName: typeof identity.traits?.firstName === "string" ? identity.traits.firstName : void 0,
143
+ lastName: typeof identity.traits?.lastName === "string" ? identity.traits.lastName : void 0,
144
+ profileId: identity.id,
145
+ properties: {
146
+ ...identity.traits ?? {},
147
+ groupId: identity.groupId,
148
+ groupName: identity.groupName
149
+ }
150
+ },
151
+ type: "identify"
152
+ }), "application/json");
153
+ };
154
+ const clearIdentity = () => {
155
+ identity = void 0;
156
+ };
157
+ const postProductAndSession = (name, props, keepalive = false) => {
158
+ const line = encodeProductEventLine({
159
+ name,
160
+ options: {
161
+ attributes: props,
162
+ groupId: identity?.groupId,
163
+ sessionId: resolveSessionId(),
164
+ userId: identity?.id
165
+ },
166
+ serviceName: config?.serviceName ?? "unknown",
167
+ source: "browser"
168
+ });
169
+ if (line === void 0) return;
170
+ post("/events", encodeNdjson([line]), "application/x-ndjson", keepalive);
171
+ post("/sessionEvents", `${JSON.stringify({
172
+ attributes: line.attributes,
173
+ message: line.name,
174
+ session_id: line.session_id,
175
+ timestamp: line.timestamp,
176
+ type: "custom"
177
+ })}\n`, "application/x-ndjson", keepalive);
178
+ };
179
+ const drainQueue = (keepalive = false) => {
180
+ if (!captureAllowed()) return;
181
+ const pending = queue;
182
+ queue = [];
183
+ for (const event of pending) postProductAndSession(event.name, event.props, keepalive);
184
+ };
185
+ const track = (name, props) => {
186
+ if (!started || !consentGranted || !config?.clientId) {
187
+ if (queue.length >= MAX_QUEUE) queue.shift();
188
+ queue.push({
189
+ name,
190
+ props
191
+ });
192
+ return;
193
+ }
194
+ postProductAndSession(name, props);
195
+ };
196
+ const postProfileDelta = (type, property, value) => {
197
+ if (!captureAllowed() || identity === void 0) return;
198
+ post("/track", JSON.stringify({
199
+ payload: {
200
+ profileId: identity.id,
201
+ property,
202
+ value: value ?? 1
203
+ },
204
+ type
205
+ }), "application/json");
206
+ };
207
+ const increment = (property, value) => {
208
+ postProfileDelta("increment", property, value);
209
+ };
210
+ const decrement = (property, value) => {
211
+ postProfileDelta("decrement", property, value);
212
+ };
213
+ const flushAnalytics = (keepalive = false) => {
214
+ drainQueue(keepalive);
215
+ };
216
+ //#endregion
217
+ //#region src/browser/replay.ts
218
+ let stopRecorder;
219
+ let chunkSeq = 0;
220
+ const ingestHeaders = (clientId, apiKey, contentType) => {
221
+ const headers = {
222
+ "content-type": contentType,
223
+ "mxl-client-id": clientId,
224
+ "mxl-sdk-name": SDK_NAME,
225
+ "mxl-sdk-version": SDK_VERSION
226
+ };
227
+ if (apiKey) headers["mxl-api-key"] = apiKey;
228
+ return headers;
229
+ };
230
+ const isWebSdkPresent = () => {
231
+ const global = globalThis;
232
+ return Boolean(global.__MXL_WEB_SDK__ || global.MxlWebSdk || global.__MXL_REPLAY_SESSION__);
233
+ };
234
+ const shouldRecordReplay = (replay) => {
235
+ if (isWebSdkPresent()) return false;
236
+ if (replay?.enabled === false) return false;
237
+ const sampleRate = replay?.sampleRate ?? 1;
238
+ return sampleRate >= 1 || Math.random() < sampleRate;
239
+ };
240
+ const sessionMetaRow = (serviceName) => {
241
+ const identity = getCurrentIdentity();
242
+ const started = /* @__PURE__ */ new Date();
243
+ return {
244
+ service_name: serviceName,
245
+ session_id: getCurrentSessionId(),
246
+ start_time: started.toISOString().replace("T", " ").replace("Z", ""),
247
+ status: "active",
248
+ url_initial: typeof globalThis.location?.href === "string" ? globalThis.location.href : "",
249
+ user_agent: typeof globalThis.navigator?.userAgent === "string" ? globalThis.navigator.userAgent : "",
250
+ user_id: identity?.id ?? "",
251
+ version: 1
252
+ };
253
+ };
254
+ const postSessionReplayMeta = (ingestUrl, clientId, serviceName, apiKey) => {
255
+ try {
256
+ fetch(`${ingestUrl.replace(/\/$/, "")}/sessionReplays/meta`, {
257
+ body: `${JSON.stringify(sessionMetaRow(serviceName))}\n`,
258
+ headers: ingestHeaders(clientId, apiKey, "application/x-ndjson"),
259
+ keepalive: true,
260
+ method: "POST"
261
+ });
262
+ } catch {}
263
+ };
264
+ const postSessionReplayBlob = (ingestUrl, clientId, meta, body, apiKey) => {
265
+ try {
266
+ fetch(`${ingestUrl.replace(/\/$/, "")}/sessionReplays/blob`, {
267
+ body,
268
+ headers: {
269
+ ...ingestHeaders(clientId, apiKey, "application/octet-stream"),
270
+ "x-mxl-chunk-seq": String(meta.chunkSeq),
271
+ "x-mxl-duration-ms": String(meta.durationMs),
272
+ "x-mxl-event-count": String(meta.eventCount),
273
+ "x-mxl-is-checkpoint": meta.isCheckpoint ? "1" : "0",
274
+ "x-mxl-session-id": meta.sessionId
275
+ },
276
+ keepalive: true,
277
+ method: "POST"
278
+ });
279
+ } catch {}
280
+ };
281
+ const gzipJson = async (value) => {
282
+ if (typeof CompressionStream === "undefined") return;
283
+ const encoded = new TextEncoder().encode(JSON.stringify(value));
284
+ const stream = new Blob([encoded]).stream().pipeThrough(new CompressionStream("gzip"));
285
+ return new Uint8Array(await new Response(stream).arrayBuffer());
286
+ };
287
+ const stopReplay = () => {
288
+ stopRecorder?.();
289
+ stopRecorder = void 0;
290
+ };
291
+ const startReplay = async (config) => {
292
+ if (!shouldRecordReplay(config.replay)) return;
293
+ stopReplay();
294
+ chunkSeq = 0;
295
+ postSessionReplayMeta(config.ingestUrl, config.clientId, config.serviceName, config.apiKey);
296
+ try {
297
+ const rrweb = await import(
298
+ /* @vite-ignore */
299
+ "rrweb"
300
+ );
301
+ if (typeof rrweb.record !== "function") return;
302
+ const events = [];
303
+ const startedAt = Date.now();
304
+ const stop = rrweb.record({
305
+ emit: (event) => {
306
+ events.push(event);
307
+ },
308
+ maskAllInputs: config.replay?.maskAllInputs ?? true,
309
+ maskTextSelector: config.replay?.maskAllText === true ? "*" : void 0
310
+ });
311
+ stopRecorder = () => {
312
+ stop?.();
313
+ if (events.length === 0) return;
314
+ gzipJson(events).then((gzipped) => {
315
+ if (!gzipped) return;
316
+ postSessionReplayBlob(config.ingestUrl, config.clientId, {
317
+ chunkSeq: chunkSeq++,
318
+ durationMs: Date.now() - startedAt,
319
+ eventCount: events.length,
320
+ isCheckpoint: true,
321
+ sessionId: getCurrentSessionId()
322
+ }, gzipped, config.apiKey);
323
+ });
324
+ };
325
+ } catch {}
326
+ };
327
+ //#endregion
9
328
  //#region src/server/otel-context-bridge.ts
10
329
  const OTEL_SPAN_TYPE_ID = "~@effect/opentelemetry/OtelTracer/OtelSpan";
11
330
  const evaluate = "~effect/Effect/evaluate";
@@ -27,11 +346,11 @@ function wrapTracerWithOtelContextBridge(base) {
27
346
  return base.span(options);
28
347
  },
29
348
  context(primitive, fiber) {
30
- if (fiber.currentSpan === void 0) {
349
+ if (fiber.cache.span === void 0) {
31
350
  if (existingContext) return existingContext(primitive, fiber);
32
351
  return primitive[evaluate](fiber);
33
352
  }
34
- return Otel.context.with(effectSpanToOtelContext(Otel.context.active(), fiber.currentSpan), () => {
353
+ return Otel.context.with(effectSpanToOtelContext(Otel.context.active(), fiber.cache.span), () => {
35
354
  if (existingContext) return existingContext(primitive, fiber);
36
355
  return primitive[evaluate](fiber);
37
356
  });
@@ -50,7 +369,36 @@ function normalizeOtlpBaseUrl(url) {
50
369
  }
51
370
  //#endregion
52
371
  //#region src/browser/layer.ts
53
- var layer_exports = /* @__PURE__ */ __exportAll({ layer: () => layer });
372
+ var layer_exports = /* @__PURE__ */ __exportAll({
373
+ clearIdentity: () => clearIdentity$1,
374
+ identify: () => identify$1,
375
+ layer: () => layer,
376
+ stampUserIdOnSpan: () => stampUserIdOnSpan,
377
+ track: () => track$1
378
+ });
379
+ const stampUserIdOnSpan = (span) => {
380
+ const userId = getCurrentUserId();
381
+ if (userId) span.setAttribute("user.id", userId);
382
+ };
383
+ var UserIdSpanProcessor = class {
384
+ inner;
385
+ constructor(inner) {
386
+ this.inner = inner;
387
+ }
388
+ forceFlush() {
389
+ return this.inner.forceFlush();
390
+ }
391
+ onEnd(span) {
392
+ this.inner.onEnd(span);
393
+ }
394
+ onStart(span, parentContext) {
395
+ stampUserIdOnSpan(span);
396
+ this.inner.onStart(span, parentContext);
397
+ }
398
+ shutdown() {
399
+ return this.inner.shutdown();
400
+ }
401
+ };
54
402
  function resolveBrowserEndpoint(config) {
55
403
  if (config.disabled) return;
56
404
  if (config.endpoint && config.endpoint.length > 0) return normalizeOtlpBaseUrl(config.endpoint);
@@ -61,6 +409,22 @@ function resolveBrowserEndpoint(config) {
61
409
  * Returns a no-op layer when `disabled` is true or no OTLP endpoint is configured.
62
410
  */
63
411
  const layer = (config = {}) => {
412
+ if (config.clientId) {
413
+ configureBrowserAnalytics({
414
+ apiKey: config.apiKey,
415
+ clientId: config.clientId,
416
+ ingestUrl: config.ingestUrl,
417
+ privacy: config.privacy,
418
+ serviceName: config.serviceName
419
+ });
420
+ startReplay({
421
+ apiKey: config.apiKey,
422
+ clientId: config.clientId,
423
+ ingestUrl: config.ingestUrl ?? "http://127.0.0.1:8000",
424
+ replay: config.replay,
425
+ serviceName: config.serviceName ?? "unknown"
426
+ });
427
+ }
64
428
  const endpoint = resolveBrowserEndpoint(config);
65
429
  if (!endpoint) return Layer.empty;
66
430
  const attributes = {
@@ -71,17 +435,115 @@ const layer = (config = {}) => {
71
435
  } : {},
72
436
  ...config.attributes ?? {}
73
437
  };
74
- return WebSdk.layer({
438
+ return WebSdk.layer(() => ({
75
439
  resource: {
440
+ attributes,
76
441
  serviceName: config.serviceName ?? "unknown",
77
- serviceVersion: config.serviceVersion,
78
- attributes
442
+ serviceVersion: config.serviceVersion
79
443
  },
80
- spanProcessor: new BatchSpanProcessor(new OTLPTraceExporter({
81
- url: `${endpoint}/v1/traces`,
82
- headers: config.headers
83
- }))
84
- }).pipe(Layer.updateService(Tracer.Tracer, wrapTracerWithOtelContextBridge));
444
+ spanProcessor: new UserIdSpanProcessor(new BatchSpanProcessor(new OTLPTraceExporter({
445
+ headers: config.headers,
446
+ url: `${endpoint}/v1/traces`
447
+ })))
448
+ })).pipe(Layer.updateService(Tracer.Tracer, wrapTracerWithOtelContextBridge));
449
+ };
450
+ const identify$1 = (input) => Effect.sync(() => identify(input));
451
+ const clearIdentity$1 = Effect.sync(() => clearIdentity());
452
+ const track$1 = (name, props) => Effect.sync(() => track(name, props));
453
+ //#endregion
454
+ //#region src/shared/flush-core.ts
455
+ const COOLDOWN_MS = 6e4;
456
+ const makeSerializedFlush = (flush) => {
457
+ let pending = Promise.resolve();
458
+ return (...args) => {
459
+ pending = pending.then(() => flush(...args), () => flush(...args));
460
+ return pending;
461
+ };
462
+ };
463
+ const guardFlush = (prefix, flush, state) => {
464
+ return async (...args) => {
465
+ if (state && Date.now() < state.disabledUntil) return;
466
+ try {
467
+ await flush(...args);
468
+ } catch (error) {
469
+ if (state) state.disabledUntil = Date.now() + COOLDOWN_MS;
470
+ console.error(`${prefix} flush failed; cooling down 60s`, error);
471
+ }
472
+ };
473
+ };
474
+ //#endregion
475
+ //#region src/browser/flushable.ts
476
+ var flushable_exports = /* @__PURE__ */ __exportAll({
477
+ attachProcessor: () => attachProcessor,
478
+ make: () => make
479
+ });
480
+ const processors = /* @__PURE__ */ new WeakMap();
481
+ const DEFAULT_AUTO_FLUSH_MS = 5e3;
482
+ const attachProcessor = (telemetry, processor) => {
483
+ processors.set(telemetry, processor);
484
+ };
485
+ const make = (config = {}) => {
486
+ const layer$1 = layer(config);
487
+ const state = { disabledUntil: 0 };
488
+ const listeners = [];
489
+ const telemetry = {
490
+ captureException: (error, options) => {
491
+ track(options?.name ?? "uncaught_error", {
492
+ ...options?.attributes,
493
+ message: error instanceof Error ? error.message : String(error)
494
+ });
495
+ },
496
+ dispose: async () => {
497
+ for (const stop of listeners) stop();
498
+ listeners.length = 0;
499
+ stopReplay();
500
+ await telemetry.flush();
501
+ },
502
+ flush: makeSerializedFlush(guardFlush("[MxlFlush]", async () => {
503
+ flushAnalytics(true);
504
+ await processors.get(telemetry)?.forceFlush();
505
+ }, state)),
506
+ layer: layer$1
507
+ };
508
+ const intervalMs = config.autoFlushInterval === void 0 ? DEFAULT_AUTO_FLUSH_MS : config.autoFlushInterval === false ? 0 : config.autoFlushInterval;
509
+ if (intervalMs > 0) {
510
+ const timer = setInterval(() => {
511
+ telemetry.flush();
512
+ }, intervalMs);
513
+ timer.unref?.();
514
+ listeners.push(() => {
515
+ clearInterval(timer);
516
+ });
517
+ }
518
+ const documentRef = globalThis.document;
519
+ if (config.flushOnUnload !== false && documentRef) {
520
+ const onHidden = () => {
521
+ telemetry.flush();
522
+ };
523
+ const onVisibilityChange = () => {
524
+ if (documentRef.visibilityState === "hidden") onHidden();
525
+ };
526
+ documentRef.addEventListener("visibilitychange", onVisibilityChange);
527
+ globalThis.addEventListener?.("pagehide", onHidden);
528
+ listeners.push(() => {
529
+ documentRef.removeEventListener("visibilitychange", onVisibilityChange);
530
+ globalThis.removeEventListener?.("pagehide", onHidden);
531
+ });
532
+ }
533
+ if (config.captureGlobalErrors !== false && typeof globalThis.addEventListener === "function") {
534
+ const onError = (event) => {
535
+ const record = event;
536
+ telemetry.captureException(record.error ?? record.reason ?? event);
537
+ telemetry.flush();
538
+ };
539
+ globalThis.addEventListener("error", onError);
540
+ globalThis.addEventListener("unhandledrejection", onError);
541
+ listeners.push(() => {
542
+ globalThis.removeEventListener("error", onError);
543
+ globalThis.removeEventListener("unhandledrejection", onError);
544
+ });
545
+ }
546
+ return telemetry;
85
547
  };
86
548
  //#endregion
87
549
  //#region src/shared/dependency-spans.ts
@@ -189,6 +651,6 @@ async function withPromiseDependencySpan(name, attributes, operation, kind = "cl
189
651
  });
190
652
  }
191
653
  //#endregion
192
- export { layer_exports as Mxl, addSpanLinksFromTraceCarriers, bufferSpanAttributes, dbClientSpanAttributes, geoSpanAttributes, kafkaProducerSpanAttributes, queueConsumerSpanAttributes, queueProducerSpanAttributes, withDependencySpan, withPromiseDependencySpan };
654
+ export { layer_exports as Mxl, flushable_exports as MxlFlush, addSpanLinksFromTraceCarriers, bufferSpanAttributes, clearIdentity, dbClientSpanAttributes, decrement, geoSpanAttributes, identify, increment, kafkaProducerSpanAttributes, queueConsumerSpanAttributes, queueProducerSpanAttributes, setConsent, track, withDependencySpan, withPromiseDependencySpan };
193
655
 
194
656
  //# sourceMappingURL=index.js.map