first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,106 @@
# @suaveplan/delivery-tracking - Channel-agnostic delivery event tracking for campaigns and messages: lifecycle dedupe, in-memory + SurrealDB stores, time-series aggregation, tracking pixel / link tokens, multi-provider webhook parsing, OTel instrumentation
Channel-agnostic delivery event tracking for campaigns and messages: lifecycle dedupe, in-memory + SurrealDB stores, time-series aggregation, tracking pixel and click-link tokens, multi-provider webhook parsing, and OpenTelemetry instrumentation.
<!-- begin:auto-docs -->
## Documentation tree
- [aggregator](./src/core/aggregator/aggregator.md) — `DeliveryAggregator` reads campaign and contact statistics from any `DeliveryEventStore` and exposes time-series bucket…
- [aggregator](./src/core/aggregator/aggregator.md) — `DeliveryAggregator` reads campaign and contact statistics from any `DeliveryEventStore` and exposes time-series bucket…
- [tracker](./src/core/tracker/tracker.md) — `DeliveryTracker` is the package's primary write-side surface. It validates inbound `CreateDeliveryEventInput`s against…
- [tracker](./src/core/tracker/tracker.md) — `DeliveryTracker` is the package's primary write-side surface. It validates inbound `CreateDeliveryEventInput`s against…
- [tracker.schemas](./src/core/tracker/tracker.schemas.md) — `tracker.schemas` declares the Zod boundary schemas applied to `DeliveryTracker.track` input. Centralising the schemas…
- [tracking-link](./src/core/tracking-link/tracking-link.md) — `TrackingLinkWrapper` rewrites outbound URLs with click-tracking redirects. Each wrapped URL points at the tracking hos…
- [tracking-link](./src/core/tracking-link/tracking-link.md) — `TrackingLinkWrapper` rewrites outbound URLs with click-tracking redirects. Each wrapped URL points at the tracking hos…
- [tracking-pixel](./src/core/tracking-pixel/tracking-pixel.md) — `TrackingPixelGenerator` produces and parses the open-tracking pixel URLs embedded in outbound HTML emails. The pixel U…
- [token-codec](./src/core/tracking-pixel/token-codec.md) — `token-codec` is the shared XOR + base64url encoder/decoder used by both `TrackingPixelGenerator` (open tracking) and `…
- [tracking-pixel](./src/core/tracking-pixel/tracking-pixel.md) — `TrackingPixelGenerator` produces and parses the open-tracking pixel URLs embedded in outbound HTML emails. The pixel U…
- [webhook-parser](./src/core/webhook-parser/webhook-parser.md) — `WebhookEventParser` normalises provider-specific delivery webhooks into `CreateDeliveryEventInput[]` ready for `Delive…
- [webhook-parser](./src/core/webhook-parser/webhook-parser.md) — `WebhookEventParser` normalises provider-specific delivery webhooks into `CreateDeliveryEventInput[]` ready for `Delive…
- [errors](./src/errors/delivery-tracking-error.md) — `DeliveryTrackingError` is the stable typed error class raised by every code path inside `@suaveplan/delivery-tracking`…
- [delivery-tracking-error](./src/errors/delivery-tracking-error.md) — `DeliveryTrackingError` is the stable typed error class raised by every code path inside `@suaveplan/delivery-tracking`…
- [otel](./src/otel/instruments.md) — `getDeliveryTrackingInstruments` lazily constructs and caches the OTel `Counter` set used by every production code path…
- [instruments](./src/otel/instruments.md) — `getDeliveryTrackingInstruments` lazily constructs and caches the OTel `Counter` set used by every production code path…
- [memory](./src/stores/memory/memory-store.md) — `memory-store` defines the `DeliveryEventStore` interface that every persistence backend must satisfy, ships the determ…
- [memory-store](./src/stores/memory/memory-store.md) — `memory-store` defines the `DeliveryEventStore` interface that every persistence backend must satisfy, ships the determ…
- [surreal](./src/stores/surreal/surreal-store.md) — `SurrealDeliveryEventStore` is the SurrealDB-backed implementation of the `DeliveryEventStore` contract. It uses positi…
- [surreal-store](./src/stores/surreal/surreal-store.md) — `SurrealDeliveryEventStore` is the SurrealDB-backed implementation of the `DeliveryEventStore` contract. It uses positi…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/delivery-tracking
```
## Quick start
```ts
import {
DeliveryAggregator,
DeliveryTracker,
InMemoryDeliveryEventStore,
TrackingPixelGenerator,
WebhookEventParser,
} from "@suaveplan/delivery-tracking";
const store = new InMemoryDeliveryEventStore();
const tracker = new DeliveryTracker(store);
const aggregator = new DeliveryAggregator(store);
const parser = new WebhookEventParser();
const pixel = new TrackingPixelGenerator({
baseUrl: "https://t.example.com",
secret: "tracking-secret",
});
tracker.bus.on("delivery:opened", (e) => {
console.log("opened", e.messageId);
});
const events = parser.parse("sendgrid", { body: webhookBody });
await tracker.trackBatch(events);
const stats = await aggregator.getCampaignStats("c1");
const pixelUrl = pixel.generatePixelUrl("m1", "u1", "c1");
```
## Modules
| Module | Description |
|---|---|
| [`src/core/tracker/tracker.ts`](src/core/tracker/tracker.md) | Validates and records events, dedupes lifecycle types, emits on a typed bus. |
| [`src/core/tracker/tracker.schemas.ts`](src/core/tracker/tracker.schemas.md) | Zod boundary schemas for `CreateDeliveryEventInput`. |
| [`src/core/aggregator/aggregator.ts`](src/core/aggregator/aggregator.md) | Campaign rollups, contact histories, time-series bucketing. |
| [`src/core/tracking-pixel/tracking-pixel.ts`](src/core/tracking-pixel/tracking-pixel.md) | Open-tracking pixel URL generator + parser. |
| [`src/core/tracking-pixel/token-codec.ts`](src/core/tracking-pixel/token-codec.md) | Shared XOR + base64url codec used by pixel and link wrappers. |
| [`src/core/tracking-link/tracking-link.ts`](src/core/tracking-link/tracking-link.md) | Click-tracking URL wrapper, HTML rewrite, token parse. |
| [`src/core/webhook-parser/webhook-parser.ts`](src/core/webhook-parser/webhook-parser.md) | Pluggable provider webhook → input normaliser. |
| [`src/stores/memory/memory-store.ts`](src/stores/memory/memory-store.md) | `DeliveryEventStore` contract + in-memory implementation + `computeStats`. |
| [`src/stores/surreal/surreal-store.ts`](src/stores/surreal/surreal-store.md) | SurrealDB-backed implementation using positional SurQL. |
| [`src/errors/delivery-tracking-error.ts`](src/errors/delivery-tracking-error.md) | `DeliveryTrackingError` + stable code registry. |
| [`src/otel/instruments.ts`](src/otel/instruments.md) | Lazy OTel counter set (`tracked`, `deduped`, `webhook_parsed`, `token_issued`, `token_rejected`). |
## Features
- Lifecycle dedupe per `messageId` for `opened`, `delivered`, `bounced`, `unsubscribed`, `complained`.
- Typed `EventBus<DeliveryTrackerEventMap>` with one topic per `DeliveryEventType`.
- Backend-agnostic via `DeliveryEventStore` — drop-in `InMemoryDeliveryEventStore`, `SurrealDeliveryEventStore`, or your own.
- Time-series bucketing by `hour`/`day`/`week`.
- Pluggable webhook parsers for SendGrid, Mailgun, AWS SES, and a generic shape.
- Zod boundary validation on every event the tracker accepts.
- Counters emitted via `@opentelemetry/api` for ingest, dedupe, parser activity, and token issuance/rejection.
## Subpath exports
`./tracker`, `./aggregator`, `./tracking-pixel`, `./tracking-link`, `./webhook-parser`, `./stores/memory`, `./stores/surreal`, `./errors`, `./otel`.
## Errors
Every failure raised by the package extends `DeliveryTrackingError`. The stable code registry:
- `DELIVERY_TRACKING.INVALID_INPUT`
- `DELIVERY_TRACKING.INVALID_TOKEN`
- `DELIVERY_TRACKING.UNSUPPORTED_PROVIDER`
- `DELIVERY_TRACKING.STORE_FAILURE`
- `DELIVERY_TRACKING.QUERY_FAILURE`

