Merge pull request #32 from kubiks-inc/tembo/polar-otel-sdk-instrument

feat: Add Polar.sh OTel SDK
This commit is contained in:
Alex Holovach
2025-11-11 23:57:05 -06:00
committed by GitHub
9 changed files with 1320 additions and 1 deletions

View File

@@ -19,6 +19,7 @@ Our goal is to bring the TypeScript ecosystem the observability tools its bee
- [`@kubiks/otel-e2b`](./packages/otel-e2b/README.md)
- [`@kubiks/otel-inbound`](./packages/otel-inbound/README.md)
- [`@kubiks/otel-mongodb`](./packages/otel-mongodb/README.md)
- [`@kubiks/otel-polar`](./packages/otel-polar/README.md)
- [`@kubiks/otel-resend`](./packages/otel-resend/README.md)
- [`@kubiks/otel-upstash-queues`](./packages/otel-upstash-queues/README.md)
- [`@kubiks/otel-upstash-workflow`](./packages/otel-upstash-workflow/README.md)
@@ -28,7 +29,6 @@ Our goal is to bring the TypeScript ecosystem the observability tools its bee
## Coming soon
- [Stripe](https://stripe.com/)
- [Polar.sh](https://polar.sh/)
- [AI SDK](https://ai-sdk.dev/)
- [Mastra](https://mastra.ai/)
- [Next.js Opentelemetry SDK](https://nextjs.org)

View File

@@ -0,0 +1,18 @@
# @kubiks/otel-polar
## 1.0.0
### Major Changes
- Initial release of OpenTelemetry instrumentation for Polar.sh SDK
- Comprehensive instrumentation for all Polar SDK resources and methods
- Support for core resources: benefits, customers, products, subscriptions, checkouts, etc.
- Full customer portal instrumentation for all customer-facing operations
- Webhook validation tracing with event type capture
- Configurable resource ID and organization ID capture
- TypeScript support with full type safety
- Extensive test coverage with vitest
- Detailed span attributes following OpenTelemetry semantic conventions
- Automatic error tracking and exception recording
- Context propagation for distributed tracing
- Idempotent instrumentation (safe to call multiple times)

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,416 @@
# @kubiks/otel-polar
OpenTelemetry instrumentation for the [Polar.sh](https://polar.sh) Node.js SDK.
Capture spans for every Polar API call, enrich them with operation metadata,
and keep an eye on your billing, subscriptions, and customer operations from your traces.
## Installation
```bash
npm install @kubiks/otel-polar
# or
pnpm add @kubiks/otel-polar
# or
yarn add @kubiks/otel-polar
# or
bun add @kubiks/otel-polar
```
**Peer Dependencies:** `@opentelemetry/api` >= 1.9.0, `@polar-sh/sdk` >= 0.11.0
## Quick Start
```ts
import { Polar } from "@polar-sh/sdk";
import { instrumentPolar } from "@kubiks/otel-polar";
const polar = new Polar({
accessToken: process.env.POLAR_ACCESS_TOKEN!,
});
// Instrument the client - all operations are now traced!
instrumentPolar(polar);
// Use the SDK normally - traces are automatically created
await polar.benefits.list({ organizationId: "org_123" });
await polar.customers.create({
email: "customer@example.com",
organizationId: "org_123",
});
```
`instrumentPolar` wraps the Polar SDK instance you already use — no configuration changes needed. Every SDK call creates a client span with useful attributes.
## What Gets Traced
This instrumentation automatically wraps **all** Polar SDK methods across all resources, including:
### Core Resources
- **Benefits** - `list`, `create`, `get`, `update`, `delete`
- **Benefit Grants** - `list`, `create`, `get`, `update`
- **Checkouts** - `list`, `create`, `get`, `update`
- **Checkout Links** - `list`, `create`, `get`, `update`, `delete`
- **Customers** - `list`, `create`, `get`, `update`, `delete`
- **Customer Meters** - Track usage metrics
- **Customer Seats** - Manage seat assignments
- **Customer Sessions** - Session management
- **Discounts** - `list`, `create`, `get`, `update`, `delete`
- **Events** - `list`, `ingest`
- **Files** - `list`, `create`, `upload`
- **License Keys** - `list`, `get`, `update`, `validate`, `activate`, `deactivate`
- **Organizations** - `list`, `create`, `get`, `update`
- **Orders** - `list`, `get`
- **Products** - `list`, `create`, `get`, `update`, `delete`
- **Subscriptions** - `list`, `create`, `get`, `update`, `export`
- **Wallets** - Access wallet information
- **Custom Fields** - Define custom data fields
- **Metrics** - Access analytics and metrics
- **OAuth2** - `authorize`, `token`, `revoke`, `introspect`
### Customer Portal Resources
All customer-facing operations under `polar.customerPortal.*`:
- `benefitGrants`
- `customerMeters`
- `customers`
- `customerSession`
- `downloadables`
- `licenseKeys`
- `orders`
- `organizations`
- `seats`
- `subscriptions`
- `wallets`
### Webhook Validation
- `webhooks.validate` - Validate webhook signatures
## Configuration
The instrumentation can be customized with configuration options:
```ts
import { instrumentPolar } from "@kubiks/otel-polar";
instrumentPolar(polar, {
// Custom tracer name (default: "@kubiks/otel-polar")
tracerName: "my-custom-tracer",
// Capture resource IDs from requests and responses (default: true)
captureResourceIds: true,
// Capture organization IDs from requests (default: true)
captureOrganizationIds: true,
// Instrument customer portal operations (default: true)
instrumentCustomerPortal: true,
});
```
## Span Attributes
Each span includes comprehensive attributes to help with debugging and monitoring:
### Common Attributes
| Attribute | Description | Example |
|-----------|-------------|---------|
| `polar.operation` | Full operation name | `benefits.list`, `customers.create` |
| `polar.resource` | Resource type being accessed | `benefits`, `customers`, `checkouts` |
| `polar.resource_id` | ID of the specific resource (when available) | `benefit_123`, `cust_456` |
### Resource-Specific Attributes
Depending on the operation, additional attributes are captured:
| Attribute | Description | Example |
|-----------|-------------|---------|
| `polar.organization_id` | Organization ID from request | `org_123` |
| `polar.customer_id` | Customer ID | `cust_456` |
| `polar.product_id` | Product ID | `prod_789` |
| `polar.subscription_id` | Subscription ID | `sub_abc` |
| `polar.checkout_id` | Checkout session ID | `checkout_xyz` |
| `polar.order_id` | Order ID | `order_123` |
| `polar.benefit_id` | Benefit ID | `benefit_456` |
| `polar.license_key_id` | License key ID | `lic_789` |
| `polar.file_id` | File ID | `file_abc` |
| `polar.event_id` | Event ID | `evt_xyz` |
| `polar.discount_id` | Discount code ID | `disc_123` |
### Webhook Attributes
For webhook validation operations:
| Attribute | Description | Example |
|-----------|-------------|---------|
| `polar.webhook.event_type` | Type of webhook event | `checkout.created`, `subscription.updated` |
| `polar.webhook.valid` | Whether validation succeeded | `true`, `false` |
## Usage Examples
### Basic Operations
```ts
import { Polar } from "@polar-sh/sdk";
import { instrumentPolar } from "@kubiks/otel-polar";
const polar = new Polar({
accessToken: process.env.POLAR_ACCESS_TOKEN!,
});
instrumentPolar(polar);
// Create a benefit
const benefit = await polar.benefits.create({
organizationId: "org_123",
type: "custom",
description: "Premium Support",
});
// List customers
const customers = await polar.customers.list({
organizationId: "org_123",
});
// Create a checkout session
const checkout = await polar.checkouts.create({
productId: "prod_456",
successUrl: "https://example.com/success",
});
```
### Customer Portal Operations
```ts
// Customer portal operations are automatically instrumented
const subscriptions = await polar.customerPortal.subscriptions.list({
customerId: "cust_789",
});
const licenseKey = await polar.customerPortal.licenseKeys.validate({
key: "lic_key_value",
});
```
### Webhook Handling
```ts
import { instrumentPolar } from "@kubiks/otel-polar";
const polar = new Polar({
accessToken: process.env.POLAR_ACCESS_TOKEN!,
});
instrumentPolar(polar);
// Webhook validation is automatically traced
app.post("/webhooks/polar", async (req, res) => {
try {
const event = await polar.webhooks.validate({
body: req.body,
signature: req.headers["polar-signature"],
secret: process.env.POLAR_WEBHOOK_SECRET!,
});
// Handle the event
console.log("Received event:", event.type);
res.json({ received: true });
} catch (error) {
res.status(400).json({ error: "Invalid signature" });
}
});
```
### Error Handling
Errors are automatically captured in spans with full exception details:
```ts
try {
await polar.customers.get("invalid_id");
} catch (error) {
// Span will be marked as failed with exception details
console.error("Failed to get customer:", error);
}
```
### Advanced Configuration
```ts
import { instrumentPolar } from "@kubiks/otel-polar";
import { trace } from "@opentelemetry/api";
const polar = new Polar({
accessToken: process.env.POLAR_ACCESS_TOKEN!,
});
// Use custom configuration for fine-grained control
instrumentPolar(polar, {
tracerName: "my-app-polar-tracer",
captureResourceIds: true,
captureOrganizationIds: true,
instrumentCustomerPortal: true,
});
// Create a custom span around multiple operations
const tracer = trace.getTracer("my-app");
const span = tracer.startSpan("create-subscription-flow");
await trace.setSpan(context.active(), span).with(async () => {
const customer = await polar.customers.create({
email: "user@example.com",
organizationId: "org_123",
});
const subscription = await polar.subscriptions.create({
customerId: customer.data.id,
productId: "prod_456",
});
span.end();
});
```
## Integration with OpenTelemetry
This instrumentation integrates seamlessly with your existing OpenTelemetry setup:
```ts
import { NodeSDK } from "@opentelemetry/sdk-node";
import { ConsoleSpanExporter } from "@opentelemetry/sdk-trace-node";
import { Resource } from "@opentelemetry/resources";
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
import { Polar } from "@polar-sh/sdk";
import { instrumentPolar } from "@kubiks/otel-polar";
// Initialize OpenTelemetry
const sdk = new NodeSDK({
resource: new Resource({
[ATTR_SERVICE_NAME]: "my-app",
}),
traceExporter: new ConsoleSpanExporter(),
});
sdk.start();
// Instrument Polar client
const polar = new Polar({
accessToken: process.env.POLAR_ACCESS_TOKEN!,
});
instrumentPolar(polar);
// All Polar operations now appear in your traces!
```
## Best Practices
1. **Instrument Early**: Call `instrumentPolar()` once when initializing your Polar client
2. **Reuse Clients**: Instrument a single Polar client instance and reuse it throughout your app
3. **Context Propagation**: The instrumentation automatically propagates context for distributed tracing
4. **Error Tracking**: Errors are automatically captured - no need for manual exception recording
5. **Resource IDs**: Keep `captureResourceIds` enabled to track specific resources in your spans
## Framework Integration
This instrumentation works with any Node.js framework:
### Express
```ts
import express from "express";
import { Polar } from "@polar-sh/sdk";
import { instrumentPolar } from "@kubiks/otel-polar";
const app = express();
const polar = instrumentPolar(new Polar({ accessToken: process.env.POLAR_ACCESS_TOKEN! }));
app.get("/benefits", async (req, res) => {
const benefits = await polar.benefits.list({
organizationId: req.query.orgId,
});
res.json(benefits);
});
```
### Next.js
```ts
// lib/polar.ts
import { Polar } from "@polar-sh/sdk";
import { instrumentPolar } from "@kubiks/otel-polar";
export const polar = instrumentPolar(
new Polar({
accessToken: process.env.POLAR_ACCESS_TOKEN!,
})
);
// app/api/benefits/route.ts
import { polar } from "@/lib/polar";
export async function GET(request: Request) {
const benefits = await polar.benefits.list({
organizationId: "org_123",
});
return Response.json(benefits);
}
```
### Fastify
```ts
import Fastify from "fastify";
import { Polar } from "@polar-sh/sdk";
import { instrumentPolar } from "@kubiks/otel-polar";
const fastify = Fastify();
const polar = instrumentPolar(new Polar({ accessToken: process.env.POLAR_ACCESS_TOKEN! }));
fastify.get("/customers", async (request, reply) => {
const customers = await polar.customers.list({});
return customers;
});
```
## Troubleshooting
### No spans appearing
Ensure you have:
1. Initialized OpenTelemetry SDK before instrumenting Polar
2. Called `instrumentPolar()` after creating the Polar client
3. Configured a span exporter (Console, OTLP, etc.)
### Spans not linking to parent traces
The instrumentation uses `context.active()` to link spans. Ensure your HTTP framework supports OpenTelemetry context propagation.
### Double instrumentation
The instrumentation is idempotent. Calling `instrumentPolar()` multiple times on the same client is safe and will only instrument once.
## TypeScript Support
This package includes full TypeScript definitions. The instrumentation preserves all type information from the Polar SDK:
```ts
import { Polar } from "@polar-sh/sdk";
import { instrumentPolar } from "@kubiks/otel-polar";
const polar = instrumentPolar(new Polar({ accessToken: "..." }));
// Full type safety is preserved
const benefit = await polar.benefits.create({
organizationId: "org_123",
type: "custom", // TypeScript knows the valid types
description: "Premium Support",
});
// TypeScript error: Property 'invalidMethod' does not exist
// polar.benefits.invalidMethod();
```
## License
MIT

View File

@@ -0,0 +1,64 @@
{
"name": "@kubiks/otel-polar",
"version": "1.0.0",
"private": false,
"publishConfig": {
"access": "public"
},
"description": "OpenTelemetry instrumentation for the Polar.sh Node.js SDK",
"keywords": [
"opentelemetry",
"otel",
"instrumentation",
"polar",
"polar.sh",
"observability",
"tracing",
"monitoring",
"telemetry"
],
"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",
"@polar-sh/sdk": "^0.11.0",
"@types/node": "18.15.11",
"rimraf": "3.0.2",
"typescript": "^5",
"vitest": "0.33.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.9.0 <2.0.0",
"@polar-sh/sdk": ">=0.11.0"
}
}

View File

@@ -0,0 +1,384 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import {
trace,
context,
SpanStatusCode,
type Span,
type Tracer,
} from "@opentelemetry/api";
import {
BasicTracerProvider,
InMemorySpanExporter,
SimpleSpanProcessor,
} from "@opentelemetry/sdk-trace-base";
import { instrumentPolar, SEMATTRS_POLAR_OPERATION, SEMATTRS_POLAR_RESOURCE } from "./index";
describe("@kubiks/otel-polar", () => {
let exporter: InMemorySpanExporter;
let provider: BasicTracerProvider;
beforeEach(() => {
exporter = new InMemorySpanExporter();
provider = new BasicTracerProvider({
spanProcessors: [new SimpleSpanProcessor(exporter)],
});
trace.setGlobalTracerProvider(provider);
});
afterEach(async () => {
await provider.shutdown();
exporter.reset();
trace.disable();
});
describe("instrumentPolar", () => {
it("should instrument the Polar client without errors", () => {
const mockClient = createMockPolarClient();
const instrumented = instrumentPolar(mockClient as any);
expect(instrumented).toBe(mockClient);
});
it("should not double-instrument the same client", () => {
const mockClient = createMockPolarClient();
const instrumented1 = instrumentPolar(mockClient as any);
const instrumented2 = instrumentPolar(instrumented1 as any);
expect(instrumented1).toBe(instrumented2);
});
it("should handle null/undefined client gracefully", () => {
expect(instrumentPolar(null as any)).toBe(null);
expect(instrumentPolar(undefined as any)).toBe(undefined);
});
it("should create spans for benefits.list operation", async () => {
const mockClient = createMockPolarClient();
const instrumented = instrumentPolar(mockClient as any);
await instrumented.benefits.list({ organizationId: "org_123" });
const spans = exporter.getFinishedSpans();
expect(spans.length).toBe(1);
const span = spans[0];
expect(span.name).toBe("polar.benefits.list");
expect(span.attributes[SEMATTRS_POLAR_OPERATION]).toBe("benefits.list");
expect(span.attributes[SEMATTRS_POLAR_RESOURCE]).toBe("benefits");
expect(span.status.code).toBe(SpanStatusCode.OK);
});
it("should create spans for customers.create operation", async () => {
const mockClient = createMockPolarClient();
const instrumented = instrumentPolar(mockClient as any);
await instrumented.customers.create({
email: "test@example.com",
organizationId: "org_123",
});
const spans = exporter.getFinishedSpans();
expect(spans.length).toBe(1);
const span = spans[0];
expect(span.name).toBe("polar.customers.create");
expect(span.attributes[SEMATTRS_POLAR_OPERATION]).toBe("customers.create");
expect(span.attributes[SEMATTRS_POLAR_RESOURCE]).toBe("customers");
expect(span.status.code).toBe(SpanStatusCode.OK);
});
it("should create spans for products.get operation with ID", async () => {
const mockClient = createMockPolarClient();
const instrumented = instrumentPolar(mockClient as any);
await instrumented.products.get("prod_123");
const spans = exporter.getFinishedSpans();
expect(spans.length).toBe(1);
const span = spans[0];
expect(span.name).toBe("polar.products.get");
// The resource_id should be captured from the first argument (string ID)
expect(span.attributes["polar.resource_id"]).toBeDefined();
expect(span.status.code).toBe(SpanStatusCode.OK);
});
it("should create spans for subscriptions.update operation", async () => {
const mockClient = createMockPolarClient();
const instrumented = instrumentPolar(mockClient as any);
await instrumented.subscriptions.update("sub_123", {
status: "active",
});
const spans = exporter.getFinishedSpans();
expect(spans.length).toBe(1);
const span = spans[0];
expect(span.name).toBe("polar.subscriptions.update");
expect(span.attributes[SEMATTRS_POLAR_OPERATION]).toBe("subscriptions.update");
expect(span.status.code).toBe(SpanStatusCode.OK);
});
it("should create spans for checkouts.create operation", async () => {
const mockClient = createMockPolarClient();
const instrumented = instrumentPolar(mockClient as any);
await instrumented.checkouts.create({
productId: "prod_123",
successUrl: "https://example.com/success",
});
const spans = exporter.getFinishedSpans();
expect(spans.length).toBe(1);
const span = spans[0];
expect(span.name).toBe("polar.checkouts.create");
expect(span.status.code).toBe(SpanStatusCode.OK);
});
it("should create spans for licenseKeys.get operation", async () => {
const mockClient = createMockPolarClient();
const instrumented = instrumentPolar(mockClient as any);
await instrumented.licenseKeys.get("lic_123");
const spans = exporter.getFinishedSpans();
expect(spans.length).toBe(1);
const span = spans[0];
expect(span.name).toBe("polar.licenseKeys.get");
expect(span.status.code).toBe(SpanStatusCode.OK);
});
it("should create spans for organizations.list operation", async () => {
const mockClient = createMockPolarClient();
const instrumented = instrumentPolar(mockClient as any);
await instrumented.organizations.list({});
const spans = exporter.getFinishedSpans();
expect(spans.length).toBe(1);
const span = spans[0];
expect(span.name).toBe("polar.organizations.list");
expect(span.status.code).toBe(SpanStatusCode.OK);
});
it("should handle errors and mark span as failed", async () => {
const mockClient = createMockPolarClient();
mockClient.benefits.list = vi.fn().mockRejectedValue(
new Error("API Error")
);
const instrumented = instrumentPolar(mockClient as any);
await expect(
instrumented.benefits.list({ organizationId: "org_123" })
).rejects.toThrow("API Error");
const spans = exporter.getFinishedSpans();
expect(spans.length).toBe(1);
const span = spans[0];
expect(span.status.code).toBe(SpanStatusCode.ERROR);
expect(span.events.length).toBeGreaterThan(0);
expect(span.events[0].name).toBe("exception");
});
it("should instrument customer portal operations when enabled", async () => {
const mockClient = createMockPolarClient();
const instrumented = instrumentPolar(mockClient as any, {
instrumentCustomerPortal: true,
});
await instrumented.customerPortal.subscriptions.list({});
const spans = exporter.getFinishedSpans();
expect(spans.length).toBe(1);
const span = spans[0];
expect(span.name).toBe("polar.customerPortal.subscriptions.list");
expect(span.status.code).toBe(SpanStatusCode.OK);
});
it("should not instrument customer portal when disabled", () => {
const mockClient = createMockPolarClient();
const originalMethod = mockClient.customerPortal.subscriptions.list;
instrumentPolar(mockClient as any, {
instrumentCustomerPortal: false,
});
// Method should remain the same
expect(mockClient.customerPortal.subscriptions.list).toBe(originalMethod);
});
it("should capture organization ID from request params", async () => {
const mockClient = createMockPolarClient();
const instrumented = instrumentPolar(mockClient as any, {
captureOrganizationIds: true,
});
await instrumented.benefits.list({ organizationId: "org_456" });
const spans = exporter.getFinishedSpans();
expect(spans.length).toBe(1);
const span = spans[0];
expect(span.attributes["polar.organization_id"]).toBe("org_456");
});
it("should use custom tracer name when provided", async () => {
const mockClient = createMockPolarClient();
const customTracerName = "custom-tracer";
instrumentPolar(mockClient as any, {
tracerName: customTracerName,
});
await mockClient.benefits.list({});
const spans = exporter.getFinishedSpans();
expect(spans.length).toBe(1);
// Tracer name is used internally but not directly testable via span attributes
});
it("should instrument files operations", async () => {
const mockClient = createMockPolarClient();
const instrumented = instrumentPolar(mockClient as any);
await instrumented.files.upload({ name: "test.pdf", data: "..." as any });
const spans = exporter.getFinishedSpans();
expect(spans.length).toBe(1);
const span = spans[0];
expect(span.name).toBe("polar.files.upload");
expect(span.status.code).toBe(SpanStatusCode.OK);
});
it("should instrument events operations", async () => {
const mockClient = createMockPolarClient();
const instrumented = instrumentPolar(mockClient as any);
await instrumented.events.list({});
const spans = exporter.getFinishedSpans();
expect(spans.length).toBe(1);
const span = spans[0];
expect(span.name).toBe("polar.events.list");
expect(span.status.code).toBe(SpanStatusCode.OK);
});
it("should instrument discounts operations", async () => {
const mockClient = createMockPolarClient();
const instrumented = instrumentPolar(mockClient as any);
await instrumented.discounts.create({
code: "SAVE10",
organizationId: "org_123",
type: "percentage",
value: 10,
});
const spans = exporter.getFinishedSpans();
expect(spans.length).toBe(1);
const span = spans[0];
expect(span.name).toBe("polar.discounts.create");
expect(span.status.code).toBe(SpanStatusCode.OK);
});
it("should handle multiple concurrent operations", async () => {
const mockClient = createMockPolarClient();
const instrumented = instrumentPolar(mockClient as any);
await Promise.all([
instrumented.benefits.list({}),
instrumented.customers.list({}),
instrumented.products.list({}),
]);
const spans = exporter.getFinishedSpans();
expect(spans.length).toBe(3);
const spanNames = spans.map((s) => s.name);
expect(spanNames).toContain("polar.benefits.list");
expect(spanNames).toContain("polar.customers.list");
expect(spanNames).toContain("polar.products.list");
});
it("should preserve method context and return values", async () => {
const mockClient = createMockPolarClient();
const expectedResult = { data: { id: "benefit_123" } };
const getSpy = vi.fn().mockResolvedValue(expectedResult);
mockClient.benefits.get = getSpy;
const instrumented = instrumentPolar(mockClient as any);
const result = await instrumented.benefits.get("benefit_123");
expect(result).toEqual(expectedResult);
// Check the spy was called (before instrumentation wrapping)
expect(getSpy).toHaveBeenCalledWith("benefit_123");
});
});
});
/**
* Helper function to create a mock Polar client for testing
*/
function createMockPolarClient() {
const createResource = () => ({
list: vi.fn().mockResolvedValue({ data: [] }),
create: vi.fn().mockResolvedValue({ data: { id: "test_id" } }),
get: vi.fn().mockResolvedValue({ data: { id: "test_id" } }),
update: vi.fn().mockResolvedValue({ data: { id: "test_id" } }),
delete: vi.fn().mockResolvedValue({ data: {} }),
search: vi.fn().mockResolvedValue({ data: [] }),
export: vi.fn().mockResolvedValue({ data: {} }),
validate: vi.fn().mockResolvedValue({ data: { valid: true } }),
activate: vi.fn().mockResolvedValue({ data: {} }),
deactivate: vi.fn().mockResolvedValue({ data: {} }),
upload: vi.fn().mockResolvedValue({ data: { id: "file_id" } }),
download: vi.fn().mockResolvedValue({ data: {} }),
});
return {
benefitGrants: createResource(),
benefits: createResource(),
checkoutLinks: createResource(),
checkouts: createResource(),
customerMeters: createResource(),
customers: createResource(),
customerSeats: createResource(),
customerSessions: createResource(),
customFields: createResource(),
discounts: createResource(),
events: createResource(),
files: createResource(),
licenseKeys: createResource(),
organizations: createResource(),
orders: createResource(),
products: createResource(),
subscriptions: createResource(),
wallets: createResource(),
metrics: createResource(),
oauth2: createResource(),
customerPortal: {
benefitGrants: createResource(),
customerMeters: createResource(),
customers: createResource(),
customerSession: createResource(),
downloadables: createResource(),
licenseKeys: createResource(),
orders: createResource(),
organizations: createResource(),
seats: createResource(),
subscriptions: createResource(),
wallets: createResource(),
},
webhooks: {
validate: vi.fn().mockResolvedValue({ type: "checkout.created" }),
},
};
}

View File

@@ -0,0 +1,362 @@
import {
context,
SpanKind,
SpanStatusCode,
trace,
type Span,
} from "@opentelemetry/api";
import type { Polar } from "@polar-sh/sdk";
const DEFAULT_TRACER_NAME = "@kubiks/otel-polar";
const INSTRUMENTED_FLAG = Symbol("kubiksOtelPolarInstrumented");
// Semantic attribute constants following OpenTelemetry conventions
export const SEMATTRS_POLAR_OPERATION = "polar.operation" as const;
export const SEMATTRS_POLAR_RESOURCE = "polar.resource" as const;
export const SEMATTRS_POLAR_RESOURCE_ID = "polar.resource_id" as const;
export const SEMATTRS_POLAR_ORGANIZATION_ID = "polar.organization_id" as const;
export const SEMATTRS_POLAR_CUSTOMER_ID = "polar.customer_id" as const;
export const SEMATTRS_POLAR_PRODUCT_ID = "polar.product_id" as const;
export const SEMATTRS_POLAR_SUBSCRIPTION_ID = "polar.subscription_id" as const;
export const SEMATTRS_POLAR_CHECKOUT_ID = "polar.checkout_id" as const;
export const SEMATTRS_POLAR_ORDER_ID = "polar.order_id" as const;
export const SEMATTRS_POLAR_BENEFIT_ID = "polar.benefit_id" as const;
export const SEMATTRS_POLAR_LICENSE_KEY_ID = "polar.license_key_id" as const;
export const SEMATTRS_POLAR_FILE_ID = "polar.file_id" as const;
export const SEMATTRS_POLAR_EVENT_ID = "polar.event_id" as const;
export const SEMATTRS_POLAR_DISCOUNT_ID = "polar.discount_id" as const;
export const SEMATTRS_POLAR_WEBHOOK_EVENT_TYPE = "polar.webhook.event_type" as const;
export const SEMATTRS_POLAR_WEBHOOK_VALID = "polar.webhook.valid" as const;
export const SEMATTRS_POLAR_HTTP_METHOD = "http.method" as const;
export const SEMATTRS_POLAR_HTTP_STATUS_CODE = "http.status_code" as const;
/**
* Configuration options for Polar instrumentation.
*/
export interface InstrumentPolarConfig {
/**
* Custom tracer name. Defaults to "@kubiks/otel-polar".
*/
tracerName?: string;
/**
* Whether to capture resource IDs in spans.
* @default true
*/
captureResourceIds?: boolean;
/**
* Whether to capture organization IDs in spans.
* @default true
*/
captureOrganizationIds?: boolean;
/**
* Whether to instrument customer portal operations.
* @default true
*/
instrumentCustomerPortal?: boolean;
}
interface InstrumentedPolar extends Polar {
[INSTRUMENTED_FLAG]?: true;
}
interface InstrumentedResource {
[INSTRUMENTED_FLAG]?: true;
}
/**
* Finalizes a span with status, timing, and optional error.
*/
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();
}
/**
* Generic wrapper to instrument async methods.
*/
function wrapAsyncMethod(
originalMethod: any,
operationName: string,
resourceName: string,
tracer: ReturnType<typeof trace.getTracer>,
config?: InstrumentPolarConfig
): any {
return async function instrumentedMethod(...args: any[]): Promise<any> {
const span = tracer.startSpan(`polar.${resourceName}.${operationName}`, {
kind: SpanKind.CLIENT,
});
span.setAttributes({
[SEMATTRS_POLAR_OPERATION]: `${resourceName}.${operationName}`,
[SEMATTRS_POLAR_RESOURCE]: resourceName,
});
// Extract and set resource IDs from arguments if available
if (config?.captureResourceIds !== false && args.length > 0) {
const firstArg = args[0];
if (typeof firstArg === "string") {
span.setAttribute(SEMATTRS_POLAR_RESOURCE_ID, firstArg);
} else if (firstArg && typeof firstArg === "object") {
// Try to extract common ID fields
if (firstArg.id) {
span.setAttribute(SEMATTRS_POLAR_RESOURCE_ID, firstArg.id);
}
if (firstArg.organizationId) {
span.setAttribute(SEMATTRS_POLAR_ORGANIZATION_ID, firstArg.organizationId);
}
if (firstArg.customerId) {
span.setAttribute(SEMATTRS_POLAR_CUSTOMER_ID, firstArg.customerId);
}
if (firstArg.productId) {
span.setAttribute(SEMATTRS_POLAR_PRODUCT_ID, firstArg.productId);
}
if (firstArg.subscriptionId) {
span.setAttribute(SEMATTRS_POLAR_SUBSCRIPTION_ID, firstArg.subscriptionId);
}
if (firstArg.checkoutId) {
span.setAttribute(SEMATTRS_POLAR_CHECKOUT_ID, firstArg.checkoutId);
}
}
}
const activeContext = trace.setSpan(context.active(), span);
try {
const result = await context.with(activeContext, () =>
originalMethod.apply(this, args)
);
// Try to extract ID from response
if (config?.captureResourceIds !== false && result?.data?.id) {
span.setAttribute(SEMATTRS_POLAR_RESOURCE_ID, result.data.id);
}
finalizeSpan(span);
return result;
} catch (error) {
finalizeSpan(span, error);
throw error;
}
};
}
/**
* Instruments a resource object (like benefits, customers, etc.) with all its methods.
*/
function instrumentResource(
resource: any,
resourceName: string,
tracer: ReturnType<typeof trace.getTracer>,
config?: InstrumentPolarConfig
): any {
if (!resource || (resource as InstrumentedResource)[INSTRUMENTED_FLAG]) {
return resource;
}
// Common CRUD operations to instrument
const operationsToInstrument = [
"list",
"create",
"get",
"update",
"delete",
"search",
"export",
"validate",
"activate",
"deactivate",
"claim",
"release",
"ingest",
"upload",
"download",
"authorize",
"token",
"revoke",
"introspect",
"getLimits",
"getActivation",
];
for (const operation of operationsToInstrument) {
if (typeof resource[operation] === "function") {
const originalMethod = resource[operation].bind(resource);
resource[operation] = wrapAsyncMethod(
originalMethod,
operation,
resourceName,
tracer,
config
);
}
}
// Mark as instrumented
(resource as InstrumentedResource)[INSTRUMENTED_FLAG] = true;
return resource;
}
/**
* Instruments the Polar SDK client with OpenTelemetry tracing.
*
* This function wraps all SDK methods to create spans for each operation.
* The instrumentation is idempotent - calling it multiple times on the same
* client will only instrument it once.
*
* @param client - The Polar SDK client to instrument
* @param config - Optional configuration for instrumentation behavior
* @returns The instrumented client (same instance, modified in place)
*
* @example
* ```typescript
* import { Polar } from '@polar-sh/sdk';
* import { instrumentPolar } from '@kubiks/otel-polar';
*
* const polar = new Polar({
* accessToken: process.env.POLAR_ACCESS_TOKEN,
* });
*
* instrumentPolar(polar, {
* captureResourceIds: true,
* captureOrganizationIds: true,
* });
*
* // All operations are now traced
* await polar.benefits.list({ organizationId: 'org_123' });
* ```
*/
export function instrumentPolar(
client: Polar,
config?: InstrumentPolarConfig
): Polar {
if (!client) {
return client;
}
// Check if already instrumented
if ((client as InstrumentedPolar)[INSTRUMENTED_FLAG]) {
return client;
}
const { tracerName = DEFAULT_TRACER_NAME, instrumentCustomerPortal = true } =
config ?? {};
const tracer = trace.getTracer(tracerName);
// Instrument all main resources
const mainResources = [
{ name: "benefitGrants", prop: "benefitGrants" },
{ name: "benefits", prop: "benefits" },
{ name: "checkoutLinks", prop: "checkoutLinks" },
{ name: "checkouts", prop: "checkouts" },
{ name: "customerMeters", prop: "customerMeters" },
{ name: "customers", prop: "customers" },
{ name: "customerSeats", prop: "customerSeats" },
{ name: "customerSessions", prop: "customerSessions" },
{ name: "customFields", prop: "customFields" },
{ name: "discounts", prop: "discounts" },
{ name: "events", prop: "events" },
{ name: "files", prop: "files" },
{ name: "licenseKeys", prop: "licenseKeys" },
{ name: "organizations", prop: "organizations" },
{ name: "orders", prop: "orders" },
{ name: "products", prop: "products" },
{ name: "subscriptions", prop: "subscriptions" },
{ name: "wallets", prop: "wallets" },
{ name: "metrics", prop: "metrics" },
{ name: "oauth2", prop: "oauth2" },
];
for (const { name, prop } of mainResources) {
if ((client as any)[prop]) {
instrumentResource((client as any)[prop], name, tracer, config);
}
}
// Instrument customer portal if enabled
if (instrumentCustomerPortal && (client as any).customerPortal) {
const portalResources = [
{ name: "customerPortal.benefitGrants", prop: "benefitGrants" },
{ name: "customerPortal.customerMeters", prop: "customerMeters" },
{ name: "customerPortal.customers", prop: "customers" },
{ name: "customerPortal.customerSession", prop: "customerSession" },
{ name: "customerPortal.downloadables", prop: "downloadables" },
{ name: "customerPortal.licenseKeys", prop: "licenseKeys" },
{ name: "customerPortal.orders", prop: "orders" },
{ name: "customerPortal.organizations", prop: "organizations" },
{ name: "customerPortal.seats", prop: "seats" },
{ name: "customerPortal.subscriptions", prop: "subscriptions" },
{ name: "customerPortal.wallets", prop: "wallets" },
];
const customerPortal = (client as any).customerPortal;
for (const { name, prop } of portalResources) {
if (customerPortal[prop]) {
instrumentResource(customerPortal[prop], name, tracer, config);
}
}
}
// Instrument webhook validation if available
if (typeof (client as any).webhooks?.validate === "function") {
const originalValidate = (client as any).webhooks.validate.bind(
(client as any).webhooks
);
(client as any).webhooks.validate = async function instrumentedValidate(
...args: any[]
): Promise<any> {
const span = tracer.startSpan("polar.webhooks.validate", {
kind: SpanKind.SERVER,
});
span.setAttributes({
[SEMATTRS_POLAR_OPERATION]: "webhooks.validate",
[SEMATTRS_POLAR_RESOURCE]: "webhooks",
});
const activeContext = trace.setSpan(context.active(), span);
try {
const result = await context.with(activeContext, () =>
originalValidate(...args)
);
span.setAttribute(SEMATTRS_POLAR_WEBHOOK_VALID, true);
if (result?.type) {
span.setAttribute(SEMATTRS_POLAR_WEBHOOK_EVENT_TYPE, result.type);
}
finalizeSpan(span);
return result;
} catch (error) {
span.setAttribute(SEMATTRS_POLAR_WEBHOOK_VALID, false);
finalizeSpan(span, error);
throw error;
}
};
}
// Mark as instrumented
(client as InstrumentedPolar)[INSTRUMENTED_FLAG] = true;
return client;
}
/**
* Re-export types for convenience
*/
export type { Polar } from "@polar-sh/sdk";

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"]
}

33
pnpm-lock.yaml generated
View File

@@ -198,6 +198,30 @@ importers:
specifier: 0.33.0
version: 0.33.0(less@4.2.0)(sass@1.69.7)(stylus@0.59.0)
packages/otel-polar:
devDependencies:
'@opentelemetry/api':
specifier: ^1.9.0
version: 1.9.0
'@opentelemetry/sdk-trace-base':
specifier: ^2.1.0
version: 2.1.0(@opentelemetry/api@1.9.0)
'@polar-sh/sdk':
specifier: ^0.11.0
version: 0.11.1(zod@4.1.11)
'@types/node':
specifier: 18.15.11
version: 18.15.11
rimraf:
specifier: 3.0.2
version: 3.0.2
typescript:
specifier: ^5
version: 5.3.3
vitest:
specifier: 0.33.0
version: 0.33.0(less@4.2.0)(sass@1.69.7)(stylus@0.59.0)
packages/otel-resend:
devDependencies:
'@opentelemetry/api':
@@ -880,6 +904,11 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
'@polar-sh/sdk@0.11.1':
resolution: {integrity: sha512-U5RNi2BFn7H+mKLr58323vSvbOvI7XZNpQ59ry90wX6N6+gBcSCJ4e4ZNHwaSM6Bn6BJGGiQhkdswmFk82fOmg==}
peerDependencies:
zod: '>= 3'
'@react-email/render@0.0.16':
resolution: {integrity: sha512-wDaMy27xAq1cJHtSFptp0DTKPuV2GYhloqia95ub/DH9Dea1aWYsbdM918MOc/b/HvVS3w1z8DWzfAk13bGStQ==}
engines: {node: '>=18.0.0'}
@@ -3516,6 +3545,10 @@ snapshots:
'@pkgjs/parseargs@0.11.0':
optional: true
'@polar-sh/sdk@0.11.1(zod@4.1.11)':
dependencies:
zod: 4.1.11
'@react-email/render@0.0.16(react-dom@18.3.1(react@18.2.0))(react@18.2.0)':
dependencies:
html-to-text: 9.0.5