From 3adbbb5de2466231db3c560c57eae847e4608fc3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 4 Sep 2026 06:04:05 +0000 Subject: [PATCH 1/3] Add production connectors, finance filters/pagination, observability Co-authored-by: charles2ke <6725706+charles2ke@users.noreply.github.com> --- README.md | 62 ++++++- src/config/finance.js | 11 ++ src/connectors/httpClient.js | 116 +++++++++++++ src/connectors/index.js | 50 ++++++ src/connectors/opentrading/httpConnector.js | 18 ++ src/connectors/opentrading/mockConnector.js | 1 + .../portfolio-watcher/httpConnector.js | 13 ++ .../portfolio-watcher/mockConnector.js | 1 + src/connectors/tax-break/httpConnector.js | 13 ++ src/connectors/tax-break/mockConnector.js | 1 + src/domain/finance.js | 60 ++++++- src/index.js | 24 ++- src/observability/apolloPlugin.js | 39 +++++ src/observability/logger.js | 44 +++++ src/observability/metrics.js | 88 ++++++++++ src/schema.js | 55 +++++- src/server.js | 9 +- src/services/financeService.js | 157 ++++++++++++------ test/connectors.test.js | 143 ++++++++++++++++ test/finance.test.js | 60 +++++++ test/graphql.test.js | 42 +++++ test/observability.test.js | 94 +++++++++++ 22 files changed, 1032 insertions(+), 69 deletions(-) create mode 100644 src/connectors/httpClient.js create mode 100644 src/connectors/index.js create mode 100644 src/connectors/opentrading/httpConnector.js create mode 100644 src/connectors/portfolio-watcher/httpConnector.js create mode 100644 src/connectors/tax-break/httpConnector.js create mode 100644 src/observability/apolloPlugin.js create mode 100644 src/observability/logger.js create mode 100644 src/observability/metrics.js create mode 100644 test/connectors.test.js create mode 100644 test/observability.test.js diff --git a/README.md b/README.md index 62ab158..69bbb89 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,9 @@ The server listens on port `4000` by default (override with the `PORT` environment variable): - GraphQL endpoint: -- Health check: +- Liveness check: +- Readiness check (per-upstream): +- Metrics (Prometheus text): Opening the GraphQL endpoint in a browser loads the Apollo Sandbox, where you can explore the schema and run the operations below. @@ -131,6 +133,17 @@ The running server loads connector settings from the environment via | `TAX_BREAK_ENDPOINT` | tax-break endpoint placeholder | `mock://tax-break` | | `TAX_BREAK_API_KEY` | tax-break credential placeholder | empty | | `FINANCE_CACHE_TTL_MS` | Minimal resolver cache TTL | `1000` | +| `FINANCE_HTTP_TIMEOUT_MS` | Per-request upstream timeout | `5000` | +| `FINANCE_HTTP_MAX_RETRIES` | Retries for timeouts, 429s, and 5xx responses | `2` | +| `FINANCE_DEFAULT_PAGE_SIZE` | Default page size when `limit` is omitted | `25` | +| `FINANCE_MAX_PAGE_SIZE` | Upper bound applied to any requested `limit` | `100` | +| `LOG_LEVEL` | Structured log level (`debug`/`info`/`warn`/`error`) | `info` | + +Each connector endpoint that is **not** a `mock://` URL is served by the +production HTTP client in `src/connectors/httpClient.js`, which adds bearer +authentication, request timeouts, bounded retries with exponential backoff, and +per-call metrics. Mock adapters remain the default so the service still runs +from a clean checkout. Do not commit real credentials. Production connectors should read credentials from environment variables or a secret manager and keep the same method names as @@ -202,11 +215,48 @@ npm start npm test ``` +### Filtering and pagination + +Finance queries accept optional filters and offset pagination: + +- `portfolioOverview(accountId, from, to, limit, offset)` — `from`/`to` bound + performance snapshots (inclusive ISO-8601); `limit`/`offset` page positions. +- `tradeHistory(accountId, symbol, side, status, from, to, limit, offset)` — + filters trades and orders, then pages them. Returned tax events always match + the trades on the current page. +- `taxEstimate(taxYear, accountId, symbol, from, to, limit, offset)` — totals are + always computed over every matching event; `limit`/`offset` only page the + returned `events`. + +Every finance payload includes `pageInfo { totalCount limit offset hasNextPage +hasPreviousPage }`. Requested limits are clamped to `FINANCE_MAX_PAGE_SIZE`. + +```graphql +query RecentSells { + tradeHistory(side: "SELL", from: "2026-01-01T00:00:00.000Z", limit: 10) { + trades { id symbol quantity price executedAt } + pageInfo { totalCount hasNextPage } + } +} +``` + +### Observability + +- **Structured logs**: JSON lines from `src/observability/logger.js`, with + credential-like fields redacted. One line per GraphQL operation includes the + operation name, duration, outcome, and error codes. +- **Metrics**: `src/observability/metrics.js` records GraphQL operation + counts/latency, connector call counts/latency per source and operation, + upstream retry failures, and cache hit/miss/coalesced counters. Scrape them at + `GET /metrics`. +- **Health**: `GET /health` is a liveness probe; `GET /ready` calls each + connector's health check and returns `503` when any upstream is degraded. + Follow-up production tasks: -- Replace mock connectors with authenticated clients for each upstream domain. -- Add pagination/date filters once live trade and snapshot volumes grow. - Add persisted caching/batching if upstream latency becomes significant. +- Move from offset pagination to cursor pagination if upstream APIs expose + stable cursors. ## API @@ -216,9 +266,9 @@ Follow-up production tasks: | `user(id: ID!)` | Fetch a single user, `null` when unknown | | `posts` | List all posts | | `post(id: ID!)` | Fetch a single post, `null` when unknown | -| `portfolioOverview(accountId)` | Fetch finance accounts, positions, snapshots, and P/L | -| `tradeHistory(accountId, symbol)` | Fetch trades/orders enriched with tax events | -| `taxEstimate(taxYear, accountId)` | Estimate tax from tax-relevant trading activity | +| `portfolioOverview(accountId, from, to, limit, offset)` | Fetch finance accounts, positions, snapshots, and P/L | +| `tradeHistory(accountId, symbol, side, status, from, to, limit, offset)` | Fetch trades/orders enriched with tax events | +| `taxEstimate(taxYear, accountId, symbol, from, to, limit, offset)` | Estimate tax from tax-relevant trading activity | | `createUser(name, email)` | Create a user | | `createPost(title, content, authorId)` | Create a post for an existing user | diff --git a/src/config/finance.js b/src/config/finance.js index bb1f8b1..e141ebb 100644 --- a/src/config/finance.js +++ b/src/config/finance.js @@ -1,18 +1,29 @@ /** Environment-driven configuration for finance-cluster connectors. */ export function loadFinanceConfig(env = process.env) { + const timeoutMs = Number(env.FINANCE_HTTP_TIMEOUT_MS ?? 5000); + const maxRetries = Number(env.FINANCE_HTTP_MAX_RETRIES ?? 2); + return { openTrading: { endpoint: env.OPENTRADING_ENDPOINT ?? 'mock://opentrading', apiKey: env.OPENTRADING_API_KEY ?? '', + timeoutMs, + maxRetries, }, portfolioWatcher: { endpoint: env.PORTFOLIO_WATCHER_ENDPOINT ?? 'mock://portfolio-watcher', apiKey: env.PORTFOLIO_WATCHER_API_KEY ?? '', + timeoutMs, + maxRetries, }, taxBreak: { endpoint: env.TAX_BREAK_ENDPOINT ?? 'mock://tax-break', apiKey: env.TAX_BREAK_API_KEY ?? '', + timeoutMs, + maxRetries, }, cacheTtlMs: Number(env.FINANCE_CACHE_TTL_MS ?? 1000), + defaultPageSize: Number(env.FINANCE_DEFAULT_PAGE_SIZE ?? 25), + maxPageSize: Number(env.FINANCE_MAX_PAGE_SIZE ?? 100), }; } diff --git a/src/connectors/httpClient.js b/src/connectors/httpClient.js new file mode 100644 index 0000000..ef30f8e --- /dev/null +++ b/src/connectors/httpClient.js @@ -0,0 +1,116 @@ +import { logger as defaultLogger } from '../observability/logger.js'; +import { metrics as defaultMetrics } from '../observability/metrics.js'; + +/** Error carrying the upstream source and HTTP status for actionable messages. */ +export class UpstreamHttpError extends Error { + constructor(source, message, { status = null, retryable = false } = {}) { + super(message); + this.name = 'UpstreamHttpError'; + this.source = source; + this.status = status; + this.retryable = retryable; + } +} + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** A 5xx or 429 response is worth retrying; client errors are not. */ +function isRetryableStatus(status) { + return status === 429 || status >= 500; +} + +/** + * Shared HTTP client for production connectors. + * + * Adds the pieces a real upstream call needs but a mock does not: bearer + * authentication, request timeouts, bounded retries with backoff, structured + * logging, and latency/outcome metrics. + */ +export function createHttpClient({ + source, + endpoint, + apiKey = '', + timeoutMs = 5000, + maxRetries = 2, + retryBackoffMs = 100, + fetchImpl = globalThis.fetch, + logger = defaultLogger, + metrics = defaultMetrics, +} = {}) { + if (!endpoint) throw new Error(`${source} connector requires an endpoint`); + if (typeof fetchImpl !== 'function') throw new Error(`${source} connector requires a fetch implementation`); + + const baseUrl = endpoint.endsWith('/') ? endpoint.slice(0, -1) : endpoint; + + async function once(path, { method = 'GET', body, signal } = {}) { + const headers = { accept: 'application/json' }; + if (apiKey) headers.authorization = ['Bearer', apiKey].join(' '); + if (body !== undefined) headers['content-type'] = 'application/json'; + + const response = await fetchImpl(`${baseUrl}${path}`, { + method, + headers, + signal, + body: body === undefined ? undefined : JSON.stringify(body), + }); + + if (!response.ok) { + throw new UpstreamHttpError(source, `HTTP ${response.status} from ${path}`, { + status: response.status, + retryable: isRetryableStatus(response.status), + }); + } + + return response.json(); + } + + async function request(path, options = {}) { + return metrics.time('finance_upstream_request', { source, path }, async () => { + let lastError; + + for (let attempt = 0; attempt <= maxRetries; attempt += 1) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + return await once(path, { ...options, signal: controller.signal }); + } catch (error) { + lastError = error instanceof UpstreamHttpError + ? error + : new UpstreamHttpError(source, error?.name === 'AbortError' ? `request to ${path} timed out after ${timeoutMs}ms` : String(error?.message ?? error), { retryable: true }); + + metrics.increment('finance_upstream_attempt_failures_total', { source, path }); + logger.warn('upstream request failed', { + source, + path, + attempt: attempt + 1, + status: lastError.status, + error: lastError.message, + }); + + if (!lastError.retryable || attempt === maxRetries) break; + await sleep(retryBackoffMs * 2 ** attempt); + } finally { + clearTimeout(timer); + } + } + + throw lastError; + }); + } + + return { + source, + endpoint: baseUrl, + request, + /** Lightweight readiness probe used by the /health endpoint. */ + async health() { + try { + await request('/health'); + return { source, status: 'ok', endpoint: baseUrl }; + } catch (error) { + return { source, status: 'degraded', endpoint: baseUrl, error: error.message }; + } + }, + }; +} diff --git a/src/connectors/index.js b/src/connectors/index.js new file mode 100644 index 0000000..b5d12e1 --- /dev/null +++ b/src/connectors/index.js @@ -0,0 +1,50 @@ +import { loadFinanceConfig } from '../config/finance.js'; +import { logger as defaultLogger } from '../observability/logger.js'; +import { metrics as defaultMetrics } from '../observability/metrics.js'; +import { createOpenTradingConnector } from './opentrading/mockConnector.js'; +import { createOpenTradingHttpConnector } from './opentrading/httpConnector.js'; +import { createPortfolioWatcherConnector } from './portfolio-watcher/mockConnector.js'; +import { createPortfolioWatcherHttpConnector } from './portfolio-watcher/httpConnector.js'; +import { createTaxBreakConnector } from './tax-break/mockConnector.js'; +import { createTaxBreakHttpConnector } from './tax-break/httpConnector.js'; + +/** Mock endpoints keep the service runnable from a clean checkout. */ +export function isMockEndpoint(endpoint = '') { + return endpoint.startsWith('mock://'); +} + +const FACTORIES = { + openTrading: { mock: createOpenTradingConnector, http: createOpenTradingHttpConnector }, + portfolioWatcher: { mock: createPortfolioWatcherConnector, http: createPortfolioWatcherHttpConnector }, + taxBreak: { mock: createTaxBreakConnector, http: createTaxBreakHttpConnector }, +}; + +/** + * Builds one connector per upstream domain, choosing the production HTTP client + * whenever a real endpoint is configured and falling back to the mock adapter + * for `mock://` endpoints. + */ +export function createConnectors({ + config = loadFinanceConfig(), + logger = defaultLogger, + metrics = defaultMetrics, + fetchImpl, +} = {}) { + return Object.fromEntries( + Object.entries(FACTORIES).map(([name, factory]) => { + const settings = config[name] ?? {}; + const mode = isMockEndpoint(settings.endpoint) ? 'mock' : 'http'; + + // Live wiring is worth an info line; mock wiring stays at debug so test + // runs and local demos are not noisy. + logger[mode === 'http' ? 'info' : 'debug']('finance connector configured', { connector: name, mode, endpoint: settings.endpoint }); + + return [ + name, + mode === 'mock' + ? factory.mock(settings) + : factory.http({ ...settings, logger, metrics, ...(fetchImpl ? { fetchImpl } : {}) }), + ]; + }) + ); +} diff --git a/src/connectors/opentrading/httpConnector.js b/src/connectors/opentrading/httpConnector.js new file mode 100644 index 0000000..040c51a --- /dev/null +++ b/src/connectors/opentrading/httpConnector.js @@ -0,0 +1,18 @@ +import { createHttpClient } from '../httpClient.js'; + +/** + * Production OpenTrading connector. + * Exposes exactly the same contract as the mock adapter so the finance service + * and GraphQL schema are unchanged when switching to a live endpoint. + */ +export function createOpenTradingHttpConnector(config = {}) { + const client = createHttpClient({ source: 'OpenTrading', ...config }); + + return { + source: 'OpenTrading', + health: client.health, + listAccounts: async () => (await client.request('/accounts')).accounts ?? [], + listTrades: async () => (await client.request('/trades')).trades ?? [], + listOrders: async () => (await client.request('/orders')).orders ?? [], + }; +} diff --git a/src/connectors/opentrading/mockConnector.js b/src/connectors/opentrading/mockConnector.js index 8734334..3bc423c 100644 --- a/src/connectors/opentrading/mockConnector.js +++ b/src/connectors/opentrading/mockConnector.js @@ -38,6 +38,7 @@ export function createOpenTradingConnector(_config = {}) { return { source: 'OpenTrading', + health: async () => ({ source: 'OpenTrading', status: 'ok', endpoint: 'mock://opentrading' }), listAccounts: async () => accounts, listTrades: async () => trades, listOrders: async () => orders, diff --git a/src/connectors/portfolio-watcher/httpConnector.js b/src/connectors/portfolio-watcher/httpConnector.js new file mode 100644 index 0000000..668578d --- /dev/null +++ b/src/connectors/portfolio-watcher/httpConnector.js @@ -0,0 +1,13 @@ +import { createHttpClient } from '../httpClient.js'; + +/** Production Portfolio-Watcher connector mirroring the mock adapter contract. */ +export function createPortfolioWatcherHttpConnector(config = {}) { + const client = createHttpClient({ source: 'Portfolio-Watcher', ...config }); + + return { + source: 'Portfolio-Watcher', + health: client.health, + listPositions: async () => (await client.request('/positions')).positions ?? [], + listPerformanceSnapshots: async () => (await client.request('/performance')).snapshots ?? [], + }; +} diff --git a/src/connectors/portfolio-watcher/mockConnector.js b/src/connectors/portfolio-watcher/mockConnector.js index 0549f4b..de7c616 100644 --- a/src/connectors/portfolio-watcher/mockConnector.js +++ b/src/connectors/portfolio-watcher/mockConnector.js @@ -11,6 +11,7 @@ export function createPortfolioWatcherConnector(_config = {}) { return { source: 'Portfolio-Watcher', + health: async () => ({ source: 'Portfolio-Watcher', status: 'ok', endpoint: 'mock://portfolio-watcher' }), listPositions: async () => positions, listPerformanceSnapshots: async () => snapshots, }; diff --git a/src/connectors/tax-break/httpConnector.js b/src/connectors/tax-break/httpConnector.js new file mode 100644 index 0000000..e43a4a1 --- /dev/null +++ b/src/connectors/tax-break/httpConnector.js @@ -0,0 +1,13 @@ +import { createHttpClient } from '../httpClient.js'; + +/** Production tax-break connector mirroring the mock adapter contract. */ +export function createTaxBreakHttpConnector(config = {}) { + const client = createHttpClient({ source: 'tax-break', ...config }); + + return { + source: 'tax-break', + health: client.health, + mapTradesToTaxEvents: async (trades) => (await client.request('/tax-events', { method: 'POST', body: { trades } })).events ?? [], + estimateTax: async ({ events, taxYear }) => client.request('/tax-estimate', { method: 'POST', body: { events, taxYear } }), + }; +} diff --git a/src/connectors/tax-break/mockConnector.js b/src/connectors/tax-break/mockConnector.js index a090036..7b12980 100644 --- a/src/connectors/tax-break/mockConnector.js +++ b/src/connectors/tax-break/mockConnector.js @@ -4,6 +4,7 @@ import { estimateTaxFromEvents, tradeToTaxEvent } from '../../domain/finance.js' export function createTaxBreakConnector(_config = {}) { return { source: 'tax-break', + health: async () => ({ source: 'tax-break', status: 'ok', endpoint: 'mock://tax-break' }), mapTradesToTaxEvents: async (trades) => trades.filter((trade) => trade.side === 'SELL').map(tradeToTaxEvent), estimateTax: async ({ events, taxYear }) => estimateTaxFromEvents(events, taxYear, 0.22), }; diff --git a/src/domain/finance.js b/src/domain/finance.js index 0e968eb..cbbbab4 100644 --- a/src/domain/finance.js +++ b/src/domain/finance.js @@ -113,14 +113,70 @@ export function filterByAccount(records, accountId) { return accountId ? records.filter((record) => record.accountId === accountId || record.id === accountId) : records; } -export function filterTrades(trades, { accountId, symbol } = {}) { +/** Inclusive date-range check against an ISO timestamp field. */ +export function withinRange(timestamp, { from, to } = {}) { + if (!from && !to) return true; + + const value = new Date(timestamp).getTime(); + if (Number.isNaN(value)) return false; + if (from && value < new Date(from).getTime()) return false; + if (to && value > new Date(to).getTime()) return false; + return true; +} + +export function filterTrades(trades, { accountId, symbol, side, status, from, to } = {}) { return trades.filter((trade) => { const accountMatches = accountId ? trade.accountId === accountId : true; const symbolMatches = symbol ? trade.symbol === symbol.toUpperCase() : true; - return accountMatches && symbolMatches; + const sideMatches = side ? trade.side === side.toUpperCase() : true; + const statusMatches = status ? trade.status === status.toUpperCase() : true; + return accountMatches && symbolMatches && sideMatches && statusMatches && withinRange(trade.executedAt, { from, to }); + }); +} + +export function filterOrders(orders, { accountId, symbol, side, status, from, to } = {}) { + return filterByAccount(orders, accountId).filter((order) => { + const symbolMatches = symbol ? order.symbol === symbol.toUpperCase() : true; + const sideMatches = side ? order.side === side.toUpperCase() : true; + const statusMatches = status ? order.status === status.toUpperCase() : true; + return symbolMatches && sideMatches && statusMatches && withinRange(order.createdAt, { from, to }); + }); +} + +export function filterTaxEvents(events, { symbol, from, to } = {}) { + return events.filter((event) => { + const symbolMatches = symbol ? event.symbol === symbol.toUpperCase() : true; + return symbolMatches && withinRange(event.occurredAt, { from, to }); }); } +export function filterSnapshots(snapshots, { from, to } = {}) { + return snapshots.filter((snapshot) => withinRange(snapshot.asOf, { from, to })); +} + +/** + * Offset/limit pagination that always reports the total so clients can build + * page controls without a second round trip. + */ +export function paginate(records, { limit, offset = 0 } = {}, { defaultLimit = 25, maxLimit = 100 } = {}) { + const totalCount = records.length; + const safeOffset = Number.isFinite(Number(offset)) ? Math.max(0, Math.trunc(Number(offset))) : 0; + const requested = limit === undefined || limit === null ? defaultLimit : Number(limit); + const safeLimit = Math.min(Math.max(0, Number.isFinite(requested) ? Math.trunc(requested) : defaultLimit), maxLimit); + const items = records.slice(safeOffset, safeOffset + safeLimit); + + return { + items, + pageInfo: { + totalCount, + limit: safeLimit, + offset: safeOffset, + hasNextPage: safeOffset + items.length < totalCount, + hasPreviousPage: safeOffset > 0, + }, + }; +} + export function estimateTaxFromEvents(events, taxYear, rate = 0.22) { const taxableEvents = events.filter((event) => new Date(event.occurredAt).getUTCFullYear() === taxYear); const totalProceeds = money(taxableEvents.reduce((sum, event) => sum + event.proceeds, 0)); diff --git a/src/index.js b/src/index.js index 985d2ce..9d38120 100644 --- a/src/index.js +++ b/src/index.js @@ -3,6 +3,8 @@ import cors from 'cors'; import express from 'express'; import { store } from './data/store.js'; +import { logger } from './observability/logger.js'; +import { metrics } from './observability/metrics.js'; import { financeService } from './services/financeService.js'; import { createApolloServer } from './server.js'; @@ -10,29 +12,41 @@ const PORT = Number(process.env.PORT) || 4000; /** Starts the HTTP server exposing the GraphQL endpoint at /graphql. */ async function main() { - const apolloServer = createApolloServer(); + const apolloServer = createApolloServer({ logger, metrics }); await apolloServer.start(); const app = express(); - // Simple liveness probe, handy when running the service in a container. + // Liveness probe: the process is up and serving. app.get('/health', (_req, res) => res.json({ status: 'ok' })); + // Readiness probe: reports per-upstream connector status so a degraded + // finance cluster is visible without inspecting logs. + app.get('/ready', async (_req, res) => { + const health = await financeService.health(); + res.status(health.status === 'ok' ? 200 : 503).json(health); + }); + + // Prometheus-style scrape endpoint for connector and GraphQL metrics. + app.get('/metrics', (_req, res) => { + res.set('content-type', 'text/plain; version=0.0.4').send(metrics.toPrometheus()); + }); + app.use( '/graphql', cors(), express.json(), expressMiddleware(apolloServer, { // Every request shares the same in-memory store. - context: async () => ({ store, finance: financeService }), + context: async () => ({ store, finance: financeService, logger, metrics }), }) ); await new Promise((resolve) => app.listen(PORT, resolve)); - console.log(`🚀 GraphQL endpoint ready at http://localhost:${PORT}/graphql`); + logger.info('graphql endpoint ready', { url: `http://localhost:${PORT}/graphql` }); } main().catch((error) => { - console.error('Failed to start server:', error); + logger.error('failed to start server', { error: error?.message ?? String(error) }); process.exitCode = 1; }); diff --git a/src/observability/apolloPlugin.js b/src/observability/apolloPlugin.js new file mode 100644 index 0000000..6299c8c --- /dev/null +++ b/src/observability/apolloPlugin.js @@ -0,0 +1,39 @@ +import { logger as defaultLogger } from './logger.js'; +import { metrics as defaultMetrics } from './metrics.js'; + +/** + * Apollo plugin emitting one structured log line and latency/outcome metrics + * per GraphQL operation, which is the minimum needed to alert on error rates + * and slow queries in production. + */ +export function createObservabilityPlugin({ logger = defaultLogger, metrics = defaultMetrics } = {}) { + return { + async requestDidStart(requestContext) { + const startedAt = Date.now(); + const requestId = requestContext.request.http?.headers?.get?.('x-request-id') ?? undefined; + + return { + async willSendResponse(context) { + const durationMs = Date.now() - startedAt; + const operationName = context.operationName ?? context.operation?.name?.value ?? 'anonymous'; + const errors = context.errors ?? context.response?.body?.singleResult?.errors ?? []; + const outcome = errors.length > 0 ? 'failure' : 'success'; + + metrics.observe('graphql_operation_duration_ms', durationMs, { operationName, outcome }); + metrics.increment('graphql_operation_total', { operationName, outcome }); + if (errors.length > 0) metrics.increment('graphql_operation_errors_total', { operationName }, errors.length); + + logger[outcome === 'failure' ? 'error' : 'info']('graphql operation completed', { + requestId, + operationName, + operationType: context.operation?.operation, + durationMs, + outcome, + errorCount: errors.length, + errorCodes: errors.map((error) => error.extensions?.code ?? 'INTERNAL_SERVER_ERROR'), + }); + }, + }; + }, + }; +} diff --git a/src/observability/logger.js b/src/observability/logger.js new file mode 100644 index 0000000..7c26547 --- /dev/null +++ b/src/observability/logger.js @@ -0,0 +1,44 @@ +const LEVELS = { debug: 10, info: 20, warn: 30, error: 40 }; + +const REDACTED_KEYS = ['apikey', 'authorization', 'password', 'secret', 'token']; + +/** Strips credential-like values so logs never leak connector secrets. */ +function redact(fields) { + return Object.fromEntries( + Object.entries(fields).map(([key, value]) => [ + key, + REDACTED_KEYS.includes(key.toLowerCase()) ? '[redacted]' : value, + ]) + ); +} + +/** + * Minimal structured (JSON lines) logger. Keeping it dependency-free makes the + * output easy to ship to any log aggregator without extra libraries. + */ +export function createLogger({ level = process.env.LOG_LEVEL ?? 'info', write = console.log, name = 'graphql-demo-service' } = {}) { + const threshold = LEVELS[level] ?? LEVELS.info; + + function log(logLevel, message, fields = {}) { + if (LEVELS[logLevel] < threshold) return; + write( + JSON.stringify({ + level: logLevel, + time: new Date().toISOString(), + service: name, + message, + ...redact(fields), + }) + ); + } + + return { + level, + debug: (message, fields) => log('debug', message, fields), + info: (message, fields) => log('info', message, fields), + warn: (message, fields) => log('warn', message, fields), + error: (message, fields) => log('error', message, fields), + }; +} + +export const logger = createLogger(); diff --git a/src/observability/metrics.js b/src/observability/metrics.js new file mode 100644 index 0000000..fe267f2 --- /dev/null +++ b/src/observability/metrics.js @@ -0,0 +1,88 @@ +/** Serializes label objects into a stable Prometheus-style label key. */ +function labelKey(labels = {}) { + const entries = Object.entries(labels) + .filter(([, value]) => value !== undefined && value !== null) + .sort(([a], [b]) => a.localeCompare(b)); + + return entries.map(([key, value]) => `${key}="${String(value).replace(/["\\\n]/g, '_')}"`).join(','); +} + +/** + * In-process metrics registry with counters and latency summaries. + * It is intentionally tiny: no dependency is required and the snapshot can be + * rendered as Prometheus text or consumed as JSON by a health endpoint. + */ +export function createMetrics() { + const counters = new Map(); + const durations = new Map(); + + function increment(name, labels = {}, value = 1) { + const key = `${name}|${labelKey(labels)}`; + const current = counters.get(key) ?? { name, labels, value: 0 }; + current.value += value; + counters.set(key, current); + } + + function observe(name, milliseconds, labels = {}) { + const key = `${name}|${labelKey(labels)}`; + const current = durations.get(key) ?? { name, labels, count: 0, totalMs: 0, maxMs: 0 }; + current.count += 1; + current.totalMs += milliseconds; + current.maxMs = Math.max(current.maxMs, milliseconds); + durations.set(key, current); + } + + /** Times an async task and records success/failure counters plus latency. */ + async function time(name, labels, task) { + const startedAt = Date.now(); + try { + const result = await task(); + observe(`${name}_duration_ms`, Date.now() - startedAt, { ...labels, outcome: 'success' }); + increment(`${name}_total`, { ...labels, outcome: 'success' }); + return result; + } catch (error) { + observe(`${name}_duration_ms`, Date.now() - startedAt, { ...labels, outcome: 'failure' }); + increment(`${name}_total`, { ...labels, outcome: 'failure' }); + throw error; + } + } + + function snapshot() { + return { + counters: [...counters.values()].map((counter) => ({ ...counter })), + durations: [...durations.values()].map((duration) => ({ + ...duration, + avgMs: duration.count === 0 ? 0 : Number((duration.totalMs / duration.count).toFixed(3)), + })), + }; + } + + function toPrometheus() { + const current = snapshot(); + const lines = []; + + for (const counter of current.counters) { + const labels = labelKey(counter.labels); + lines.push(`${counter.name}${labels ? `{${labels}}` : ''} ${counter.value}`); + } + + for (const duration of current.durations) { + const labels = labelKey(duration.labels); + const suffix = labels ? `{${labels}}` : ''; + lines.push(`${duration.name}_count${suffix} ${duration.count}`); + lines.push(`${duration.name}_sum${suffix} ${duration.totalMs}`); + lines.push(`${duration.name}_max${suffix} ${duration.maxMs}`); + } + + return `${lines.join('\n')}\n`; + } + + function reset() { + counters.clear(); + durations.clear(); + } + + return { increment, observe, time, snapshot, toPrometheus, reset }; +} + +export const metrics = createMetrics(); diff --git a/src/schema.js b/src/schema.js index 2ea7612..afb7ec6 100644 --- a/src/schema.js +++ b/src/schema.js @@ -106,6 +106,15 @@ export const typeDefs = /* GraphQL */ ` message: String! } + "Offset-based pagination metadata for finance collections." + type PageInfo { + totalCount: Int! + limit: Int! + offset: Int! + hasNextPage: Boolean! + hasPreviousPage: Boolean! + } + "Cross-source portfolio overview composed from OpenTrading and Portfolio-Watcher." type PortfolioOverview { accounts: [Account!]! @@ -114,6 +123,8 @@ export const typeDefs = /* GraphQL */ ` currency: String! totalMarketValue: Float! totalUnrealizedPnL: Float! + "Pagination metadata for the returned positions." + pageInfo: PageInfo! errors: [FinanceUpstreamError!]! } @@ -122,6 +133,8 @@ export const typeDefs = /* GraphQL */ ` trades: [Trade!]! orders: [Order!]! taxEvents: [TaxEvent!]! + "Pagination metadata for the returned trades." + pageInfo: PageInfo! errors: [FinanceUpstreamError!]! } @@ -135,6 +148,8 @@ export const typeDefs = /* GraphQL */ ` estimatedTax: Float! taxRate: Float! events: [TaxEvent!]! + "Pagination metadata for the returned tax events." + pageInfo: PageInfo! errors: [FinanceUpstreamError!]! } @@ -147,12 +162,40 @@ export const typeDefs = /* GraphQL */ ` posts: [Post!]! "A single post by id, or null when not found." post(id: ID!): Post - "Unified finance overview with accounts, positions, snapshots, and P/L." - portfolioOverview(accountId: ID): PortfolioOverview! - "OpenTrading trades/orders mapped to tax-break tax events." - tradeHistory(accountId: ID, symbol: String): TradeHistory! - "Tax estimate derived from normalized trading activity." - taxEstimate(taxYear: Int!, accountId: ID): TaxEstimateSummary! + """ + Unified finance overview with accounts, positions, snapshots, and P/L. + Date bounds (from/to, inclusive ISO-8601) apply to performance snapshots; + limit/offset paginate the returned positions. + """ + portfolioOverview(accountId: ID, from: String, to: String, limit: Int, offset: Int): PortfolioOverview! + """ + OpenTrading trades/orders mapped to tax-break tax events. + Trades and orders can be filtered by account, symbol, side, status, and an + inclusive ISO-8601 execution date range, then paginated. + """ + tradeHistory( + accountId: ID + symbol: String + side: String + status: String + from: String + to: String + limit: Int + offset: Int + ): TradeHistory! + """ + Tax estimate derived from normalized trading activity. Totals always cover + every matching event; limit/offset only paginate the returned events. + """ + taxEstimate( + taxYear: Int! + accountId: ID + symbol: String + from: String + to: String + limit: Int + offset: Int + ): TaxEstimateSummary! } type Mutation { diff --git a/src/server.js b/src/server.js index 0a5a4f2..63cf5ee 100644 --- a/src/server.js +++ b/src/server.js @@ -1,5 +1,6 @@ import { ApolloServer } from '@apollo/server'; +import { createObservabilityPlugin } from './observability/apolloPlugin.js'; import { resolvers } from './resolvers.js'; import { typeDefs } from './schema.js'; @@ -8,6 +9,10 @@ import { typeDefs } from './schema.js'; * Exported separately from the HTTP bootstrap so tests can run queries * against the server without opening a port. */ -export function createApolloServer() { - return new ApolloServer({ typeDefs, resolvers }); +export function createApolloServer({ logger, metrics, plugins = [] } = {}) { + return new ApolloServer({ + typeDefs, + resolvers, + plugins: [createObservabilityPlugin({ logger, metrics }), ...plugins], + }); } diff --git a/src/services/financeService.js b/src/services/financeService.js index 3988265..2d38983 100644 --- a/src/services/financeService.js +++ b/src/services/financeService.js @@ -1,54 +1,72 @@ -import { createOpenTradingConnector } from '../connectors/opentrading/mockConnector.js'; -import { createPortfolioWatcherConnector } from '../connectors/portfolio-watcher/mockConnector.js'; -import { createTaxBreakConnector } from '../connectors/tax-break/mockConnector.js'; +import { createConnectors } from '../connectors/index.js'; import { loadFinanceConfig } from '../config/finance.js'; +import { logger as defaultLogger } from '../observability/logger.js'; +import { metrics as defaultMetrics } from '../observability/metrics.js'; import { aggregatePortfolio, estimateTaxFromEvents, filterByAccount, + filterOrders, + filterSnapshots, + filterTaxEvents, filterTrades, normalizeAccount, normalizeOrder, normalizePerformanceSnapshot, normalizePosition, normalizeTrade, + paginate, } from '../domain/finance.js'; function upstreamError(source, error) { const message = error instanceof Error ? error.message : String(error); return { source, - code: 'UPSTREAM_UNAVAILABLE', + code: error?.status ? `UPSTREAM_HTTP_${error.status}` : 'UPSTREAM_UNAVAILABLE', message: `${source} connector failed: ${message}`, }; } -async function capture(source, task) { - try { - return { data: await task(), error: null }; - } catch (error) { - return { data: [], error: upstreamError(source, error) }; - } -} - -export function createFinanceService({ config = loadFinanceConfig(), connectors, cacheTtlMs = config.cacheTtlMs } = {}) { - const upstreams = connectors ?? { - openTrading: createOpenTradingConnector(config.openTrading), - portfolioWatcher: createPortfolioWatcherConnector(config.portfolioWatcher), - taxBreak: createTaxBreakConnector(config.taxBreak), - }; +export function createFinanceService({ + config = loadFinanceConfig(), + connectors, + cacheTtlMs = config.cacheTtlMs, + logger = defaultLogger, + metrics = defaultMetrics, +} = {}) { + const upstreams = connectors ?? createConnectors({ config, logger, metrics }); + const pageLimits = { defaultLimit: config.defaultPageSize ?? 25, maxLimit: config.maxPageSize ?? 100 }; const cache = new Map(); // Concurrent resolvers asking for the same upstream data share a single // in-flight request instead of fanning out duplicate connector calls. const inFlight = new Map(); + /** Runs an upstream call, recording latency/outcome and converting failures. */ + async function capture(source, operation, task) { + try { + const data = await metrics.time('finance_connector_call', { source, operation }, task); + return { data, error: null }; + } catch (error) { + logger.error('finance connector call failed', { source, operation, error: error?.message ?? String(error) }); + return { data: [], error: upstreamError(source, error) }; + } + } + async function cached(key, load) { const now = Date.now(); const hit = cache.get(key); - if (hit && hit.expiresAt > now) return hit.value; + if (hit && hit.expiresAt > now) { + metrics.increment('finance_cache_total', { key, outcome: 'hit' }); + return hit.value; + } const pending = inFlight.get(key); - if (pending) return pending; + if (pending) { + metrics.increment('finance_cache_total', { key, outcome: 'coalesced' }); + return pending; + } + + metrics.increment('finance_cache_total', { key, outcome: 'miss' }); const request = (async () => { try { @@ -67,9 +85,9 @@ export function createFinanceService({ config = loadFinanceConfig(), connectors, async function getTradingData() { return cached('trading', async () => { const [accounts, trades, orders] = await Promise.all([ - capture('OpenTrading', () => upstreams.openTrading.listAccounts()), - capture('OpenTrading', () => upstreams.openTrading.listTrades()), - capture('OpenTrading', () => upstreams.openTrading.listOrders()), + capture('OpenTrading', 'listAccounts', () => upstreams.openTrading.listAccounts()), + capture('OpenTrading', 'listTrades', () => upstreams.openTrading.listTrades()), + capture('OpenTrading', 'listOrders', () => upstreams.openTrading.listOrders()), ]); return { @@ -84,8 +102,8 @@ export function createFinanceService({ config = loadFinanceConfig(), connectors, async function getPortfolioData() { return cached('portfolio', async () => { const [positions, snapshots] = await Promise.all([ - capture('Portfolio-Watcher', () => upstreams.portfolioWatcher.listPositions()), - capture('Portfolio-Watcher', () => upstreams.portfolioWatcher.listPerformanceSnapshots()), + capture('Portfolio-Watcher', 'listPositions', () => upstreams.portfolioWatcher.listPositions()), + capture('Portfolio-Watcher', 'listPerformanceSnapshots', () => upstreams.portfolioWatcher.listPerformanceSnapshots()), ]); return { @@ -97,47 +115,90 @@ export function createFinanceService({ config = loadFinanceConfig(), connectors, } async function mapTaxEvents(trades) { - const mapped = await capture('tax-break', () => upstreams.taxBreak.mapTradesToTaxEvents(trades)); + const mapped = await capture('tax-break', 'mapTradesToTaxEvents', () => upstreams.taxBreak.mapTradesToTaxEvents(trades)); return { events: mapped.data, errors: mapped.error ? [mapped.error] : [] }; } + /** Full (unpaginated) filtered history, shared by tradeHistory and taxEstimate. */ + async function collectHistory(filter = {}) { + const trading = await getTradingData(); + const trades = filterTrades(trading.trades, filter); + const orders = filterOrders(trading.orders, filter); + const taxEvents = await mapTaxEvents(trades); + + return { + trades, + orders, + taxEvents: filterTaxEvents(taxEvents.events, filter), + errors: [...trading.errors, ...taxEvents.errors], + }; + } + return { - async portfolioOverview({ accountId } = {}) { + async portfolioOverview({ accountId, from, to, limit, offset } = {}) { const [trading, portfolio] = await Promise.all([getTradingData(), getPortfolioData()]); + const positions = paginate(filterByAccount(portfolio.positions, accountId), { limit, offset }, pageLimits); - return aggregatePortfolio({ - accounts: accountId ? trading.accounts.filter((account) => account.id === accountId) : trading.accounts, - positions: filterByAccount(portfolio.positions, accountId), - snapshots: filterByAccount(portfolio.snapshots, accountId), - errors: [...trading.errors, ...portfolio.errors], - }); + return { + ...aggregatePortfolio({ + accounts: accountId ? trading.accounts.filter((account) => account.id === accountId) : trading.accounts, + positions: positions.items, + snapshots: filterSnapshots(filterByAccount(portfolio.snapshots, accountId), { from, to }), + errors: [...trading.errors, ...portfolio.errors], + }), + pageInfo: positions.pageInfo, + }; }, - async tradeHistory({ accountId, symbol } = {}) { - const trading = await getTradingData(); - const trades = filterTrades(trading.trades, { accountId, symbol }); - const orders = filterByAccount(trading.orders, accountId).filter((order) => (symbol ? order.symbol === symbol.toUpperCase() : true)); - const taxEvents = await mapTaxEvents(trades); + async tradeHistory({ limit, offset, ...filter } = {}) { + const history = await collectHistory(filter); + const trades = paginate(history.trades, { limit, offset }, pageLimits); + const tradeIds = new Set(trades.items.map((trade) => trade.id)); return { - trades, - orders, - taxEvents: taxEvents.events, - errors: [...trading.errors, ...taxEvents.errors], + trades: trades.items, + orders: paginate(history.orders, { limit, offset }, pageLimits).items, + taxEvents: history.taxEvents.filter((event) => tradeIds.has(event.tradeId)), + pageInfo: trades.pageInfo, + errors: history.errors, }; }, - async taxEstimate({ taxYear, accountId } = {}) { - const history = await this.tradeHistory({ accountId }); - const estimate = await capture('tax-break', () => upstreams.taxBreak.estimateTax({ events: history.taxEvents, taxYear })); - const fallback = estimate.error ? estimateTaxFromEvents(history.taxEvents, taxYear) : null; + async taxEstimate({ taxYear, limit, offset, ...filter } = {}) { + const history = await collectHistory(filter); + const estimate = await capture('tax-break', 'estimateTax', () => upstreams.taxBreak.estimateTax({ events: history.taxEvents, taxYear })); + const summary = estimate.error ? estimateTaxFromEvents(history.taxEvents, taxYear) : estimate.data; + const events = paginate(summary.events ?? [], { limit, offset }, pageLimits); return { - ...(estimate.error ? fallback : estimate.data), - events: estimate.error ? fallback.events : estimate.data.events, + ...summary, + events: events.items, + pageInfo: events.pageInfo, errors: [...history.errors, estimate.error].filter(Boolean), }; }, + + /** Aggregated upstream readiness used by the HTTP /health endpoint. */ + async health() { + const checks = await Promise.all( + Object.values(upstreams).map(async (connector) => { + if (typeof connector.health !== 'function') { + return { source: connector.source ?? 'unknown', status: 'unknown' }; + } + + try { + return await connector.health(); + } catch (error) { + return { source: connector.source ?? 'unknown', status: 'degraded', error: error?.message ?? String(error) }; + } + }) + ); + + return { + status: checks.every((check) => check.status === 'ok') ? 'ok' : 'degraded', + upstreams: checks, + }; + }, }; } diff --git a/test/connectors.test.js b/test/connectors.test.js new file mode 100644 index 0000000..10f973e --- /dev/null +++ b/test/connectors.test.js @@ -0,0 +1,143 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { createConnectors, isMockEndpoint } from '../src/connectors/index.js'; +import { createHttpClient } from '../src/connectors/httpClient.js'; +import { createOpenTradingHttpConnector } from '../src/connectors/opentrading/httpConnector.js'; +import { loadFinanceConfig } from '../src/config/finance.js'; +import { createLogger } from '../src/observability/logger.js'; +import { createMetrics } from '../src/observability/metrics.js'; + +const silentLogger = createLogger({ write: () => {} }); + +function jsonResponse(body, status = 200) { + return { ok: status >= 200 && status < 300, status, json: async () => body }; +} + +describe('production HTTP connectors', () => { + it('sends bearer auth and parses the upstream payload', async () => { + const calls = []; + const connector = createOpenTradingHttpConnector({ + endpoint: 'https://trading.example/api/', + apiKey: 'test-key', + logger: silentLogger, + metrics: createMetrics(), + fetchImpl: async (url, options) => { + calls.push({ url, options }); + return jsonResponse({ accounts: [{ acct_id: 'acct-9' }] }); + }, + }); + + const accounts = await connector.listAccounts(); + + assert.equal(calls[0].url, 'https://trading.example/api/accounts'); + assert.equal(calls[0].options.headers.authorization, ['Bearer', 'test-key'].join(' ')); + assert.deepEqual(accounts, [{ acct_id: 'acct-9' }]); + }); + + it('retries retryable failures and records metrics', async () => { + const metrics = createMetrics(); + let attempts = 0; + const client = createHttpClient({ + source: 'OpenTrading', + endpoint: 'https://trading.example', + maxRetries: 2, + retryBackoffMs: 0, + logger: silentLogger, + metrics, + fetchImpl: async () => { + attempts += 1; + return attempts < 3 ? jsonResponse({}, 503) : jsonResponse({ ok: true }); + }, + }); + + assert.deepEqual(await client.request('/accounts'), { ok: true }); + assert.equal(attempts, 3); + + const successes = metrics + .snapshot() + .counters.find((counter) => counter.name === 'finance_upstream_request_total' && counter.labels.outcome === 'success'); + assert.equal(successes.value, 1); + }); + + it('does not retry client errors and reports the upstream status', async () => { + let attempts = 0; + const client = createHttpClient({ + source: 'tax-break', + endpoint: 'https://tax.example', + retryBackoffMs: 0, + logger: silentLogger, + metrics: createMetrics(), + fetchImpl: async () => { + attempts += 1; + return jsonResponse({}, 400); + }, + }); + + await assert.rejects(() => client.request('/tax-events'), /HTTP 400/); + assert.equal(attempts, 1); + }); + + it('aborts requests that exceed the configured timeout', async () => { + const client = createHttpClient({ + source: 'Portfolio-Watcher', + endpoint: 'https://portfolio.example', + timeoutMs: 10, + maxRetries: 0, + retryBackoffMs: 0, + logger: silentLogger, + metrics: createMetrics(), + fetchImpl: (_url, { signal }) => + new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { + const error = new Error('aborted'); + error.name = 'AbortError'; + reject(error); + }); + }), + }); + + await assert.rejects(() => client.request('/positions'), /timed out after 10ms/); + }); + + it('reports degraded health when the readiness probe fails', async () => { + const client = createHttpClient({ + source: 'OpenTrading', + endpoint: 'https://trading.example', + maxRetries: 0, + retryBackoffMs: 0, + logger: silentLogger, + metrics: createMetrics(), + fetchImpl: async () => jsonResponse({}, 500), + }); + + assert.equal((await client.health()).status, 'degraded'); + }); +}); + +describe('connector selection', () => { + it('uses mock adapters for mock endpoints and HTTP clients otherwise', async () => { + assert.equal(isMockEndpoint('mock://opentrading'), true); + assert.equal(isMockEndpoint('https://trading.example'), false); + + const mocks = createConnectors({ config: loadFinanceConfig({}), logger: silentLogger, metrics: createMetrics() }); + assert.equal((await mocks.openTrading.health()).endpoint, 'mock://opentrading'); + + const live = createConnectors({ + config: loadFinanceConfig({ OPENTRADING_ENDPOINT: 'https://trading.example' }), + logger: silentLogger, + metrics: createMetrics(), + fetchImpl: async () => jsonResponse({ accounts: [] }), + }); + + assert.deepEqual(await live.openTrading.listAccounts(), []); + assert.equal((await live.portfolioWatcher.health()).endpoint, 'mock://portfolio-watcher'); + }); + + it('reads timeout and retry settings from the environment', () => { + const config = loadFinanceConfig({ FINANCE_HTTP_TIMEOUT_MS: '250', FINANCE_HTTP_MAX_RETRIES: '4', FINANCE_MAX_PAGE_SIZE: '10' }); + assert.equal(config.openTrading.timeoutMs, 250); + assert.equal(config.taxBreak.maxRetries, 4); + assert.equal(config.maxPageSize, 10); + }); +}); diff --git a/test/finance.test.js b/test/finance.test.js index 2d7e3de..0edea0b 100644 --- a/test/finance.test.js +++ b/test/finance.test.js @@ -8,7 +8,9 @@ import { normalizeAccount, normalizePosition, normalizeTrade, + paginate, tradeToTaxEvent, + withinRange, } from '../src/domain/finance.js'; import { loadFinanceConfig } from '../src/config/finance.js'; import { createFinanceService } from '../src/services/financeService.js'; @@ -134,3 +136,61 @@ describe('finance service configuration and batching', () => { assert.equal(accountCalls, 1); }); }); + +describe('finance filtering and pagination', () => { + const trades = [ + { id: 't-1', accountId: 'acct-1', symbol: 'AAPL', side: 'BUY', status: 'FILLED', executedAt: '2026-01-10T14:31:00.000Z' }, + { id: 't-2', accountId: 'acct-1', symbol: 'AAPL', side: 'SELL', status: 'FILLED', executedAt: '2026-04-05T15:45:00.000Z' }, + { id: 't-3', accountId: 'acct-2', symbol: 'MSFT', side: 'SELL', status: 'CANCELLED', executedAt: '2025-12-01T10:00:00.000Z' }, + ]; + + it('filters trades by side, status, and an inclusive date range', () => { + assert.deepEqual(filterTrades(trades, { side: 'sell' }).map((trade) => trade.id), ['t-2', 't-3']); + assert.deepEqual(filterTrades(trades, { status: 'cancelled' }).map((trade) => trade.id), ['t-3']); + assert.deepEqual( + filterTrades(trades, { from: '2026-01-01T00:00:00.000Z', to: '2026-02-01T00:00:00.000Z' }).map((trade) => trade.id), + ['t-1'] + ); + assert.equal(withinRange('2026-01-10T14:31:00.000Z', { from: '2026-01-10T14:31:00.000Z' }), true); + assert.equal(withinRange('not-a-date', { from: '2026-01-01T00:00:00.000Z' }), false); + }); + + it('paginates with clamped limits and reports page metadata', () => { + const first = paginate(trades, { limit: 2, offset: 0 }); + assert.deepEqual(first.items.map((trade) => trade.id), ['t-1', 't-2']); + assert.deepEqual(first.pageInfo, { totalCount: 3, limit: 2, offset: 0, hasNextPage: true, hasPreviousPage: false }); + + const second = paginate(trades, { limit: 2, offset: 2 }); + assert.deepEqual(second.items.map((trade) => trade.id), ['t-3']); + assert.equal(second.pageInfo.hasNextPage, false); + assert.equal(second.pageInfo.hasPreviousPage, true); + + const clamped = paginate(trades, { limit: 500, offset: -5 }, { defaultLimit: 25, maxLimit: 2 }); + assert.equal(clamped.pageInfo.limit, 2); + assert.equal(clamped.pageInfo.offset, 0); + + const defaulted = paginate(trades, {}, { defaultLimit: 1, maxLimit: 10 }); + assert.equal(defaulted.items.length, 1); + }); +}); + +describe('finance service health', () => { + it('aggregates upstream connector health', async () => { + const finance = createFinanceService({ + connectors: { + openTrading: { source: 'OpenTrading', health: async () => ({ source: 'OpenTrading', status: 'ok' }) }, + portfolioWatcher: { + source: 'Portfolio-Watcher', + health: async () => { + throw new Error('probe failed'); + }, + }, + taxBreak: { source: 'tax-break', health: async () => ({ source: 'tax-break', status: 'ok' }) }, + }, + }); + + const health = await finance.health(); + assert.equal(health.status, 'degraded'); + assert.equal(health.upstreams.find((check) => check.source === 'Portfolio-Watcher').status, 'degraded'); + }); +}); diff --git a/test/graphql.test.js b/test/graphql.test.js index a7201ed..7299278 100644 --- a/test/graphql.test.js +++ b/test/graphql.test.js @@ -153,6 +153,48 @@ describe('GraphQL API', () => { assert.deepEqual(result.data.taxEstimate.errors, []); }); + it('filters trade history by side and date range', async () => { + const result = await execute(`{ + tradeHistory(side: "SELL", from: "2026-02-01T00:00:00.000Z") { + trades { id side executedAt } + orders { id } + taxEvents { tradeId } + pageInfo { totalCount limit offset hasNextPage hasPreviousPage } + } + }`); + + assert.equal(result.errors, undefined); + assert.deepEqual(result.data.tradeHistory.trades.map((trade) => trade.id), ['trade-2']); + assert.deepEqual(result.data.tradeHistory.taxEvents.map((event) => event.tradeId), ['trade-2']); + assert.equal(result.data.tradeHistory.pageInfo.totalCount, 1); + assert.equal(result.data.tradeHistory.pageInfo.hasNextPage, false); + }); + + it('paginates trade history and reports page metadata', async () => { + const first = await execute('{ tradeHistory(limit: 1) { trades { id } pageInfo { totalCount hasNextPage hasPreviousPage } } }'); + assert.deepEqual(first.data.tradeHistory.trades.map((trade) => trade.id), ['trade-1']); + assert.equal(first.data.tradeHistory.pageInfo.totalCount, 2); + assert.equal(first.data.tradeHistory.pageInfo.hasNextPage, true); + + const second = await execute('{ tradeHistory(limit: 1, offset: 1) { trades { id } pageInfo { hasNextPage hasPreviousPage } } }'); + assert.deepEqual(second.data.tradeHistory.trades.map((trade) => trade.id), ['trade-2']); + assert.equal(second.data.tradeHistory.pageInfo.hasNextPage, false); + assert.equal(second.data.tradeHistory.pageInfo.hasPreviousPage, true); + }); + + it('paginates portfolio positions and keeps tax totals over all matching events', async () => { + const overview = await execute('{ portfolioOverview(limit: 1) { positions { symbol } pageInfo { totalCount hasNextPage } } }'); + assert.equal(overview.data.portfolioOverview.positions.length, 1); + assert.equal(overview.data.portfolioOverview.pageInfo.totalCount, 2); + assert.equal(overview.data.portfolioOverview.pageInfo.hasNextPage, true); + + const estimate = await execute('{ taxEstimate(taxYear: 2026, limit: 0) { totalProceeds estimatedTax events { id } pageInfo { totalCount } } }'); + assert.equal(estimate.data.taxEstimate.totalProceeds, 720); + assert.equal(estimate.data.taxEstimate.estimatedTax, 28.51); + assert.deepEqual(estimate.data.taxEstimate.events, []); + assert.equal(estimate.data.taxEstimate.pageInfo.totalCount, 1); + }); + it('surfaces upstream errors while returning partial finance data', async () => { finance = createFinanceService({ connectors: { diff --git a/test/observability.test.js b/test/observability.test.js new file mode 100644 index 0000000..8b7d4f6 --- /dev/null +++ b/test/observability.test.js @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { createLogger } from '../src/observability/logger.js'; +import { createMetrics } from '../src/observability/metrics.js'; +import { createObservabilityPlugin } from '../src/observability/apolloPlugin.js'; + +describe('structured logging', () => { + it('emits JSON lines, redacts credentials, and respects the level threshold', () => { + const lines = []; + const logger = createLogger({ level: 'info', write: (line) => lines.push(line) }); + + logger.debug('ignored'); + logger.info('connector configured', { connector: 'openTrading', apiKey: 'super-secret' }); + + assert.equal(lines.length, 1); + const entry = JSON.parse(lines[0]); + assert.equal(entry.level, 'info'); + assert.equal(entry.message, 'connector configured'); + assert.equal(entry.connector, 'openTrading'); + assert.equal(entry.apiKey, '[redacted]'); + }); +}); + +describe('metrics registry', () => { + it('records counters, latency summaries, and Prometheus output', async () => { + const metrics = createMetrics(); + + metrics.increment('finance_cache_total', { key: 'trading', outcome: 'hit' }); + metrics.increment('finance_cache_total', { key: 'trading', outcome: 'hit' }); + await metrics.time('finance_connector_call', { source: 'OpenTrading' }, async () => 'ok'); + await assert.rejects(() => + metrics.time('finance_connector_call', { source: 'OpenTrading' }, async () => { + throw new Error('boom'); + }) + ); + + const snapshot = metrics.snapshot(); + const hits = snapshot.counters.find((counter) => counter.name === 'finance_cache_total'); + assert.equal(hits.value, 2); + assert.equal(snapshot.durations.filter((duration) => duration.name === 'finance_connector_call_duration_ms').length, 2); + + const text = metrics.toPrometheus(); + assert.match(text, /finance_cache_total\{key="trading",outcome="hit"\} 2/); + assert.match(text, /finance_connector_call_duration_ms_count/); + + metrics.reset(); + assert.deepEqual(metrics.snapshot().counters, []); + }); +}); + +describe('graphql observability plugin', () => { + async function run(plugin, requestContext) { + const hooks = await plugin.requestDidStart(requestContext); + await hooks.willSendResponse(requestContext); + } + + it('logs and measures successful operations', async () => { + const lines = []; + const metrics = createMetrics(); + const plugin = createObservabilityPlugin({ logger: createLogger({ write: (line) => lines.push(line) }), metrics }); + + await run(plugin, { request: {}, operationName: 'PortfolioOverview', operation: { operation: 'query' }, errors: [] }); + + const entry = JSON.parse(lines[0]); + assert.equal(entry.operationName, 'PortfolioOverview'); + assert.equal(entry.outcome, 'success'); + assert.equal(typeof entry.durationMs, 'number'); + + const counter = metrics.snapshot().counters.find((item) => item.name === 'graphql_operation_total'); + assert.equal(counter.labels.outcome, 'success'); + }); + + it('logs failures with error codes and counts them', async () => { + const lines = []; + const metrics = createMetrics(); + const plugin = createObservabilityPlugin({ logger: createLogger({ write: (line) => lines.push(line) }), metrics }); + + await run(plugin, { + request: { http: { headers: new Map([['x-request-id', 'req-1']]) } }, + operationName: 'CreatePost', + operation: { operation: 'mutation' }, + errors: [{ extensions: { code: 'BAD_USER_INPUT' } }], + }); + + const entry = JSON.parse(lines[0]); + assert.equal(entry.level, 'error'); + assert.equal(entry.requestId, 'req-1'); + assert.deepEqual(entry.errorCodes, ['BAD_USER_INPUT']); + + const errorCounter = metrics.snapshot().counters.find((item) => item.name === 'graphql_operation_errors_total'); + assert.equal(errorCounter.value, 1); + }); +}); From b31cf315b9ce9d21314ca3798f4399f3f27f92fa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 5 Sep 2026 02:06:48 +0000 Subject: [PATCH 2/3] Harden finance env integer parsing Co-authored-by: charles2ke <6725706+charles2ke@users.noreply.github.com> --- src/config/finance.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/config/finance.js b/src/config/finance.js index e141ebb..24be178 100644 --- a/src/config/finance.js +++ b/src/config/finance.js @@ -1,7 +1,11 @@ /** Environment-driven configuration for finance-cluster connectors. */ export function loadFinanceConfig(env = process.env) { - const timeoutMs = Number(env.FINANCE_HTTP_TIMEOUT_MS ?? 5000); - const maxRetries = Number(env.FINANCE_HTTP_MAX_RETRIES ?? 2); + const parseNonNegativeInt = (value, fallback) => { + const parsed = Number.parseInt(value, 10); + return Number.isNaN(parsed) ? fallback : Math.max(0, parsed); + }; + const timeoutMs = parseNonNegativeInt(env.FINANCE_HTTP_TIMEOUT_MS, 5000); + const maxRetries = parseNonNegativeInt(env.FINANCE_HTTP_MAX_RETRIES, 2); return { openTrading: { @@ -22,7 +26,7 @@ export function loadFinanceConfig(env = process.env) { timeoutMs, maxRetries, }, - cacheTtlMs: Number(env.FINANCE_CACHE_TTL_MS ?? 1000), + cacheTtlMs: parseNonNegativeInt(env.FINANCE_CACHE_TTL_MS, 1000), defaultPageSize: Number(env.FINANCE_DEFAULT_PAGE_SIZE ?? 25), maxPageSize: Number(env.FINANCE_MAX_PAGE_SIZE ?? 100), }; From b2f14747ae611d7b2b4bd7784b7192386fd04b19 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 5 Sep 2026 02:10:15 +0000 Subject: [PATCH 3/3] Address remaining finance and observability review feedback Co-authored-by: charles2ke <6725706+charles2ke@users.noreply.github.com> --- src/connectors/httpClient.js | 2 +- src/domain/finance.js | 7 +++++-- src/index.js | 2 +- src/server.js | 6 ++++-- src/services/financeService.js | 21 ++++++++++---------- test/finance.test.js | 36 ++++++++++++++++++++++++++++++++++ 6 files changed, 58 insertions(+), 16 deletions(-) diff --git a/src/connectors/httpClient.js b/src/connectors/httpClient.js index ef30f8e..74f40c9 100644 --- a/src/connectors/httpClient.js +++ b/src/connectors/httpClient.js @@ -103,7 +103,7 @@ export function createHttpClient({ source, endpoint: baseUrl, request, - /** Lightweight readiness probe used by the /health endpoint. */ + /** Lightweight readiness probe used by the /ready endpoint. */ async health() { try { await request('/health'); diff --git a/src/domain/finance.js b/src/domain/finance.js index cbbbab4..4a9af39 100644 --- a/src/domain/finance.js +++ b/src/domain/finance.js @@ -119,8 +119,11 @@ export function withinRange(timestamp, { from, to } = {}) { const value = new Date(timestamp).getTime(); if (Number.isNaN(value)) return false; - if (from && value < new Date(from).getTime()) return false; - if (to && value > new Date(to).getTime()) return false; + const fromValue = from ? new Date(from).getTime() : null; + const toValue = to ? new Date(to).getTime() : null; + if ((from && Number.isNaN(fromValue)) || (to && Number.isNaN(toValue))) return false; + if (fromValue !== null && value < fromValue) return false; + if (toValue !== null && value > toValue) return false; return true; } diff --git a/src/index.js b/src/index.js index 9d38120..d6ba649 100644 --- a/src/index.js +++ b/src/index.js @@ -12,7 +12,7 @@ const PORT = Number(process.env.PORT) || 4000; /** Starts the HTTP server exposing the GraphQL endpoint at /graphql. */ async function main() { - const apolloServer = createApolloServer({ logger, metrics }); + const apolloServer = createApolloServer({ logger, metrics, enableObservability: true }); await apolloServer.start(); const app = express(); diff --git a/src/server.js b/src/server.js index 63cf5ee..c414601 100644 --- a/src/server.js +++ b/src/server.js @@ -9,10 +9,12 @@ import { typeDefs } from './schema.js'; * Exported separately from the HTTP bootstrap so tests can run queries * against the server without opening a port. */ -export function createApolloServer({ logger, metrics, plugins = [] } = {}) { +export function createApolloServer({ logger, metrics, enableObservability = false, plugins = [] } = {}) { + const observabilityPlugins = enableObservability ? [createObservabilityPlugin({ logger, metrics })] : []; + return new ApolloServer({ typeDefs, resolvers, - plugins: [createObservabilityPlugin({ logger, metrics }), ...plugins], + plugins: [...observabilityPlugins, ...plugins], }); } diff --git a/src/services/financeService.js b/src/services/financeService.js index 2d38983..8ec993e 100644 --- a/src/services/financeService.js +++ b/src/services/financeService.js @@ -119,18 +119,16 @@ export function createFinanceService({ return { events: mapped.data, errors: mapped.error ? [mapped.error] : [] }; } - /** Full (unpaginated) filtered history, shared by tradeHistory and taxEstimate. */ + /** Full (unpaginated) filtered trading history shared by tradeHistory and taxEstimate. */ async function collectHistory(filter = {}) { const trading = await getTradingData(); const trades = filterTrades(trading.trades, filter); const orders = filterOrders(trading.orders, filter); - const taxEvents = await mapTaxEvents(trades); return { trades, orders, - taxEvents: filterTaxEvents(taxEvents.events, filter), - errors: [...trading.errors, ...taxEvents.errors], + errors: [...trading.errors], }; } @@ -153,32 +151,35 @@ export function createFinanceService({ async tradeHistory({ limit, offset, ...filter } = {}) { const history = await collectHistory(filter); const trades = paginate(history.trades, { limit, offset }, pageLimits); + const taxEvents = await mapTaxEvents(trades.items); const tradeIds = new Set(trades.items.map((trade) => trade.id)); return { trades: trades.items, orders: paginate(history.orders, { limit, offset }, pageLimits).items, - taxEvents: history.taxEvents.filter((event) => tradeIds.has(event.tradeId)), + taxEvents: filterTaxEvents(taxEvents.events, filter).filter((event) => tradeIds.has(event.tradeId)), pageInfo: trades.pageInfo, - errors: history.errors, + errors: [...history.errors, ...taxEvents.errors], }; }, async taxEstimate({ taxYear, limit, offset, ...filter } = {}) { const history = await collectHistory(filter); - const estimate = await capture('tax-break', 'estimateTax', () => upstreams.taxBreak.estimateTax({ events: history.taxEvents, taxYear })); - const summary = estimate.error ? estimateTaxFromEvents(history.taxEvents, taxYear) : estimate.data; + const taxEvents = await mapTaxEvents(history.trades); + const filteredEvents = filterTaxEvents(taxEvents.events, filter); + const estimate = await capture('tax-break', 'estimateTax', () => upstreams.taxBreak.estimateTax({ events: filteredEvents, taxYear })); + const summary = estimate.error ? estimateTaxFromEvents(filteredEvents, taxYear) : estimate.data; const events = paginate(summary.events ?? [], { limit, offset }, pageLimits); return { ...summary, events: events.items, pageInfo: events.pageInfo, - errors: [...history.errors, estimate.error].filter(Boolean), + errors: [...history.errors, ...taxEvents.errors, estimate.error].filter(Boolean), }; }, - /** Aggregated upstream readiness used by the HTTP /health endpoint. */ + /** Aggregated upstream readiness used by the HTTP /ready endpoint. */ async health() { const checks = await Promise.all( Object.values(upstreams).map(async (connector) => { diff --git a/test/finance.test.js b/test/finance.test.js index 0edea0b..d24e9b1 100644 --- a/test/finance.test.js +++ b/test/finance.test.js @@ -153,6 +153,8 @@ describe('finance filtering and pagination', () => { ); assert.equal(withinRange('2026-01-10T14:31:00.000Z', { from: '2026-01-10T14:31:00.000Z' }), true); assert.equal(withinRange('not-a-date', { from: '2026-01-01T00:00:00.000Z' }), false); + assert.equal(withinRange('2026-01-10T14:31:00.000Z', { from: 'not-a-date' }), false); + assert.equal(withinRange('2026-01-10T14:31:00.000Z', { to: 'not-a-date' }), false); }); it('paginates with clamped limits and reports page metadata', () => { @@ -194,3 +196,37 @@ describe('finance service health', () => { assert.equal(health.upstreams.find((check) => check.source === 'Portfolio-Watcher').status, 'degraded'); }); }); + +describe('finance service pagination behavior', () => { + it('maps tax events only for the paginated trade history page', async () => { + const mappedTradeIds = []; + const finance = createFinanceService({ + connectors: { + openTrading: { + listAccounts: async () => [{ acct_id: 'acct-1', display_name: 'Primary', source: 'OpenTrading' }], + listTrades: async () => [ + { trade_id: 'trade-1', acct_id: 'acct-1', order_ref: 'order-1', ticker: 'AAPL', action: 'BUY', qty: 2, avg_px: 150, trade_time: '2026-01-10T14:31:00.000Z' }, + { trade_id: 'trade-2', acct_id: 'acct-1', order_ref: 'order-2', ticker: 'AAPL', action: 'SELL', qty: 4, avg_px: 180, trade_time: '2026-04-05T15:45:00.000Z' }, + ], + listOrders: async () => [], + }, + portfolioWatcher: { + listPositions: async () => [], + listPerformanceSnapshots: async () => [], + }, + taxBreak: { + mapTradesToTaxEvents: async (pageTrades) => { + mappedTradeIds.push(pageTrades.map((trade) => trade.id)); + return pageTrades.map(tradeToTaxEvent); + }, + estimateTax: async () => ({}), + }, + }, + }); + + const result = await finance.tradeHistory({ limit: 1, offset: 1 }); + assert.deepEqual(result.trades.map((trade) => trade.id), ['trade-2']); + assert.deepEqual(mappedTradeIds, [['trade-2']]); + assert.deepEqual(result.taxEvents.map((event) => event.tradeId), ['trade-2']); + }); +});