mirror of
https://github.com/zoriya/drizzle-otel.git
synced 2025-12-06 00:46:09 +00:00
refactor
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kubiks/otel-resend",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
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<object>();
|
||||
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<string, string> {
|
||||
const attributes: Record<string, string> = {
|
||||
[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<CreateEmailOptions>;
|
||||
|
||||
// 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<string, unknown>;
|
||||
|
||||
// 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<string, unknown>;
|
||||
|
||||
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<string, unknown>;
|
||||
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<unknown>).then === "function") {
|
||||
return (result as Promise<unknown>)
|
||||
.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<string>();
|
||||
|
||||
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<TClient extends ResendLike>(
|
||||
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<CreateEmailResponse> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user