Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions docs/adapters/error-tracking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Error-Tracking Adapter

## Interface

Source of truth: [`packages/core/src/contracts/adapters/error-tracking.ts`](../../packages/core/src/contracts/adapters/error-tracking.ts) - `ErrorTrackingAdapter`, `ErrorContext`, and the `ERROR_TRACKING` token.

`captureException` is fire-and-forget (returns `void`) - a reporter failure never breaks the
path that reported the error. Core names **no vendor**: the port is the only seam. Sentry,
PostHog, Rollbar, etc. are consumer overlays.

## How reporting flows (no per-call-site capture)

Core does not call `captureException` at each failure site. Instead the logger is the bridge:
every `logger.error({ err, ... }, 'message')` is mirrored to the bound tracker. So one log call
both logs and reports, and **all** error logs are covered - the oRPC unhandled-error
interceptor, EventBus subscriber failures, OutboxRelay drops, and anything else that logs an
error with an `err` field. Error logs without an `err` (validation notices) are not reported.

Every unhandled error is caught at one of three global seams - never per-call-site in a module:

- **oRPC `onError` interceptor** - every route/service error; `handler.handle` maps it to a
response, and non-`ORPCError`s are logged (expected `ORPCError`s are not reported).
- **Hono `app.onError`** - anything thrown in the middleware chain before oRPC runs (session
resolution, raw-body capture, etag/cache); returns a 500 and reports non-`HTTPException` errors.
- **EventBus / OutboxRelay** - async subscriber and outbox-drain failures.

