mirror of
https://github.com/zoriya/drizzle-otel.git
synced 2025-12-06 00:46:09 +00:00
resend telemetry package
This commit is contained in:
5
packages/otel-resend/CHANGELOG.md
Normal file
5
packages/otel-resend/CHANGELOG.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# @kubiks/otel-resend
|
||||
|
||||
## 1.0.0
|
||||
|
||||
- Initial release.
|
||||
21
packages/otel-resend/LICENSE
Normal file
21
packages/otel-resend/LICENSE
Normal 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.
|
||||
82
packages/otel-resend/README.md
Normal file
82
packages/otel-resend/README.md
Normal 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
|
||||
53
packages/otel-resend/package.json
Normal file
53
packages/otel-resend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
190
packages/otel-resend/src/index.test.ts
Normal file
190
packages/otel-resend/src/index.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
465
packages/otel-resend/src/index.ts
Normal file
465
packages/otel-resend/src/index.ts
Normal 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;
|
||||
}
|
||||
21
packages/otel-resend/tsconfig.json
Normal file
21
packages/otel-resend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user