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