mirror of
https://github.com/zoriya/drizzle-otel.git
synced 2025-12-06 00:46:09 +00:00
feat(otel-polar): Add OpenTelemetry instrumentation for Polar.sh SDK
This commit is contained in:
@@ -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)
|
||||
|
||||
18
packages/otel-polar/CHANGELOG.md
Normal file
18
packages/otel-polar/CHANGELOG.md
Normal 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)
|
||||
21
packages/otel-polar/LICENSE
Normal file
21
packages/otel-polar/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.
|
||||
416
packages/otel-polar/README.md
Normal file
416
packages/otel-polar/README.md
Normal 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
|
||||
64
packages/otel-polar/package.json
Normal file
64
packages/otel-polar/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
384
packages/otel-polar/src/index.test.ts
Normal file
384
packages/otel-polar/src/index.test.ts
Normal 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" }),
|
||||
},
|
||||
};
|
||||
}
|
||||
362
packages/otel-polar/src/index.ts
Normal file
362
packages/otel-polar/src/index.ts
Normal 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";
|
||||
21
packages/otel-polar/tsconfig.json
Normal file
21
packages/otel-polar/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"]
|
||||
}
|
||||
33
pnpm-lock.yaml
generated
33
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user