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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

Express API for the TalentTrust decentralized freelancer escrow protocol. Handles contract metadata, reputation, and integration with Stellar/Soroban.

## Request tracing

The backend now emits lightweight tracing spans for:

- incoming API requests
- contract repository lookups
- Stellar RPC health checks

Every request receives `x-trace-id` and `x-request-id` headers. If a client sends those headers, the backend preserves them so traces can be correlated across services.

Detailed tracing notes live in [docs/backend/request-tracing.md](docs/backend/request-tracing.md).

## Prerequisites

- Node.js 18+
Expand Down
41 changes: 41 additions & 0 deletions docs/backend/request-tracing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Request Tracing

## Overview

TalentTrust Backend includes lightweight request tracing that covers:

- API spans for each inbound Express request
- DB spans for contract repository operations
- RPC spans for Stellar RPC-facing operations

The implementation is intentionally dependency-light and uses `AsyncLocalStorage` so trace context flows through nested async work.

## Headers

Each response includes:

- `x-trace-id`
- `x-request-id`

If a caller provides either header, the middleware preserves it. Otherwise the backend generates one.

## Current span names

- `GET /health`
- `GET /api/v1/contracts`
- `contracts.repository.list`
- `contracts.rpc.fetch_registry_health`

## Security notes

- Trace payloads avoid request-body logging to reduce accidental leakage of secrets and PII.
- The middleware records only high-signal route and status metadata by default.
- Error capture stores message-level diagnostics only.

## Test coverage

Tracing behavior is covered by:

- `src/tracing/tracer.test.ts`
- `src/contracts/contracts.service.test.ts`
- `src/app.test.ts`
5 changes: 0 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

89 changes: 89 additions & 0 deletions src/app.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import type { AddressInfo } from 'node:net';
import { createApp } from './app';
import { InMemorySpanLogger, Tracer } from './tracing/tracer';

describe('app tracing', () => {
it('emits an api span for /health and returns trace headers', async () => {
const logger = new InMemorySpanLogger();
const tracer = new Tracer(logger);
const { app } = createApp({ tracer });
const server = app.listen(0);

try {
const address = server.address() as AddressInfo;
const response = await fetch(`http://127.0.0.1:${address.port}/health`, {
headers: {
'x-trace-id': 'trace-health',
'x-request-id': 'request-health',
},
});

expect(response.status).toBe(200);
expect(response.headers.get('x-trace-id')).toBe('trace-health');
expect(response.headers.get('x-request-id')).toBe('request-health');

const body = await response.json();
expect(body).toEqual({ status: 'ok', service: 'talenttrust-backend' });

expect(logger.spans).toEqual(
expect.arrayContaining([
expect.objectContaining({
traceId: 'trace-health',
requestId: 'request-health',
kind: 'api',
name: 'GET /health',
status: 'ok',
}),
]),
);
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
});

it('emits api, db, and rpc spans for /api/v1/contracts', async () => {
const logger = new InMemorySpanLogger();
const tracer = new Tracer(logger);
const { app } = createApp({ tracer });
const server = app.listen(0);

try {
const address = server.address() as AddressInfo;
const response = await fetch(`http://127.0.0.1:${address.port}/api/v1/contracts`);

expect(response.status).toBe(200);
expect(response.headers.get('x-rpc-network')).toBe('testnet');
expect(response.headers.get('x-rpc-healthy')).toBe('true');
expect(await response.json()).toEqual({ contracts: [] });

expect(logger.spans).toEqual(
expect.arrayContaining([
expect.objectContaining({ kind: 'api', name: 'GET /api/v1/contracts' }),
expect.objectContaining({ kind: 'db', name: 'contracts.repository.list' }),
expect.objectContaining({
kind: 'rpc',
name: 'contracts.rpc.fetch_registry_health',
}),
]),
);
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
});
});
38 changes: 38 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import express, { Request, Response } from 'express';
import {
ContractsService,
} from './contracts/contracts.service';
import { InMemoryContractRepository } from './contracts/contracts.repository';
import { StellarRpcClient } from './contracts/contracts.rpc';
import { createRequestTracingMiddleware } from './tracing/request-tracing';
import { Tracer } from './tracing/tracer';

export interface AppDependencies {
tracer?: Tracer;
contractsService?: ContractsService;
}

export const createApp = (dependencies: AppDependencies = {}) => {
const tracer = dependencies.tracer ?? new Tracer();
const contractsService =
dependencies.contractsService ??
new ContractsService(
new InMemoryContractRepository(tracer),
new StellarRpcClient(tracer),
);

const app = express();
app.use(express.json());
app.use(createRequestTracingMiddleware(tracer));

app.get('/health', (_req: Request, res: Response) => {
res.json({ status: 'ok', service: 'talenttrust-backend' });
});

app.get('/api/v1/contracts', async (_req: Request, res: Response) => {
const payload = await contractsService.listContracts(res);
res.json(payload);
});

return { app, tracer, contractsService };
};
31 changes: 31 additions & 0 deletions src/contracts/contracts.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Tracer } from '../tracing/tracer';

export interface ContractRecord {
id: string;
status: 'active' | 'draft';
}

export interface ContractRepository {
listContracts(): Promise<ContractRecord[]>;
}

export class InMemoryContractRepository implements ContractRepository {
constructor(private readonly tracer: Tracer) {}

async listContracts(): Promise<ContractRecord[]> {
const span = this.tracer.startSpan('contracts.repository.list', 'db', {
'db.system': 'memory',
'db.operation': 'select',
'db.collection': 'contracts',
});

try {
return [];
} catch (error) {
span.recordError(error);
throw error;
} finally {
span.end();
}
}
}
33 changes: 33 additions & 0 deletions src/contracts/contracts.rpc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Tracer } from '../tracing/tracer';