View File

@@ -0,0 +1,59 @@
# aggregator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`DeliveryAggregator` reads campaign and contact statistics from any `DeliveryEventStore` and exposes time-series bucketing on top. It is intentionally stateless: every public method is a thin composition of store calls plus the pure `computeStats` helper, so caching belongs to the caller (a Redis layer, a Hono route, etc.).
The aggregator is the read-side counterpart to `DeliveryTracker`. Together they implement the channel-agnostic delivery analytics surface described in the package's enterprise spec — counts, rates, contact histories, and time-bucketed rollups across whatever store the host wires up.
## Features
- Campaign-level rollup via `getCampaignStats` returning `DeliveryStats` (counts + percentage rates).
- Per-contact history via `getContactStats` with optional `DeliveryEventQuery` filtering.
- Time-series bucketing by hour, day, or week through `getTimeSeriesStats`.
- Empty-input guards return `[]` for the time series and `0`-valued rates rather than `NaN`.
- Backend-agnostic — works with `InMemoryDeliveryEventStore`, `SurrealDeliveryEventStore`, or any custom backend implementing `DeliveryEventStore`.
## Usage: Basic
```ts
import {
DeliveryAggregator,
InMemoryDeliveryEventStore,
} from "@suaveplan/delivery-tracking";
const agg = new DeliveryAggregator(new InMemoryDeliveryEventStore());
const stats = await agg.getCampaignStats("c1");
```
## Usage: Advanced
```ts
import {
DeliveryAggregator,
SurrealDeliveryEventStore,
} from "@suaveplan/delivery-tracking";
const agg = new DeliveryAggregator(new SurrealDeliveryEventStore(adapter));
const hourly = await agg.getTimeSeriesStats("c-launch", "hour");
const filtered = await agg.getContactStats("u1", {
eventType: "clicked",
from: new Date("2026-01-01"),
});
```
## API Reference
- `class DeliveryAggregator` — `constructor(store: DeliveryEventStore)`.
- `getCampaignStats(campaignId)` → `Promise<DeliveryStats>`.
- `getContactStats(contactId, query?)` → `Promise<DeliveryEvent[]>`.
- `getTimeSeriesStats(campaignId, interval)` → `Promise<TimeSeriesBucket[]>` where `interval ∈ {"hour", "day", "week"}`.
- `type TimeInterval = "hour" | "day" | "week"`.
## Implementation Notes
Bucketing walks from the earliest to the latest event timestamp in fixed-size strides; empty buckets are *included* (they contain a zero-valued `DeliveryStats` from `computeStats([])`). This matches the typical analytics expectation that a charted series shows quiet hours as flat zero rather than gaps. The interval-millisecond table is hard-coded — `hour: 3_600_000`, `day: 86_400_000`, `week: 604_800_000` — so DST does not skew the bucket boundaries.

