@safaricom-mxl/effect-sdk 0.0.22 → 0.0.24

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.
@@ -1,101 +1,45 @@
1
- import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
2
- import { Cause, Config, Context, Duration, Effect, Layer, ManagedRuntime, Match, Option, References, Schedule, Schema, Semaphore } from "effect";
1
+ import { t as __exportAll } from "./rolldown-runtime-D8jQpDG7.mjs";
2
+ import { Cause, Config, Context, Duration, Effect, Layer, ManagedRuntime, Match, Option, Predicate, References, Schedule, Schema, Semaphore } from "effect";
3
+ import * as Otel from "@opentelemetry/api";
4
+ import { SpanKind, SpanStatusCode, TraceFlags, context, propagation, trace } from "@opentelemetry/api";
3
5
  import { FetchHttpClient, HttpClient, HttpClientRequest, HttpMiddleware } from "effect/http";
6
+ import { SchemaJITCompiler } from "effect/schema";
7
+ import * as Option$1 from "effect/Option";
4
8
  import { Otlp } from "effect/observability";
5
9
  import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks";
6
- import * as Otel from "@opentelemetry/api";
7
- import { SpanKind, SpanStatusCode, TraceFlags, context, propagation, trace } from "@opentelemetry/api";
8
- import * as Tracer from "effect/Tracer";
10
+ import * as Tracer$1 from "effect/Tracer";
9
11
  import { v7 } from "uuid";
10
12
  import { platform, provider, runtime } from "std-env";
11
- import { SchemaJITCompiler } from "effect/schema";
12
- import * as Option$1 from "effect/Option";
13
13
  import * as OtelTracer from "@effect/opentelemetry/OtelTracer";
14
- //#region src/server/otel-context-bridge.ts
15
- const evaluate = "~effect/Effect/evaluate";
16
- let contextManagerInstalled = false;
17
- /**
18
- * Install AsyncLocalStorage so `Otel.context.with` actually propagates.
19
- * Does not replace a context manager the host app already registered.
20
- */
21
- function ensureOtelContextManager() {
22
- if (contextManagerInstalled) return;
23
- const manager = new AsyncLocalStorageContextManager();
24
- manager.enable();
25
- if (!Otel.context.setGlobalContextManager(manager)) {
26
- manager.disable();
27
- contextManagerInstalled = true;
28
- return;
29
- }
30
- contextManagerInstalled = true;
31
- }
32
- function formatTraceparent(span) {
33
- const flags = span.sampled ? "01" : "00";
34
- return `00-${span.traceId}-${span.spanId}-${flags}`;
35
- }
36
- function buildTraceCarrierFromSpan(span, headers) {
37
- const carrier = { traceparent: formatTraceparent(span) };
38
- if (headers?.["tracestate"]) carrier["tracestate"] = headers["tracestate"];
39
- if (headers?.["baggage"]) carrier["baggage"] = headers["baggage"];
40
- return carrier;
41
- }
42
- /**
43
- * Put Effect span ids on the global OpenTelemetry context.
44
- *
45
- * Id-only `setSpanContext`. Live `Otel.Span` objects come from
46
- * `OtelTracer.currentOtelSpan` in Effect space, not from a private TypeId.
47
- */
48
- function effectSpanToOtelContext(activeContext, span) {
49
- return Otel.trace.setSpanContext(activeContext, {
50
- isRemote: span._tag === "ExternalSpan",
51
- spanId: span.spanId,
52
- traceFlags: span.sampled ? Otel.TraceFlags.SAMPLED : Otel.TraceFlags.NONE,
53
- traceId: span.traceId
54
- });
55
- }
56
- /**
57
- * Wrap a tracer so Effect spans activate global `@opentelemetry/api` context.
58
- *
59
- * Prefer this over `Otlp.layerJson({ tracerContext })` until Effect changes
60
- * that hook. On effect@4.0.2 the callback is still `(primitive, span)`
61
- * and must return the evaluation, but `EffectPrimitive` only evaluates with a
62
- * `fiber`. Keep this wrap until Effect passes fiber or evaluates after the hook.
63
- */
64
- function wrapTracerWithOtelContextBridge(base) {
65
- ensureOtelContextManager();
66
- const existingContext = base.context;
67
- return Tracer.make({
68
- span(options) {
69
- return base.span(options);
70
- },
71
- context(primitive, fiber) {
72
- if (fiber.cache.span === void 0) {
73
- if (existingContext) return existingContext(primitive, fiber);
74
- return primitive[evaluate](fiber);
75
- }
76
- return Otel.context.with(effectSpanToOtelContext(Otel.context.active(), fiber.cache.span), () => {
77
- if (existingContext) return existingContext(primitive, fiber);
78
- return primitive[evaluate](fiber);
79
- });
80
- }
81
- });
82
- }
83
- /**
84
- * Layer that wraps the configured Effect tracer with a global OTel context bridge.
85
- *
86
- * Must be provided over `Otlp.layerJson` so queue propagation, log correlation,
87
- * and outbound W3C headers see the active server span.
88
- */
89
- const otelGlobalContextBridgeLayer = Layer.effect(Tracer.Tracer, Effect.gen(function* () {
90
- return wrapTracerWithOtelContextBridge(yield* Tracer.Tracer);
91
- }));
92
- //#endregion
93
- //#region src/server/otlp.ts
94
- function normalizeOtlpBaseUrl(url) {
95
- const trimmed = url.replace(/\/$/, "");
96
- if (trimmed.endsWith("/v1/traces") || trimmed.endsWith("/v1/logs")) return trimmed.replace(/\/v1\/(traces|logs|metrics)$/, "");
97
- return trimmed;
98
- }
14
+ // src/shared/span-options.ts
15
+ const prefixMatcher = (prefixes) => {
16
+ const list = prefixes === void 0 ? [] : [...prefixes];
17
+ return list.length > 0 ? (name) => list.some((prefix) => name.startsWith(prefix)) : void 0;
18
+ };
19
+ const failureIdentifier = (error) => {
20
+ if (Predicate.hasProperty(error, "_tag") && typeof error._tag === "string") return error._tag;
21
+ if (Predicate.hasProperty(error, "name") && typeof error.name === "string") return error.name;
22
+ const body = Predicate.hasProperty(error, "error") ? error.error : void 0;
23
+ if (Predicate.hasProperty(body, "_tag") && typeof body._tag === "string") return body._tag;
24
+ };
25
+ const resolveSpanFilter = (input = {}) => {
26
+ const dropName = prefixMatcher(input.dropSpanNames);
27
+ const dropPredicate = input.dropSpan;
28
+ const drop = dropName !== void 0 && dropPredicate !== void 0 ? (span) => dropName(span.name) || dropPredicate(span) : dropName !== void 0 ? (span) => dropName(span.name) : dropPredicate;
29
+ const identifiers = /* @__PURE__ */ new Set([...input.anticipatedErrorIdentifiers ?? [], ...input.anticipatedErrorTags ?? []]);
30
+ const byIdentifier = identifiers.size > 0 ? (error) => {
31
+ const identifier = failureIdentifier(error);
32
+ return identifier !== void 0 && identifiers.has(identifier);
33
+ } : void 0;
34
+ const byPredicate = input.isAnticipatedError;
35
+ const isAnticipated = byIdentifier !== void 0 && byPredicate !== void 0 ? (error) => byIdentifier(error) || byPredicate(error) : byIdentifier ?? byPredicate;
36
+ return {
37
+ dropSubtree: prefixMatcher(input.dropSpanSubtrees),
38
+ drop,
39
+ isAnticipated,
40
+ includeCauseInStack: input.includeCauseInStack ?? false
41
+ };
42
+ };
99
43
  /** Resolve the OTLP exporter endpoint from standard env vars. */
