@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/README.md +449 -0
- package/dist/index.cjs +1119 -0
- package/dist/index.d.cts +558 -0
- package/dist/index.d.ts +558 -0
- package/dist/index.js +1092 -0
- package/package.json +81 -0
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;
|