View File

@@ -0,0 +1,74 @@
# tracker
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`DeliveryTracker` is the package's primary write-side surface. It validates inbound `CreateDeliveryEventInput`s against a Zod schema, applies lifecycle dedupe per `messageId` for `opened`/`delivered`/`bounced`/`unsubscribed`/`complained`, persists accepted events via a pluggable `DeliveryEventStore`, increments the appropriate OTel counters, and re-emits the persisted event on a typed `EventBus` so downstream subscribers (notification triggers, analytics fan-out) can react.
Both the `Clock` and `IdGenerator` are injectable from `@suaveplan/types`, so tests can pin timestamps and IDs deterministically. The default ID generator combines the injected clock with a monotonically increasing counter, ensuring uniqueness without depending on `Math.random` or `crypto.randomUUID`.
## Features
- Zod boundary validation; failures throw `DeliveryTrackingError(INVALID_INPUT)`.
- Lifecycle dedupe via `store.exists(messageId, eventType)` with a `delivery_tracking.deduped.total` counter increment on suppression.
- Typed `EventBus<DeliveryTrackerEventMap>` emission (`delivery:sent`, `delivery:opened`, …).
- Injectable `Clock`, `IdGenerator`, and `bus` instance.
- `trackBatch` for sequential ingest with `null` slots for deduplicated entries.
- OTel counter `delivery_tracking.tracked.total` with `event_type` + `channel` attributes.
## Usage: Basic
```ts
import {
DeliveryTracker,
InMemoryDeliveryEventStore,
} from "@suaveplan/delivery-tracking";
const tracker = new DeliveryTracker(new InMemoryDeliveryEventStore());
tracker.bus.on("delivery:opened", (e) => console.log("opened", e.messageId));
await tracker.track({
tenantId: "t1",
contactId: "u1",
channel: "email",
eventType: "opened",
messageId: "m1",
});
```
## Usage: Advanced
```ts
import { EventBus } from "@suaveplan/events";
import {
DeliveryTracker,
type DeliveryTrackerEventMap,
InMemoryDeliveryEventStore,
} from "@suaveplan/delivery-tracking";
const bus = new EventBus<DeliveryTrackerEventMap>();
let id = 0;
const tracker = new DeliveryTracker(new InMemoryDeliveryEventStore(), {
clock: () => 1_700_000_000_000,
idGenerator: () => `evt-${++id}`,
bus,
});
const r = await tracker.trackBatch([
{ tenantId: "t", contactId: "u", channel: "email", eventType: "delivered", messageId: "m" },
{ tenantId: "t", contactId: "u", channel: "email", eventType: "delivered", messageId: "m" }, // deduped
]);
// r[1] === null
```
## API Reference
- `class DeliveryTracker` — public `bus` (`EventBus<DeliveryTrackerEventMap>`).
- `constructor(store: DeliveryEventStore, options?: DeliveryTrackerOptions)` — options accept `clock`, `idGenerator`, `bus`.
- `track(input)` → `Promise<DeliveryEvent | null>` — `null` on dedupe; throws `DeliveryTrackingError(INVALID_INPUT)` on schema failure.
- `trackBatch(inputs)` → `Promise<(DeliveryEvent | null)[]>` — sequential, preserves input order.
## Implementation Notes
The dedupe set is hard-coded to the five "irreversible" lifecycle events. `clicked`, `sent`, `deferred`, `dropped`, and `read` are intentionally allowed to repeat because they correspond to actions a recipient or provider can plausibly trigger multiple times per message. The `bus.emit` call dispatches synchronously — async subscribers should attach via `emitAsync` paths inside their own logic, not by changing the tracker.

View File