100
44
  const endpoint = Effect.gen(function* () {
101
45
  const otlp = yield* Config.option(Config.String("OTEL_EXPORTER_OTLP_ENDPOINT"));
@@ -147,36 +91,547 @@ const otelResourceAttributes = Effect.gen(function* () {
147
91
  onNone: () => ({}),
148
92
  onSome: (value) => parseOtelResourceAttributes(value)
149
93
  });
150
- });
94
+ });
95
+ /**
96
+ * Parse `OTEL_EXPORTER_OTLP_HEADERS` (`key=value,key2=value2`) into a header map.
97
+ */
98
+ const parseOtlpHeaders = (input) => {
99
+ const result = {};
100
+ for (const pair of input.split(",")) {
101
+ const eq = pair.indexOf("=");
102
+ if (eq === -1) continue;
103
+ const key = pair.slice(0, eq).trim();
104
+ if (!key) continue;
105
+ const raw = pair.slice(eq + 1).trim();
106
+ try {
107
+ result[key] = decodeURIComponent(raw);
108
+ } catch {
109
+ result[key] = raw;
110
+ }
111
+ }
112
+ return result;
113
+ };
114
+ /** Resolve OTLP export headers from env. */
115
+ const otlpHeaders = Effect.gen(function* () {
116
+ const raw = yield* Config.option(Config.String("OTEL_EXPORTER_OTLP_HEADERS"));
117
+ return Option.match(raw, {
118
+ onNone: () => ({}),
119
+ onSome: (value) => parseOtlpHeaders(value)
120
+ });
121
+ });
122
+ // src/shared/try-sync.ts
123
+ /** Run a throwing synchronous call, `None` if it threw. */
124
+ const trySync = (thunk) => Effect.runSync(Effect.option(Effect.try(thunk)));
125
+ /** Run a throwing synchronous call, `undefined` if it threw. */
126
+ const trySyncOrUndefined = (thunk) => Option.getOrUndefined(trySync(thunk));
127
+ // src/server/container.ts
128
+ const CONTAINER_ID_RE = /\/docker\/containers\/([0-9a-f]{64})\//;
129
+ const CGROUP_ID_RE = /([0-9a-f]{64})/;
130
+ const SHORT_ID_HOSTNAME_RE = /^[0-9a-f]{12}$/;
131
+ /**
132
+ * Pure derivation from probed inputs — the testable core. Every probe call is
133
+ * guarded by the caller (`getContainerAttributes` wraps the whole thing).
134
+ */
135
+ const deriveContainerAttributes = (probe) => {
136
+ const inDocker = trySyncOrUndefined(() => probe.exists("/.dockerenv")) ?? false;
137
+ const attrs = {};
138
+ if (inDocker) attrs["container.runtime.name"] = "docker";
139
+ const containerId = trySyncOrUndefined(() => probe.readFile("/proc/self/mountinfo").match(CONTAINER_ID_RE)?.[1]) ?? trySyncOrUndefined(() => probe.readFile("/proc/self/cgroup").match(CGROUP_ID_RE)?.[1]) ?? (inDocker ? trySyncOrUndefined(() => {
140
+ const name = probe.hostname();
141
+ return SHORT_ID_HOSTNAME_RE.test(name) ? name : void 0;
142
+ }) : void 0);
143
+ if (containerId) attrs["container.id"] = containerId;
144
+ return attrs;
145
+ };
146
+ let cached;
147
+ /**
148
+ * Read live container identity from the filesystem, memoized for the process
149
+ * lifetime (container identity cannot change under a running process). Returns
150
+ * `{}` anywhere the probes aren't available — non-Linux, workerd, browsers.
151
+ */
152
+ const getContainerAttributes = () => {
153
+ if (cached) return cached;
154
+ const proc = globalThis.process;
155
+ const loadBuiltin = proc?.getBuiltinModule;
156
+ if (proc?.platform !== "linux" || typeof loadBuiltin !== "function") {
157
+ cached = {};
158
+ return cached;
159
+ }
160
+ const fs = trySyncOrUndefined(() => loadBuiltin("node:fs"));
161
+ const os = trySyncOrUndefined(() => loadBuiltin("node:os"));
162
+ if (!fs || !os) {
163
+ cached = {};
164
+ return cached;
165
+ }
166
+ cached = deriveContainerAttributes({
167
+ exists: (path) => fs.existsSync(path),
168
+ readFile: (path) => fs.readFileSync(path, "utf8"),
169
+ hostname: () => os.hostname()
170
+ });
171
+ return cached;
172
+ };
173
+ // src/shared/dependency-spans.ts
174
+ const MAX_FLUSH_SPAN_LINKS = 32;
175
+ function dbClientSpanAttributes(dbSystem, operation) {
176
+ return {
177
+ "db.system": dbSystem,
178
+ "db.system.name": dbSystem,
179
+ "peer.service": dbSystem,
180
+ ...operation ? { "db.operation.name": operation } : {}
181
+ };
182
+ }
183
+ function queueProducerSpanAttributes(destinationName, peerService = "mxl-worker") {
184
+ return {
185
+ "messaging.destination.name": destinationName,
186
+ "messaging.system": "bullmq",
187
+ "peer.service": peerService
188
+ };
189
+ }
190
+ function queueConsumerSpanAttributes(destinationName) {
191
+ return {
192
+ "messaging.destination.name": destinationName,
193
+ "messaging.system": "bullmq"
194
+ };
195
+ }
196
+ function kafkaProducerSpanAttributes(topic, peerService = "kafka") {
197
+ return {
198
+ "messaging.destination.name": topic,
199
+ "messaging.system": "kafka",
200
+ "peer.service": peerService
201
+ };
202
+ }
203
+ function bufferSpanAttributes(bufferName, operation) {
204
+ return {
205
+ "buffer.name": bufferName,
206
+ "db.operation.name": operation,
207
+ "db.system": "redis",
208
+ "db.system.name": "redis",
209
+ "peer.service": "redis"
210
+ };
211
+ }
212
+ function geoSpanAttributes() {
213
+ return {
214
+ "db.operation.name": "resolve",
215
+ "db.system": "maxmind",
216
+ "db.system.name": "maxmind",
217
+ "peer.service": "maxmind"
218
+ };
219
+ }
220
+ function addSpanLinksFromTraceCarriers(carriers) {
221
+ const span = trace.getActiveSpan();
222
+ if (!span) return 0;
223
+ const seen = /* @__PURE__ */ new Set();
224
+ let linked = 0;
225
+ for (const carrier of carriers) {
226
+ if (!carrier.traceparent || seen.has(carrier.traceparent)) continue;
227
+ seen.add(carrier.traceparent);
228
+ const extracted = propagation.extract(context.active(), carrier);
229
+ const linkedContext = trace.getSpanContext(extracted);
230
+ if (!linkedContext) continue;
231
+ span.addLink({ context: linkedContext });
232
+ linked += 1;
233
+ if (linked >= MAX_FLUSH_SPAN_LINKS) break;
234
+ }
235
+ return linked;
236
+ }
237
+ function withDependencySpan(name, attributesOrOptions, program) {
238
+ const options = isWithDependencySpanOptions(attributesOrOptions) ? attributesOrOptions : { attributes: attributesOrOptions };
239
+ const attributes = options.attributes ?? {};
240
+ const kind = options.kind ?? "client";
241
+ return program.pipe(Effect.withSpan(name, {
242
+ attributes,
243
+ kind
244
+ }));
245
+ }
246
+ function isWithDependencySpanOptions(value) {
247
+ return typeof value === "object" && ("kind" in value || "attributes" in value && typeof value.attributes === "object");
248
+ }
249
+ /** Promise-based dependency span for non-Effect code paths. */
250
+ async function withPromiseDependencySpan(name, attributes, operation, kind = "client") {
251
+ const tracer = trace.getTracer("mxl-effect-sdk");
252
+ const spanKind = kind === "producer" ? SpanKind.PRODUCER : kind === "consumer" ? SpanKind.CONSUMER : SpanKind.CLIENT;
253
+ return tracer.startActiveSpan(name, {
254
+ attributes,
255
+ kind: spanKind
256
+ }, async (span) => {
257
+ try {
258
+ const result = await operation();
259
+ span.end();
260
+ return result;
261
+ } catch (error) {
262
+ if (error instanceof Error) {
263
+ span.recordException(error);
264
+ span.setStatus({
265
+ code: SpanStatusCode.ERROR,
266
+ message: error.message
267
+ });
268
+ } else span.setStatus({
269
+ code: SpanStatusCode.ERROR,
270
+ message: String(error)
271
+ });
272
+ span.end();
273
+ throw error;
274
+ }
275
+ });
276
+ }
277
+ const RESERVED_EVENT_NAMES = ["session_start", "session_end"];
278
+ const isReservedEventName = (name) => RESERVED_EVENT_NAMES.some((reserved) => reserved === name);
279
+ const coerceTrackProps = (props) => {
280
+ if (props === void 0) return {};
281
+ const attributes = {};
282
+ for (const [key, value] of Object.entries(props)) {
283
+ if (value === void 0) continue;
284
+ attributes[key] = typeof value === "string" ? value : String(value);
285
+ }
286
+ return attributes;
287
+ };
288
+ const toEventTimestamp = (value) => (value instanceof Date ? value : new Date(value ?? Date.now())).toISOString();
289
+ const encodeProductEventLine = (input) => {
290
+ const trimmed = typeof input.name === "string" ? input.name.trim() : "";
291
+ if (trimmed.length === 0 || isReservedEventName(trimmed)) return;
292
+ const options = input.options ?? {};
293
+ return {
294
+ attributes: coerceTrackProps(options.attributes),
295
+ group_id: options.groupId ?? "",
296
+ name: trimmed.slice(0, 80),
297
+ page_path: options.pagePath ?? "",
298
+ service_name: input.serviceName,
299
+ session_id: options.sessionId ?? "",
300
+ source: input.source,
301
+ timestamp: toEventTimestamp(options.timestamp),
302
+ traceparent: options.traceparent,
303
+ url: options.url ?? "",
304
+ user_id: options.userId ?? "",
305
+ visitor_id: options.visitorId ?? ""
306
+ };
307
+ };
308
+ const encodeNdjson = (lines) => `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`;
309
+ // src/version.ts
310
+ const SDK_NAME = "mxl-effect-sdk";
311
+ const SDK_VERSION = "0.0.24";
312
+ // src/server/events.ts
313
+ var events_exports = /* @__PURE__ */ __exportAll({
314
+ MxlEvents: () => MxlEvents,
315
+ ProductEventsPostError: () => ProductEventsPostError,
316
+ layer: () => layer$1,
317
+ make: () => make$1,
318
+ makeHandle: () => makeHandle
319
+ });
320
+ const DEFAULT_FLUSH_INTERVAL = Duration.seconds(5);
321
+ const DEFAULT_MAX_BATCH_SIZE = 100;
322
+ const MAX_BUFFERED_EVENTS = 1e3;
323
+ const WARN_INTERVAL_MS = 3e4;
324
+ const DEFAULT_BASKET_URL = "http://127.0.0.1:8000";
325
+ var MxlEvents = class extends Context.Service()("@safaricom-mxl/effect-sdk/MxlEvents") {};
326
+ var ProductEventsPostError = class extends Schema.TaggedError()("@safaricom-mxl/effect-sdk/ProductEventsPostError", {
327
+ cause: Schema.optionalKey(Schema.Defect()),
328
+ message: Schema.String,
329
+ status: Schema.optionalKey(Schema.Number)
330
+ }) {};
331
+ SchemaJITCompiler.enable(ProductEventsPostError.ast);
332
+ const stringOrUndefined$1 = (value) => value && value.length > 0 ? value : void 0;
333
+ const resolveEventsConfig = (config) => {
334
+ const endpoint = (config.endpoint ?? process.env["MXL_BASKET_URL"] ?? DEFAULT_BASKET_URL).replace(/\/$/, "");
335
+ return {
336
+ apiKey: stringOrUndefined$1(config.apiKey ?? process.env["MXL_API_KEY"]),
337
+ clientId: stringOrUndefined$1(config.clientId ?? process.env["MXL_CLIENT_ID"]),
338
+ serviceName: config.serviceName ?? process.env["OTEL_SERVICE_NAME"] ?? "unknown",
339
+ url: `${endpoint}${config.eventsPath ?? "/events"}`
340
+ };
341
+ };
342
+ const make$1 = Effect.fn("MxlEvents.make")(function* (config = {}) {
343
+ const resolved = resolveEventsConfig(config);
344
+ const maxBatchSize = Math.max(1, config.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE);
345
+ const client = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk);
346
+ const flushing = yield* Semaphore.make(1);
347
+ let buffer = [];
348
+ let warnedAboutName = false;
349
+ let warnedAboutClient = false;
350
+ let lastPostWarnAt = 0;
351
+ const warnPost = (error) => {
352
+ const now = Date.now();
353
+ if (now - lastPostWarnAt < WARN_INTERVAL_MS) return;
354
+ lastPostWarnAt = now;
355
+ console.warn(`[MxlEvents] ${error.message} (dropping batch)`, error.cause ?? "");
356
+ };
357
+ const warnMissingClient = () => {
358
+ if (warnedAboutClient) return;
359
+ warnedAboutClient = true;
360
+ console.warn("[MxlEvents] no client id — set MXL_CLIENT_ID or pass `clientId`; dropping events");
361
+ };
362
+ const warnEmptyName = () => {
363
+ if (warnedAboutName) return;
364
+ warnedAboutName = true;
365
+ console.warn("[MxlEvents] track() needs a non-empty event name; the call was ignored.");
366
+ };
367
+ const post = (lines) => {
368
+ const headers = {
369
+ "mxl-client-id": resolved.clientId ?? "",
370
+ "mxl-sdk-name": SDK_NAME,
371
+ "mxl-sdk-version": SDK_VERSION,
372
+ "user-agent": `${SDK_NAME}-server/${SDK_VERSION}`
373
+ };
374
+ if (resolved.apiKey) headers["mxl-api-key"] = resolved.apiKey;
375
+ return HttpClientRequest.post(resolved.url).pipe(HttpClientRequest.setHeaders(headers), HttpClientRequest.bodyText(encodeNdjson(lines), "application/x-ndjson"), client.execute, Effect.asVoid, Effect.mapError((error) => error.response !== void 0 ? new ProductEventsPostError({
376
+ message: `POST ${resolved.url} → ${error.response.status}`,
377
+ status: error.response.status
378
+ }) : new ProductEventsPostError({
379
+ cause: error,
380
+ message: `POST ${resolved.url} failed: ${error.message}`
381
+ })));
382
+ };
383
+ const flush = flushing.withPermits(1)(Effect.suspend(() => {
384
+ if (buffer.length === 0) return Effect.void;
385
+ const batch = buffer;
386
+ buffer = [];
387
+ if (resolved.clientId === void 0) {
388
+ warnMissingClient();
389
+ return Effect.void;
390
+ }
391
+ return post(batch).pipe(Effect.catchCause((cause) => Effect.sync(() => {
392
+ warnPost(Option.getOrElse(Cause.findErrorOption(cause), () => new ProductEventsPostError({
393
+ cause: Cause.squash(cause),
394
+ message: `POST ${resolved.url} failed`
395
+ })));
396
+ })));
397
+ }));
398
+ const track = (name, options = {}) => Effect.sync(() => {
399
+ const line = encodeProductEventLine({
400
+ name,
401
+ options,
402
+ serviceName: resolved.serviceName,
403
+ source: "server"
404
+ });
405
+ if (line === void 0) {
406
+ warnEmptyName();
407
+ return false;
408
+ }
409
+ buffer.push(line);
410
+ if (buffer.length > MAX_BUFFERED_EVENTS) buffer.splice(0, buffer.length - MAX_BUFFERED_EVENTS);
411
+ return buffer.length >= maxBatchSize;
412
+ }).pipe(Effect.flatMap((full) => full ? Effect.forkDetach(flush) : Effect.void), Effect.asVoid);
413
+ const interval = config.flushInterval ?? DEFAULT_FLUSH_INTERVAL;
414
+ if (Duration.toMillis(interval) > 0) yield* Effect.forkScoped(flush.pipe(Effect.schedule(Schedule.spaced(interval))));
415
+ yield* Effect.addFinalizer(() => flush);
416
+ return {
417
+ flush,
418
+ track
419
+ };
420
+ });
421
+ const layer$1 = (config = {}) => Layer.effect(MxlEvents, make$1(config)).pipe(Layer.provide(FetchHttpClient.layer));
422
+ const makeHandle = (config = {}) => {
423
+ const runtime = ManagedRuntime.make(layer$1(config));
424
+ const swallow = (promise) => promise.then(() => void 0, () => void 0);
425
+ return {
426
+ dispose: () => swallow(runtime.dispose()),
427
+ flush: () => swallow(runtime.runPromise(Effect.gen(function* () {
428
+ yield* (yield* MxlEvents).flush;
429
+ }))),
430
+ track: (name, options) => {
431
+ swallow(runtime.runPromise(Effect.gen(function* () {
432
+ yield* (yield* MxlEvents).track(name, options);
433
+ })));
434
+ }
435
+ };
436
+ };
437
+ // src/server/trace-correlation.ts
438
+ const ZERO_TRACE_ID = "00000000000000000000000000000000";
439
+ const ZERO_SPAN_ID = "0000000000000000";
440
+ /** Parse W3C `traceparent` (`00-{traceId}-{spanId}-{flags}`). */
441
+ function parseTraceparent(traceparent) {
442
+ if (!traceparent) return {};
443
+ const parts = traceparent.trim().split("-");
444
+ if (parts.length < 4) return {};
445
+ const traceId = parts[1];
446
+ const spanId = parts[2];
447
+ if (!traceId || !spanId) return {};
448
+ if (traceId.length !== 32 || spanId.length !== 16) return {};
449
+ if (traceId === ZERO_TRACE_ID || spanId === ZERO_SPAN_ID) return {};
450
+ return {
451
+ spanId,
452
+ traceId
453
+ };
454
+ }
455
+ /**
456
+ * Resolve trace/span IDs for log correlation.
457
+ *
458
+ * Prefers the active OpenTelemetry span (when a host bridge or queue propagation
459
+ * is active), then falls back to a W3C `traceparent` carrier.
460
+ */
461
+ function getEffectTraceCorrelation(options) {
462
+ const activeSpan = trace.getSpan(context.active());
463
+ if (activeSpan) {
464
+ const spanContext = activeSpan.spanContext();
465
+ if (spanContext.traceId && spanContext.traceId !== ZERO_TRACE_ID && spanContext.spanId && spanContext.spanId !== ZERO_SPAN_ID) return {
466
+ spanId: spanContext.spanId,
467
+ traceId: spanContext.traceId
468
+ };
469
+ }
470
+ return parseTraceparent(options?.traceparent);
471
+ }
472
+ /** Build log annotation fields from active span correlation. */
473
+ function traceCorrelationLogAnnotations(options) {
474
+ const { traceparent, ...extra } = options ?? {};
475
+ const correlation = getEffectTraceCorrelation({ traceparent });
476
+ const annotations = { ...extra };
477
+ if (correlation.traceId) {
478
+ annotations["trace.trace_id"] = correlation.traceId;
479
+ annotations["trace_id"] = correlation.traceId;
480
+ }
481
+ if (correlation.spanId) {
482
+ annotations["trace.span_id"] = correlation.spanId;
483
+ annotations["span_id"] = correlation.spanId;
484
+ }
485
+ return annotations;
486
+ }
487
+ /** Annotate Effect logs with the active span's trace/span IDs. */
488
+ function annotateLogsWithActiveSpanCorrelation(program, fields) {
489
+ return Effect.gen(function* () {
490
+ const { traceparent, ...extra } = fields ?? {};
491
+ const annotations = { ...extra };
492
+ const currentSpan = yield* Effect.currentSpan.pipe(Effect.option);
493
+ if (Option$1.isSome(currentSpan)) {
494
+ annotations["trace.trace_id"] = currentSpan.value.traceId;
495
+ annotations["trace_id"] = currentSpan.value.traceId;
496
+ annotations["trace.span_id"] = currentSpan.value.spanId;
497
+ annotations["span_id"] = currentSpan.value.spanId;
498
+ } else Object.assign(annotations, traceCorrelationLogAnnotations({
499
+ ...traceparent !== void 0 ? { traceparent } : {},
500
+ ...extra
501
+ }));
502
+ if (Object.keys(annotations).length === 0) return yield* program;
503
+ return yield* program.pipe(Effect.annotateLogs(annotations));
504
+ });
505
+ }
506
+ // src/server/evlog-bridge.ts
507
+ function headerValue$1(headers, name) {
508
+ if (!headers) return void 0;
509
+ const direct = headers[name];
510
+ if (direct !== void 0) return direct;
511
+ const lower = headers[name.toLowerCase()];
512
+ if (lower !== void 0) return lower;
513
+ }
514
+ function assignTraceFields(event, traceId, spanId, overwrite) {
515
+ if (overwrite || event["trace_id"] === void 0) event["trace_id"] = traceId;
516
+ if (overwrite || event["span_id"] === void 0) event["span_id"] = spanId;
517
+ if (overwrite || event["traceId"] === void 0) event["traceId"] = traceId;
518
+ if (overwrite || event["spanId"] === void 0) event["spanId"] = spanId;
519
+ const trace = typeof event["trace"] === "object" && event["trace"] !== null ? event["trace"] : {};
520
+ if (overwrite || trace["trace_id"] === void 0) trace["trace_id"] = traceId;
521
+ if (overwrite || trace["span_id"] === void 0) trace["span_id"] = spanId;
522
+ if (Object.keys(trace).length > 0) event["trace"] = trace;
523
+ }
524
+ /**
525
+ * Enrich evlog wide events with trace/span IDs from OTel global context or W3C headers.
526
+ *
527
+ * Use `createEffectTraceEnricher` in the MXL evlog enricher chain.
528
+ */
529
+ function createEffectTraceEnricher(options = {}) {
530
+ const overwrite = options.overwrite ?? false;
531
+ return (ctx) => {
532
+ const traceparent = headerValue$1(ctx.headers, "traceparent");
533
+ const correlation = getEffectTraceCorrelation({ traceparent });
534
+ if (correlation.traceId && correlation.spanId) {
535
+ assignTraceFields(ctx.event, correlation.traceId, correlation.spanId, overwrite);
536
+ return;
537
+ }
538
+ const parsed = parseTraceparent(traceparent);
539
+ if (parsed.traceId && parsed.spanId) assignTraceFields(ctx.event, parsed.traceId, parsed.spanId, overwrite);
540
+ };
541
+ }
542
+ // src/server/otel-context-bridge.ts
543
+ const evaluate = "~effect/Effect/evaluate";
544
+ const contextManagerProbe = Otel.createContextKey("mxl.effect-sdk.context-manager-probe");
545
+ let contextManagerInstalled = false;
546
+ function hasWorkingContextManager() {
547
+ return Otel.context.with(Otel.ROOT_CONTEXT.setValue(contextManagerProbe, true), () => Otel.context.active().getValue(contextManagerProbe) === true);
548
+ }
549
+ /**
550
+ * Install AsyncLocalStorage so `Otel.context.with` actually propagates.
551
+ * Does not replace a context manager the host app already registered.
552
+ */
553
+ function ensureOtelContextManager() {
554
+ if (contextManagerInstalled) return;
555
+ if (hasWorkingContextManager()) {
556
+ contextManagerInstalled = true;
557
+ return;
558
+ }
559
+ const manager = new AsyncLocalStorageContextManager();
560
+ manager.enable();
561
+ if (!Otel.context.setGlobalContextManager(manager)) {
562
+ manager.disable();
563
+ contextManagerInstalled = true;
564
+ return;
565
+ }
566
+ contextManagerInstalled = true;
567
+ }
568
+ function formatTraceparent(span) {
569
+ const flags = span.sampled ? "01" : "00";
570
+ return `00-${span.traceId}-${span.spanId}-${flags}`;
571
+ }
572
+ function buildTraceCarrierFromSpan(span, headers) {
573
+ const carrier = { traceparent: formatTraceparent(span) };
574
+ if (headers?.["tracestate"]) carrier["tracestate"] = headers["tracestate"];
575
+ if (headers?.["baggage"]) carrier["baggage"] = headers["baggage"];
576
+ return carrier;
577
+ }
578
+ /**
579
+ * Put Effect span ids on the global OpenTelemetry context.
580
+ *
581
+ * Id-only `setSpanContext`. Live `Otel.Span` objects come from
582
+ * `OtelTracer.currentOtelSpan` in Effect space, not from a private TypeId.
583
+ */
584
+ function effectSpanToOtelContext(activeContext, span) {
585
+ return Otel.trace.setSpanContext(activeContext, {
586
+ isRemote: span._tag === "ExternalSpan",
587
+ spanId: span.spanId,
588
+ traceFlags: span.sampled ? Otel.TraceFlags.SAMPLED : Otel.TraceFlags.NONE,
589
+ traceId: span.traceId
590
+ });
591
+ }
151
592
  /**
152
- * Parse `OTEL_EXPORTER_OTLP_HEADERS` (`key=value,key2=value2`) into a header map.
593
+ * Wrap a tracer so Effect spans activate global `@opentelemetry/api` context.
594
+ *
595
+ * Prefer this over `Otlp.layerJson({ tracerContext })` until Effect changes
596
+ * that hook. On effect@4.0.6 the callback is still `(primitive, span)`
597
+ * and must return the evaluation, but `EffectPrimitive` only evaluates with a
598
+ * `fiber`. Keep this wrap until Effect passes fiber or evaluates after the hook.
153
599
  */
