resend telemetry package

This commit is contained in:
Alex Holovach
2025-10-04 10:22:43 -05:00
parent 536c42f773
commit 7955826d8b
8 changed files with 1284 additions and 2 deletions

View File

@@ -0,0 +1,5 @@
# @kubiks/otel-resend
## 1.0.0
- Initial release.

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Kubiks
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,82 @@
# @kubiks/otel-resend
OpenTelemetry instrumentation for the [Resend](https://resend.com) Node.js SDK.
Capture spans for every Resend API call, enrich them with operation metadata,
and keep an eye on message delivery from your traces.
## Installation
```bash
npm install @kubiks/otel-resend
# or
pnpm add @kubiks/otel-resend
```
**Peer Dependencies:** `@opentelemetry/api` >= 1.9.0, `resend` >= 3.0.0
## Quick Start
```ts
import { Resend } from "resend";
import { instrumentResend } from "@kubiks/otel-resend";
const resend = instrumentResend(new Resend(process.env.RESEND_API_KEY!));
await resend.emails.send({
from: "hello@example.com",
to: ["user@example.com"],
subject: "Welcome",
html: "<p>Hello world</p>",
});
```
`instrumentResend` wraps the instance you already use—no configuration changes
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
## Span Attributes
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` |
Sensitive request payloads are never recorded—only counts and identifiers that
Resend already exposes.
## Configuration
```ts
instrumentResend(resend, {
tracerName: "my-service",
captureRequestMetadata: true,
captureResponseMetadata: true,
shouldInstrument: (path, method) => !(path[0] === "emails" && method === "list"),
});
```
- `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.
## License
MIT

View File

@@ -0,0 +1,53 @@
{
"name": "@kubiks/otel-resend",
"version": "1.0.0",
"private": false,
"publishConfig": {
"access": "public"
},
"description": "OpenTelemetry instrumentation for the Resend Node.js SDK",
"author": "Kubiks",
"license": "MIT",
"repository": "kubiks-inc/otel",
"sideEffects": false,
"type": "module",
"exports": {
".": {
"types": "./dist/types/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
},
"main": "./dist/index.js",
"types": "./dist/types/index.d.ts",
"files": [
"dist",
"LICENSE",
"README.md"
],
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"scripts": {
"build": "pnpm clean && tsc",
"clean": "rimraf dist",
"prepublishOnly": "pnpm build",
"type-check": "tsc --noEmit",
"unit-test": "vitest --run",
"unit-test-watch": "vitest"
},
"dependencies": {},
"devDependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/sdk-trace-base": "^2.1.0",
"@types/node": "18.15.11",
"resend": "^3.0.0",
"rimraf": "3.0.2",
"typescript": "^5",
"vitest": "0.33.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.9.0 <2.0.0",
"resend": ">=3.0.0"
}
}

View File

@@ -0,0 +1,190 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SpanStatusCode, trace } from "@opentelemetry/api";
import {
BasicTracerProvider,
InMemorySpanExporter,
SimpleSpanProcessor,
} from "@opentelemetry/sdk-trace-base";
import {
instrumentResend,
SEMATTRS_MESSAGING_OPERATION,
SEMATTRS_MESSAGING_SYSTEM,
SEMATTRS_RESEND_MESSAGE_COUNT,
SEMATTRS_RESEND_MESSAGE_ID,
SEMATTRS_RESEND_RECIPIENT_COUNT,
SEMATTRS_RESEND_RESOURCE,
SEMATTRS_RESEND_RESOURCE_ID,
SEMATTRS_RESEND_TARGET,
SEMATTRS_RESEND_TEMPLATE_ID,
} from "./index";
describe("instrumentResend", () => {
let provider: BasicTracerProvider;
let exporter: InMemorySpanExporter;
beforeEach(() => {
exporter = new InMemorySpanExporter();
provider = new BasicTracerProvider({
spanProcessors: [new SimpleSpanProcessor(exporter)],
});
trace.setGlobalTracerProvider(provider);
});
afterEach(async () => {
await provider.shutdown();
exporter.reset();
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" })),
},
ping: vi.fn(() => "pong"),
};
};
it("wraps methods and records spans", async () => {
const resend = createMockResend();
instrumentResend(resend);
const payload = {
to: ["user@example.com", { email: "second@example.com" }],
template_id: "tmpl_123",
};
const response = await resend.emails.send(payload);
expect(response.id).toBe("email_123");
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.emails.send");
expect(span.attributes[SEMATTRS_MESSAGING_SYSTEM]).toBe("resend");
expect(span.attributes[SEMATTRS_MESSAGING_OPERATION]).toBe("send");
expect(span.attributes[SEMATTRS_RESEND_RESOURCE]).toBe("emails");
expect(span.attributes[SEMATTRS_RESEND_TARGET]).toBe("emails.send");
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.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.status.code).toBe(SpanStatusCode.OK);
});
it("captures errors and marks span status", async () => {
const resend = createMockResend();
instrumentResend(resend);
await expect(async () => resend.emails.fail()).rejects.toThrowError("boom");
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.status.code).toBe(SpanStatusCode.ERROR);
const hasException = span.events.some((event) => event.name === "exception");
expect(hasException).toBe(true);
});
it("is idempotent", async () => {
const resend = createMockResend();
const first = instrumentResend(resend);
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 resend.emails.list();
const spans = exporter.getFinishedSpans();
expect(spans).toHaveLength(0);
});
});