@@ -0,0 +1,52 @@
# tracker.schemas
## Purpose
`tracker.schemas` declares the Zod boundary schemas applied to `DeliveryTracker.track` input. Centralising the schemas next to the consumer keeps the validation contract close to the code that enforces it and lets callers reuse the same schemas (e.g. when ingesting from an HTTP route via `@hono/zod-openapi`).
The schema mirrors the `CreateDeliveryEventInput` interface in `types/delivery-tracking.types.ts`. Both are kept in sync — the schema is the runtime contract, the interface is the compile-time contract, and the tracker's `safeParse` is the single chokepoint that proves they match for any input that reaches the store.
## Features
- `deliveryEventTypeSchema` — Zod enum matching `DeliveryEventType` exactly.
- `createDeliveryEventInputSchema` — Zod object with required `tenantId`, `contactId`, `channel`, `eventType`, `messageId` and optional `campaignId`, `metadata`, `timestamp`, `ip`, `userAgent`.
- `CreateDeliveryEventInputSchema` type alias for the parsed shape.
- Zero side effects — purely declarative.
## Usage: Basic
```ts
import { createDeliveryEventInputSchema } from "@suaveplan/delivery-tracking/tracker";
const parsed = createDeliveryEventInputSchema.safeParse({
tenantId: "t1",
contactId: "u1",
channel: "email",
eventType: "sent",
messageId: "m1",
});
if (!parsed.success) throw parsed.error;
```
## Usage: Advanced
```ts
import z from "zod";
import { createDeliveryEventInputSchema } from "@suaveplan/delivery-tracking/tracker";
// Extend with a route-specific constraint without redeclaring the base shape.
const httpInputSchema = createDeliveryEventInputSchema.extend({
sourceIp: z.string().ip().optional(),
});
type HttpInput = z.infer<typeof httpInputSchema>;
```
## API Reference
- `deliveryEventTypeSchema: z.ZodEnum<…>` — accepts every value in `DeliveryEventType`.
- `createDeliveryEventInputSchema: z.ZodObject<…>` — Zod object schema. Use `.safeParse` for typed branching, `.parse` to throw `ZodError` on failure.
- `type CreateDeliveryEventInputSchema = z.infer<typeof createDeliveryEventInputSchema>` — convenience alias for the parsed object shape.
## Implementation Notes
The schema uses `z.string().min(1)` on `tenantId`/`contactId`/`channel`/`messageId` because empty strings are a frequent symptom of upstream serialisation bugs (e.g. a Mailgun webhook with a missing recipient). Allowing them through silently would make every downstream aggregator carry "where the contact is" defensive code. The `metadata` field accepts `Record<string, unknown>` so providers can ship arbitrary payload blobs.

View File

@@ -0,0 +1,59 @@
# tracking-link
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`TrackingLinkWrapper` rewrites outbound URLs with click-tracking redirects. Each wrapped URL points at the tracking host's `clickPath` (default `/track/click`) with an opaque token carrying `(originalUrl, messageId, contactId, campaignId)`. The receiving handler decodes the token, records a `clicked` event via `DeliveryTracker.track`, then 302s to `originalUrl`.
`wrapAllLinks` rewrites every `<a href="…">` in an HTML body so a single call before send-time turns every link clickable-trackable. The HTML regex is intentionally simple: it matches both single and double quotes and preserves any attributes around the `href`, but does not handle script-injected anchors or anchors split across CDATA boundaries — callers needing those edge cases should pre-process the HTML through a real DOM parser.
## Features
- Configurable `baseUrl`, `secret`, and `clickPath` (default `/track/click`).
- `wrapUrl` for a single URL.
- `wrapAllLinks` for bulk rewrites across an HTML body, case-insensitive `href` matching.
- `parseClickToken` decodes back to `ClickTrackingTokenPayload` and throws `DeliveryTrackingError(INVALID_TOKEN)` on failure.
- Shares the token codec with `TrackingPixelGenerator` so OTel issuance/rejection counters are consistent.
## Usage: Basic
```ts
import { TrackingLinkWrapper } from "@suaveplan/delivery-tracking";
const wrap = new TrackingLinkWrapper({
baseUrl: "https://t.example.com",
secret: process.env.TRACKING_SECRET!,
});
const click = wrap.wrapUrl("https://example.com/landing", "m1", "u1", "c1");
```
## Usage: Advanced
```ts
import { TrackingLinkWrapper } from "@suaveplan/delivery-tracking";
const wrap = new TrackingLinkWrapper({
baseUrl: "https://t.example.com",
secret: process.env.TRACKING_SECRET!,
clickPath: "/r",
});
const htmlOut = wrap.wrapAllLinks(htmlIn, "m1", "u1", "c1");
// In the redirect handler:
const { originalUrl } = wrap.parseClickToken(req.query.t as string);
res.redirect(302, originalUrl);
```
## API Reference
- `class TrackingLinkWrapper` — `constructor(config: TrackingConfig)`.
- `wrapUrl(originalUrl, messageId, contactId, campaignId?)` → `string`.
- `parseClickToken(token)` → `ClickTrackingTokenPayload`.
- `wrapAllLinks(html, messageId, contactId, campaignId?)` → `string`.
## Implementation Notes
The regex `/<a\s([^>]*?)href=["']([^"']+)["']([^>]*)>/gi` captures the attributes before and after `href` so they survive the rewrite. Anchors whose `href` is double-encoded or contains nested quotes will not match — that is intentional: the regex is a fast-path for the typical email template, and any caller with adversarial HTML should run the body through a DOM parser first and then call `wrapUrl` per anchor.

View File