154
- const parseOtlpHeaders = (input) => {
155
- const result = {};
156
- for (const pair of input.split(",")) {
157
- const eq = pair.indexOf("=");
158
- if (eq === -1) continue;
159
- const key = pair.slice(0, eq).trim();
160
- if (!key) continue;
161
- const raw = pair.slice(eq + 1).trim();
162
- try {
163
- result[key] = decodeURIComponent(raw);
164
- } catch {
165
- result[key] = raw;
600
+ function wrapTracerWithOtelContextBridge(base) {
601
+ ensureOtelContextManager();
602
+ const existingContext = base.context;
603
+ return Tracer$1.make({
604
+ span(options) {
605
+ return base.span(options);
606
+ },
607
+ context(primitive, fiber) {
608
+ if (fiber.cache.span === void 0) {
609
+ if (existingContext) return existingContext(primitive, fiber);
610
+ return primitive[evaluate](fiber);
611
+ }
612
+ return Otel.context.with(effectSpanToOtelContext(Otel.context.active(), fiber.cache.span), () => {
613
+ if (existingContext) return existingContext(primitive, fiber);
614
+ return primitive[evaluate](fiber);
615
+ });
166
616
  }
167
- }
168
- return result;
169
- };
170
- /** Resolve OTLP export headers from env. */
171
- const otlpHeaders = Effect.gen(function* () {
172
- const raw = yield* Config.option(Config.String("OTEL_EXPORTER_OTLP_HEADERS"));
173
- return Option.match(raw, {
174
- onNone: () => ({}),
175
- onSome: (value) => parseOtlpHeaders(value)
176
617
  });
177
- });
178
- //#endregion
179
- //#region src/server/platform.ts
618
+ }
619
+ /**
620
+ * Layer that wraps the configured Effect tracer with a global OTel context bridge.
621
+ *
622
+ * Must be provided over `Otlp.layerJson` so queue propagation, log correlation,
623
+ * and outbound W3C headers see the active server span.
624
+ */
625
+ const otelGlobalContextBridgeLayer = Layer.effect(Tracer$1.Tracer, Effect.gen(function* () {
626
+ return wrapTracerWithOtelContextBridge(yield* Tracer$1.Tracer);
627
+ }));
628
+ // src/server/otlp.ts
629
+ function normalizeOtlpBaseUrl(url) {
630
+ const trimmed = url.replace(/\/$/, "");
631
+ if (trimmed.endsWith("/v1/traces") || trimmed.endsWith("/v1/logs")) return trimmed.replace(/\/v1\/(traces|logs|metrics)$/, "");
632
+ return trimmed;
633
+ }
634
+ // src/server/platform.ts
180
635
  const empty = {};
181
636
  const runtimeAttrs = (rt) => Match.value(rt).pipe(Match.when("node", () => ({ "process.runtime.name": "nodejs" })), Match.whenOr("bun", "deno", "workerd", "edge-light", "fastly", (name) => ({ "process.runtime.name": name })), Match.orElse(() => empty));
182
637
  const osAttrs = (plat) => Match.value(plat).pipe(Match.when("win32", () => ({ "os.type": "windows" })), Match.whenOr("darwin", "linux", "freebsd", "openbsd", "netbsd", (p) => ({ "os.type": p })), Match.orElse(() => empty));
@@ -288,15 +743,20 @@ const getAutoPlatformAttributes = () => {
288
743
  env
289
744
  });
290
745
  };
291
- //#endregion
292
- //#region src/server/resource.ts
293
- const stringOrUndefined$1 = (value) => typeof value === "string" && value.length > 0 ? value : void 0;
746
+ // src/server/resource.ts
747
+ const stringOrUndefined = (value) => typeof value === "string" && value.length > 0 ? value : void 0;
294
748
  const DEFAULT_VCS_REPOSITORY_URL = "https://gitlab.safaricom.co.ke/mxl/apm/mxl-effect-sdk";
295
749
  function resolveCommitRevision() {
296
- return stringOrUndefined$1(process.env["COMMIT_SHA"]) ?? stringOrUndefined$1(process.env["VERCEL_GIT_COMMIT_SHA"]) ?? stringOrUndefined$1(process.env["GITHUB_SHA"]) ?? stringOrUndefined$1(process.env["RAILWAY_GIT_COMMIT_SHA"]) ?? stringOrUndefined$1(process.env["CF_PAGES_COMMIT_SHA"]) ?? stringOrUndefined$1(process.env["RENDER_GIT_COMMIT"]);
750
+ return stringOrUndefined(process.env["COMMIT_SHA"]) ?? stringOrUndefined(process.env["VERCEL_GIT_COMMIT_SHA"]) ?? stringOrUndefined(process.env["GITHUB_SHA"]) ?? stringOrUndefined(process.env["RAILWAY_GIT_COMMIT_SHA"]) ?? stringOrUndefined(process.env["CF_PAGES_COMMIT_SHA"]) ?? stringOrUndefined(process.env["RENDER_GIT_COMMIT"]);
297
751
  }
298
752
  let serviceInstanceId;
299
753
  const getServiceInstanceId = () => serviceInstanceId ??= v7();
754
+ /**
755
+ * Best-effort guard for `vcs.ref.head.revision`: only stamp values that look
756
+ * like a git commit SHA — `serviceVersion` may legitimately be a semver
757
+ * release string, which belongs in `service.version` but not in `vcs.*`.
758
+ */
759
+ const isCommitSha = (value) => value !== void 0 && /^[0-9a-f]{7,40}$/i.test(value);
300
760
  const resolveResource = (config) => Effect.gen(function* () {
301
761
  if (yield* sdkDisabled) return {
302
762
  endpoint: void 0,
@@ -321,231 +781,68 @@ const resolveResource = (config) => Effect.gen(function* () {
321
781
  const envResourceAttributes = yield* otelResourceAttributes;
322
782
  const attributes = {};
323
783
  Object.assign(attributes, getAutoPlatformAttributes());
784
+ Object.assign(attributes, getContainerAttributes());
324
785
  attributes["mxl.sdk.type"] = config.sdkType ?? "server";
325
786
  attributes["service.instance.id"] = getServiceInstanceId();
326
787
  if (environment$1) {
327
788
  attributes["deployment.environment"] = environment$1;
328
789
  attributes["deployment.environment.name"] = environment$1;
329
- }
330
- if (serviceVersion$1) attributes["service.version"] = serviceVersion$1;
331
- const commitRevision = resolveCommitRevision();
332
- if (commitRevision) {
333
- attributes["deployment.commit_sha"] = commitRevision;
334
- attributes["vcs.ref.head.revision"] = commitRevision;
335
- }
336
- attributes["vcs.repository.url.full"] = stringOrUndefined$1(process.env["MXL_VCS_REPOSITORY_URL"]) ?? DEFAULT_VCS_REPOSITORY_URL;
337
- Object.assign(attributes, envResourceAttributes);
338
- if (config.attributes) Object.assign(attributes, config.attributes);
339
- return {
340
- endpoint: endpoint$1,
341
- environment: environment$1,
342
- headers,
343
- resource: {
344
- serviceName,
345
- serviceVersion: serviceVersion$1,
346
- attributes
347
- }
348
- };
349
- }).pipe(Effect.orDie);
350
- //#endregion
351
- //#region src/server/layer.ts
352
- var layer_exports = /* @__PURE__ */ __exportAll({ layer: () => layer$1 });
353
- /**
354
- * Create an Effect Layer that provides OpenTelemetry traces, logs, and metrics
355
- * configured for MXL's OTLP collector.
356
- *
357
- * Returns a no-op layer when `OTEL_SDK_DISABLED=true`.
358
- *
359
- * Wraps the OTLP tracer with `otelGlobalContextBridgeLayer` via `provideMerge`.
360
- * Measured on effect@4.0.2: `Otlp.layerJson({ tracerContext })` still
361
- * types the hook as `(primitive, span) => X` and Effect returns that result as
362
- * the evaluation, so the callback cannot call `~effect/Effect/evaluate` without
363
- * a `fiber`. `Layer.updateService` also cannot wrap a Tracer the OTLP layer
364
- * already provides. Keep the merge wrap until Effect passes fiber or evaluates
365
- * after a side-effect-only context hook.
366
- */
367
- const layer$1 = (config = {}) => Layer.unwrap(Effect.gen(function* () {
368
- const resolved = yield* resolveResource({
369
- ...config,
370
- sdkType: "server"
371
- });
372
- if (!resolved.endpoint) return Layer.empty;
373
- const otlp = Otlp.layerJson({
374
- baseUrl: normalizeOtlpBaseUrl(resolved.endpoint),
375
- headers: resolved.headers,
376
- resource: resolved.resource,
377
- maxBatchSize: config.maxBatchSize,
378
- loggerExportInterval: config.loggerExportInterval,
379
- metricsExportInterval: config.metricsExportInterval,
380
- tracerExportInterval: config.tracerExportInterval,
381
- shutdownTimeout: config.shutdownTimeout
382
- }).pipe(Layer.provide(FetchHttpClient.layer));
383
- return otelGlobalContextBridgeLayer.pipe(Layer.provideMerge(otlp));
384
- }));
385
- const RESERVED_EVENT_NAMES = ["session_start", "session_end"];
386
- const isReservedEventName = (name) => RESERVED_EVENT_NAMES.some((reserved) => reserved === name);
387
- const coerceTrackProps = (props) => {
388
- if (props === void 0) return {};
389
- const attributes = {};
390
- for (const [key, value] of Object.entries(props)) {
391
- if (value === void 0) continue;
392
- attributes[key] = typeof value === "string" ? value : String(value);
393
- }
394
- return attributes;
395
- };
396
- const toEventTimestamp = (value) => (value instanceof Date ? value : new Date(value ?? Date.now())).toISOString();
397
- const encodeProductEventLine = (input) => {
398
- const trimmed = typeof input.name === "string" ? input.name.trim() : "";
399
- if (trimmed.length === 0 || isReservedEventName(trimmed)) return;
400
- const options = input.options ?? {};
401
- return {
402
- attributes: coerceTrackProps(options.attributes),
403
- group_id: options.groupId ?? "",
404
- name: trimmed.slice(0, 80),
405
- page_path: options.pagePath ?? "",
406
- service_name: input.serviceName,
407
- session_id: options.sessionId ?? "",
408
- source: input.source,
409
- timestamp: toEventTimestamp(options.timestamp),
410
- traceparent: options.traceparent,
411
- url: options.url ?? "",
412
- user_id: options.userId ?? "",
413
- visitor_id: options.visitorId ?? ""
414
- };
415
- };
416
- const encodeNdjson = (lines) => `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`;
417
- //#endregion
418
- //#region src/version.ts
419
- const SDK_NAME = "mxl-effect-sdk";
420
- const SDK_VERSION = "0.0.15";
421
- //#endregion
422
- //#region src/server/events.ts
423
- var events_exports = /* @__PURE__ */ __exportAll({
424
- MxlEvents: () => MxlEvents,
425
- ProductEventsPostError: () => ProductEventsPostError,
426
- layer: () => layer,
427
- make: () => make$1,
428
- makeHandle: () => makeHandle
429
- });
430
- const DEFAULT_FLUSH_INTERVAL = Duration.seconds(5);
431
- const DEFAULT_MAX_BATCH_SIZE = 100;
432
- const MAX_BUFFERED_EVENTS = 1e3;
433
- const WARN_INTERVAL_MS = 3e4;
434
- const DEFAULT_BASKET_URL = "http://127.0.0.1:8000";
435
- var MxlEvents = class extends Context.Service()("@safaricom-mxl/effect-sdk/MxlEvents") {};
436
- var ProductEventsPostError = class extends Schema.TaggedError()("@safaricom-mxl/effect-sdk/ProductEventsPostError", {
437
- cause: Schema.optionalKey(Schema.Defect()),
438
- message: Schema.String,
439
- status: Schema.optionalKey(Schema.Number)
440
- }) {};
441
- SchemaJITCompiler.enable(ProductEventsPostError.ast);
442
- const stringOrUndefined = (value) => value && value.length > 0 ? value : void 0;
443
- const resolveEventsConfig = (config) => {
444
- const endpoint = (config.endpoint ?? process.env["MXL_BASKET_URL"] ?? DEFAULT_BASKET_URL).replace(/\/$/, "");
445
- return {
446
- apiKey: stringOrUndefined(config.apiKey ?? process.env["MXL_API_KEY"]),
447
- clientId: stringOrUndefined(config.clientId ?? process.env["MXL_CLIENT_ID"]),
448
- serviceName: config.serviceName ?? process.env["OTEL_SERVICE_NAME"] ?? "unknown",
449
- url: `${endpoint}${config.eventsPath ?? "/events"}`
450
- };
451
- };
452
- const make$1 = Effect.fn("MxlEvents.make")(function* (config = {}) {
453
- const resolved = resolveEventsConfig(config);
454
- const maxBatchSize = Math.max(1, config.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE);
455
- const client = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk);
456
- const flushing = yield* Semaphore.make(1);
457
- let buffer = [];
458
- let warnedAboutName = false;
459
- let warnedAboutClient = false;
460
- let lastPostWarnAt = 0;
461
- const warnPost = (error) => {
462
- const now = Date.now();
463
- if (now - lastPostWarnAt < WARN_INTERVAL_MS) return;
464
- lastPostWarnAt = now;
465
- console.warn(`[MxlEvents] ${error.message} (dropping batch)`, error.cause ?? "");
466
- };
467
- const warnMissingClient = () => {
468
- if (warnedAboutClient) return;
469
- warnedAboutClient = true;
470
- console.warn("[MxlEvents] no client id — set MXL_CLIENT_ID or pass `clientId`; dropping events");
471
- };
472
- const warnEmptyName = () => {
473
- if (warnedAboutName) return;
474
- warnedAboutName = true;
475
- console.warn("[MxlEvents] track() needs a non-empty event name; the call was ignored.");
476
- };
477
- const post = (lines) => {
478
- const headers = {
479
- "mxl-client-id": resolved.clientId ?? "",
480
- "mxl-sdk-name": SDK_NAME,
481
- "mxl-sdk-version": SDK_VERSION,
482
- "user-agent": `${SDK_NAME}-server/${SDK_VERSION}`
483
- };
484
- if (resolved.apiKey) headers["mxl-api-key"] = resolved.apiKey;
485
- return HttpClientRequest.post(resolved.url).pipe(HttpClientRequest.setHeaders(headers), HttpClientRequest.bodyText(encodeNdjson(lines), "application/x-ndjson"), client.execute, Effect.asVoid, Effect.mapError((error) => error.response !== void 0 ? new ProductEventsPostError({
486
- message: `POST ${resolved.url} → ${error.response.status}`,
487
- status: error.response.status
488
- }) : new ProductEventsPostError({
489
- cause: error,
490
- message: `POST ${resolved.url} failed: ${error.message}`
491
- })));
492
- };
493
- const flush = flushing.withPermits(1)(Effect.suspend(() => {
494
- if (buffer.length === 0) return Effect.void;
495
- const batch = buffer;
496
- buffer = [];
497
- if (resolved.clientId === void 0) {
498
- warnMissingClient();
499
- return Effect.void;
500
- }
501
- return post(batch).pipe(Effect.catchCause((cause) => Effect.sync(() => {
502
- warnPost(Option.getOrElse(Cause.findErrorOption(cause), () => new ProductEventsPostError({
503
- cause: Cause.squash(cause),
504
- message: `POST ${resolved.url} failed`
505
- })));
506
- })));
507
- }));
508
- const track = (name, options = {}) => Effect.sync(() => {
509
- const line = encodeProductEventLine({
510
- name,
511
- options,
512
- serviceName: resolved.serviceName,
513
- source: "server"
514
- });
515
- if (line === void 0) {
516
- warnEmptyName();
517
- return false;
518
- }
519
- buffer.push(line);
520
- if (buffer.length > MAX_BUFFERED_EVENTS) buffer.splice(0, buffer.length - MAX_BUFFERED_EVENTS);
521
- return buffer.length >= maxBatchSize;
522
- }).pipe(Effect.flatMap((full) => full ? Effect.forkDetach(flush) : Effect.void), Effect.asVoid);
523
- const interval = config.flushInterval ?? DEFAULT_FLUSH_INTERVAL;
524
- if (Duration.toMillis(interval) > 0) yield* Effect.forkScoped(flush.pipe(Effect.schedule(Schedule.spaced(interval))));
525
- yield* Effect.addFinalizer(() => flush);
526
- return {
527
- flush,
528
- track
529
- };
530
- });
531
- const layer = (config = {}) => Layer.effect(MxlEvents, make$1(config)).pipe(Layer.provide(FetchHttpClient.layer));
532
- const makeHandle = (config = {}) => {
533
- const runtime = ManagedRuntime.make(layer(config));
534
- const swallow = (promise) => promise.then(() => void 0, () => void 0);
790
+ }
791
+ if (serviceVersion$1) attributes["service.version"] = serviceVersion$1;
792
+ const commitRevision = resolveCommitRevision();
793
+ if (commitRevision) attributes["deployment.commit_sha"] = commitRevision;
794
+ const headRevision = commitRevision ?? (isCommitSha(serviceVersion$1) ? serviceVersion$1 : void 0);
795
+ if (headRevision) attributes["vcs.ref.head.revision"] = headRevision;
796
+ attributes["vcs.repository.url.full"] = stringOrUndefined(process.env["MXL_VCS_REPOSITORY_URL"]) ?? DEFAULT_VCS_REPOSITORY_URL;
797
+ if (config.serviceNamespace) attributes["service.namespace"] = config.serviceNamespace;
798
+ Object.assign(attributes, envResourceAttributes);
799
+ if (config.attributes) Object.assign(attributes, config.attributes);
535
800
  return {
536
- dispose: () => swallow(runtime.dispose()),
537
- flush: () => swallow(runtime.runPromise(Effect.gen(function* () {
538
- yield* (yield* MxlEvents).flush;
539
- }))),
540
- track: (name, options) => {
541
- swallow(runtime.runPromise(Effect.gen(function* () {
542
- yield* (yield* MxlEvents).track(name, options);
543
- })));
801
+ endpoint: endpoint$1,
802
+ environment: environment$1,
803
+ headers,
804
+ resource: {
805
+ serviceName,
806
+ serviceVersion: serviceVersion$1,
807
+ attributes
544
808
  }
545
809
  };
546
- };
547
- //#endregion
548
- //#region src/server/flushable.ts
810
+ }).pipe(Effect.orDie);
811
+ // src/server/layer.ts
812
+ var layer_exports = /* @__PURE__ */ __exportAll({ layer: () => layer });
813
+ /**
814
+ * Create an Effect Layer that provides OpenTelemetry traces, logs, and metrics
815
+ * configured for MXL's OTLP collector.
816
+ *
817
+ * Returns a no-op layer when `OTEL_SDK_DISABLED=true`.
818
+ *
819
+ * Wraps the OTLP tracer with `otelGlobalContextBridgeLayer` via `provideMerge`.
820
+ * Measured on effect@4.0.6: `Otlp.layerJson({ tracerContext })` still
821
+ * types the hook as `(primitive, span) => X` and Effect returns that result as
822
+ * the evaluation, so the callback cannot call `~effect/Effect/evaluate` without
823
+ * a `fiber`. `Layer.updateService` also cannot wrap a Tracer the OTLP layer
824
+ * already provides. Keep the merge wrap until Effect passes fiber or evaluates
825
+ * after a side-effect-only context hook.
826
+ */
827
+ const layer = (config = {}) => Layer.unwrap(Effect.gen(function* () {
828
+ const resolved = yield* resolveResource({
829
+ ...config,
830
+ sdkType: "server"
831
+ });
832
+ if (!resolved.endpoint) return Layer.empty;
833
+ const otlp = Otlp.layerJson({
834
+ baseUrl: normalizeOtlpBaseUrl(resolved.endpoint),
835
+ headers: resolved.headers,
836
+ resource: resolved.resource,
837
+ maxBatchSize: config.maxBatchSize,
838
+ loggerExportInterval: config.loggerExportInterval,
839
+ metricsExportInterval: config.metricsExportInterval,
840
+ tracerExportInterval: config.tracerExportInterval,
841
+ shutdownTimeout: config.shutdownTimeout
842
+ }).pipe(Layer.provide(FetchHttpClient.layer));
843
+ return otelGlobalContextBridgeLayer.pipe(Layer.provideMerge(otlp));
844
+ }));
845
+ // src/server/flushable.ts
549
846
  var flushable_exports = /* @__PURE__ */ __exportAll({ make: () => make });
550
847
  const make = (config = {}) => {
551
848
  const eventsConfig = {
@@ -559,90 +856,18 @@ const make = (config = {}) => {
559
856
  return {
560
857
  dispose: handle.dispose,
561
858
  flush: handle.flush,
562
- layer: Layer.mergeAll(layer$1(config), layer(eventsConfig)),
859
+ layer: Layer.mergeAll(layer(config), layer$1(eventsConfig)),
563
860
  track: handle.track
564
861
  };
565
862
  };
566
- //#endregion
567
- //#region src/server/http-middleware.ts
863
+ // src/server/http-middleware.ts
568
864
  function shouldDisableHttpTrace(request) {
569
865
  return request.method === "OPTIONS" || request.url === "/health" || request.url === "/healthcheck" || request.url.startsWith("/healthz") || request.url === "/metrics" || /\.(png|ico|jpg|jpeg|gif|css|js|svg|webp|woff2?)(\?.*)?$/i.test(request.url);
570
866
  }
571
867
  function tracerDisabledWhenLayer(filter = shouldDisableHttpTrace) {
572
868
  return Layer.succeed(HttpMiddleware.TracerDisabledWhen, filter);
573
869
  }
574
- //#endregion
575
- //#region src/server/trace-correlation.ts
576
- const ZERO_TRACE_ID = "00000000000000000000000000000000";
577
- const ZERO_SPAN_ID = "0000000000000000";
578
- /** Parse W3C `traceparent` (`00-{traceId}-{spanId}-{flags}`). */
579
- function parseTraceparent(traceparent) {
580
- if (!traceparent) return {};
581
- const parts = traceparent.trim().split("-");
582
- if (parts.length < 4) return {};
583
- const traceId = parts[1];
584
- const spanId = parts[2];
585
- if (!traceId || !spanId) return {};
586
- if (traceId.length !== 32 || spanId.length !== 16) return {};
587
- if (traceId === ZERO_TRACE_ID || spanId === ZERO_SPAN_ID) return {};
588
- return {
589
- spanId,
590
- traceId
591
- };
592
- }
593
- /**
594
- * Resolve trace/span IDs for log correlation.
595
- *
596
- * Prefers the active OpenTelemetry span (when a host bridge or queue propagation
597
- * is active), then falls back to a W3C `traceparent` carrier.
598
- */
599
- function getEffectTraceCorrelation(options) {
600
- const activeSpan = trace.getSpan(context.active());
601
- if (activeSpan) {
602
- const spanContext = activeSpan.spanContext();
603
- if (spanContext.traceId && spanContext.traceId !== ZERO_TRACE_ID && spanContext.spanId && spanContext.spanId !== ZERO_SPAN_ID) return {
604
- spanId: spanContext.spanId,
605
- traceId: spanContext.traceId
606
- };
607
- }
608
- return parseTraceparent(options?.traceparent);
609
- }
610
- /** Build log annotation fields from active span correlation. */
611
- function traceCorrelationLogAnnotations(options) {
612
- const { traceparent, ...extra } = options ?? {};
613
- const correlation = getEffectTraceCorrelation({ traceparent });
614
- const annotations = { ...extra };
615
- if (correlation.traceId) {
616
- annotations["trace.trace_id"] = correlation.traceId;
617
- annotations["trace_id"] = correlation.traceId;
618
- }
619
- if (correlation.spanId) {
620
- annotations["trace.span_id"] = correlation.spanId;
621
- annotations["span_id"] = correlation.spanId;
622
- }
623
- return annotations;
624
- }
625
- /** Annotate Effect logs with the active span's trace/span IDs. */
626
- function annotateLogsWithActiveSpanCorrelation(program, fields) {
627
- return Effect.gen(function* () {
628
- const { traceparent, ...extra } = fields ?? {};
629
- const annotations = { ...extra };
630
- const currentSpan = yield* Effect.currentSpan.pipe(Effect.option);
631
- if (Option$1.isSome(currentSpan)) {
632
- annotations["trace.trace_id"] = currentSpan.value.traceId;
633
- annotations["trace_id"] = currentSpan.value.traceId;
634
- annotations["trace.span_id"] = currentSpan.value.spanId;
635
- annotations["span_id"] = currentSpan.value.spanId;
636
- } else Object.assign(annotations, traceCorrelationLogAnnotations({
637
- ...traceparent !== void 0 ? { traceparent } : {},
638
- ...extra
639
- }));
640
- if (Object.keys(annotations).length === 0) return yield* program;
641
- return yield* program.pipe(Effect.annotateLogs(annotations));
642
- });
643
- }
644
- //#endregion
645
- //#region src/server/logging.ts
870
+ // src/server/logging.ts
646
871
  const LOG_LEVEL_BY_ENV = {
647
872
  trace: "Debug",
648
873
  debug: "Debug",
@@ -733,9 +958,8 @@ function logAtLevel(level, message, fields) {
733
958
  case "Fatal": return logError(message, fields);
734
959
  }
735
960
  }
736
- //#endregion
737
- //#region src/server/http-tracing.ts
738
- function headerValue$1(value) {
961
+ // src/server/http-tracing.ts
962
+ function headerValue(value) {
739
963
  if (value === void 0) return void 0;
740
964
  return typeof value === "string" ? value : value[0];
741
965
  }
@@ -750,7 +974,7 @@ function spanContextFromTraceparent(traceparent) {
750
974
  };
751
975
  }
752
976
  function resolveTraceparentFromHeaders(headers) {
753
- return headerValue$1(headers["traceparent"]);
977
+ return headerValue(headers["traceparent"]);
754
978
  }
755
979
  function withParentFromTraceparent(headers, program) {
756
980
  const parentSpanContext = spanContextFromTraceparent(resolveTraceparentFromHeaders(headers));
@@ -871,8 +1095,7 @@ function buildQueueTraceCarrierEffect(headers) {
871
1095
  return buildQueueTraceCarrier(headers);
872
1096
  });
873
1097
  }
874
- //#endregion
875
- //#region src/server/jobs.ts
1098
+ // src/server/jobs.ts
876
1099
  function resolveParentSpanContext(options) {
877
1100
  if (options.parentSpanContext !== void 0) return options.parentSpanContext;
878
1101
  return spanContextFromCarrier(options.parentTraceCarrier);
@@ -904,149 +1127,6 @@ function jobWithSpanEffect(options, program) {
904
1127
  function runJobWithSpan(runtime, options, program) {
905
1128
  return runtime.runPromise(jobWithSpanEffect(options, program));
906
1129
  }
907
- //#endregion
908
- //#region src/server/evlog-bridge.ts
909
- function headerValue(headers, name) {
910
- if (!headers) return void 0;
911
- const direct = headers[name];
912
- if (direct !== void 0) return direct;
913
- const lower = headers[name.toLowerCase()];
914
- if (lower !== void 0) return lower;
915
- }
916
- function assignTraceFields(event, traceId, spanId, overwrite) {
917
- if (overwrite || event["trace_id"] === void 0) event["trace_id"] = traceId;
918
- if (overwrite || event["span_id"] === void 0) event["span_id"] = spanId;
919
- if (overwrite || event["traceId"] === void 0) event["traceId"] = traceId;
920
- if (overwrite || event["spanId"] === void 0) event["spanId"] = spanId;
921
- const trace = typeof event["trace"] === "object" && event["trace"] !== null ? event["trace"] : {};
922
- if (overwrite || trace["trace_id"] === void 0) trace["trace_id"] = traceId;
923
- if (overwrite || trace["span_id"] === void 0) trace["span_id"] = spanId;
924
- if (Object.keys(trace).length > 0) event["trace"] = trace;
925
- }
926
- /**
927
- * Enrich evlog wide events with trace/span IDs from OTel global context or W3C headers.
928
- *
929
- * Use `createEffectTraceEnricher` in the MXL evlog enricher chain.
930
- */
931
- function createEffectTraceEnricher(options = {}) {
932
- const overwrite = options.overwrite ?? false;
933
- return (ctx) => {
934
- const traceparent = headerValue(ctx.headers, "traceparent");
935
- const correlation = getEffectTraceCorrelation({ traceparent });
936
- if (correlation.traceId && correlation.spanId) {
937
- assignTraceFields(ctx.event, correlation.traceId, correlation.spanId, overwrite);
938
- return;
939
- }
940
- const parsed = parseTraceparent(traceparent);
941
- if (parsed.traceId && parsed.spanId) assignTraceFields(ctx.event, parsed.traceId, parsed.spanId, overwrite);
942
- };
943
- }
944
- //#endregion
945
- //#region src/shared/dependency-spans.ts
946
- const MAX_FLUSH_SPAN_LINKS = 32;
947
- function dbClientSpanAttributes(dbSystem, operation) {
948
- return {
949
- "db.system": dbSystem,
950
- "db.system.name": dbSystem,
951
- "peer.service": dbSystem,
952
- ...operation ? { "db.operation.name": operation } : {}
953
- };
954
- }
955
- function queueProducerSpanAttributes(destinationName, peerService = "mxl-worker") {
956
- return {
957
- "messaging.destination.name": destinationName,
958
- "messaging.system": "bullmq",
959
- "peer.service": peerService
960
- };
961
- }
962
- function queueConsumerSpanAttributes(destinationName) {
963
- return {
964
- "messaging.destination.name": destinationName,
965
- "messaging.system": "bullmq"
966
- };
967
- }
968
- function kafkaProducerSpanAttributes(topic, peerService = "kafka") {
969
- return {
970
- "messaging.destination.name": topic,
971
- "messaging.system": "kafka",
972
- "peer.service": peerService
973
- };
974
- }
975
- function bufferSpanAttributes(bufferName, operation) {
976
- return {
977
- "buffer.name": bufferName,
978
- "db.operation.name": operation,
979
- "db.system": "redis",
980
- "db.system.name": "redis",
981
- "peer.service": "redis"
982
- };
983
- }
984
- function geoSpanAttributes() {
985
- return {
986
- "db.operation.name": "resolve",
987
- "db.system": "maxmind",
988
- "db.system.name": "maxmind",
989
- "peer.service": "maxmind"
990
- };
991
- }
992
- function addSpanLinksFromTraceCarriers(carriers) {
993
- const span = trace.getActiveSpan();
994
- if (!span) return 0;
995
- const seen = /* @__PURE__ */ new Set();
996
- let linked = 0;
997
- for (const carrier of carriers) {
998
- if (!carrier.traceparent || seen.has(carrier.traceparent)) continue;
999
- seen.add(carrier.traceparent);
1000
- const extracted = propagation.extract(context.active(), carrier);
1001
- const linkedContext = trace.getSpanContext(extracted);
1002
- if (!linkedContext) continue;
1003
- span.addLink({ context: linkedContext });
1004
- linked += 1;
1005
- if (linked >= MAX_FLUSH_SPAN_LINKS) break;
1006
- }
1007
- return linked;
1008
- }
1009
- function withDependencySpan(name, attributesOrOptions, program) {
1010
- const options = isWithDependencySpanOptions(attributesOrOptions) ? attributesOrOptions : { attributes: attributesOrOptions };
1011
- const attributes = options.attributes ?? {};
1012
- const kind = options.kind ?? "client";
1013
- return program.pipe(Effect.withSpan(name, {
1014
- attributes,
1015
- kind
1016
- }));
1017
- }
1018
- function isWithDependencySpanOptions(value) {
1019
- return typeof value === "object" && ("kind" in value || "attributes" in value && typeof value.attributes === "object");
1020
- }
1021
- /** Promise-based dependency span for non-Effect code paths. */
1022
- async function withPromiseDependencySpan(name, attributes, operation, kind = "client") {
1023
- const tracer = trace.getTracer("mxl-effect-sdk");
1024
- const spanKind = kind === "producer" ? SpanKind.PRODUCER : kind === "consumer" ? SpanKind.CONSUMER : SpanKind.CLIENT;
1025
- return tracer.startActiveSpan(name, {
1026
- attributes,
1027
- kind: spanKind
1028
- }, async (span) => {
1029
- try {
1030
- const result = await operation();
1031
- span.end();
1032
- return result;
1033
- } catch (error) {
1034
- if (error instanceof Error) {
1035
- span.recordException(error);
1036
- span.setStatus({
1037
- code: SpanStatusCode.ERROR,
1038
- message: error.message
1039
- });
1040
- } else span.setStatus({
1041
- code: SpanStatusCode.ERROR,
1042
- message: String(error)
1043
- });
1044
- span.end();
1045
- throw error;
1046
- }
1047
- });
1048
- }
1049
- //#endregion
1050
- export { minimumLogLevelLayer as A, flushable_exports as B, withParentFromTraceparent as C, logError as D, logDebug as E, getEffectTraceCorrelation as F, parseOtelResourceAttributes as G, layer_exports as H, parseTraceparent as I, buildTraceCarrierFromSpan as J, parseOtlpHeaders as K, traceCorrelationLogAnnotations as L, resolveLogLevelFromEnv as M, serializeLogError as N, logInfo as O, annotateLogsWithActiveSpanCorrelation as P, shouldDisableHttpTrace as R, withParentFromTraceCarrier as S, logAtLevel as T, derivePlatformAttributes as U, events_exports as V, getAutoPlatformAttributes as W, otelGlobalContextBridgeLayer as X, formatTraceparent as Y, wrapTracerWithOtelContextBridge as Z, logHttpResponse as _, kafkaProducerSpanAttributes as a, spanContextFromTraceparent as b, withDependencySpan as c, jobWithSpanEffect as d, runJobWithSpan as f, logHttpRequestFailure as g, finalizeHttpSpan as h, geoSpanAttributes as i, resolveEffectLogLevelFromHttpStatus as j, logWarning as k, withPromiseDependencySpan as l, buildQueueTraceCarrierEffect as m, bufferSpanAttributes as n, queueConsumerSpanAttributes as o, buildQueueTraceCarrier as p, normalizeOtlpBaseUrl as q, dbClientSpanAttributes as r, queueProducerSpanAttributes as s, addSpanLinksFromTraceCarriers as t, createEffectTraceEnricher as u, resolveTraceparentFromHeaders as v, buildHttpStatusLogFields as w, withHttpServerSpan as x, spanContextFromCarrier as y, tracerDisabledWhenLayer as z };
1130
+ export { parseOtlpHeaders as $, normalizeOtlpBaseUrl as A, events_exports as B, serializeLogError as C, layer_exports as D, flushable_exports as E, createEffectTraceEnricher as F, kafkaProducerSpanAttributes as G, bufferSpanAttributes as H, annotateLogsWithActiveSpanCorrelation as I, withDependencySpan as J, queueConsumerSpanAttributes as K, getEffectTraceCorrelation as L, formatTraceparent as M, otelGlobalContextBridgeLayer as N, derivePlatformAttributes as O, wrapTracerWithOtelContextBridge as P, parseOtelResourceAttributes as Q, parseTraceparent as R, resolveLogLevelFromEnv as S, tracerDisabledWhenLayer as T, dbClientSpanAttributes as U, addSpanLinksFromTraceCarriers as V, geoSpanAttributes as W, deriveContainerAttributes as X, withPromiseDependencySpan as Y, getContainerAttributes as Z, logError as _, finalizeHttpSpan as a, minimumLogLevelLayer as b, resolveTraceparentFromHeaders as c, withHttpServerSpan as d, failureIdentifier as et, withParentFromTraceCarrier as f, logDebug as g, logAtLevel as h, buildQueueTraceCarrierEffect as i, buildTraceCarrierFromSpan as j, getAutoPlatformAttributes as k, spanContextFromCarrier as l, buildHttpStatusLogFields as m, runJobWithSpan as n, logHttpRequestFailure as o, withParentFromTraceparent as p, queueProducerSpanAttributes as q, buildQueueTraceCarrier as r, logHttpResponse as s, jobWithSpanEffect as t, resolveSpanFilter as tt, spanContextFromTraceparent as u, logInfo as v, shouldDisableHttpTrace as w, resolveEffectLogLevelFromHttpStatus as x, logWarning as y, traceCorrelationLogAnnotations as z };
1051
1131
 
1052
- //# sourceMappingURL=server-BoMrxG4x.mjs.map
1132
+ //# sourceMappingURL=server-BMDPjez-.mjs.map