View File

@@ -0,0 +1,465 @@
import {
context,
SpanKind,
SpanStatusCode,
trace,
type Span,
type Tracer,
} from "@opentelemetry/api";
const DEFAULT_TRACER_NAME = "@kubiks/otel-resend";
const INSTRUMENTED_FLAG = "__kubiksOtelResendInstrumented" as const;
const INSTRUMENTED_METHOD_FLAG = Symbol("kubiksOtelResendInstrumentedMethod");
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;
export const SEMATTRS_RESEND_TARGET = "resend.target" as const;
export const SEMATTRS_RESEND_MESSAGE_ID = "resend.message_id" as const;
export const SEMATTRS_RESEND_MESSAGE_COUNT = "resend.message_count" as const;
export const SEMATTRS_RESEND_TEMPLATE_ID = "resend.template_id" as const;
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 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 {
[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: unknown): number {
if (!value) {
return 0;
}
if (typeof value === "string") {
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<unknown>) {
count += countRecipients(item);
}
return count;
}
if (
typeof (value as { email?: unknown }).email === "string" ||
typeof (value as { address?: unknown }).address === "string"
) {
return 1;
}
}
return 0;
}
function annotateRequest(
span: Span,
path: readonly string[],
args: unknown[],
capture: boolean,
): void {
if (!capture || !args.length) {
return;
}
const payload = args[0];
if (!payload || typeof payload !== "object") {
return;
}
const data = payload as Record<string, unknown>;
const recipientCount =
countRecipients(data.to) + countRecipients(data.cc) + countRecipients(data.bcc);
if (recipientCount > 0) {
span.setAttribute(SEMATTRS_RESEND_RECIPIENT_COUNT, recipientCount);
}
const templateId =
(typeof data.template_id === "string" && data.template_id) ||
(typeof data.templateId === "string" && data.templateId) ||
(typeof data.template === "string" && data.template);
if (templateId) {
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 finalizeSpan(span: Span, error?: unknown): void {
if (error) {
if (error instanceof Error) {
span.recordException(error);
} else {
span.recordException(new Error(String(error)));
}
span.setStatus({ code: SpanStatusCode.ERROR });
} else {
span.setStatus({ code: SpanStatusCode.OK });
}
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]) {
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,
};
instrumentObject(client, [], tracer, normalizedConfig);
(client as InstrumentedResendLike)[INSTRUMENTED_FLAG] = true;
return client;
}

View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM"],
"outDir": "dist",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": false,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declarationDir": "dist/types",
"stripInternal": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}