mirror of
https://github.com/zoriya/drizzle-otel.git
synced 2025-12-06 00:46:09 +00:00
Merge pull request #32 from kubiks-inc/tembo/polar-otel-sdk-instrument
feat: Add Polar.sh OTel 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-e2b`](./packages/otel-e2b/README.md)
|
||||||
- [`@kubiks/otel-inbound`](./packages/otel-inbound/README.md)
|
- [`@kubiks/otel-inbound`](./packages/otel-inbound/README.md)
|
||||||
- [`@kubiks/otel-mongodb`](./packages/otel-mongodb/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-resend`](./packages/otel-resend/README.md)
|
||||||
- [`@kubiks/otel-upstash-queues`](./packages/otel-upstash-queues/README.md)
|
- [`@kubiks/otel-upstash-queues`](./packages/otel-upstash-queues/README.md)
|
||||||
- [`@kubiks/otel-upstash-workflow`](./packages/otel-upstash-workflow/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
|
## Coming soon
|
||||||
|
|
||||||
- [Stripe](https://stripe.com/)
|
- [Stripe](https://stripe.com/)
|
||||||
- [Polar.sh](https://polar.sh/)
|
|
||||||
- [AI SDK](https://ai-sdk.dev/)
|
- [AI SDK](https://ai-sdk.dev/)
|
||||||
- [Mastra](https://mastra.ai/)
|
- [Mastra](https://mastra.ai/)
|
||||||
- [Next.js Opentelemetry SDK](https://nextjs.org)
|
- [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
|
specifier: 0.33.0
|
||||||
version: 0.33.0(less@4.2.0)(sass@1.69.7)(stylus@0.59.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:
|
packages/otel-resend:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@opentelemetry/api':
|
'@opentelemetry/api':
|
||||||
@@ -880,6 +904,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
|
'@polar-sh/sdk@0.11.1':
|
||||||
|
resolution: {integrity: sha512-U5RNi2BFn7H+mKLr58323vSvbOvI7XZNpQ59ry90wX6N6+gBcSCJ4e4ZNHwaSM6Bn6BJGGiQhkdswmFk82fOmg==}
|
||||||
|
peerDependencies:
|
||||||
|
zod: '>= 3'
|
||||||
|
|
||||||
'@react-email/render@0.0.16':
|
'@react-email/render@0.0.16':
|
||||||
resolution: {integrity: sha512-wDaMy27xAq1cJHtSFptp0DTKPuV2GYhloqia95ub/DH9Dea1aWYsbdM918MOc/b/HvVS3w1z8DWzfAk13bGStQ==}
|
resolution: {integrity: sha512-wDaMy27xAq1cJHtSFptp0DTKPuV2GYhloqia95ub/DH9Dea1aWYsbdM918MOc/b/HvVS3w1z8DWzfAk13bGStQ==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
@@ -3516,6 +3545,10 @@ snapshots:
|
|||||||
'@pkgjs/parseargs@0.11.0':
|
'@pkgjs/parseargs@0.11.0':
|
||||||
optional: true
|
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)':
|
'@react-email/render@0.0.16(react-dom@18.3.1(react@18.2.0))(react@18.2.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
html-to-text: 9.0.5
|
html-to-text: 9.0.5
|
||||||
|
|||||||
Reference in New Issue
Block a user