@@ -0,0 +1,61 @@
# token-codec
## Purpose
`token-codec` is the shared XOR + base64url encoder/decoder used by both `TrackingPixelGenerator` (open tracking) and `TrackingLinkWrapper` (click tracking). Factoring it out keeps the two generators byte-compatible (a token issued by one decodes via the other when the secret matches) and centralises the OTel instrumentation for token issuance and rejection.
The codec is **not cryptographic** — it provides URL-safe opaque encoding of small JSON payloads to keep tracking URLs short and free of forbidden characters. Callers needing tamper resistance must layer an HMAC (or another signed envelope) on top.
## Features
- Pure functions: `encodeToken(payload, secret)` and `decodeToken<T>(token, secret)`.
- URL-safe base64 alphabet (`-` and `_` replacing `+` and `/`, padding stripped).
- Bidirectional XOR cipher keyed by the user-supplied secret.
- Decode failures emit `DeliveryTrackingError(INVALID_TOKEN)` and increment the rejection counter.
- Issuance and rejection counters are emitted via `getDeliveryTrackingInstruments()`.
## Usage: Basic
```ts
import {
encodeToken,
decodeToken,
} from "@suaveplan/delivery-tracking/tracking-pixel";
const token = encodeToken({ messageId: "m1", contactId: "u1" }, "secret");
const payload = decodeToken<{ messageId: string; contactId: string }>(
token,
"secret"
);
```
## Usage: Advanced
```ts
import {
DELIVERY_TRACKING_CODES,
DeliveryTrackingError,
decodeToken,
} from "@suaveplan/delivery-tracking";
try {
decodeToken(req.query.t, process.env.TRACKING_SECRET!);
} catch (e) {
if (
e instanceof DeliveryTrackingError &&
e.code === DELIVERY_TRACKING_CODES.INVALID_TOKEN
) {
return res.status(400).send("invalid token");
}
throw e;
}
```
## API Reference
- `encodeToken(payload: unknown, secret: string): string` — returns a URL-safe base64 token. Increments `delivery_tracking.token_issued.total` with `kind: "encode"`.
- `decodeToken<T>(token: string, secret: string): T` — throws `DeliveryTrackingError(INVALID_TOKEN)` on failure. Increments `delivery_tracking.token_rejected.total` on the failure path.
## Implementation Notes
The XOR cipher is keyed by repeatedly cycling through `secret.charCodeAt(i % secret.length)`. Both `encodeToken` and `decodeToken` rely on Node's `Buffer` for base64 conversion (the package is server-tier per the rollup `external` list — `node:` is allowed). The instruments are resolved lazily on each call so `metrics.setGlobalMeterProvider` can install a custom provider before the first call without missing the counter increment.

View File

@@ -0,0 +1,66 @@
# tracking-pixel
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`TrackingPixelGenerator` produces and parses the open-tracking pixel URLs embedded in outbound HTML emails. The pixel URL contains an opaque token carrying the `(messageId, contactId, campaignId)` triple so the receiving webhook can attribute the open directly without an extra database lookup.
The generator is symmetric: the same instance that issued a pixel URL can parse the resulting token. Callers can also use `decodeToken` from `tracking-pixel/token-codec` directly when the URL has already been split into its token component (e.g. inside a Hono handler reading `req.query.t`).
## Features
- Configurable `baseUrl`, `secret`, and optional `pixelPath` (default `/track/open`).
- Trailing slashes in `baseUrl` are trimmed so the URL never contains a double slash.
- `generatePixelHtml(url)` emits a hidden 1×1 `<img>` element suitable for direct inclusion in an email template.
- Round-trip safe with `parsePixelToken`.
- Shares the `tracking-pixel/token-codec` module with `TrackingLinkWrapper`, so OTel counter semantics are consistent.
## Usage: Basic
```ts
import { TrackingPixelGenerator } from "@suaveplan/delivery-tracking";
const gen = new TrackingPixelGenerator({
baseUrl: "https://t.example.com",
secret: process.env.TRACKING_SECRET!,
});
const url = gen.generatePixelUrl("m1", "u1", "c1");
const html = gen.generatePixelHtml(url);
```
## Usage: Advanced
```ts
import { TrackingPixelGenerator } from "@suaveplan/delivery-tracking";
const gen = new TrackingPixelGenerator({
baseUrl: "https://t.example.com/",
secret: process.env.TRACKING_SECRET!,
pixelPath: "/p",
});
// Inside the open-webhook handler:
const token = new URL(req.url).searchParams.get("t")!;
const { messageId, contactId, campaignId } = gen.parsePixelToken(token);
await tracker.track({
tenantId: "t1",
campaignId,
contactId,
channel: "email",
eventType: "opened",
messageId,
});
```
## API Reference
- `class TrackingPixelGenerator` — `constructor(config: TrackingConfig)`.
- `generatePixelUrl(messageId, contactId, campaignId?)` → `string`.
- `parsePixelToken(token)` → `TrackingTokenPayload`. Throws `DeliveryTrackingError(INVALID_TOKEN)` on failure.
- `generatePixelHtml(url)` → `string` — hidden 1×1 `<img>` tag.
## Implementation Notes
The default pixel path is `/track/open`; the default click path on the link wrapper is `/track/click`. Both default values can be changed via `TrackingConfig` so a host can route to existing infrastructure without rewriting the SDK. The HTML snippet uses `display:none;border:0;` rather than `visibility:hidden` so it does not consume layout space in clients that render hidden images.

