diff --git a/packages/otel-resend/package.json b/packages/otel-resend/package.json index 7d4d95e..93b6a73 100644 --- a/packages/otel-resend/package.json +++ b/packages/otel-resend/package.json @@ -1,6 +1,6 @@ { "name": "@kubiks/otel-resend", - "version": "1.0.0", + "version": "1.0.2", "private": false, "publishConfig": { "access": "public" diff --git a/packages/otel-resend/src/index.test.ts b/packages/otel-resend/src/index.test.ts index 547ccb8..85c12a7 100644 --- a/packages/otel-resend/src/index.test.ts +++ b/packages/otel-resend/src/index.test.ts @@ -16,6 +16,11 @@ import { SEMATTRS_RESEND_RESOURCE_ID, SEMATTRS_RESEND_TARGET, SEMATTRS_RESEND_TEMPLATE_ID, + SEMATTRS_RESEND_TO_ADDRESSES, + SEMATTRS_RESEND_CC_ADDRESSES, + SEMATTRS_RESEND_BCC_ADDRESSES, + SEMATTRS_RESEND_FROM, + SEMATTRS_RESEND_SUBJECT, } from "./index"; describe("instrumentResend", () => { @@ -72,7 +77,7 @@ describe("instrumentResend", () => { instrumentResend(resend); const payload = { - to: ["user@example.com", { email: "second@example.com" }], + to: ["user@example.com", "second@example.com"], template_id: "tmpl_123", }; @@ -96,6 +101,7 @@ describe("instrumentResend", () => { expect(span.attributes[SEMATTRS_RESEND_MESSAGE_COUNT]).toBe(1); expect(span.attributes[SEMATTRS_RESEND_RECIPIENT_COUNT]).toBe(2); expect(span.attributes[SEMATTRS_RESEND_TEMPLATE_ID]).toBe("tmpl_123"); + expect(span.attributes[SEMATTRS_RESEND_TO_ADDRESSES]).toBe("user@example.com, second@example.com"); expect(span.status.code).toBe(SpanStatusCode.OK); }); @@ -187,4 +193,90 @@ describe("instrumentResend", () => { const spans = exporter.getFinishedSpans(); expect(spans).toHaveLength(0); }); + + it("captures email addresses from all recipient fields", async () => { + const resend = createMockResend(); + instrumentResend(resend); + + const payload = { + to: ["to1@example.com", "to2@example.com", "to3@example.com"], + cc: ["cc1@example.com", "cc2@example.com"], + bcc: "bcc@example.com", + subject: "Test Email", + from: "sender@example.com", + text: "Test content", + }; + + await resend.emails.send(payload); + + const spans = exporter.getFinishedSpans(); + expect(spans).toHaveLength(1); + + const span = spans[0]; + if (!span) { + throw new Error("Expected a span to be recorded"); + } + + expect(span.attributes[SEMATTRS_RESEND_RECIPIENT_COUNT]).toBe(6); + expect(span.attributes[SEMATTRS_RESEND_TO_ADDRESSES]).toBe("to1@example.com, to2@example.com, to3@example.com"); + expect(span.attributes[SEMATTRS_RESEND_CC_ADDRESSES]).toBe("cc1@example.com, cc2@example.com"); + expect(span.attributes[SEMATTRS_RESEND_BCC_ADDRESSES]).toBe("bcc@example.com"); + expect(span.attributes[SEMATTRS_RESEND_SUBJECT]).toBe("Test Email"); + expect(span.attributes[SEMATTRS_RESEND_FROM]).toBe("sender@example.com"); + }); + + it("handles missing recipient fields gracefully", async () => { + const resend = createMockResend(); + instrumentResend(resend); + + const payload = { + to: "single@example.com", + }; + + await resend.emails.send(payload); + + const spans = exporter.getFinishedSpans(); + expect(spans).toHaveLength(1); + + const span = spans[0]; + if (!span) { + throw new Error("Expected a span to be recorded"); + } + + expect(span.attributes[SEMATTRS_RESEND_RECIPIENT_COUNT]).toBe(1); + expect(span.attributes[SEMATTRS_RESEND_TO_ADDRESSES]).toBe("single@example.com"); + expect(span.attributes[SEMATTRS_RESEND_CC_ADDRESSES]).toBeUndefined(); + expect(span.attributes[SEMATTRS_RESEND_BCC_ADDRESSES]).toBeUndefined(); + }); + + it("handles mixed string and array formats correctly", async () => { + const resend = createMockResend(); + instrumentResend(resend); + + const payload = { + to: "single@example.com", + cc: ["cc1@example.com", "cc2@example.com"], + bcc: ["bcc1@example.com"], + from: "noreply@example.com", + subject: "Mixed Format Test", + text: "Test", + }; + + await resend.emails.send(payload); + + const spans = exporter.getFinishedSpans(); + expect(spans).toHaveLength(1); + + const span = spans[0]; + if (!span) { + throw new Error("Expected a span to be recorded"); + } + + expect(span.attributes[SEMATTRS_RESEND_TO_ADDRESSES]).toBe("single@example.com"); + expect(span.attributes[SEMATTRS_RESEND_CC_ADDRESSES]).toBe("cc1@example.com, cc2@example.com"); + expect(span.attributes[SEMATTRS_RESEND_BCC_ADDRESSES]).toBe("bcc1@example.com"); + expect(span.attributes[SEMATTRS_RESEND_RECIPIENT_COUNT]).toBe(4); + expect(span.attributes[SEMATTRS_RESEND_FROM]).toBe("noreply@example.com"); + expect(span.attributes[SEMATTRS_RESEND_SUBJECT]).toBe("Mixed Format Test"); + }); }); diff --git a/packages/otel-resend/src/index.ts b/packages/otel-resend/src/index.ts index ace5492..8555ace 100644 --- a/packages/otel-resend/src/index.ts +++ b/packages/otel-resend/src/index.ts @@ -6,6 +6,7 @@ import { type Span, type Tracer, } from "@opentelemetry/api"; +import type { CreateEmailOptions } from "resend"; const DEFAULT_TRACER_NAME = "@kubiks/otel-resend"; const INSTRUMENTED_FLAG = "__kubiksOtelResendInstrumented" as const; @@ -22,6 +23,11 @@ export const SEMATTRS_RESEND_SEGMENT_ID = "resend.segment_id" as const; export const SEMATTRS_RESEND_AUDIENCE_ID = "resend.audience_id" as const; export const SEMATTRS_RESEND_RECIPIENT_COUNT = "resend.recipient_count" as const; export const SEMATTRS_RESEND_RESOURCE_ID = "resend.resource_id" as const; +export const SEMATTRS_RESEND_TO_ADDRESSES = "resend.to_addresses" as const; +export const SEMATTRS_RESEND_CC_ADDRESSES = "resend.cc_addresses" as const; +export const SEMATTRS_RESEND_BCC_ADDRESSES = "resend.bcc_addresses" as const; +export const SEMATTRS_RESEND_FROM = "resend.from" as const; +export const SEMATTRS_RESEND_SUBJECT = "resend.subject" as const; export interface InstrumentResendConfig { tracerName?: string; @@ -86,7 +92,7 @@ function buildBaseAttributes( return attributes; } -function countRecipients(value: unknown): number { +function countRecipients(value: string | string[] | undefined): number { if (!value) { return 0; } @@ -94,30 +100,25 @@ function countRecipients(value: unknown): number { return value.trim() ? 1 : 0; } if (Array.isArray(value)) { - return value.reduce((count, item) => count + countRecipients(item), 0); - } - if (typeof value === "object") { - // Array-like or iterable structures - if (typeof (value as { length?: number }).length === "number") { - return (value as { length: number }).length; - } - if (Symbol.iterator in (value as object)) { - let count = 0; - for (const item of value as Iterable) { - count += countRecipients(item); - } - return count; - } - if ( - typeof (value as { email?: unknown }).email === "string" || - typeof (value as { address?: unknown }).address === "string" - ) { - return 1; - } + return value.filter(email => typeof email === "string" && email.trim()).length; } return 0; } +function extractEmailAddresses(value: string | string[] | undefined): string[] { + if (!value) { + return []; + } + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed ? [trimmed] : []; + } + if (Array.isArray(value)) { + return value.filter(email => typeof email === "string" && email.trim()).map(email => email.trim()); + } + return []; +} + function annotateRequest( span: Span, path: readonly string[], @@ -133,14 +134,64 @@ function annotateRequest( return; } - const data = payload as Record; + // Check if this is an email send operation + if (path[0] === "emails" || path[0] === "batch") { + // Try to cast to CreateEmailOptions for better type safety + const data = payload as Partial; - const recipientCount = - countRecipients(data.to) + countRecipients(data.cc) + countRecipients(data.bcc); - if (recipientCount > 0) { - span.setAttribute(SEMATTRS_RESEND_RECIPIENT_COUNT, recipientCount); + // Extract and set email addresses using proper types + const toAddresses = extractEmailAddresses(data.to); + if (toAddresses.length > 0) { + span.setAttribute(SEMATTRS_RESEND_TO_ADDRESSES, toAddresses.join(", ")); + } + + const ccAddresses = extractEmailAddresses(data.cc); + if (ccAddresses.length > 0) { + span.setAttribute(SEMATTRS_RESEND_CC_ADDRESSES, ccAddresses.join(", ")); + } + + const bccAddresses = extractEmailAddresses(data.bcc); + if (bccAddresses.length > 0) { + span.setAttribute(SEMATTRS_RESEND_BCC_ADDRESSES, bccAddresses.join(", ")); + } + + // Count recipients + const recipientCount = toAddresses.length + ccAddresses.length + bccAddresses.length; + if (recipientCount > 0) { + span.setAttribute(SEMATTRS_RESEND_RECIPIENT_COUNT, recipientCount); + } + + // Handle other email-specific attributes + if (data.subject) { + span.setAttribute(SEMATTRS_RESEND_SUBJECT, data.subject); + } + + if (data.from) { + span.setAttribute(SEMATTRS_RESEND_FROM, data.from); + } + } else { + // For non-email operations, use generic handling + const data = payload as Record; + + // Only count if the fields exist and are the right type + let recipientCount = 0; + if (typeof data.to === "string" || Array.isArray(data.to)) { + recipientCount += countRecipients(data.to as string | string[]); + } + if (typeof data.cc === "string" || Array.isArray(data.cc)) { + recipientCount += countRecipients(data.cc as string | string[]); + } + if (typeof data.bcc === "string" || Array.isArray(data.bcc)) { + recipientCount += countRecipients(data.bcc as string | string[]); + } + if (recipientCount > 0) { + span.setAttribute(SEMATTRS_RESEND_RECIPIENT_COUNT, recipientCount); + } } + // Handle generic attributes that apply to all operations + const data = payload as Record; + const templateId = (typeof data.template_id === "string" && data.template_id) || (typeof data.templateId === "string" && data.templateId) ||