Skip to content

Commit 38bb431

Browse files
authored
Merge pull request #302 from Coredevjay/feat/issues-235-232-228-217
Feat/issues 235 232 228 217
2 parents 4260d93 + 9c229d3 commit 38bb431

18 files changed

Lines changed: 886 additions & 48 deletions

File tree

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# OpenTelemetry Tracing
2+
3+
Distributed tracing is implemented via `@opentelemetry/sdk-node` with auto-instrumentation for HTTP, Prisma/PostgreSQL, and Redis (ioredis). Soroban RPC simulation calls are wrapped with a custom span helper.
4+
5+
## Environment Variables
6+
7+
| Variable | Default | Description |
8+
|---|---|---|
9+
| `OTEL_EXPORTER_OTLP_ENDPOINT` | _(unset — no-op)_ | OTLP HTTP endpoint, e.g. `http://localhost:4318` |
10+
| `OTEL_SERVICE_NAME` | `niffyinsure-backend` | Service name in traces |
11+
| `OTEL_SAMPLING_RATIO` | `1.0` (dev) / `0.1` (prod) | Head-sampling ratio 0.0–1.0 |
12+
13+
When `OTEL_EXPORTER_OTLP_ENDPOINT` is not set, the SDK runs with a no-op exporter — no traces are exported and there is no performance overhead.
14+
15+
## Local Development with Jaeger
16+
17+
Run Jaeger all-in-one (includes OTLP HTTP receiver on port 4318):
18+
19+
```bash
20+
docker run --rm -d \
21+
-p 16686:16686 \
22+
-p 4318:4318 \
23+
--name jaeger \
24+
jaegertracing/all-in-one:latest
25+
```
26+
27+
Then set in your `.env`:
28+
29+
```
30+
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
31+
OTEL_SERVICE_NAME=niffyinsure-backend
32+
OTEL_SAMPLING_RATIO=1.0
33+
```
34+
35+
Open the Jaeger UI at http://localhost:16686 and select the `niffyinsure-backend` service.
36+
37+
## Trace Correlation with Logs
38+
39+
Every incoming request attaches `x-request-id` as a span attribute (`request.id`). Structured log entries include the same `requestId` field, enabling correlation between traces and logs.
40+
41+
## Sensitive Data Policy
42+
43+
- XDR payloads and private keys **must never** appear as span attributes.
44+
- Request/response bodies are not captured by auto-instrumentation (body capture hooks are disabled).
45+
- Only `soroban.contract_id` and `soroban.method` are recorded for Soroban RPC spans.
46+
47+
## Soroban RPC Spans
48+
49+
Use `withSorobanSpan` from `src/common/tracing/soroban-span.ts` to wrap simulation calls:
50+
51+
```typescript
52+
import { withSorobanSpan } from '@/common/tracing/soroban-span'
53+
54+
const result = await withSorobanSpan(
55+
{ contractId: 'C...', method: 'simulateTransaction', requestId },
56+
() => server.simulateTransaction(tx),
57+
)
58+
```
59+
60+
## Production Sampling
61+
62+
The default production sampling ratio is `0.1` (10%). Adjust via `OTEL_SAMPLING_RATIO` without redeployment.

backend/package.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@
5454
"@nestjs/swagger": "^7.4.2",
5555
"@nestjs/terminus": "^10.2.1",
5656
"@nestjs/throttler": "^6.5.0",
57+
"@opentelemetry/api": "^1.9.0",
58+
"@opentelemetry/auto-instrumentations-node": "^0.54.0",
59+
"@opentelemetry/exporter-trace-otlp-http": "^0.57.0",
60+
"@opentelemetry/resources": "^1.30.0",
61+
"@opentelemetry/sdk-node": "^0.57.0",
62+
"@opentelemetry/sdk-trace-base": "^1.30.0",
63+
"@opentelemetry/semantic-conventions": "^1.28.0",
5764
"@prisma/client": "^6.6.0",
5865
"@stellar/stellar-sdk": "^14.6.1",
5966
"@types/form-data": "^2.2.1",