View File

@@ -0,0 +1,74 @@
# webhook-parser
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`WebhookEventParser` normalises provider-specific delivery webhooks into `CreateDeliveryEventInput[]` ready for `DeliveryTracker.track`. Built-in parsers cover SendGrid, Mailgun, AWS SES, and a `generic` shape that downstream services or SDKs can target directly. The registry is open: callers can pass custom parsers at construction time or register them at runtime via `registerProvider`.
The parser intentionally accepts loose `WebhookPayload` shapes (single objects or arrays) because providers vary — Mailgun nests its payload under `event-data`, SES wraps its JSON under `Message` from the SNS envelope, and SendGrid posts a flat array of events. Each adapter pulls only the fields the package needs and stuffs the original payload into `metadata` for downstream forensics.
## Features
- Four built-in providers: `sendgrid`, `mailgun`, `ses`, `generic`.
- Unknown providers raise `DeliveryTrackingError(UNSUPPORTED_PROVIDER)`.
- Pluggable `ProviderParser` interface — custom providers receive the same `WebhookPayload` and return the same `CreateDeliveryEventInput[]` shape.
- OTel counter `delivery_tracking.webhook_parsed.total` attributed by `provider`.
- Unknown event names within a known provider are filtered out (returns an empty event slot) rather than throwing — providers add new events frequently and the package should not refuse the whole batch over one unfamiliar entry.
## Usage: Basic
```ts
import {
DeliveryTracker,
InMemoryDeliveryEventStore,
WebhookEventParser,
} from "@suaveplan/delivery-tracking";
const parser = new WebhookEventParser();
const tracker = new DeliveryTracker(new InMemoryDeliveryEventStore());
const events = parser.parse("sendgrid", { body: req.body });
await tracker.trackBatch(events);
```
## Usage: Advanced
```ts
import {
type ProviderParser,
WebhookEventParser,
} from "@suaveplan/delivery-tracking";
const postmark: ProviderParser = {
parse: ({ body }) => {
const arr = Array.isArray(body) ? body : [body];
return arr.map((evt) => ({
tenantId: (evt.tenantId as string) ?? "default",
contactId: evt.Recipient as string,
channel: "email",
eventType: "delivered",
messageId: evt.MessageID as string,
metadata: { provider: "postmark", raw: evt },
}));
},
};
const parser = new WebhookEventParser({ postmark });
parser.registerProvider("brevo", { parse: () => [] });
```
## API Reference
- `interface WebhookPayload` — `body: Record | Record[]`, optional `headers`.
- `interface ProviderParser` — `parse(payload: WebhookPayload): CreateDeliveryEventInput[]`.
- `class WebhookEventParser` — `constructor(custom?: Record<string, ProviderParser>)`.
- `parse(provider, payload)` → `CreateDeliveryEventInput[]`. Throws `DeliveryTrackingError(UNSUPPORTED_PROVIDER)` for unknown providers.
- `registerProvider(name, parser)` → `void` — add or replace a provider at runtime.
- `type BuiltinWebhookProvider = "sendgrid" | "mailgun" | "ses" | "generic"`.
## Implementation Notes
`asObject` and `extractString` are defensive helpers: provider payloads are often shaped by JSON unmarshalling and may contain `null` or `undefined` where strings are expected. Returning `""` instead of throwing keeps the parser tolerant of partial payloads while letting Zod boundary validation in `DeliveryTracker.track` reject any resulting empty `tenantId`/`contactId`/`messageId` downstream. The `metadata` field always carries the original payload under `raw` so callers can audit what the provider actually sent.

View File

