@upyo/opentelemetry 0.2.0-dev.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.
package/dist/index.cjs ADDED
@@ -0,0 +1,1119 @@
1
+ //#region rolldown:runtime
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __copyProps = (to, from, except, desc) => {
9
+ if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
10
+ key = keys[i];
11
+ if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
12
+ get: ((k) => from[k]).bind(null, key),
13
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
14
+ });
15
+ }
16
+ return to;
17
+ };
18
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
19
+ value: mod,
20
+ enumerable: true
21
+ }) : target, mod));
22
+
23
+ //#endregion
24
+ const __opentelemetry_api = __toESM(require("@opentelemetry/api"));
25
+
26
+ //#region src/config.ts
27
+ /**
28
+ * Default configuration values.
29
+ */
30
+ const DEFAULT_CONFIG = {
31
+ metrics: {
32
+ enabled: true,
33
+ samplingRate: 1,
34
+ prefix: "upyo",
35
+ durationBuckets: [
36
+ .001,
37
+ .005,
38
+ .01,
39
+ .05,
40
+ .1,
41
+ .5,
42
+ 1,
43
+ 5,
44
+ 10
45
+ ]
46
+ },
47
+ tracing: {
48
+ enabled: true,
49
+ samplingRate: 1,
50
+ recordSensitiveData: false,
51
+ spanPrefix: "email"
52
+ }
53
+ };
54
+ /**
55
+ * Creates a resolved OpenTelemetry configuration with defaults applied.
56
+ *
57
+ * @param config The input configuration options.
58
+ * @returns A resolved configuration with all defaults applied.
59
+ * @throws {Error} When tracing is enabled but no TracerProvider is provided.
60
+ * @throws {Error} When metrics are enabled but no MeterProvider is provided.
61
+ * @since 0.2.0
62
+ */
63
+ function createOpenTelemetryConfig(config = {}) {
64
+ const resolvedConfig = {
65
+ tracerProvider: config.tracerProvider,
66
+ meterProvider: config.meterProvider,
67
+ metrics: {
68
+ ...DEFAULT_CONFIG.metrics,
69
+ ...config.metrics
70
+ },
71
+ tracing: {
72
+ ...DEFAULT_CONFIG.tracing,
73
+ ...config.tracing
74
+ },
75
+ attributeExtractor: config.attributeExtractor,
76
+ errorClassifier: config.errorClassifier
77
+ };
78
+ if (resolvedConfig.tracing.enabled && !resolvedConfig.tracerProvider) throw new Error("TracerProvider is required when tracing is enabled. Provide tracerProvider in config or use auto-configuration.");
79
+ if (resolvedConfig.metrics.enabled && !resolvedConfig.meterProvider) throw new Error("MeterProvider is required when metrics are enabled. Provide meterProvider in config or use auto-configuration.");
80
+ return resolvedConfig;
81
+ }
82
+ /**
83
+ * Default error classifier that categorizes errors into standard types.
84
+ *
85
+ * @example
86
+ * ```typescript
87
+ * import { defaultErrorClassifier } from "@upyo/opentelemetry";
88
+ *
89
+ * console.log(defaultErrorClassifier(new Error("401 Unauthorized"))); // "auth"
90
+ * console.log(defaultErrorClassifier(new Error("Rate limit exceeded"))); // "rate_limit"
91
+ * console.log(defaultErrorClassifier(new Error("Connection timeout"))); // "network"
92
+ * console.log(defaultErrorClassifier(new Error("Invalid email format"))); // "validation"
93
+ * console.log(defaultErrorClassifier(new Error("500 Internal Server Error"))); // "server_error"
94
+ * console.log(defaultErrorClassifier(new Error("Something else"))); // "unknown"
95
+ * ```
96
+ *
97
+ * @param error The error to classify.
98
+ * @returns A string category such as `"auth"`, `"rate_limit"`, `"network"`,
99
+ * `"validation"`, `"server_error"`, or `"unknown"`.
100
+ * @since 0.2.0
101
+ */
102
+ function defaultErrorClassifier(error) {
103
+ if (error instanceof Error) {
104
+ const message = error.message.toLowerCase();
105
+ const name$1 = error.name.toLowerCase();
106
+ if (message.includes("auth") || message.includes("unauthorized") || message.includes("invalid api key") || message.includes("403")) return "auth";
107
+ if (message.includes("rate limit") || message.includes("429") || message.includes("quota exceeded") || message.includes("throttle")) return "rate_limit";
108
+ if (message.includes("500") || message.includes("502") || message.includes("504") || message.includes("internal server error")) return "server_error";
109
+ if (name$1.includes("network") || message.includes("connect") || message.includes("timeout") || message.includes("dns") || name$1 === "aborterror") return "network";
110
+ if (message.includes("invalid") || message.includes("malformed") || message.includes("validation") || message.includes("400")) return "validation";
111
+ if (message.includes("503") || message.includes("service unavailable") || message.includes("temporarily unavailable")) return "service_unavailable";
112
+ }
113
+ return "unknown";
114
+ }
115
+
116
+ //#endregion
117
+ //#region package.json
118
+ var name = "@upyo/opentelemetry";
119
+ var version = "0.2.0-dev.24+97755606";
120
+ var description = "OpenTelemetry observability transport for Upyo email library";
121
+ var keywords = [
122
+ "email",
123
+ "mail",
124
+ "sendmail",
125
+ "opentelemetry",
126
+ "observability",
127
+ "tracing",
128
+ "metrics",
129
+ "monitoring"
130
+ ];
131
+ var license = "MIT";
132
+ var author = {
133
+ "name": "Hong Minhee",
134
+ "email": "hong@minhee.org",
135
+ "url": "https://hongminhee.org/"
136
+ };
137
+ var homepage = "https://upyo.org/transports/opentelemetry";
138
+ var repository = {
139
+ "type": "git",
140
+ "url": "git+https://github.com/dahlia/upyo.git",
141
+ "directory": "packages/opentelemetry/"
142
+ };
143
+ var bugs = { "url": "https://github.com/dahlia/upyo/issues" };
144
+ var funding = ["https://github.com/sponsors/dahlia"];
145
+ var engines = {
146
+ "node": ">=20.0.0",
147
+ "bun": ">=1.2.0",
148
+ "deno": ">=2.3.0"
149
+ };
150
+ var files = [
151
+ "dist/",
152
+ "package.json",
153
+ "README.md"
154
+ ];
155
+ var type = "module";
156
+ var module$1 = "./dist/index.js";
157
+ var main = "./dist/index.cjs";
158
+ var types = "./dist/index.d.ts";
159
+ var exports$1 = {
160
+ ".": {
161
+ "types": {
162
+ "import": "./dist/index.d.ts",
163
+ "require": "./dist/index.d.cts"
164
+ },
165
+ "import": "./dist/index.js",
166
+ "require": "./dist/index.cjs"
167
+ },
168
+ "./package.json": "./package.json"
169
+ };
170
+ var sideEffects = false;
171
+ var peerDependencies = {
172
+ "@opentelemetry/api": "catalog:",
173
+ "@upyo/core": "workspace:*"
174
+ };
175
+ var devDependencies = {
176
+ "@dotenvx/dotenvx": "catalog:",
177
+ "@opentelemetry/api": "catalog:",
178
+ "@opentelemetry/context-async-hooks": "^1.25.1",
179
+ "@opentelemetry/resources": "catalog:",
180
+ "@opentelemetry/sdk-metrics": "catalog:",
181
+ "@opentelemetry/sdk-trace-base": "catalog:",
182
+ "@opentelemetry/semantic-conventions": "catalog:",
183
+ "tsdown": "catalog:",
184
+ "typescript": "catalog:"
185
+ };
186
+ var scripts = {
187
+ "build": "tsdown",
188
+ "prepack": "tsdown",
189
+ "prepublish": "tsdown",
190
+ "test": "tsdown && dotenvx run --ignore=MISSING_ENV_FILE -- node --experimental-transform-types --test",
191
+ "test:bun": "tsdown && bun test --timeout=30000 --env-file=.env",
192
+ "test:deno": "deno test --allow-env --allow-net --env-file=.env"
193
+ };
194
+ var package_default = {
195
+ name,
196
+ version,
197
+ description,
198
+ keywords,
199
+ license,
200
+ author,
201
+ homepage,
202
+ repository,
203
+ bugs,
204
+ funding,
205
+ engines,
206
+ files,
207
+ type,
208
+ module: module$1,
209
+ main,
210
+ types,
211
+ exports: exports$1,
212
+ sideEffects,
213
+ peerDependencies,
214
+ devDependencies,
215
+ scripts
216
+ };
217
+
218
+ //#endregion
219
+ //#region src/metrics.ts
220
+ /**
221
+ * Manages OpenTelemetry metrics collection for email operations.
222
+ */
223
+ var MetricsCollector = class {
224
+ meter;
225
+ config;
226
+ attemptCounter;
227
+ messageCounter;
228
+ durationHistogram;
229
+ messageSizeHistogram;
230
+ attachmentCountHistogram;
231
+ activeSendsGauge;
232
+ constructor(meterProvider, config) {
233
+ this.config = config;
234
+ this.meter = meterProvider.getMeter(package_default.name, package_default.version);
235
+ this.attemptCounter = this.meter.createCounter(`${config.prefix}_email_attempts_total`, {
236
+ description: "Total number of email send attempts",
237
+ unit: "1"
238
+ });
239
+ this.messageCounter = this.meter.createCounter(`${config.prefix}_email_messages_total`, {
240
+ description: "Total number of email messages processed",
241
+ unit: "1"
242
+ });
243
+ this.durationHistogram = this.meter.createHistogram(`${config.prefix}_email_duration_seconds`, {
244
+ description: "Duration of email send operations",
245
+ unit: "s"
246
+ });
247
+ this.messageSizeHistogram = this.meter.createHistogram(`${config.prefix}_email_message_size_bytes`, {
248
+ description: "Size of email messages in bytes",
249
+ unit: "By"
250
+ });
251
+ this.attachmentCountHistogram = this.meter.createHistogram(`${config.prefix}_email_attachments_count`, {
252
+ description: "Number of attachments per email",
253
+ unit: "1"
254
+ });
255
+ this.activeSendsGauge = this.meter.createUpDownCounter(`${config.prefix}_email_active_sends`, {
256
+ description: "Number of currently active email send operations",
257
+ unit: "1"
258
+ });
259
+ }
260
+ /**
261
+ * Records the start of an email send operation.
262
+ */
263
+ recordSendStart(transport, messageCount = 1) {
264
+ if (!this.shouldSample()) return;
265
+ const labels = { transport };
266
+ this.activeSendsGauge.add(1, labels);
267
+ this.messageCounter.add(messageCount, {
268
+ ...labels,
269
+ priority: "normal"
270
+ });
271
+ }
272
+ /**
273
+ * Records the completion of an email send operation.
274
+ */
275
+ recordSendComplete(transport, duration, success, errorType) {
276
+ if (!this.shouldSample()) return;
277
+ const labels = {
278
+ transport,
279
+ status: success ? "success" : "failure",
280
+ ...errorType && { error_type: errorType }
281
+ };
282
+ this.activeSendsGauge.add(-1, { transport });
283
+ this.attemptCounter.add(1, labels);
284
+ this.durationHistogram.record(duration, labels);
285
+ }
286
+ /**
287
+ * Records metrics for individual messages.
288
+ */
289
+ recordMessage(message, transport) {
290
+ if (!this.shouldSample()) return;
291
+ const labels = {
292
+ transport,
293
+ priority: message.priority,
294
+ content_type: this.getContentType(message)
295
+ };
296
+ const messageSize = this.estimateMessageSize(message);
297
+ this.messageSizeHistogram.record(messageSize, labels);
298
+ this.attachmentCountHistogram.record(message.attachments.length, labels);
299
+ }
300
+ /**
301
+ * Records metrics for batch operations.
302
+ */
303
+ recordBatch(messages, transport, duration, successCount, failureCount) {
304
+ if (!this.shouldSample()) return;
305
+ const _totalMessages = messages.length;
306
+ const labels = { transport };
307
+ this.attemptCounter.add(1, {
308
+ ...labels,
309
+ status: failureCount === 0 ? "success" : "partial_failure",
310
+ operation: "batch"
311
+ });
312
+ this.durationHistogram.record(duration, {
313
+ ...labels,
314
+ operation: "batch"
315
+ });
316
+ for (const message of messages) this.recordMessage(message, transport);
317
+ if (successCount > 0) this.messageCounter.add(successCount, {
318
+ ...labels,
319
+ status: "success"
320
+ });
321
+ if (failureCount > 0) this.messageCounter.add(failureCount, {
322
+ ...labels,
323
+ status: "failure"
324
+ });
325
+ }
326
+ /**
327
+ * Records an error for metrics classification.
328
+ */
329
+ recordError(transport, errorType, operation = "send") {
330
+ if (!this.shouldSample()) return;
331
+ this.attemptCounter.add(1, {
332
+ transport,
333
+ status: "failure",
334
+ error_type: errorType,
335
+ operation
336
+ });
337
+ }
338
+ shouldSample() {
339
+ return Math.random() < this.config.samplingRate;
340
+ }
341
+ getContentType(message) {
342
+ if ("html" in message.content) return message.content.text ? "multipart" : "html";
343
+ return "text";
344
+ }
345
+ estimateMessageSize(message) {
346
+ let size = 0;
347
+ size += 500;
348
+ size += message.subject.length;
349
+ if ("html" in message.content) {
350
+ size += message.content.html.length;
351
+ if (message.content.text) size += message.content.text.length;
352
+ } else size += message.content.text.length;
353
+ size += message.attachments.length * 100;
354
+ return size;
355
+ }
356
+ };
357
+
358
+ //#endregion
359
+ //#region src/attributes.ts
360
+ /**
361
+ * Email attribute extractor class for generating OpenTelemetry attributes.
362
+ * Handles extraction of message metadata, error information, and transport details
363
+ * while respecting privacy settings for sensitive data.
364
+ *
365
+ * This class provides methods for extracting attributes from email messages,
366
+ * batch operations, and errors, making them suitable for use in OpenTelemetry
367
+ * spans and metrics.
368
+ *
369
+ * @example
370
+ * ```typescript
371
+ * import { EmailAttributeExtractor } from "@upyo/opentelemetry";
372
+ *
373
+ * const extractor = new EmailAttributeExtractor(
374
+ * "smtp", // transport name
375
+ * false, // don't record sensitive data
376
+ * "2.1.0" // transport version
377
+ * );
378
+ *
379
+ * // Extract attributes from a message
380
+ * const messageAttrs = extractor.extractMessageAttributes(message);
381
+ *
382
+ * // Extract attributes from batch operations
383
+ * const batchAttrs = extractor.extractBatchAttributes(messages, batchSize);
384
+ *
385
+ * // Extract attributes from errors
386
+ * const errorAttrs = extractor.extractErrorAttributes(error, "network");
387
+ * ```
388
+ *
389
+ * @since 0.2.0
390
+ */
391
+ var EmailAttributeExtractor = class {
392
+ recordSensitiveData;
393
+ transportName;
394
+ transportVersion;
395
+ constructor(transportName, recordSensitiveData = false, transportVersion) {
396
+ this.transportName = transportName;
397
+ this.recordSensitiveData = recordSensitiveData;
398
+ this.transportVersion = transportVersion;
399
+ }
400
+ /**
401
+ * Extracts attributes for a single message send operation.
402
+ */
403
+ extractMessageAttributes(message) {
404
+ const attributes = {
405
+ "operation.name": "email.send",
406
+ "operation.type": "email",
407
+ "email.to.count": message.recipients.length,
408
+ "email.cc.count": message.ccRecipients.length,
409
+ "email.bcc.count": message.bccRecipients.length,
410
+ "email.attachments.count": message.attachments.length,
411
+ "email.priority": message.priority,
412
+ "email.tags": JSON.stringify(message.tags),
413
+ "upyo.transport.name": this.transportName,
414
+ "upyo.retry.count": 0,
415
+ "upyo.batch.size": 1
416
+ };
417
+ if (this.transportVersion) attributes["upyo.transport.version"] = this.transportVersion;
418
+ if (this.recordSensitiveData) {
419
+ attributes["email.from.address"] = message.sender.address;
420
+ attributes["email.subject.length"] = message.subject.length;
421
+ } else {
422
+ attributes["email.from.domain"] = this.extractDomain(message.sender.address);
423
+ attributes["email.subject.length"] = message.subject.length;
424
+ }
425
+ attributes["email.content.type"] = this.detectContentType(message);
426
+ attributes["email.message.size"] = this.estimateMessageSize(message);
427
+ return attributes;
428
+ }
429
+ /**
430
+ * Extracts attributes for batch send operations.
431
+ */
432
+ extractBatchAttributes(messages, batchSize) {
433
+ const totalAttachments = messages.reduce((sum, msg) => sum + msg.attachments.length, 0);
434
+ const totalSize = messages.reduce((sum, msg) => sum + this.estimateMessageSize(msg), 0);
435
+ const attributes = {
436
+ "operation.name": "email.send_batch",
437
+ "operation.type": "email",
438
+ "email.batch.size": batchSize,
439
+ "email.batch.total_recipients": messages.reduce((sum, msg) => sum + msg.recipients.length + msg.ccRecipients.length + msg.bccRecipients.length, 0),
440
+ "email.batch.total_attachments": totalAttachments,
441
+ "email.batch.total_size": totalSize,
442
+ "upyo.transport.name": this.transportName,
443
+ "upyo.batch.size": batchSize
444
+ };
445
+ if (this.transportVersion) attributes["upyo.transport.version"] = this.transportVersion;
446
+ return attributes;
447
+ }
448
+ /**
449
+ * Extracts network-related attributes from transport configuration.
450
+ */
451
+ extractNetworkAttributes(protocol, serverAddress, serverPort) {
452
+ const attributes = { "network.protocol.name": protocol };
453
+ if (serverAddress) attributes["server.address"] = serverAddress;
454
+ if (serverPort) attributes["server.port"] = serverPort;
455
+ return attributes;
456
+ }
457
+ /**
458
+ * Extracts error-related attributes.
459
+ */
460
+ extractErrorAttributes(error, errorCategory) {
461
+ const attributes = { "error.type": errorCategory };
462
+ if (error instanceof Error) {
463
+ attributes["error.name"] = error.name;
464
+ if (this.recordSensitiveData || this.isSafeErrorMessage(error.message)) attributes["error.message"] = error.message;
465
+ }
466
+ return attributes;
467
+ }
468
+ detectContentType(message) {
469
+ if (message.attachments.length > 0) return "multipart";
470
+ if ("html" in message.content) return "html";
471
+ return "text";
472
+ }
473
+ estimateMessageSize(message) {
474
+ let size = 0;
475
+ size += 500;
476
+ size += message.subject.length;
477
+ if ("html" in message.content) {
478
+ size += message.content.html.length;
479
+ if (message.content.text) size += message.content.text.length;
480
+ } else size += message.content.text.length;
481
+ size += message.attachments.length * 100;
482
+ return size;
483
+ }
484
+ extractDomain(email) {
485
+ const atIndex = email.lastIndexOf("@");
486
+ return atIndex > 0 ? email.substring(atIndex + 1) : "unknown";
487
+ }
488
+ isSafeErrorMessage(message) {
489
+ const safePatterns = [
490
+ /^timeout/i,
491
+ /^connection/i,
492
+ /^network/i,
493
+ /^dns/i,
494
+ /^rate limit/i,
495
+ /^quota exceeded/i,
496
+ /^service unavailable/i,
497
+ /^internal server error/i,
498
+ /^bad gateway/i,
499
+ /^gateway timeout/i
500
+ ];
501
+ return safePatterns.some((pattern) => pattern.test(message));
502
+ }
503
+ };
504
+
505
+ //#endregion
506
+ //#region src/tracing.ts
507
+ /**
508
+ * Manages OpenTelemetry tracing for email operations.
509
+ */
510
+ var TracingCollector = class {
511
+ tracer;
512
+ config;
513
+ attributeExtractor;
514
+ constructor(tracerProvider, config, transportName, transportVersion) {
515
+ this.config = config;
516
+ this.tracer = tracerProvider.getTracer(package_default.name, package_default.version);
517
+ this.attributeExtractor = new EmailAttributeExtractor(transportName, config.recordSensitiveData, transportVersion);
518
+ }
519
+ /**
520
+ * Creates a span for a single email send operation.
521
+ */
522
+ createSendSpan(message, networkAttributes) {
523
+ if (!this.shouldSample()) return new NoOpEmailSpan();
524
+ const spanName = `${this.config.spanPrefix} send`;
525
+ const span = this.tracer.startSpan(spanName, { kind: __opentelemetry_api.SpanKind.CLIENT }, __opentelemetry_api.context.active());
526
+ const messageAttributes = this.attributeExtractor.extractMessageAttributes(message);
527
+ span.setAttributes(messageAttributes);
528
+ if (networkAttributes) span.setAttributes(networkAttributes);
529
+ return new LiveEmailSpan(span, this.attributeExtractor);
530
+ }
531
+ /**
532
+ * Creates a span for a batch email send operation.
533
+ */
534
+ createBatchSpan(messages, batchSize, networkAttributes) {
535
+ if (!this.shouldSample()) return new NoOpEmailSpan();
536
+ const spanName = `${this.config.spanPrefix} send_batch`;
537
+ const span = this.tracer.startSpan(spanName, { kind: __opentelemetry_api.SpanKind.CLIENT }, __opentelemetry_api.context.active());
538
+ const batchAttributes = this.attributeExtractor.extractBatchAttributes(messages, batchSize);
539
+ span.setAttributes(batchAttributes);
540
+ if (networkAttributes) span.setAttributes(networkAttributes);
541
+ return new LiveEmailSpan(span, this.attributeExtractor);
542
+ }
543
+ shouldSample() {
544
+ return Math.random() < this.config.samplingRate;
545
+ }
546
+ };
547
+ /**
548
+ * Live implementation of EmailSpan that delegates to OpenTelemetry Span.
549
+ */
550
+ var LiveEmailSpan = class {
551
+ constructor(span, attributeExtractor) {
552
+ this.span = span;
553
+ this.attributeExtractor = attributeExtractor;
554
+ }
555
+ addEvent(name$1, attributes) {
556
+ this.span.addEvent(name$1, attributes);
557
+ }
558
+ setAttributes(attributes) {
559
+ this.span.setAttributes(attributes);
560
+ }
561
+ recordError(error, errorCategory) {
562
+ this.span.setStatus({
563
+ code: __opentelemetry_api.SpanStatusCode.ERROR,
564
+ message: error instanceof Error ? error.message : "Unknown error"
565
+ });
566
+ const errorAttributes = this.attributeExtractor.extractErrorAttributes(error, errorCategory);
567
+ this.span.setAttributes(errorAttributes);
568
+ this.span.addEvent("exception", {
569
+ "exception.type": error instanceof Error ? error.constructor.name : "unknown",
570
+ "exception.message": error instanceof Error ? error.message : String(error)
571
+ });
572
+ }
573
+ recordRetry(attempt, reason) {
574
+ this.span.setAttributes({ "upyo.retry.count": attempt });
575
+ this.span.addEvent("retry", {
576
+ "retry.attempt": attempt,
577
+ ...reason && { "retry.reason": reason }
578
+ });
579
+ }
580
+ recordSuccess(messageId) {
581
+ this.span.setStatus({ code: __opentelemetry_api.SpanStatusCode.OK });
582
+ if (messageId) this.span.setAttributes({ "upyo.message.id": messageId });
583
+ this.span.addEvent("email.sent", { ...messageId && { "message.id": messageId } });
584
+ }
585
+ recordFailure(errorMessages) {
586
+ this.span.setStatus({
587
+ code: __opentelemetry_api.SpanStatusCode.ERROR,
588
+ message: errorMessages[0] || "Send failed"
589
+ });
590
+ this.span.setAttributes({ "email.error.count": errorMessages.length });
591
+ this.span.addEvent("email.send_failed", {
592
+ "error.count": errorMessages.length,
593
+ "error.messages": JSON.stringify(errorMessages)
594
+ });
595
+ }
596
+ end() {
597
+ this.span.end();
598
+ }
599
+ };
600
+ /**
601
+ * No-op implementation for when sampling is disabled.
602
+ */
603
+ var NoOpEmailSpan = class {
604
+ addEvent() {}
605
+ setAttributes() {}
606
+ recordError() {}
607
+ recordRetry() {}
608
+ recordSuccess() {}
609
+ recordFailure() {}
610
+ end() {}
611
+ };
612
+
613
+ //#endregion
614
+ //#region src/opentelemetry-transport.ts
615
+ /**
616
+ * OpenTelemetry decorator transport that adds observability to any existing transport.
617
+ *
618
+ * This transport wraps another transport implementation to provide automatic
619
+ * OpenTelemetry metrics and tracing without requiring changes to existing code.
620
+ * It follows the decorator pattern, accepting any transport and adding standardized
621
+ * observability features including email delivery metrics, latency histograms,
622
+ * error classification, and distributed tracing support.
623
+ *
624
+ * The transport also supports automatic resource cleanup by implementing
625
+ * AsyncDisposable and properly disposing wrapped transports that support
626
+ * either Disposable or AsyncDisposable interfaces.
627
+ *
628
+ * @example Basic usage with explicit providers
629
+ * ```typescript
630
+ * import { OpenTelemetryTransport } from "@upyo/opentelemetry";
631
+ * import { createSmtpTransport } from "@upyo/smtp";
632
+ * import { trace, metrics } from "@opentelemetry/api";
633
+ *
634
+ * const baseTransport = createSmtpTransport({
635
+ * host: "smtp.example.com",
636
+ * port: 587,
637
+ * });
638
+ *
639
+ * const transport = new OpenTelemetryTransport(baseTransport, {
640
+ * tracerProvider: trace.getTracerProvider(),
641
+ * meterProvider: metrics.getMeterProvider(),
642
+ * tracing: {
643
+ * enabled: true,
644
+ * samplingRate: 1.0,
645
+ * recordSensitiveData: false,
646
+ * },
647
+ * metrics: {
648
+ * enabled: true,
649
+ * },
650
+ * });
651
+ *
652
+ * // Use the transport normally - it will automatically create spans and metrics
653
+ * await transport.send(message);
654
+ * ```
655
+ *
656
+ * @example With custom error classification and attribute extraction
657
+ * ```typescript
658
+ * const transport = new OpenTelemetryTransport(baseTransport, {
659
+ * tracerProvider: trace.getTracerProvider(),
660
+ * meterProvider: metrics.getMeterProvider(),
661
+ * errorClassifier: (error) => {
662
+ * if (error.message.includes("spam")) return "spam";
663
+ * if (error.message.includes("bounce")) return "bounce";
664
+ * return "unknown";
665
+ * },
666
+ * attributeExtractor: (operation, transportName) => ({
667
+ * "service.environment": process.env.NODE_ENV,
668
+ * "transport.type": transportName,
669
+ * }),
670
+ * });
671
+ * ```
672
+ *
673
+ * @since 0.2.0
674
+ */
675
+ var OpenTelemetryTransport = class {
676
+ /**
677
+ * The resolved OpenTelemetry configuration.
678
+ */
679
+ config;
680
+ wrappedTransport;
681
+ metricsCollector;
682
+ tracingCollector;
683
+ transportName;
684
+ transportVersion;
685
+ /**
686
+ * Creates a new OpenTelemetry transport that wraps an existing transport.
687
+ *
688
+ * @param transport The base transport to wrap with observability.
689
+ * @param config OpenTelemetry configuration options.
690
+ */
691
+ constructor(transport, config = {}) {
692
+ this.wrappedTransport = transport;
693
+ this.config = createOpenTelemetryConfig(config);
694
+ this.transportName = this.extractTransportName(transport);
695
+ this.transportVersion = this.extractTransportVersion(transport);
696
+ if (this.config.metrics.enabled && this.config.meterProvider) this.metricsCollector = new MetricsCollector(this.config.meterProvider, this.config.metrics);
697
+ if (this.config.tracing.enabled && this.config.tracerProvider) this.tracingCollector = new TracingCollector(this.config.tracerProvider, this.config.tracing, this.transportName, this.transportVersion);
698
+ }
699
+ /**
700
+ * Sends a single email message with OpenTelemetry observability.
701
+ *
702
+ * @param message The email message to send.
703
+ * @param options Optional transport options including abort signal.
704
+ * @returns A promise that resolves to a receipt indicating success or failure.
705
+ */
706
+ async send(message, options) {
707
+ const startTime = performance.now();
708
+ const span = this.tracingCollector?.createSendSpan(message, this.extractNetworkAttributes());
709
+ this.metricsCollector?.recordSendStart(this.transportName, 1);
710
+ this.metricsCollector?.recordMessage(message, this.transportName);
711
+ try {
712
+ if (this.config.attributeExtractor && span) {
713
+ const customAttributes = this.config.attributeExtractor("send", this.transportName, 1, this.estimateMessageSize(message));
714
+ span.setAttributes(customAttributes);
715
+ }
716
+ const receipt = await this.wrappedTransport.send(message, options);
717
+ const duration = (performance.now() - startTime) / 1e3;
718
+ if (receipt.successful) {
719
+ span?.recordSuccess(receipt.messageId);
720
+ this.metricsCollector?.recordSendComplete(this.transportName, duration, true);
721
+ } else {
722
+ const errorCategory = this.classifyErrors(receipt.errorMessages);
723
+ span?.recordFailure(receipt.errorMessages);
724
+ this.metricsCollector?.recordSendComplete(this.transportName, duration, false, errorCategory);
725
+ }
726
+ return receipt;
727
+ } catch (error) {
728
+ const duration = (performance.now() - startTime) / 1e3;
729
+ const errorCategory = this.classifyError(error);
730
+ span?.recordError(error, errorCategory);
731
+ this.metricsCollector?.recordSendComplete(this.transportName, duration, false, errorCategory);
732
+ throw error;
733
+ } finally {
734
+ span?.end();
735
+ }
736
+ }
737
+ /**
738
+ * Sends multiple email messages with OpenTelemetry observability.
739
+ *
740
+ * @param messages An iterable or async iterable of messages to send.
741
+ * @param options Optional transport options including abort signal.
742
+ * @returns An async iterable that yields receipts for each sent message.
743
+ */
744
+ async *sendMany(messages, options) {
745
+ const startTime = performance.now();
746
+ const messageArray = [];
747
+ for await (const message of messages) messageArray.push(message);
748
+ const batchSize = messageArray.length;
749
+ const span = this.tracingCollector?.createBatchSpan(messageArray, batchSize, this.extractNetworkAttributes());
750
+ this.metricsCollector?.recordSendStart(this.transportName, batchSize);
751
+ let successCount = 0;
752
+ let failureCount = 0;
753
+ const receipts = [];
754
+ try {
755
+ if (this.config.attributeExtractor && span) {
756
+ const totalSize = messageArray.reduce((sum, msg) => sum + this.estimateMessageSize(msg), 0);
757
+ const customAttributes = this.config.attributeExtractor("send_batch", this.transportName, batchSize, totalSize);
758
+ span.setAttributes(customAttributes);
759
+ }
760
+ for await (const receipt of this.wrappedTransport.sendMany(messageArray, options)) {
761
+ receipts.push(receipt);
762
+ if (receipt.successful) successCount++;
763
+ else failureCount++;
764
+ yield receipt;
765
+ }
766
+ const duration = (performance.now() - startTime) / 1e3;
767
+ this.metricsCollector?.recordBatch(messageArray, this.transportName, duration, successCount, failureCount);
768
+ if (failureCount === 0) span?.recordSuccess();
769
+ else if (successCount > 0) span?.addEvent("partial_success", {
770
+ "success_count": successCount,
771
+ "failure_count": failureCount
772
+ });
773
+ else {
774
+ const allErrorMessages = receipts.filter((r) => !r.successful).flatMap((r) => r.errorMessages);
775
+ span?.recordFailure(allErrorMessages);
776
+ }
777
+ } catch (error) {
778
+ const _duration = (performance.now() - startTime) / 1e3;
779
+ const errorCategory = this.classifyError(error);
780
+ span?.recordError(error, errorCategory);
781
+ this.metricsCollector?.recordError(this.transportName, errorCategory, "send_batch");
782
+ throw error;
783
+ } finally {
784
+ span?.end();
785
+ }
786
+ }
787
+ /**
788
+ * Cleanup resources if the wrapped transport supports Disposable or AsyncDisposable.
789
+ */
790
+ async [Symbol.asyncDispose]() {
791
+ if (!this.wrappedTransport) return;
792
+ if (typeof this.wrappedTransport[Symbol.asyncDispose] === "function") {
793
+ await this.wrappedTransport[Symbol.asyncDispose]();
794
+ return;
795
+ }
796
+ if (typeof this.wrappedTransport[Symbol.dispose] === "function") this.wrappedTransport[Symbol.dispose]();
797
+ }
798
+ extractTransportName(transport) {
799
+ const constructorName = transport.constructor.name;
800
+ if (constructorName && constructorName !== "Object") return constructorName.toLowerCase().replace("transport", "");
801
+ if ("config" in transport && transport.config && typeof transport.config === "object") {
802
+ if ("domain" in transport.config) return "mailgun";
803
+ if ("apiKey" in transport.config && "region" in transport.config) return "sendgrid";
804
+ if ("accessKeyId" in transport.config) return "ses";
805
+ if ("host" in transport.config) return "smtp";
806
+ }
807
+ return "unknown";
808
+ }
809
+ extractTransportVersion(transport) {
810
+ if ("config" in transport && transport.config && typeof transport.config === "object") {
811
+ if ("version" in transport.config) return String(transport.config.version);
812
+ }
813
+ return void 0;
814
+ }
815
+ extractNetworkAttributes() {
816
+ const transport = this.wrappedTransport;
817
+ if ("config" in transport && transport.config && typeof transport.config === "object") {
818
+ const config = transport.config;
819
+ if ("host" in config && "port" in config) return {
820
+ "network.protocol.name": "smtp",
821
+ "server.address": String(config.host),
822
+ "server.port": Number(config.port)
823
+ };
824
+ if ("baseUrl" in config || "endpoint" in config || "domain" in config) {
825
+ const url = config.baseUrl || config.endpoint || config.domain && `https://api.${config.region === "eu" ? "eu." : ""}mailgun.net`;
826
+ if (url && typeof url === "string") try {
827
+ const parsedUrl = new URL(url);
828
+ return {
829
+ "network.protocol.name": parsedUrl.protocol.replace(":", ""),
830
+ "server.address": parsedUrl.hostname,
831
+ ...parsedUrl.port && { "server.port": parseInt(parsedUrl.port) }
832
+ };
833
+ } catch {}
834
+ }
835
+ }
836
+ return void 0;
837
+ }
838
+ classifyError(error) {
839
+ const classifier = this.config.errorClassifier || defaultErrorClassifier;
840
+ return classifier(error);
841
+ }
842
+ classifyErrors(errorMessages) {
843
+ if (errorMessages.length === 0) return "unknown";
844
+ const syntheticError = new Error(errorMessages[0]);
845
+ return this.classifyError(syntheticError);
846
+ }
847
+ estimateMessageSize(message) {
848
+ let size = 0;
849
+ size += 500;
850
+ size += message.subject.length;
851
+ if ("html" in message.content) {
852
+ size += message.content.html.length;
853
+ if (message.content.text) size += message.content.text.length;
854
+ } else size += message.content.text.length;
855
+ size += message.attachments.length * 100;
856
+ return size;
857
+ }
858
+ };
859
+
860
+ //#endregion
861
+ //#region src/index.ts
862
+ /**
863
+ * Creates an OpenTelemetry transport for email operations with comprehensive
864
+ * observability features.
865
+ *
866
+ * This function wraps an existing email transport with OpenTelemetry tracing
867
+ * and metrics collection, providing automatic instrumentation for email sending
868
+ * operations. It supports both single message and batch operations with
869
+ * configurable sampling, error classification, and attribute extraction.
870
+ * The function simplifies setup by automatically configuring providers when
871
+ * they're not explicitly provided, using global `TracerProvider` and
872
+ * `MeterProvider` from OpenTelemetry API as fallbacks.
873
+ *
874
+ * @param baseTransport The underlying email transport to wrap with
875
+ * OpenTelemetry instrumentation.
876
+ * @param config Configuration options for OpenTelemetry setup including
877
+ * providers, sampling rates, and custom extractors.
878
+ * @param config.serviceName Service name for OpenTelemetry resource attributes.
879
+ * Defaults to "email-service".
880
+ * @param config.serviceVersion Service version for OpenTelemetry resource
881
+ * attributes.
882
+ * @param config.tracerProvider Custom TracerProvider instance. Uses global
883
+ * provider if not specified.
884
+ * @param config.meterProvider Custom MeterProvider instance. Uses global
885
+ * provider if not specified.
886
+ * @param config.auto Auto-configuration options for setting up exporters and
887
+ * processors.
888
+ * @param config.tracing Tracing configuration including sampling rates and
889
+ * span settings.
890
+ * @param config.metrics Metrics configuration including histogram boundaries
891
+ * and collection settings.
892
+ * @param config.attributeExtractor Custom function for extracting attributes
893
+ * from operations.
894
+ * @param config.errorClassifier Custom function for categorizing errors into
895
+ * types.
896
+ * @returns An OpenTelemetry-enabled transport that instruments all email
897
+ * operations with traces and metrics.
898
+ *
899
+ * @example With minimal configuration
900
+ * ```typescript
901
+ * import { createOpenTelemetryTransport } from "@upyo/opentelemetry";
902
+ * import { createSmtpTransport } from "@upyo/smtp";
903
+ *
904
+ * const smtpTransport = createSmtpTransport({
905
+ * host: "smtp.example.com",
906
+ * port: 587,
907
+ * });
908
+ *
909
+ * const transport = createOpenTelemetryTransport(smtpTransport, {
910
+ * serviceName: "email-service",
911
+ * serviceVersion: "1.0.0",
912
+ * });
913
+ * ```
914
+ *
915
+ * @example With explicit providers
916
+ * ```typescript
917
+ * import { createOpenTelemetryTransport } from "@upyo/opentelemetry";
918
+ * import { trace, metrics } from "@opentelemetry/api";
919
+ *
920
+ * const transport = createOpenTelemetryTransport(baseTransport, {
921
+ * tracerProvider: trace.getTracerProvider(),
922
+ * meterProvider: metrics.getMeterProvider(),
923
+ * serviceName: "my-email-service",
924
+ * tracing: {
925
+ * enabled: true,
926
+ * samplingRate: 0.1,
927
+ * recordSensitiveData: false,
928
+ * },
929
+ * metrics: {
930
+ * enabled: true,
931
+ * histogramBoundaries: [10, 50, 100, 500, 1000, 5000],
932
+ * },
933
+ * });
934
+ * ```
935
+ *
936
+ * @example With auto-configuration
937
+ * ```typescript
938
+ * const transport = createOpenTelemetryTransport(baseTransport, {
939
+ * serviceName: "email-service",
940
+ * auto: {
941
+ * tracing: { endpoint: "http://jaeger:14268/api/traces" },
942
+ * metrics: { endpoint: "http://prometheus:9090/api/v1/write" }
943
+ * }
944
+ * });
945
+ * ```
946
+ *
947
+ * @since 0.2.0
948
+ */
949
+ function createOpenTelemetryTransport(baseTransport, config = {}) {
950
+ const { serviceName = "email-service", serviceVersion, auto,...otherConfig } = config;
951
+ const finalConfig = {
952
+ tracerProvider: otherConfig.tracerProvider || __opentelemetry_api.trace.getTracerProvider(),
953
+ meterProvider: otherConfig.meterProvider || __opentelemetry_api.metrics.getMeterProvider(),
954
+ ...otherConfig,
955
+ ...auto && { auto: {
956
+ serviceName,
957
+ serviceVersion,
958
+ ...auto
959
+ } }
960
+ };
961
+ return new OpenTelemetryTransport(baseTransport, finalConfig);
962
+ }
963
+ /**
964
+ * Creates a custom email attribute extractor function for OpenTelemetry
965
+ * spans and metrics.
966
+ *
967
+ * This function creates a reusable attribute extractor that can be used to
968
+ * generate consistent attributes for OpenTelemetry traces and metrics across
969
+ * different email operations. It supports both basic transport information
970
+ * and custom attribute generation based on operation context.
971
+ *
972
+ * @param transportName The name of the email transport
973
+ * (e.g., `"smtp"`, `"mailgun"`, `"sendgrid"`).
974
+ * @param options Configuration options for customizing attribute extraction
975
+ * behavior.
976
+ * @param options.recordSensitiveData Whether to include sensitive data like
977
+ * email addresses in attributes.
978
+ * Defaults to false for security.
979
+ * @param options.transportVersion The version of the transport implementation
980
+ * for better observability.
981
+ * @param options.customAttributes A function to generate custom attributes
982
+ * based on operation context.
983
+ * @returns A function that extracts attributes from email operations,
984
+ * suitable for use with OpenTelemetry spans and metrics.
985
+ *
986
+ * @example Basic usage
987
+ * ```typescript
988
+ * import { createEmailAttributeExtractor } from "@upyo/opentelemetry";
989
+ *
990
+ * const extractor = createEmailAttributeExtractor("smtp", {
991
+ * transportVersion: "2.1.0",
992
+ * });
993
+ *
994
+ * const transport = createOpenTelemetryTransport(baseTransport, {
995
+ * attributeExtractor: extractor,
996
+ * });
997
+ * ```
998
+ *
999
+ * @example With custom attributes
1000
+ * ```typescript
1001
+ * import { createEmailAttributeExtractor } from "@upyo/opentelemetry";
1002
+ *
1003
+ * const extractor = createEmailAttributeExtractor("smtp", {
1004
+ * recordSensitiveData: false,
1005
+ * transportVersion: "2.1.0",
1006
+ * customAttributes: (operation, transportName, messageCount, totalSize) => ({
1007
+ * "service.environment": process.env.NODE_ENV || "development",
1008
+ * "email.batch.size": messageCount || 1,
1009
+ * "email.total.bytes": totalSize || 0,
1010
+ * "transport.type": transportName,
1011
+ * }),
1012
+ * });
1013
+ *
1014
+ * const transport = createOpenTelemetryTransport(baseTransport, {
1015
+ * attributeExtractor: extractor,
1016
+ * });
1017
+ * ```
1018
+ *
1019
+ * @since 0.2.0
1020
+ */
1021
+ function createEmailAttributeExtractor(_transportName, options = {}) {
1022
+ return (operation, transport, messageCount, totalSize) => {
1023
+ const baseAttributes = {
1024
+ "operation.name": operation === "send" ? "email.send" : "email.send_batch",
1025
+ "operation.type": "email",
1026
+ "upyo.transport.name": transport
1027
+ };
1028
+ if (options.transportVersion) baseAttributes["upyo.transport.version"] = options.transportVersion;
1029
+ if (operation === "send_batch") {
1030
+ baseAttributes["upyo.batch.size"] = messageCount;
1031
+ if (totalSize !== void 0) baseAttributes["email.batch.total_size"] = totalSize;
1032
+ }
1033
+ const customAttributes = options.customAttributes?.(operation, transport, messageCount, totalSize) || {};
1034
+ return {
1035
+ ...baseAttributes,
1036
+ ...customAttributes
1037
+ };
1038
+ };
1039
+ }
1040
+ /**
1041
+ * Creates a custom error classifier function for categorizing email sending
1042
+ * errors.
1043
+ *
1044
+ * This function creates an error classifier that categorizes errors into
1045
+ * meaningful groups for better observability and alerting. It supports custom
1046
+ * regex patterns for domain-specific error classification while falling back
1047
+ * to the default classifier for common error types. The classifier is used to
1048
+ * tag metrics and traces with error categories, enabling better
1049
+ * monitoring and debugging of email delivery issues.
1050
+ *
1051
+ * @param options Configuration options for error classification behavior.
1052
+ * @param options.patterns A record of category names mapped to regex patterns
1053
+ * for custom error classification.
1054
+ * @param options.fallback The fallback category to use when no patterns match
1055
+ * and the default classifier returns "unknown".
1056
+ * Defaults to "unknown".
1057
+ * @returns A function that classifies errors into string categories for use
1058
+ * in OpenTelemetry attributes and metrics.
1059
+ *
1060
+ * @example Basic usage with common patterns
1061
+ * ```typescript
1062
+ * import { createErrorClassifier } from "@upyo/opentelemetry";
1063
+ *
1064
+ * const classifier = createErrorClassifier({
1065
+ * patterns: {
1066
+ * spam: /blocked.*spam|spam.*detected/i,
1067
+ * bounce: /bounce|undeliverable|invalid.*recipient/i,
1068
+ * },
1069
+ * fallback: "email_error",
1070
+ * });
1071
+ *
1072
+ * const transport = createOpenTelemetryTransport(baseTransport, {
1073
+ * errorClassifier: classifier,
1074
+ * });
1075
+ * ```
1076
+ *
1077
+ * @example Advanced patterns for specific email providers
1078
+ * ```typescript
1079
+ * import { createErrorClassifier } from "@upyo/opentelemetry";
1080
+ *
1081
+ * const classifier = createErrorClassifier({
1082
+ * patterns: {
1083
+ * spam: /blocked.*spam|spam.*detected|reputation/i,
1084
+ * bounce: /bounce|undeliverable|invalid.*recipient/i,
1085
+ * quota: /quota.*exceeded|mailbox.*full/i,
1086
+ * reputation: /reputation|blacklist|blocked.*ip/i,
1087
+ * temporary: /temporary.*failure|try.*again.*later/i,
1088
+ * authentication: /authentication.*failed|invalid.*credentials/i,
1089
+ * },
1090
+ * fallback: "email_error",
1091
+ * });
1092
+ *
1093
+ * // The classifier will categorize errors like:
1094
+ * // new Error("Message blocked as spam") -> "spam"
1095
+ * // new Error("Mailbox quota exceeded") -> "quota"
1096
+ * // new Error("Authentication failed") -> "auth" (from default classifier)
1097
+ * // new Error("Unknown error") -> "email_error" (fallback)
1098
+ * ```
1099
+ *
1100
+ * @since 0.2.0
1101
+ */
1102
+ function createErrorClassifier(options = {}) {
1103
+ const { patterns = {}, fallback = "unknown" } = options;
1104
+ return (error) => {
1105
+ if (error instanceof Error) {
1106
+ const message = error.message.toLowerCase();
1107
+ for (const [category, pattern] of Object.entries(patterns)) if (pattern.test(message)) return category;
1108
+ }
1109
+ const defaultCategory = defaultErrorClassifier(error);
1110
+ return defaultCategory === "unknown" ? fallback : defaultCategory;
1111
+ };
1112
+ }
1113
+
1114
+ //#endregion
1115
+ exports.OpenTelemetryTransport = OpenTelemetryTransport;
1116
+ exports.createEmailAttributeExtractor = createEmailAttributeExtractor;
1117
+ exports.createErrorClassifier = createErrorClassifier;
1118
+ exports.createOpenTelemetryTransport = createOpenTelemetryTransport;
1119
+ exports.defaultErrorClassifier = defaultErrorClassifier;