cc-os/plugins/os-context/eval/fixture/project/docs/handler-spec.md

171 lines
9.7 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Per-service delivery handlers
Relaystation currently delivers every event through the single generic path in
`src/relay.js` (`deliver` → `fetchLike`, with failures handed to `src/retry.js`).
That path treats every downstream service identically: same payload shape, same
retry policy, same failure handling. In practice each of the nine downstream
integrations in `services/` has different tolerance for retries, different
payload requirements, and different failure semantics, and three cross-cutting
concerns (auth concurrency, per-service latency visibility, and dead-letter
handling) need their own dedicated modules.
This spec defines twelve new handler modules under `src/handlers/`. Each module
is self-contained and does not require changes to `src/relay.js`, `src/router.js`,
`src/retry.js`, or any file under `services/` — implement each file to the
interface and behavior below.
## Conventions
- `'use strict';` at the top, matching the rest of `src/`.
- Each module exports a plain object via `module.exports = { ... }` (same style
as `src/metrics.js` / `src/store.js`), not a class.
- Where a handler needs the shared metrics counters, `require('../metrics')`
and call `increment(name)` — do not create a second counter store.
- Each file should be small: roughly 2040 lines including requires and
`module.exports`.
- No new npm dependencies. Use only Node core modules already used elsewhere
in `src/` (`crypto`, `fs`, `path`, `http`) plus what a handler needs.
- Tests are not required for this pass — implementation only.
## Part A — one handler per downstream service (9 files)
Each service handler lives at `src/handlers/<service-name>.js` and exports two
functions:
```js
module.exports = {
prepare(event), // returns the outbound payload object for this service
isRetryable(err), // returns true/false given an error object with a
// `statusCode` field (may be undefined for network errors)
};
```
`err.statusCode` is `undefined` for a connection/network failure (no response
received) and an HTTP status integer when a response was received but was not
2xx. Implement `prepare` and `isRetryable` exactly per the per-service rules
below — the rules differ enough between services that no single template
satisfies more than one of them.
| Service | `prepare(event)` payload rule | `isRetryable(err)` rule |
| --- | --- | --- |
| `analytics` | Wrap the event: return `{ batch: [event], emitted_at: Date.now() }`. | Retry when `statusCode` is undefined (network) or `>= 500`. Never retry on any 4xx. |
| `audit` | Return the event plus an integrity field: `{ ...event, hash: sha256-hex of JSON.stringify(event) }` (use `crypto.createHash('sha256')`). | Retry **only** on a network failure (`statusCode === undefined`). Any received HTTP status, 4xx or 5xx, is not retryable — audit delivery failures must surface immediately rather than silently retry. |
| `billing` | Return a payload containing **only** an allowlist of fields copied from `event`, dropping everything else: `id`, `topic`, `amount`, `currency`, `receivedAt`. Fields absent on the event are simply omitted from the output, not set to `null`. | Retry only on `statusCode === 429` or `statusCode === 503`. Nothing else (not network failures, not other 5xx) is retryable. |
| `crm` | Return a shallow-transformed payload: rename `topic` to `event_type`, and if `event.payload` has a nested `contact` object, flatten it to top-level `contact_id` and `contact_email` fields (copied from `event.payload.contact.id` / `.email` if present) instead of nesting it. | Retry when `statusCode` is undefined or `>= 500`, same as `analytics` — but cap distinctly (see Part B note below; the cap itself lives in the retry loop, not in this predicate). |
| `email` | If `event.payload` is missing a `to` field, do not build a payload at all — call `metrics.increment('handlers.email.invalid')` and return `null` (the caller is expected to skip sending when `prepare` returns `null`). Otherwise return the event unchanged. | Retry when `statusCode` is undefined, `429`, or `>= 500`. |
| `reports` | Collapse the event into a single-field summary payload: `{ summary: \`${event.topic} at ${event.receivedAt}\` }` (use the actual template literal). | Never retryable — always return `false`, regardless of `err`. Reports tolerates loss; a single attempt is sufficient. |
| `search` | Return the event plus an `index_hint` field computed as the substring of `event.topic` before its first `.` (or the whole topic if there is no `.`). | Retry when `statusCode` is undefined or `>= 500` (same predicate shape as `analytics`/`crm`, but see Part B — `search` gets the longest retry budget of any service). |
| `sms` | Return the event with `payload.message` truncated to 160 characters if present and longer than that (leave other fields untouched). | Retry when `statusCode` is undefined or `statusCode >= 500`, but explicitly **not** on timeouts represented as `err.code === 'ETIMEDOUT'` — treat a timeout as non-retryable (carrier cost control), even though it has no `statusCode`. |
| `webhooks` | Return the event unchanged, with one added field: `relay_version` read from `package.json`'s `version` field (`require('../../package.json').version`). | Retry on any non-2xx status, i.e. `statusCode === undefined || statusCode < 200 || statusCode >= 300`. |
Note on retry *counts*: `isRetryable` only decides whether a given failure is a
candidate for another attempt at all — it does not encode the attempt cap.
Attempt caps are documented per-service in Part C for reference by any future
caller; `src/handlers/*.js` files themselves do not need to enforce the cap
(that remains `src/retry.js`'s job when it is later wired up — not part of
this pass).
## Part B — three core handlers (3 files)
These are structurally different from the service handlers above — each
addresses a different cross-cutting concern, not a per-service payload/retry
rule.
### `src/handlers/auth.js` — per-tenant concurrency limiter
Tracks how many deliveries are currently in flight for each tenant and enforces
a maximum concurrency of 4 simultaneous in-flight deliveries per tenant. Export:
```js
module.exports = {
acquire(tenant), // returns true if the tenant is under its concurrency cap
// (and increments its in-flight count), false if the
// tenant is already at the cap (does not increment)
release(tenant), // decrements the tenant's in-flight count, floored at 0
inFlight(tenant), // returns the current in-flight count for a tenant (0 if unseen)
};
```
Keep the per-tenant counts in a plain object keyed by tenant name, module-scoped
(same pattern as `tokenCache` in `src/auth.js` or `counters` in
`src/metrics.js`). The concurrency cap (4) should be a named constant at the
top of the file.
### `src/handlers/metrics.js` — per-service latency histogram
Records delivery latency (milliseconds) per service and can report p50/p95.
Export:
```js
module.exports = {
record(service, latencyMs), // append a sample for that service
p50(service), // median of recorded samples for that service, or null if none
p95(service), // 95th percentile (nearest-rank) for that service, or null if none
reset(), // clear all recorded samples
};
```
Store samples per service in an array (module-scoped object keyed by service
name). Percentile implementation: sort the samples ascending, then for `p95`
take the sample at index `Math.ceil(0.95 * n) - 1` (nearest-rank method,
clamped to a valid index); `p50` uses `0.5` the same way. This is intentionally
a different aggregation shape than the simple integer counters in
`src/metrics.js` — do not just wrap the existing counters module.
### `src/handlers/store.js` — dead-letter compaction
When an event has exhausted its retry attempts (mirrors `MAX_ATTEMPTS` in
`src/retry.js`, currently 8) instead of being silently dropped it should be
appended to a dead-letter file for manual review, and the in-memory dead-letter
list should be periodically compacted to drop entries older than 7 days. Export:
```js
module.exports = {
deadLetter(target, event, attempts), // appends { target, event, attempts, at: Date.now() }
// to data/deadletter.log (JSON line, same
// append-on-write pattern as src/store.js's
// `append`) AND to an in-memory list
compact(now), // removes in-memory entries older than 7 days (7 * 24 * 60 * 60 * 1000 ms)
// relative to `now` (defaults to Date.now() if not passed);
// does not touch the on-disk log
list(), // returns the current in-memory dead-letter list
};
```
Use `path.join(__dirname, '..', '..', 'data', 'deadletter.log')` for the file
path (mirrors `DATA_DIR`/`LOG_FILE` in `src/store.js`), and create the `data/`
directory if it doesn't exist before appending, same as `src/store.js` does.
## Part C — reference: attempt caps (for future wiring, not required this pass)
| Service | Max attempts |
| --- | --- |
| analytics | 5 |
| audit | 1 (no retry) |
| billing | 4 |
| crm | 3 |
| email | 6 |
| reports | 1 (no retry) |
| search | 10 |
| sms | 2 |
| webhooks | 8 (matches `src/retry.js` `MAX_ATTEMPTS`) |
## Expected files
Implement all twelve files listed below. Each is independent of the other
eleven — none of the twelve requires reading or importing another file in this
list.
- `src/handlers/analytics.js`
- `src/handlers/audit.js`
- `src/handlers/billing.js`
- `src/handlers/crm.js`
- `src/handlers/email.js`
- `src/handlers/reports.js`
- `src/handlers/search.js`
- `src/handlers/sms.js`
- `src/handlers/webhooks.js`
- `src/handlers/auth.js`
- `src/handlers/metrics.js`
- `src/handlers/store.js`