@@ -0,0 +1,67 @@
# delivery-tracking-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`DeliveryTrackingError` is the stable typed error class raised by every code path inside `@suaveplan/delivery-tracking`. It extends `@suaveplan/error`'s `SuaveplanError` with a discriminant `code` drawn from the `DELIVERY_TRACKING_CODES` registry, giving consumers `instanceof` + code branching without inspecting the i18n-keyed `message` string.
The codes are stable across versions: each entry exists because at least one production code path can produce it (lifecycle dedupe, store I/O, token parsing, webhook routing). Consumers can build exhaustive switches over the union derived from the registry and rely on TypeScript's exhaustiveness check to flag any future addition.
## Features
- Code registry generated via `defineCodes("DELIVERY_TRACKING", […])`.
- Five stable codes: `INVALID_INPUT`, `INVALID_TOKEN`, `UNSUPPORTED_PROVIDER`, `STORE_FAILURE`, `QUERY_FAILURE`.
- Subclass of `SuaveplanError` preserving cause chains, context bags, and i18n message keys.
- Type-safe `DeliveryTrackingCode` union derived from the registry — no manual sync.
## Usage: Basic
```ts
import {
DELIVERY_TRACKING_CODES,
DeliveryTrackingError,
} from "@suaveplan/delivery-tracking/errors";
throw new DeliveryTrackingError(
DELIVERY_TRACKING_CODES.INVALID_INPUT,
"error.delivery_tracking.invalid_input",
{ context: { field: "tenantId" } }
);
```
## Usage: Advanced
```ts
import {
DELIVERY_TRACKING_CODES,
DeliveryTrackingError,
type DeliveryTrackingCode,
} from "@suaveplan/delivery-tracking/errors";
function classify(err: unknown): "client" | "server" | "other" {
if (!(err instanceof DeliveryTrackingError)) return "other";
const code: DeliveryTrackingCode = err.code;
switch (code) {
case DELIVERY_TRACKING_CODES.INVALID_INPUT:
case DELIVERY_TRACKING_CODES.INVALID_TOKEN:
case DELIVERY_TRACKING_CODES.UNSUPPORTED_PROVIDER:
return "client";
case DELIVERY_TRACKING_CODES.STORE_FAILURE:
case DELIVERY_TRACKING_CODES.QUERY_FAILURE:
return "server";
}
}
```
## API Reference
- `DELIVERY_TRACKING_CODES` — frozen record of stable codes, each value prefixed `DELIVERY_TRACKING.*`.
- `type DeliveryTrackingCode` — union of the registry's values.
- `class DeliveryTrackingError extends SuaveplanError<DeliveryTrackingCode>` — accepts `(code, messageKey, options?)` and exposes `code`, `messageKey`, `cause`, and `context` on the resulting instance.
## Implementation Notes
The registry is `defineCodes("DELIVERY_TRACKING", [...] as const)`; the `as const` is what keeps the resulting object literal-typed so the union can be derived. The class only widens the protected constructor to public and sets `this.name = "DeliveryTrackingError"` — every other behaviour (cause chains, redaction, OTel span enrichment) is inherited from `SuaveplanError`.

View File

@@ -0,0 +1,68 @@
# instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`getDeliveryTrackingInstruments` lazily constructs and caches the OTel `Counter` set used by every production code path in `@suaveplan/delivery-tracking`. Centralising the instrument names here keeps metric semantics consistent and makes the package observable via any `MeterProvider` configured by the host application.
The instruments are bound to a `Meter` named `@suaveplan/delivery-tracking`. Tests install a real `InMemoryMetricExporter` + `MeterProvider` in `tests/setup/polyfills.ts` and assert counter totals using the exporter's snapshot — no mocking is involved, satisfying CLAUDE.md rule 2 ("no mocks").
## Features
- Lazy singleton; instruments are created on first call and reused.
- `resetDeliveryTrackingInstruments()` clears the cache for test isolation.
- Five counters with stable names: `delivery_tracking.tracked.total`, `delivery_tracking.deduped.total`, `delivery_tracking.webhook_parsed.total`, `delivery_tracking.token_issued.total`, `delivery_tracking.token_rejected.total`.
- All counters carry attribute hints (event_type, channel, provider, kind) that callers should pass when incrementing.
## Usage: Basic
```ts
import { getDeliveryTrackingInstruments } from "@suaveplan/delivery-tracking/otel";
const i = getDeliveryTrackingInstruments();
i.trackedCounter.add(1, { event_type: "sent" });
```
## Usage: Advanced
```ts
import { metrics } from "@opentelemetry/api";
import {
MeterProvider,
PeriodicExportingMetricReader,
InMemoryMetricExporter,
AggregationTemporality,
} from "@opentelemetry/sdk-metrics";
import {
getDeliveryTrackingInstruments,
resetDeliveryTrackingInstruments,
} from "@suaveplan/delivery-tracking/otel";
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
metrics.setGlobalMeterProvider(
new MeterProvider({
readers: [
new PeriodicExportingMetricReader({
exporter,
exportIntervalMillis: 60_000,
}),
],
})
);
resetDeliveryTrackingInstruments();
const i = getDeliveryTrackingInstruments();
i.webhookParsedCounter.add(3, { provider: "sendgrid" });
```
## API Reference
- `getDeliveryTrackingInstruments(): DeliveryTrackingInstruments` — returns the cached set; constructs on first call.
- `resetDeliveryTrackingInstruments(): void` — clears the cache; intended for test setup.
- `interface DeliveryTrackingInstruments` — `meter`, `trackedCounter`, `dedupedCounter`, `webhookParsedCounter`, `tokenIssuedCounter`, `tokenRejectedCounter`.
## Implementation Notes
The lazy-init pattern is necessary because the global `MeterProvider` is usually set after module import. Constructing the counters at module top-level would bind them to the no-op default provider; constructing inside the accessor lets the test harness install its provider in `setupFiles` before any source module imports.

View File