export interface RpcStatus {
network: string;
healthy: boolean;
}

export interface ContractsRpcClient {
fetchRegistryHealth(): Promise<RpcStatus>;
}

export class StellarRpcClient implements ContractsRpcClient {
constructor(private readonly tracer: Tracer) {}

async fetchRegistryHealth(): Promise<RpcStatus> {
const span = this.tracer.startSpan('contracts.rpc.fetch_registry_health', 'rpc', {
'rpc.system': 'stellar',
'rpc.method': 'getHealth',
});

try {
return {
network: 'testnet',
healthy: true,
};
} catch (error) {
span.recordError(error);
throw error;
} finally {
span.end();
}
}
}
28 changes: 28 additions & 0 deletions src/contracts/contracts.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { Response } from 'express';
import { ContractsService } from './contracts.service';
import type { ContractRepository } from './contracts.repository';
import type { ContractsRpcClient } from './contracts.rpc';

describe('ContractsService', () => {
it('returns contracts and writes rpc health headers', async () => {
const repository: ContractRepository = {
listContracts: jest.fn().mockResolvedValue([{ id: 'c1', status: 'active' }]),
};
const rpcClient: ContractsRpcClient = {
fetchRegistryHealth: jest
.fn()
.mockResolvedValue({ network: 'testnet', healthy: true }),
};
const service = new ContractsService(repository, rpcClient);
const setHeader = jest.fn();
const response = {
setHeader,
} as unknown as Response;

const result = await service.listContracts(response);

expect(result).toEqual({ contracts: [{ id: 'c1', status: 'active' }] });
expect(setHeader).toHaveBeenCalledWith('x-rpc-network', 'testnet');
expect(setHeader).toHaveBeenCalledWith('x-rpc-healthy', 'true');
});
});
24 changes: 24 additions & 0 deletions src/contracts/contracts.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { Response } from 'express';
import type { ContractRepository } from './contracts.repository';
import type { ContractsRpcClient } from './contracts.rpc';

export class ContractsService {
constructor(
private readonly repository: ContractRepository,
private readonly rpcClient: ContractsRpcClient,
) {}

async listContracts(response?: Response): Promise<{ contracts: unknown[] }> {
const [contracts, rpcStatus] = await Promise.all([
this.repository.listContracts(),
this.rpcClient.fetchRegistryHealth(),
]);

if (response) {
response.setHeader('x-rpc-network', rpcStatus.network);
response.setHeader('x-rpc-healthy', String(rpcStatus.healthy));
}

return { contracts };
}
}
5 changes: 0 additions & 5 deletions src/health.test.ts

This file was deleted.

24 changes: 10 additions & 14 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,14 @@
import express, { Request, Response } from 'express';
import { createApp } from './app';

const app = express();
const PORT = process.env.PORT || 3001;

app.use(express.json());
export const startServer = () => {
const { app } = createApp();
return app.listen(PORT, () => {
console.log(`TalentTrust API listening on http://localhost:${PORT}`);
});
};

app.get('/health', (_req: Request, res: Response) => {
res.json({ status: 'ok', service: 'talenttrust-backend' });
});

app.get('/api/v1/contracts', (_req: Request, res: Response) => {
res.json({ contracts: [] });
});

app.listen(PORT, () => {
console.log(`TalentTrust API listening on http://localhost:${PORT}`);
});
if (require.main === module) {
startServer();
}
Loading
Loading