A module never writes tracker-specific code: it throws a typed error (caught by the interceptor)
or logs with `logger.error({ err })`. To opt an expected/noisy error out of forwarding - a caught
third-party timeout you log-and-retry - pass `report: false`: `logger.error({ err, report: false },
'psp timeout, retrying')` is still logged, just not sent to the tracker (so it doesn't burn quota).

Wiring, in `createApp`, after plugins load:

```ts
if (container.has(ERROR_TRACKING)) {
const tracker = container.get(ERROR_TRACKING);
setErrorSink((error, context) => tracker.captureException(error, context));
}
```

No overlay binds `ERROR_TRACKING` -> no sink -> error logs stay logs-only. The engine imports
no vendor SDK and has no `SENTRY_DSN`/vendor env of its own.

The forwarded `ErrorContext` carries `userId`/`traceId` from the active request context plus the
log's structured fields (event topic, path, message) as `extra`.

## Bind a vendor (consumer overlay)

Real vendor adapters live in the consumer, bound via a self-disabling overlay plugin - the same
pattern as the payment/KYC/realtime vendor overlays. Ship an `ErrorTrackingAdapter` impl and
provide it; loading the overlay last makes its binding win.

```ts
// extensions/sentry/plugin.ts (consumer)
import { ERROR_TRACKING } from '@openora/core/contracts';
import { definePlugin } from '@openora/core/server';
import * as Sentry from '@sentry/node';

export default definePlugin({
id: 'sentry',
register(ctx) {
const dsn = process.env['SENTRY_DSN'];
if (!dsn) {
return; // self-disabling: no DSN -> platform stays logs-only
}
Sentry.init({ dsn });
ctx.provide(ERROR_TRACKING, () => ({
captureException(error, context) {
Sentry.captureException(error, {
...(context?.userId ? { user: { id: context.userId } } : {}),
tags: { ...context?.tags, ...(context?.traceId ? { trace_id: context.traceId } : {}) },
...(context?.extra ? { extra: context.extra } : {}),
});
},
}));
},
});
```

Register it last in `extensions.config.ts` (a `kind: 'infra'` overlay). A PostHog/Rollbar shop
writes the equivalent overlay against the same port - core is unchanged.
51 changes: 3 additions & 48 deletions docs/adapters/kyc.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,58 +2,13 @@

## Interface

```ts
// packages/core/src/contracts/adapters/kyc.ts
export type KycAdapter = {
// True when the adapter rubber-stamps every submission (the default MockKycAdapter).
// A boot guard refuses/warns if withdrawals are KYC-gated while this is bound, so an
// operator can't enable the gate yet leave verification a no-op. Real providers omit it.
readonly autoApproves?: boolean;
submit(userId: string, documents: KycDocument[]): Promise<KycResult>;
getStatus(userId: string): Promise<KycVendorStatus>;
// Provider-specific normalization of a raw webhook into a vendor decision, or null when
// the body is not a reconcilable decision. Optional - MockKycAdapter omits it.
parseWebhook?(
rawBody: string,
headers: Record<string, string | string[] | undefined>,
): KycResult | null;
};

export type KycDocument = {
type: 'passport' | 'drivers_license' | 'national_id';
frontUrl: string;
backUrl?: string;
};

export type KycVendorStatus = 'pending' | 'approved' | 'rejected' | 'not_started';

export type KycResult = {
referenceId: string;
status: KycVendorStatus;
// Hosted verification URL to redirect the end user to, for vendors whose flow collects
// documents on their own hosted page rather than accepting them from our backend.
// Omitted by document-forwarding vendors (eg SumSub, MockKycAdapter).
verificationUrl?: string;
};

export const KYC_ADAPTER: Token<KycAdapter> = createToken('KYC_ADAPTER');
```
Source of truth: [`packages/core/src/contracts/adapters/kyc.ts`](../../packages/core/src/contracts/adapters/kyc.ts) - `KycAdapter`, `KycDocument`, `KycVendorStatus`, `KycResult`, and the `KYC_ADAPTER` token.

`KYC_ADAPTER` is a `Token<T>` created via `createToken` (`packages/core/src/contracts/adapters/token.ts`),
not a bare `Symbol` - the `Container` uses it to type both `provide` and `get` calls.
Two fields drive behavior beyond their types: `KycAdapter.autoApproves` marks a rubber-stamp adapter (the default `MockKycAdapter`) - a boot guard refuses/warns if withdrawals are KYC-gated while it's bound, so the gate can't be on with verification a no-op; real providers omit it. `KycResult.verificationUrl` is set only by hosted-session vendors whose own page collects documents (see recipe 2), and omitted by document-forwarding vendors.

## Webhook verifier

A second port authenticates the vendor's async webhook before `KycAdapter.parseWebhook`
ever sees the body:

```ts
export type KycWebhookVerifier = {
verify(rawBody: string, headers: Record<string, string | string[] | undefined>): boolean;
};

export const KYC_WEBHOOK_VERIFIER: Token<KycWebhookVerifier> = createToken('KYC_WEBHOOK_VERIFIER');
```
A second port, `KycWebhookVerifier` + the `KYC_WEBHOOK_VERIFIER` token (same source file), authenticates the vendor's async webhook before `KycAdapter.parseWebhook` ever sees the body.

`verify` takes the full headers map, not a single pre-extracted value - the signing header
name is vendor-specific (SumSub, a hosted-session vendor, and any future provider each name
Expand Down
9 changes: 1 addition & 8 deletions docs/adapters/notification.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,7 @@

## Interface

```ts
// packages/core/src/contracts/adapters/src/notification.ts
export interface NotificationDeliveryAdapter {
sendEmail(to: string, subject: string, body: string): Promise<void>;
}

export const NOTIFICATION_DELIVERY_ADAPTER = Symbol('NOTIFICATION_DELIVERY_ADAPTER');
```
Source of truth: [`packages/core/src/contracts/adapters/notification.ts`](../../packages/core/src/contracts/adapters/notification.ts) - `NotificationDeliveryAdapter` and the `NOTIFICATION_DELIVERY_ADAPTER` token.

## Default binding

Expand Down
57 changes: 3 additions & 54 deletions docs/adapters/payment.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,60 +2,9 @@

## Interface

```ts
// packages/core/src/contracts/adapters/payment.ts
import { createToken, type Token } from './token.js';

export type PaymentAdapter = {
processDeposit(
amount: string,
currency: string,
metadata: Record<string, unknown>,
): Promise<{ externalId: string; status: string }>;

processWithdrawal(
amount: string,
currency: string,
metadata: Record<string, unknown>,
): Promise<{ externalId: string; status: string }>;

// Address-based deposit vendors only (eg a custody rail). Omit entirely for a
// synchronous PSP.
issueDepositAddress?(userId: string, currency: string): Promise<{ address: string }>;

// Async vendors only. Normalizes a raw webhook body into a reconcilable event.
parseWebhook?(
rawBody: string,
headers: Record<string, string | string[] | undefined>,
): PaymentWebhookEvent | null;
};

export type PaymentWebhookEvent =
| {
kind: 'deposit';
address: string;
amount: string;
currency: string;
txHash: string;
externalId: string;
}
| {
kind: 'withdrawal';
externalId: string;
status: 'processing' | 'completed' | 'failed';
txHash?: string;
};

export const PAYMENT_ADAPTER: Token<PaymentAdapter> = createToken('PAYMENT_ADAPTER');

export type PaymentWebhookVerifier = {
verify(rawBody: string, headers: Record<string, string | string[] | undefined>): boolean;
};

export const PAYMENT_WEBHOOK_VERIFIER: Token<PaymentWebhookVerifier> = createToken(
'PAYMENT_WEBHOOK_VERIFIER',
);
```
Source of truth: [`packages/core/src/contracts/adapters/payment.ts`](../../packages/core/src/contracts/adapters/payment.ts) - `PaymentAdapter`, `PaymentWebhookEvent`, `PaymentWebhookVerifier`, and the `PAYMENT_ADAPTER` / `PAYMENT_WEBHOOK_VERIFIER` tokens.

`issueDepositAddress` and `parseWebhook` are optional - present only on address-based/async vendors, omitted by a synchronous PSP (see the two vendor shapes below).

## Default binding

Expand Down
9 changes: 9 additions & 0 deletions docs/catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,15 @@
"packages/core/src/pam/identity/plugin.ts"
]
},
{
"category": "error-tracking",
"interface": "ErrorTrackingAdapter",
"token": "ERROR_TRACKING",
"status": "wired",
"boundIn": [
"packages/core/src/server/runtime/create-app.ts"
]
},
{
"category": "game",
"interface": "GameAdapter",
Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/contracts/adapters/error-tracking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Error-tracking seam. Core names no vendor; a consumer binds one (Sentry, PostHog,
// Rollbar, ...) via an overlay plugin. Bound -> every error-level log carrying an
// `err` is reported (forwarded through the logger); unbound -> logs stay logs-only.
import { createToken, type Token } from './token.js';

export type ErrorContext = {
userId?: string;
traceId?: string;
tags?: Record<string, string>;
extra?: Record<string, unknown>;
};

// Fire-and-forget - a reporter failure must never break the path that called it.
export type ErrorTrackingAdapter = {
captureException(error: unknown, context?: ErrorContext): void;
};

export const ERROR_TRACKING: Token<ErrorTrackingAdapter> =
createToken<ErrorTrackingAdapter>('ERROR_TRACKING');
3 changes: 3 additions & 0 deletions packages/core/src/contracts/adapters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ export type {
} from './email-template.js';
export { EMAIL_TEMPLATE_RENDERER, DEFAULT_EMAIL_TEMPLATES } from './email-template.js';

export type { ErrorTrackingAdapter, ErrorContext } from './error-tracking.js';
export { ERROR_TRACKING } from './error-tracking.js';

export type { AdminGrant, AdminPermissionResolver } from './admin-permission.js';
export { ADMIN_PERMISSION_RESOLVER } from './admin-permission.js';

Expand Down
62 changes: 62 additions & 0 deletions packages/core/src/server/kernel/__tests__/logger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { createLogger } from '../logger.js';
import { setErrorSink } from '../error-sink.js';

afterEach(() => {
setErrorSink(undefined);
});

describe('createLogger error-sink hook', () => {
it('forwards an error-level log carrying `err` to the bound sink', () => {
const sink = vi.fn();
setErrorSink(sink);

const err = new Error('boom');
createLogger('test').error({ err, event: 'x' }, 'something failed');

expect(sink).toHaveBeenCalledTimes(1);
expect(sink).toHaveBeenCalledWith(
err,
expect.objectContaining({ extra: { event: 'x', message: 'something failed' } }),
);
});

it('does not report error logs without an `err`', () => {
const sink = vi.fn();
setErrorSink(sink);

createLogger('test').error({ issues: [] }, 'validation failed');

expect(sink).not.toHaveBeenCalled();
});

it('does not report an error log opted out with `report: false`', () => {
const sink = vi.fn();
setErrorSink(sink);

createLogger('test').error({ err: new Error('boom'), report: false }, 'expected, retrying');

expect(sink).not.toHaveBeenCalled();
});

it('does not report non-error levels', () => {
const sink = vi.fn();
setErrorSink(sink);

createLogger('test').warn({ err: new Error('x') }, 'a warning');

expect(sink).not.toHaveBeenCalled();
});

it('swallows sink failures', () => {
setErrorSink(() => {
throw new Error('sink failed');
});

expect(() => createLogger('test').error({ err: new Error('x') }, 'sink failure')).not.toThrow();
});

it('is inert when no sink is bound', () => {
expect(() => createLogger('test').error({ err: new Error('x') }, 'no sink')).not.toThrow();
});
});
23 changes: 23 additions & 0 deletions packages/core/src/server/kernel/error-sink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { ErrorContext } from '@openora/core/contracts';

// The process-wide bridge from the logger to a bound ERROR_TRACKING adapter;
// createApp sets it once after plugins load, when an overlay bound the port.
export type ErrorSink = (error: unknown, context?: ErrorContext) => void;

// ponytail: module-global, single sink. Fine for the single-process, single-tenant
// runtime (ADR-0026); if the engine ever runs multiple isolated apps in one process,
// move this onto the container.
let sink: ErrorSink | undefined;

export function setErrorSink(next: ErrorSink | undefined): void {
sink = next;
}

export function reportError(error: unknown, context?: ErrorContext): void {
// Fire-and-forget: a reporter failure must never break the log call that drove it.
try {
sink?.(error, context);
} catch {
/* swallow - reporting is best-effort */
}
}
3 changes: 3 additions & 0 deletions packages/core/src/server/kernel/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ export type { Factory } from './container.js';

export { createLogger } from './logger.js';

export { setErrorSink, reportError } from './error-sink.js';
export type { ErrorSink } from './error-sink.js';

export { getUserId } from './router-utils.js';
export type { OssContext, AuthContext, NodeHeaders } from './router-utils.js';
export {
Expand Down
Loading
Loading