Skip to content

Commit 9c229d3

Browse files
committed
feat(#217): OpenTelemetry distributed tracing for HTTP, DB, Redis, and Soroban RPC
- Add @opentelemetry/sdk-node with auto-instrumentation (HTTP, pg, ioredis) - Configure OTLP exporter via OTEL_EXPORTER_OTLP_ENDPOINT env var; no-op when unset - Configurable sampling ratio via OTEL_SAMPLING_RATIO (default 1.0 dev / 0.1 prod) - Propagate x-request-id as span attribute 'request.id' for log/trace correlation - Add withSorobanSpan() helper for Soroban RPC simulation spans (contractId + method only) - Sensitive data policy: XDR and private keys never appear as span attributes - Import tracing.ts before all other modules in main.ts for correct patching order - Add local Jaeger dev setup docs and environment variable reference Closes #217
1 parent a68414e commit 9c229d3

6 files changed

Lines changed: 215 additions & 0 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
@@ -47,6 +47,13 @@
4747
"@nestjs/swagger": "^7.4.2",
4848
"@nestjs/terminus": "^10.2.1",
4949
"@nestjs/throttler": "^6.5.0",
50+
"@opentelemetry/api": "^1.9.0",
51+
"@opentelemetry/auto-instrumentations-node": "^0.54.0",
52+
"@opentelemetry/exporter-trace-otlp-http": "^0.57.0",
53+
"@opentelemetry/resources": "^1.30.0",
54+
"@opentelemetry/sdk-node": "^0.57.0",
55+
"@opentelemetry/sdk-trace-base": "^1.30.0",
56+
"@opentelemetry/semantic-conventions": "^1.28.0",
5057
"@prisma/client": "^6.6.0",
5158
"@stellar/stellar-sdk": "^14.6.1",
5259
"@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 }

0 commit comments

Comments
 (0)