@safaricom-mxl/effect-sdk 0.0.15 → 0.0.17

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