From a0184d3becee65e235496f133272c693404ef1d9 Mon Sep 17 00:00:00 2001 From: "tembo[bot]" <208362400+tembo-io[bot]@users.noreply.github.com> Date: Wed, 12 Nov 2025 02:53:55 +0000 Subject: [PATCH] feat(otel-polar): Add OpenTelemetry instrumentation for Polar.sh SDK --- README.md | 2 +- packages/otel-polar/CHANGELOG.md | 18 ++ packages/otel-polar/LICENSE | 21 ++ packages/otel-polar/README.md | 416 ++++++++++++++++++++++++++ packages/otel-polar/package.json | 64 ++++ packages/otel-polar/src/index.test.ts | 384 ++++++++++++++++++++++++ packages/otel-polar/src/index.ts | 362 ++++++++++++++++++++++ packages/otel-polar/tsconfig.json | 21 ++ pnpm-lock.yaml | 33 ++ 9 files changed, 1320 insertions(+), 1 deletion(-) create mode 100644 packages/otel-polar/CHANGELOG.md create mode 100644 packages/otel-polar/LICENSE create mode 100644 packages/otel-polar/README.md create mode 100644 packages/otel-polar/package.json create mode 100644 packages/otel-polar/src/index.test.ts create mode 100644 packages/otel-polar/src/index.ts create mode 100644 packages/otel-polar/tsconfig.json diff --git a/README.md b/README.md index 05dbcb1..653bc21 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Our goal is to bring the TypeScript ecosystem the observability tools it’s 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 it’s 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) diff --git a/packages/otel-polar/CHANGELOG.md b/packages/otel-polar/CHANGELOG.md new file mode 100644 index 0000000..d62af24 --- /dev/null +++ b/packages/otel-polar/CHANGELOG.md @@ -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) diff --git a/packages/otel-polar/LICENSE b/packages/otel-polar/LICENSE new file mode 100644 index 0000000..55f20b0 --- /dev/null +++ b/packages/otel-polar/LICENSE @@ -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. diff --git a/packages/otel-polar/README.md b/packages/otel-polar/README.md new file mode 100644 index 0000000..d2c94c9 --- /dev/null +++ b/packages/otel-polar/README.md @@ -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 diff --git a/packages/otel-polar/package.json b/packages/otel-polar/package.json new file mode 100644 index 0000000..9b142dd --- /dev/null +++ b/packages/otel-polar/package.json @@ -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" + } +} diff --git a/packages/otel-polar/src/index.test.ts b/packages/otel-polar/src/index.test.ts new file mode 100644 index 0000000..4710448 --- /dev/null +++ b/packages/otel-polar/src/index.test.ts @@ -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" }), + }, + }; +} diff --git a/packages/otel-polar/src/index.ts b/packages/otel-polar/src/index.ts new file mode 100644 index 0000000..26e3490 --- /dev/null +++ b/packages/otel-polar/src/index.ts @@ -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, + config?: InstrumentPolarConfig +): any { + return async function instrumentedMethod(...args: any[]): Promise { + 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, + 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 { + 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"; diff --git a/packages/otel-polar/tsconfig.json b/packages/otel-polar/tsconfig.json new file mode 100644 index 0000000..47fac92 --- /dev/null +++ b/packages/otel-polar/tsconfig.json @@ -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"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cdf0e74..f2385f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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