diff --git a/packages/otel-resend/README.md b/packages/otel-resend/README.md index 44cd78b..9d2a120 100644 --- a/packages/otel-resend/README.md +++ b/packages/otel-resend/README.md @@ -35,10 +35,7 @@ needed. Every SDK call creates a client span with useful attributes. ## What Gets Traced -- All top-level Resend client methods (e.g. `resend.ping`) -- Nested resource methods such as `resend.emails.send`, `resend.emails.batch`, - `resend.domains.create`, `resend.apiKeys.create`, and custom resources -- Both async and sync methods defined on resource instances or their prototypes +This instrumentation specifically wraps the `resend.emails.send` method (and its alias `resend.emails.create`), creating a single clean span for each email send operation. ## Span Attributes @@ -47,35 +44,32 @@ Each span includes: | Attribute | Description | Example | | --- | --- | --- | | `messaging.system` | Constant value `resend` | `resend` | -| `messaging.operation` | Operation derived from the method name | `send`, `create`, `list` | -| `resend.resource` | Top-level resource name | `emails`, `domains` | -| `resend.target` | Fully-qualified target (resource + method) | `emails.send` | -| `resend.recipient_count` | Total recipients detected in the request payload | `3` | -| `resend.template_id` | Template referenced in the request (when present) | `tmpl_123` | -| `resend.message_id` | Message ID returned by email operations | `email_123` | -| `resend.message_count` | How many message IDs were returned | `2` | -| `resend.resource_id` | Identifier returned by non-email resources | `domain_456` | +| `messaging.operation` | Operation type | `send` | +| `resend.resource` | Resource name | `emails` | +| `resend.target` | Full operation target | `emails.send` | +| `resend.to_addresses` | Comma-separated TO addresses | `user@example.com, another@example.com` | +| `resend.cc_addresses` | Comma-separated CC addresses (if present) | `cc@example.com` | +| `resend.bcc_addresses` | Comma-separated BCC addresses (if present) | `bcc@example.com` | +| `resend.recipient_count` | Total number of recipients | `3` | +| `resend.from` | Sender email address | `noreply@example.com` | +| `resend.subject` | Email subject | `Welcome to our service` | +| `resend.template_id` | Template ID (if using templates) | `tmpl_123` | +| `resend.message_id` | Message ID returned by Resend | `email_123` | +| `resend.message_count` | Number of messages sent (always 1 for single sends) | `1` | -Sensitive request payloads are never recorded—only counts and identifiers that -Resend already exposes. +The instrumentation captures email addresses and metadata to help with debugging and monitoring, while avoiding sensitive email content. ## Configuration ```ts instrumentResend(resend, { tracerName: "my-service", - captureRequestMetadata: true, - captureResponseMetadata: true, - shouldInstrument: (path, method) => !(path[0] === "emails" && method === "list"), + tracer: myCustomTracer, // optional: bring your own tracer }); ``` -- `tracerName` / `tracer`: reuse an existing tracer if you have one. -- `captureRequestMetadata`: toggle attributes derived from the request payload - (recipient counts, template IDs). Enabled by default. -- `captureResponseMetadata`: toggle attributes derived from the response - (message IDs, resource IDs). Enabled by default. -- `shouldInstrument`: skip specific methods programmatically. +- `tracerName`: Custom name for the tracer (defaults to `@kubiks/otel-resend`) +- `tracer`: Use an existing tracer instance instead of creating a new one ## License diff --git a/packages/otel-resend/package.json b/packages/otel-resend/package.json index 93b6a73..73a5aed 100644 --- a/packages/otel-resend/package.json +++ b/packages/otel-resend/package.json @@ -1,6 +1,6 @@ { "name": "@kubiks/otel-resend", - "version": "1.0.2", + "version": "1.0.3", "private": false, "publishConfig": { "access": "public" diff --git a/packages/otel-resend/src/index.test.ts b/packages/otel-resend/src/index.test.ts index 85c12a7..8367260 100644 --- a/packages/otel-resend/src/index.test.ts +++ b/packages/otel-resend/src/index.test.ts @@ -5,6 +5,7 @@ import { InMemorySpanExporter, SimpleSpanProcessor, } from "@opentelemetry/sdk-trace-base"; +import type { Resend } from "resend"; import { instrumentResend, SEMATTRS_MESSAGING_OPERATION, @@ -13,7 +14,6 @@ import { SEMATTRS_RESEND_MESSAGE_ID, SEMATTRS_RESEND_RECIPIENT_COUNT, SEMATTRS_RESEND_RESOURCE, - SEMATTRS_RESEND_RESOURCE_ID, SEMATTRS_RESEND_TARGET, SEMATTRS_RESEND_TEMPLATE_ID, SEMATTRS_RESEND_TO_ADDRESSES, @@ -41,48 +41,37 @@ describe("instrumentResend", () => { trace.disable(); }); - const createMockResend = () => { - class EmailsResource { - async send(payload: Record) { - return { id: "email_123", payload }; - } - - async list() { - return { data: [{ id: "email_1" }, { id: "email_2" }] }; - } - - fail() { - throw new Error("boom"); - } - } - - class DomainsResource { - async create() { - return { id: "domain_123" }; - } - } - - return { - emails: new EmailsResource(), - domains: new DomainsResource(), - apiKeys: { - create: vi.fn(async () => ({ id: "key_abc" })), + const createMockResend = (): Resend => { + const mockResend = { + emails: { + send: vi.fn(async (payload: any) => ({ + data: { id: "email_123" }, + error: null + })), + create: vi.fn(async (payload: any) => ({ + data: { id: "email_123" }, + error: null + })), }, - ping: vi.fn(() => "pong"), - }; + } as unknown as Resend; + + return mockResend; }; - it("wraps methods and records spans", async () => { + it("wraps emails.send and records spans", async () => { const resend = createMockResend(); instrumentResend(resend); const payload = { to: ["user@example.com", "second@example.com"], + from: "sender@example.com", + subject: "Test Email", + text: "Hello", template_id: "tmpl_123", }; const response = await resend.emails.send(payload); - expect(response.id).toBe("email_123"); + expect(response.data?.id).toBe("email_123"); const spans = exporter.getFinishedSpans(); expect(spans).toHaveLength(1); @@ -100,56 +89,22 @@ describe("instrumentResend", () => { expect(span.attributes[SEMATTRS_RESEND_MESSAGE_ID]).toBe("email_123"); 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); - }); - - it("records spans for prototype methods", async () => { - const resend = createMockResend(); - instrumentResend(resend); - - await resend.domains.create(); - - 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.name).toBe("resend.domains.create"); - expect(span.attributes[SEMATTRS_RESEND_RESOURCE]).toBe("domains"); - expect(span.attributes[SEMATTRS_RESEND_MESSAGE_ID]).toBeUndefined(); - expect(span.attributes[SEMATTRS_RESEND_RESOURCE_ID]).toBe("domain_123"); - expect(span.status.code).toBe(SpanStatusCode.OK); - }); - - it("handles synchronous functions", () => { - const resend = createMockResend(); - instrumentResend(resend); - - const result = resend.ping(); - expect(result).toBe("pong"); - - 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.name).toBe("resend.ping"); + expect(span.attributes[SEMATTRS_RESEND_TEMPLATE_ID]).toBe("tmpl_123"); + expect(span.attributes[SEMATTRS_RESEND_FROM]).toBe("sender@example.com"); + expect(span.attributes[SEMATTRS_RESEND_SUBJECT]).toBe("Test Email"); expect(span.status.code).toBe(SpanStatusCode.OK); }); it("captures errors and marks span status", async () => { const resend = createMockResend(); + resend.emails.send = vi.fn().mockRejectedValue(new Error("boom")); + instrumentResend(resend); - await expect(async () => resend.emails.fail()).rejects.toThrowError("boom"); + await expect(async () => + resend.emails.send({ to: "test@example.com", from: "sender@example.com", subject: "Test", text: "Test" }) + ).rejects.toThrowError("boom"); const spans = exporter.getFinishedSpans(); expect(spans).toHaveLength(1); @@ -170,28 +125,15 @@ describe("instrumentResend", () => { const second = instrumentResend(first); expect(first).toBe(second); - expect(first.emails.send).toBe(second.emails.send); - await second.emails.send({}); - - expect(exporter.getFinishedSpans()).toHaveLength(1); - }); - - it("respects shouldInstrument filter", async () => { - const resend = createMockResend(); - instrumentResend(resend, { - shouldInstrument: (path, methodName) => { - if (path[0] === "emails" && methodName === "list") { - return false; - } - return true; - }, + await second.emails.send({ + to: "test@example.com", + from: "sender@example.com", + subject: "Test", + text: "Test" }); - await resend.emails.list(); - - const spans = exporter.getFinishedSpans(); - expect(spans).toHaveLength(0); + expect(exporter.getFinishedSpans()).toHaveLength(1); }); it("captures email addresses from all recipient fields", async () => { @@ -231,6 +173,9 @@ describe("instrumentResend", () => { const payload = { to: "single@example.com", + from: "sender@example.com", + subject: "Test", + text: "Test", }; await resend.emails.send(payload); @@ -279,4 +224,31 @@ describe("instrumentResend", () => { expect(span.attributes[SEMATTRS_RESEND_FROM]).toBe("noreply@example.com"); expect(span.attributes[SEMATTRS_RESEND_SUBJECT]).toBe("Mixed Format Test"); }); -}); + + it("also instruments emails.create as an alias", async () => { + const resend = createMockResend(); + instrumentResend(resend); + + const payload = { + to: "user@example.com", + from: "sender@example.com", + subject: "Test", + text: "Test", + }; + + // Use create instead of send + await resend.emails.create(payload); + + const spans = exporter.getFinishedSpans(); + expect(spans).toHaveLength(1); + + const span = spans[0]; + if (!span) { + throw new Error("Expected a span to be recorded"); + } + + // Should still show as emails.send since create is just an alias + expect(span.name).toBe("resend.emails.send"); + expect(span.attributes[SEMATTRS_RESEND_TARGET]).toBe("emails.send"); + }); +}); \ No newline at end of file diff --git a/packages/otel-resend/src/index.ts b/packages/otel-resend/src/index.ts index 8555ace..d2259c9 100644 --- a/packages/otel-resend/src/index.ts +++ b/packages/otel-resend/src/index.ts @@ -6,12 +6,12 @@ import { type Span, type Tracer, } from "@opentelemetry/api"; -import type { CreateEmailOptions } from "resend"; +import type { Resend, CreateEmailOptions, CreateEmailResponse } from "resend"; const DEFAULT_TRACER_NAME = "@kubiks/otel-resend"; -const INSTRUMENTED_FLAG = "__kubiksOtelResendInstrumented" as const; -const INSTRUMENTED_METHOD_FLAG = Symbol("kubiksOtelResendInstrumentedMethod"); +const INSTRUMENTED_FLAG = Symbol("kubiksOtelResendInstrumented"); +// Semantic attribute constants export const SEMATTRS_MESSAGING_SYSTEM = "messaging.system" as const; export const SEMATTRS_MESSAGING_OPERATION = "messaging.operation" as const; export const SEMATTRS_RESEND_RESOURCE = "resend.resource" as const; @@ -32,79 +32,12 @@ export const SEMATTRS_RESEND_SUBJECT = "resend.subject" as const; export interface InstrumentResendConfig { tracerName?: string; tracer?: Tracer; - captureRequestMetadata?: boolean; - captureResponseMetadata?: boolean; - shouldInstrument?( - path: readonly string[], - methodName: string, - original: AnyFunction, - ): boolean; } -type AnyFunction = (...args: unknown[]) => unknown; -type ResendLike = Record; - -interface InstrumentedResendLike extends ResendLike { +interface InstrumentedResend extends Resend { [INSTRUMENTED_FLAG]?: true; } -interface NormalizedConfig { - tracer: Tracer; - tracerName: string; - captureRequestMetadata: boolean; - captureResponseMetadata: boolean; - shouldInstrument( - path: readonly string[], - methodName: string, - original: AnyFunction, - ): boolean; -} - -const instrumentedObjects = new WeakSet(); -const defaultShouldInstrument: NormalizedConfig["shouldInstrument"] = () => true; - -function toSnakeCase(input: string): string { - return input - .replace(/([a-z0-9])([A-Z])/g, "$1_$2") - .replace(/[\s./-]+/g, "_") - .toLowerCase(); -} - -function buildSpanName(path: readonly string[], methodName: string): string { - const parts = [...path, methodName].filter(Boolean); - return parts.length ? `resend.${parts.join(".")}` : "resend.call"; -} - -function buildBaseAttributes( - path: readonly string[], - methodName: string, -): Record { - const attributes: Record = { - [SEMATTRS_MESSAGING_SYSTEM]: "resend", - [SEMATTRS_MESSAGING_OPERATION]: toSnakeCase(methodName), - [SEMATTRS_RESEND_TARGET]: [...path, methodName].join("."), - }; - - if (path[0]) { - attributes[SEMATTRS_RESEND_RESOURCE] = path[0]; - } - - return attributes; -} - -function countRecipients(value: string | string[] | undefined): number { - if (!value) { - return 0; - } - if (typeof value === "string") { - return value.trim() ? 1 : 0; - } - if (Array.isArray(value)) { - return value.filter(email => typeof email === "string" && email.trim()).length; - } - return 0; -} - function extractEmailAddresses(value: string | string[] | undefined): string[] { if (!value) { return []; @@ -119,164 +52,57 @@ function extractEmailAddresses(value: string | string[] | undefined): string[] { return []; } -function annotateRequest( - span: Span, - path: readonly string[], - args: unknown[], - capture: boolean, -): void { - if (!capture || !args.length) { - return; +function annotateEmailSpan(span: Span, payload: CreateEmailOptions): void { + // Set base attributes + span.setAttributes({ + [SEMATTRS_MESSAGING_SYSTEM]: "resend", + [SEMATTRS_MESSAGING_OPERATION]: "send", + [SEMATTRS_RESEND_RESOURCE]: "emails", + [SEMATTRS_RESEND_TARGET]: "emails.send", + }); + + // Extract and set email addresses + const toAddresses = extractEmailAddresses(payload.to); + if (toAddresses.length > 0) { + span.setAttribute(SEMATTRS_RESEND_TO_ADDRESSES, toAddresses.join(", ")); } - const payload = args[0]; - if (!payload || typeof payload !== "object") { - return; + const ccAddresses = extractEmailAddresses(payload.cc); + if (ccAddresses.length > 0) { + span.setAttribute(SEMATTRS_RESEND_CC_ADDRESSES, ccAddresses.join(", ")); } - // 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; - - // 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); - } + const bccAddresses = extractEmailAddresses(payload.bcc); + if (bccAddresses.length > 0) { + span.setAttribute(SEMATTRS_RESEND_BCC_ADDRESSES, bccAddresses.join(", ")); } - // 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) || - (typeof data.template === "string" && data.template); - if (templateId) { + // Count recipients + const recipientCount = toAddresses.length + ccAddresses.length + bccAddresses.length; + if (recipientCount > 0) { + span.setAttribute(SEMATTRS_RESEND_RECIPIENT_COUNT, recipientCount); + } + + // Set other email attributes + if (payload.subject) { + span.setAttribute(SEMATTRS_RESEND_SUBJECT, payload.subject); + } + + if (payload.from) { + span.setAttribute(SEMATTRS_RESEND_FROM, payload.from); + } + + // Handle template IDs (support both formats for compatibility) + const templateId = (payload as any).template_id || (payload as any).templateId || (payload as any).template; + if (templateId && typeof templateId === "string") { span.setAttribute(SEMATTRS_RESEND_TEMPLATE_ID, templateId); } - - const segmentId = - (typeof data.segment_id === "string" && data.segment_id) || - (typeof data.segmentId === "string" && data.segmentId); - if (segmentId) { - span.setAttribute(SEMATTRS_RESEND_SEGMENT_ID, segmentId); - } - - const audienceId = - (typeof data.audience_id === "string" && data.audience_id) || - (typeof data.audienceId === "string" && data.audienceId); - if (audienceId) { - span.setAttribute(SEMATTRS_RESEND_AUDIENCE_ID, audienceId); - } } -function collectIdentifiers(value: unknown, depth = 0): string[] { - if (!value || depth > 3) { - return []; - } - - if (typeof value === "string") { - return value ? [value] : []; - } - - if (Array.isArray(value)) { - return value.flatMap((item) => collectIdentifiers(item, depth + 1)); - } - - if (typeof value === "object") { - const record = value as Record; - const ids: string[] = []; - - const directId = - (typeof record.id === "string" && record.id) || - (typeof record.messageId === "string" && record.messageId) || - (typeof record.message_id === "string" && record.message_id); - if (directId) { - ids.push(directId); - } - - const nestedKeys = ["data", "items", "messages", "results", "entries"]; - for (const key of nestedKeys) { - if (key in record) { - ids.push(...collectIdentifiers(record[key], depth + 1)); - } - } - - return ids; - } - - return []; -} - -function annotateResponse( - span: Span, - resource: string | undefined, - result: unknown, - capture: boolean, -): void { - if (!capture) { - return; - } - - const identifiers = collectIdentifiers(result); - if (!identifiers.length) { - return; - } - - const uniqueIds = Array.from(new Set(identifiers)); - span.setAttribute(SEMATTRS_RESEND_MESSAGE_COUNT, uniqueIds.length); - - if (resource === "emails") { - if (uniqueIds.length === 1) { - span.setAttribute(SEMATTRS_RESEND_MESSAGE_ID, uniqueIds[0]!); - } - } else if (uniqueIds.length === 1) { - span.setAttribute(SEMATTRS_RESEND_RESOURCE_ID, uniqueIds[0]!); +function annotateEmailResponse(span: Span, response: CreateEmailResponse): void { + if (response.data?.id) { + span.setAttribute(SEMATTRS_RESEND_MESSAGE_ID, response.data.id); + span.setAttribute(SEMATTRS_RESEND_MESSAGE_COUNT, 1); } } @@ -294,223 +120,55 @@ function finalizeSpan(span: Span, error?: unknown): void { span.end(); } -function wrapMethod( - original: AnyFunction, - path: readonly string[], - methodName: string, - tracer: Tracer, - config: NormalizedConfig, -): AnyFunction { - const spanName = buildSpanName(path, methodName); - const baseAttributes = buildBaseAttributes(path, methodName); - const resource = path[0]; - - const instrumented = function instrumentedResendMethod( - this: unknown, - ...args: unknown[] - ) { - const span = tracer.startSpan(spanName, { - kind: SpanKind.CLIENT, - attributes: baseAttributes, - }); - - annotateRequest(span, path, args, config.captureRequestMetadata); - - const activeContext = trace.setSpan(context.active(), span); - - const invokeOriginal = () => original.apply(this, args); - - try { - const result = context.with(activeContext, invokeOriginal); - - if (result && typeof (result as Promise).then === "function") { - return (result as Promise) - .then((value) => { - annotateResponse(span, resource, value, config.captureResponseMetadata); - finalizeSpan(span); - return value; - }) - .catch((error: unknown) => { - finalizeSpan(span, error); - throw error; - }); - } - - annotateResponse(span, resource, result, config.captureResponseMetadata); - finalizeSpan(span); - return result; - } catch (error) { - finalizeSpan(span, error); - throw error; - } - }; - - (instrumented as { [INSTRUMENTED_METHOD_FLAG]?: true })[INSTRUMENTED_METHOD_FLAG] = true; - return instrumented; -} - -function instrumentObject( - target: ResendLike, - path: readonly string[], - tracer: Tracer, - config: NormalizedConfig, -): void { - if (!target || typeof target !== "object") { - return; - } - - if (instrumentedObjects.has(target)) { - return; - } - instrumentedObjects.add(target); - - const processedKeys = new Set(); - - for (const key of Reflect.ownKeys(target)) { - if (typeof key === "symbol") { - continue; - } - if (key === INSTRUMENTED_FLAG) { - continue; - } - - processedKeys.add(key); - - const descriptor = Object.getOwnPropertyDescriptor(target, key); - let value: unknown; - - if (!descriptor || "value" in descriptor) { - value = (target as ResendLike)[key]; - } - - if (typeof value === "function") { - const original = value as AnyFunction; - - if ((original as { [INSTRUMENTED_METHOD_FLAG]?: true })[INSTRUMENTED_METHOD_FLAG]) { - continue; - } - - if (!config.shouldInstrument(path, key, original)) { - continue; - } - - const wrapped = wrapMethod(original, path, key, tracer, config); - - let replaced = false; - try { - replaced = Reflect.set(target, key, wrapped); - } catch { - replaced = false; - } - - if (!replaced) { - Object.defineProperty(target, key, { - configurable: descriptor?.configurable ?? true, - enumerable: descriptor?.enumerable ?? true, - writable: descriptor?.writable ?? true, - value: wrapped, - }); - } - - continue; - } - - if (value && typeof value === "object") { - instrumentObject(value as ResendLike, [...path, key], tracer, config); - continue; - } - - if (descriptor && (descriptor.get || descriptor.set)) { - try { - const resolved = (target as ResendLike)[key]; - if (resolved && typeof resolved === "object") { - instrumentObject(resolved as ResendLike, [...path, key], tracer, config); - } - } catch { - // Ignore accessor errors. - } - } - } - - let prototype = Object.getPrototypeOf(target); - while ( - prototype && - prototype !== Object.prototype && - prototype !== Function.prototype - ) { - for (const key of Reflect.ownKeys(prototype)) { - if (typeof key === "symbol" || key === "constructor") { - continue; - } - if (processedKeys.has(key)) { - continue; - } - - const descriptor = Object.getOwnPropertyDescriptor(prototype, key); - if (!descriptor || typeof descriptor.value !== "function") { - continue; - } - - const original = descriptor.value as AnyFunction; - if ((original as { [INSTRUMENTED_METHOD_FLAG]?: true })[INSTRUMENTED_METHOD_FLAG]) { - continue; - } - - if (!config.shouldInstrument(path, key, original)) { - continue; - } - - const wrapped = wrapMethod(original, path, key, tracer, config); - - let replaced = false; - try { - replaced = Reflect.set(target, key, wrapped); - } catch { - replaced = false; - } - - if (!replaced) { - Object.defineProperty(target, key, { - configurable: true, - enumerable: descriptor.enumerable ?? true, - writable: true, - value: wrapped, - }); - } - - processedKeys.add(key); - } - - prototype = Object.getPrototypeOf(prototype); - } -} - -export function instrumentResend( - client: TClient, - config?: InstrumentResendConfig, -): TClient { - if (!client || typeof client !== "object") { - return client; - } - - if ((client as InstrumentedResendLike)[INSTRUMENTED_FLAG]) { +export function instrumentResend(client: Resend, config?: InstrumentResendConfig): Resend { + // Check if already instrumented + if ((client as InstrumentedResend)[INSTRUMENTED_FLAG]) { return client; } const tracerName = config?.tracerName ?? DEFAULT_TRACER_NAME; const tracer = config?.tracer ?? trace.getTracer(tracerName); - const normalizedConfig: NormalizedConfig = { - tracer, - tracerName, - captureRequestMetadata: config?.captureRequestMetadata ?? true, - captureResponseMetadata: config?.captureResponseMetadata ?? true, - shouldInstrument: config?.shouldInstrument ?? defaultShouldInstrument, + // Save the original send method + const originalSend = client.emails.send.bind(client.emails); + + // Replace the send method with our instrumented version + client.emails.send = async function instrumentedSend( + payload: CreateEmailOptions + ): Promise { + const span = tracer.startSpan("resend.emails.send", { + kind: SpanKind.CLIENT, + }); + + // Annotate span with email details + annotateEmailSpan(span, payload); + + // Set the span as active + const activeContext = trace.setSpan(context.active(), span); + + try { + // Call the original method within the active context + const response = await context.with(activeContext, () => originalSend(payload)); + + // Annotate with response data + annotateEmailResponse(span, response); + + // Mark as successful + finalizeSpan(span); + + return response; + } catch (error) { + // Mark as failed + finalizeSpan(span, error); + throw error; + } }; - instrumentObject(client, [], tracer, normalizedConfig); + // Also wrap the create method (it's an alias for send) + client.emails.create = client.emails.send; - (client as InstrumentedResendLike)[INSTRUMENTED_FLAG] = true; + // Mark as instrumented + (client as InstrumentedResend)[INSTRUMENTED_FLAG] = true; return client; -} +} \ No newline at end of file