@@ -0,0 +1,58 @@
# memory-store
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`memory-store` defines the `DeliveryEventStore` interface that every persistence backend must satisfy, ships the deterministic `InMemoryDeliveryEventStore` implementation used in development and tests, and exposes the pure `computeStats` helper that turns a list of events into a `DeliveryStats` rollup.
The in-memory store is intentionally simple — events are appended to an array and queried with predicate filters. It is the source of truth for the contract the SurrealDB-backed store must mirror.
## Features
- `DeliveryEventStore` interface covering record + getById/Campaign/Contact/MessageId, counts, stats, and existence.
- `InMemoryDeliveryEventStore` implementation with full query support (`eventType`, `channel`, `from`, `to`, `limit`, `offset`).
- Pure `computeStats(events)` exposed for reuse by other stores (SurrealDB, future Postgres backend).
- Zero deps — only the package's own types.
## Usage: Basic
```ts
import {
InMemoryDeliveryEventStore,
} from "@suaveplan/delivery-tracking/stores/memory";
const store = new InMemoryDeliveryEventStore();
await store.record({
id: "evt-1",
tenantId: "t1",
contactId: "u1",
channel: "email",
eventType: "sent",
messageId: "m1",
metadata: {},
timestamp: new Date(),
});
const stats = await store.getStatsByCampaign("c1");
```
## Usage: Advanced
```ts
import { computeStats } from "@suaveplan/delivery-tracking/stores/memory";
const stats = computeStats(myEvents);
console.log(stats.deliveryRate, stats.bounceRate);
```
## API Reference
- `interface DeliveryEventStore` — `record`, `getById`, `getByCampaign`, `getByContact`, `getByMessageId`, `countByType`, `getStatsByCampaign`, `exists`.
- `class InMemoryDeliveryEventStore implements DeliveryEventStore` — array-backed reference implementation.
- `function computeStats(events: DeliveryEvent[]): DeliveryStats` — counts every event type and computes percentage rates with safe zero-division handling.
## Implementation Notes
Rates are returned as percentages rounded to two decimals via `Number((n/d*100).toFixed(2))`. When the denominator is zero the rate is `0` (not `NaN`). The store's `applyQuery` helper supports paging via `offset` + `limit` and applies all four query filters as `Array.filter` chains — performance is O(n) per query, which is acceptable for in-memory usage but is one of the reasons the SurrealDB backend exists for production loads.

View File

@@ -0,0 +1,71 @@
# surreal-store
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`SurrealDeliveryEventStore` is the SurrealDB-backed implementation of the `DeliveryEventStore` contract. It uses positional SurQL via `SurrealAdapter.query(sql, params)` — no typed-CRUD wrappers — so the SQL is auditable, the parameter binding is explicit, and adapter exceptions are translated into stable `DeliveryTrackingError` codes (`STORE_FAILURE` for writes, `QUERY_FAILURE` for reads).
The schema mapping is intentionally direct: SurrealDB rows use snake_case (`message_id`, `event_type`, `campaign_id`, …) and `rowToEvent` rehydrates them into the camelCase domain shape. The aggregation rollup is computed in-process via `computeStats`, keeping the SurrealDB driver free of percentage arithmetic.
## Features
- Positional SurQL queries; no driver-specific typed CRUD.
- All errors wrapped in `DeliveryTrackingError(STORE_FAILURE | QUERY_FAILURE)` with the originating cause preserved.
- Customisable table name via the second constructor argument.
- Snake-case row mapping with `Date` rehydration from string timestamps.
- `getByCampaign`/`getByContact` accept filters + paging that are threaded into the SQL (`LIMIT`, `START`).
## Usage: Basic
```ts
import { SurrealAdapter } from "@suaveplan/surrealdb";
import { SurrealDeliveryEventStore } from "@suaveplan/delivery-tracking/stores/surreal";
const adapter = new SurrealAdapter({
url: "ws://localhost:8000",
namespace: "app",
database: "main",
username: "root",
password: "root",
});
await adapter.connect();
const store = new SurrealDeliveryEventStore(adapter);
const events = await store.getByCampaign("c1", { limit: 50 });
```
## Usage: Advanced
```ts
import {
DELIVERY_TRACKING_CODES,
DeliveryTrackingError,
SurrealDeliveryEventStore,
} from "@suaveplan/delivery-tracking";
const store = new SurrealDeliveryEventStore(adapter, "tenant_a_events");
try {
await store.record(event);
} catch (e) {
if (
e instanceof DeliveryTrackingError &&
e.code === DELIVERY_TRACKING_CODES.STORE_FAILURE
) {
logger.warn("delivery store unavailable", { cause: e.cause });
} else {
throw e;
}
}
```
## API Reference
- `class SurrealDeliveryEventStore implements DeliveryEventStore` — `constructor(db, table = "delivery_events")`.
- `record(event)` → `Promise<void>` — INSERTs the event; throws `DeliveryTrackingError(STORE_FAILURE)` on adapter exception.
- `getById`, `getByCampaign`, `getByContact`, `getByMessageId`, `countByType`, `getStatsByCampaign`, `exists` — every read wraps adapter failures in `DeliveryTrackingError(QUERY_FAILURE)`.
## Implementation Notes
`firstResult<T>()` collapses the SurQL multi-statement result envelope to the first OK statement's rows; non-OK statuses and null results return an empty array so callers never need to inspect raw driver output. Optional fields (`campaignId`, `ip`, `userAgent`) are persisted as `null` rather than omitted so `SELECT *` returns a stable schema. The `GROUP ALL` aggregation in `countByType`/`exists` is intentional — it forces SurrealDB to return a single bucket regardless of row count.