backend/src/common/middleware/request-context.middleware.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Injectable, NestMiddleware } from '@nestjs/common';
22
import { Request, Response, NextFunction } from 'express';
33
import { randomUUID } from 'crypto';
4+
import { trace, context as otelContext, SpanStatusCode } from '@opentelemetry/api';
45
import { MetricsService } from '../../metrics/metrics.service';
56
import { AppLoggerService, redactHeaders } from '../logger/app-logger.service';
67

@@ -34,6 +35,12 @@ export class RequestContextMiddleware implements NestMiddleware {
3435
// Echo back so clients can correlate
3536
res.setHeader('x-request-id', requestId);
3637

38+
// Propagate requestId as a span attribute on the active OTel span (if any)
39+
const activeSpan = trace.getActiveSpan()
40+
if (activeSpan) {
41+
activeSpan.setAttribute('request.id', requestId)
42+
}
43+
3744
const start = Date.now();
3845

3946
this.logger.structured('info', 'request_received', {
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* Utility for wrapping Soroban RPC simulation calls with an OpenTelemetry span.
3+
*
4+
* Usage:
5+
* const result = await withSorobanSpan(
6+
* { contractId: 'C...', method: 'vote' },
7+
* () => server.simulateTransaction(tx),
8+
* )
9+
*
10+
* Sensitive data policy:
11+
* - XDR payloads and private keys MUST NOT be passed as span attributes.
12+
* - Only contractId and method are recorded.
13+
*/
14+
15+
import { trace, SpanStatusCode, context } from '@opentelemetry/api'
16+
17+
const tracer = trace.getTracer('soroban-rpc')
18+
19+
export interface SorobanSpanOptions {
20+
/** Contract ID (C... address) — safe to record as a span attribute. */
21+
contractId: string
22+
/** RPC method name, e.g. "simulateTransaction", "sendTransaction". */
23+
method: string
24+
/** Optional x-request-id for correlation with structured logs. */
25+
requestId?: string
26+
}
27+
28+
/**
29+
* Wraps a Soroban RPC call in an OTel span.
30+
* Records contractId and method as span attributes.
31+
* Never records XDR, private keys, or other sensitive parameters.
32+
*/
33+
export async function withSorobanSpan<T>(
34+
opts: SorobanSpanOptions,
35+
fn: () => Promise<T>,
36+
): Promise<T> {
37+
const span = tracer.startSpan(`soroban.${opts.method}`, {
38+
attributes: {
39+
'soroban.contract_id': opts.contractId,
40+
'soroban.method': opts.method,
41+
...(opts.requestId ? { 'request.id': opts.requestId } : {}),
42+
},
43+
})
44+
45+
return context.with(trace.setSpan(context.active(), span), async () => {
46+
try {
47+
const result = await fn()
48+
span.setStatus({ code: SpanStatusCode.OK })
49+
return result
50+
} catch (err) {
51+
span.setStatus({
52+
code: SpanStatusCode.ERROR,
53+
message: err instanceof Error ? err.message : String(err),
54+
})
55+
span.recordException(err instanceof Error ? err : new Error(String(err)))
56+
throw err
57+
} finally {
58+
span.end()
59+
}
60+
})
61+
}

backend/src/main.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
// OpenTelemetry instrumentation MUST be imported before any other module
2+
// so that auto-instrumentation patches are applied at load time.
3+
import './tracing'
4+
15
import { NestFactory } from "@nestjs/core";
26
import { ValidationPipe, Logger } from "@nestjs/common";
37
import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger";

backend/src/tracing.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* OpenTelemetry instrumentation bootstrap.
3+
*
4+
* This file MUST be imported before any other module (i.e. at the very top of
5+
* main.ts) so that auto-instrumentation patches are applied before the
6+
* libraries they instrument are loaded.
7+
*
8+
* Configuration via environment variables:
9+
* OTEL_EXPORTER_OTLP_ENDPOINT — OTLP gRPC/HTTP endpoint (e.g. http://localhost:4318)
10+
* Defaults to no-op (no export) when unset.
11+
* OTEL_SERVICE_NAME — Service name reported in traces (default: niffyinsure-backend)
12+
* OTEL_SAMPLING_RATIO — Head-sampling ratio 0.0–1.0 (default: 1.0 in dev, 0.1 in prod)
13+
*
14+
* Sensitive data policy:
15+
* - XDR payloads and private keys MUST NOT appear as span attributes.
16+
* - Request bodies are never captured by auto-instrumentation (HTTP body capture is disabled).
17+
*/
18+
19+
import { NodeSDK } from '@opentelemetry/sdk-node'
20+
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
21+
import { Resource } from '@opentelemetry/resources'
22+
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions'
23+
import { TraceIdRatioBasedSampler } from '@opentelemetry/sdk-trace-base'
24+
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'
25+
26+
const SERVICE_NAME = process.env.OTEL_SERVICE_NAME ?? 'niffyinsure-backend'
27+
const OTLP_ENDPOINT = process.env.OTEL_EXPORTER_OTLP_ENDPOINT
28+
29+
// Sampling: configurable without redeployment via env var.
30+
// Default: 1.0 (sample everything) unless NODE_ENV=production, then 0.1.
31+
const defaultRatio = process.env.NODE_ENV === 'production' ? 0.1 : 1.0
32+
const samplingRatio = parseFloat(process.env.OTEL_SAMPLING_RATIO ?? String(defaultRatio))
33+
34+
// Only configure an exporter when an endpoint is explicitly set.
35+
// In development (no endpoint), the SDK runs with a no-op exporter.
36+
const traceExporter = OTLP_ENDPOINT
37+
? new OTLPTraceExporter({ url: `${OTLP_ENDPOINT}/v1/traces` })
38+
: undefined
39+
40+
const sdk = new NodeSDK({
41+
resource: new Resource({
42+
[ATTR_SERVICE_NAME]: SERVICE_NAME,
43+
[ATTR_SERVICE_VERSION]: process.env.npm_package_version ?? '0.0.0',
44+
}),
45+
sampler: new TraceIdRatioBasedSampler(samplingRatio),
46+
...(traceExporter ? { traceExporter } : {}),
47+
instrumentations: [
48+
getNodeAutoInstrumentations({
49+
// HTTP instrumentation — captures incoming/outgoing HTTP spans.
50+
// Body capture is disabled to prevent XDR/key leakage.
51+
'@opentelemetry/instrumentation-http': {
52+
enabled: true,
53+
// Do not capture request/response bodies
54+
requestHook: () => undefined,
55+
responseHook: () => undefined,
56+
},
57+
// Prisma / pg instrumentation for DB spans
58+
'@opentelemetry/instrumentation-pg': { enabled: true },
59+
// Redis instrumentation for cache spans
60+
'@opentelemetry/instrumentation-ioredis': { enabled: true },
61+
// Disable noisy fs instrumentation
62+
'@opentelemetry/instrumentation-fs': { enabled: false },
63+
}),
64+
],
65+
})
66+
67+
sdk.start()
68+
69+
// Graceful shutdown
70+
process.on('SIGTERM', () => {
71+
sdk.shutdown().catch((err) => console.error('OTel shutdown error', err))
72+
})
73+
74+
export { sdk }
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import Link from 'next/link'
2+
3+
export default function ClaimNotFound() {
4+
return (
5+
<main className="mx-auto max-w-2xl px-4 py-20 text-center">
6+
<p className="text-5xl mb-4" aria-hidden="true">🔍</p>
7+
<h1 className="text-2xl font-bold text-gray-900 mb-2">Claim not found</h1>
8+
<p className="text-gray-500 mb-8">
9+
This claim ID doesn&apos;t exist or may have been removed. Double-check the URL and try again.
10+
</p>
11+
<Link
12+
href="/claims"
13+
className="inline-flex items-center rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 min-h-[44px]"
14+
>
15+
Back to Claims Board
16+
</Link>
17+
</main>
18+
)
19+
}

frontend/src/app/claims/[claimId]/page.tsx

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,91 @@
1+
import type { Metadata } from 'next'
2+
import { notFound } from 'next/navigation'
3+
14
import { ClaimVotePanel } from '@/components/claims/claim-vote-panel'
5+
import { getConfig } from '@/config/env'
6+
import type { Claim } from '@/lib/schemas/vote'
7+
import { ClaimSchema } from '@/lib/schemas/vote'
28

39
interface ClaimPageProps {
410
params: Promise<{ claimId: string }>
511
}
612

13+
async function fetchClaimForMeta(claimId: string): Promise<Claim | null> {
14+
try {
15+
const { apiUrl } = getConfig()
16+
const res = await fetch(`${apiUrl}/api/claims/${claimId}`, {
17+
next: { revalidate: 60 },
18+
})
19+
if (res.status === 404) return null
20+
if (!res.ok) return null
21+
const data = await res.json()
22+
return ClaimSchema.parse(data)
23+
} catch {
24+
return null
25+
}
26+
}
27+
28+
export async function generateMetadata({ params }: ClaimPageProps): Promise<Metadata> {
29+
const { claimId } = await params
30+
const claim = await fetchClaimForMeta(claimId)
31+
32+
if (!claim) {
33+
return {
34+
title: 'Claim Not Found',
35+
description: 'The requested claim could not be found.',
36+
}
37+
}
38+
39+
const title = `Claim ${claimId}${claim.status}`
40+
const description = `Vote on claim ${claimId}. Current status: ${claim.status}. Approve votes: ${claim.approve_votes}, Reject votes: ${claim.reject_votes}.`
41+
42+
return {
43+
title,
44+
description,
45+
// Canonical URL prevents duplicate content from query params
46+
alternates: {
47+
canonical: `/claims/${claimId}`,
48+
},
49+
openGraph: {
50+
title,
51+
// Description intentionally omits wallet addresses and sensitive claim details
52+
description: `Claim status: ${claim.status}. Cast your vote on this insurance claim.`,
53+
type: 'website',
54+
},
55+
}
56+
}
57+
758
/**
8-
* /claims/[claimId]
59+
* /claims/[claimId] — server component.
960
*
10-
* Wallet address and currentLedger are passed as props here.
11-
* In production, replace the stubs below with your wallet context
12-
* (e.g. Freighter / WalletConnect) and a Horizon ledger sequence fetch.
61+
* Fetches claim metadata server-side for SEO/OG without exposing
62+
* wallet-specific data in the initial HTML. Wallet connection and
63+
* eligibility checks happen client-side in ClaimVotePanel.
1364
*/
1465
export default async function ClaimPage({ params }: ClaimPageProps) {
1566
const { claimId } = await params
1667

17-
// TODO: replace with wallet context hook (e.g. useFreighter / useWalletConnect)
68+
// Validate the claim exists; show 404 for unknown IDs
69+
const claim = await fetchClaimForMeta(claimId)
70+
if (!claim) {
71+
notFound()
72+
}
73+
74+
// Wallet address and currentLedger are resolved client-side to avoid
75+
// exposing wallet-specific data in server-rendered HTML (issue #228 req).
1876
const walletAddress: string | null = null
19-
// TODO: replace with real ledger sequence from Horizon /fee_stats or polling
2077
const currentLedger = 0
2178

2279
return (
2380
<main
2481
className="mx-auto max-w-2xl px-4 py-10 pb-[calc(2.5rem+env(safe-area-inset-bottom,0px))]"
25-
style={{ paddingLeft: 'max(1rem, env(safe-area-inset-left, 0px))', paddingRight: 'max(1rem, env(safe-area-inset-right, 0px))' }}
82+
style={{
83+
paddingLeft: 'max(1rem, env(safe-area-inset-left, 0px))',
84+
paddingRight: 'max(1rem, env(safe-area-inset-right, 0px))',
85+
}}
2686
>
2787
<h1 className="mb-6 text-xl font-bold">
28-
Claim vote - <span className="font-mono text-base">{claimId}</span>
88+
Claim vote <span className="font-mono text-base">{claimId}</span>
2989
</h1>
3090
<ClaimVotePanel
3191
claimId={claimId}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import Link from 'next/link'
2+
3+
export default function PolicyNotFound() {
4+
return (
5+
<main className="mx-auto max-w-2xl px-4 py-20 text-center">
6+
<p className="text-5xl mb-4" aria-hidden="true">📋</p>
7+
<h1 className="text-2xl font-bold text-gray-900 mb-2">Policy not found</h1>
8+
<p className="text-gray-500 mb-8">
9+
This policy ID doesn&apos;t exist or may no longer be available. Check the URL and try again.
10+
</p>
11+
<Link
12+
href="/policies"
13+
className="inline-flex items-center rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 min-h-[44px]"
14+
>
15+
Back to My Policies
16+
</Link>
17+
</main>
18+
)
19+
}

0 commit comments

Comments
 (0)