Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,11 @@ S3_ADMIN_SECRET_KEY=
# enable distributed tracing; leave empty to disable. e.g. http://localhost:4318
OTEL_EXPORTER_OTLP_ENDPOINT=

# Continuous CPU profiling. Point this at a Pyroscope instance to push profiles
# every 60s; leave empty to disable. Independent of tracing above.
# e.g. http://localhost:4040
PYROSCOPE_SERVER_ADDRESS=

FIXER_BASE_URL=
FIXER_API_KEY=

Expand Down
88 changes: 87 additions & 1 deletion package-lock.json

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

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
"@opentelemetry/sdk-node": "^0.218.0",
"@opentelemetry/sdk-trace-base": "^2.7.1",
"@pyroscope/nodejs": "^0.6.2",
"@railgun-community/engine": "^9.4.0",
"@scure/bip32": "^1.6.2",
"@scure/bip39": "^1.5.4",
Expand Down
97 changes: 96 additions & 1 deletion src/__tests__/tracing.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
const mockStart = jest.fn();
const mockPyroscopeInit = jest.fn();
const mockStartWallProfiling = jest.fn();

jest.mock('@opentelemetry/sdk-node', () => ({
NodeSDK: jest.fn().mockImplementation(() => ({ start: mockStart })),
Expand All @@ -9,10 +11,14 @@ jest.mock('@opentelemetry/auto-instrumentations-node', () => ({
jest.mock('@opentelemetry/exporter-trace-otlp-http', () => ({
OTLPTraceExporter: jest.fn(),
}));
jest.mock('@pyroscope/nodejs', () => ({
__esModule: true,
default: { init: mockPyroscopeInit, startWallProfiling: mockStartWallProfiling },
}));

import { SpanKind, SpanStatusCode } from '@opentelemetry/api';
import { ReadableSpan } from '@opentelemetry/sdk-trace-base';
import { ClientErrorSpanProcessor, isClientError, startTracing } from '../tracing';
import { ClientErrorSpanProcessor, isClientError, startProfiling, startTracing } from '../tracing';

function fakeSpan(kind: SpanKind, statusCode: SpanStatusCode, httpStatus?: number): ReadableSpan {
return {
Expand Down Expand Up @@ -89,3 +95,92 @@ describe('startTracing', () => {
expect(mockStart).toHaveBeenCalledTimes(1);
});
});

// Every case loads its own copy of the module: importing tracing.ts calls
// startProfiling() as a side effect, and the module latches `profiling` after
// the first successful start. A fresh registry per case keeps that latch — the
// thing being tested — from leaking between them.
describe('startProfiling', () => {
const originalAddress = process.env.PYROSCOPE_SERVER_ADDRESS;
const originalEnvironment = process.env.ENVIRONMENT;

function loadTracing(): { startProfiling: () => boolean } {
let loaded: { startProfiling: () => boolean };
jest.isolateModules(() => {
// require, not import: isolateModules is synchronous and the point is to
// re-execute the module body (and with it its start-up side effects).
// eslint-disable-next-line @typescript-eslint/no-require-imports
loaded = require('../tracing');
});

return loaded;
}

beforeEach(() => {
mockPyroscopeInit.mockReset();
mockStartWallProfiling.mockReset();
jest.spyOn(console, 'error').mockImplementation(() => undefined);
});

afterEach(() => {
jest.restoreAllMocks();

if (originalAddress === undefined) delete process.env.PYROSCOPE_SERVER_ADDRESS;
else process.env.PYROSCOPE_SERVER_ADDRESS = originalAddress;

if (originalEnvironment === undefined) delete process.env.ENVIRONMENT;
else process.env.ENVIRONMENT = originalEnvironment;
});

it('is disabled (returns false) without PYROSCOPE_SERVER_ADDRESS', () => {
delete process.env.PYROSCOPE_SERVER_ADDRESS;
process.env.ENVIRONMENT = 'prd';

expect(startProfiling()).toBe(false);
expect(mockPyroscopeInit).not.toHaveBeenCalled();
expect(mockStartWallProfiling).not.toHaveBeenCalled();
});

it('starts the wall profiler with the environment tag and CPU time enabled', () => {
process.env.PYROSCOPE_SERVER_ADDRESS = 'http://localhost:4040';
process.env.ENVIRONMENT = 'prd';

const tracing = loadTracing();

expect(mockPyroscopeInit).toHaveBeenCalledWith({
appName: 'dfx-api',
serverAddress: 'http://localhost:4040',
tags: { env: 'prd' },
wall: { collectCpuTime: true },
});
// Once for the module-level call, and not again on the explicit one:
// starting a second sampler would double the profiling cost.
expect(tracing.startProfiling()).toBe(true);
expect(mockStartWallProfiling).toHaveBeenCalledTimes(1);
});

it('reports the failure and keeps running when ENVIRONMENT is missing', () => {
process.env.PYROSCOPE_SERVER_ADDRESS = 'http://localhost:4040';
delete process.env.ENVIRONMENT;

const tracing = loadTracing();

expect(tracing.startProfiling()).toBe(false);
expect(mockStartWallProfiling).not.toHaveBeenCalled();
expect(console.error).toHaveBeenCalled();
});

it('reports the failure and keeps running when the SDK throws', () => {
process.env.PYROSCOPE_SERVER_ADDRESS = 'http://localhost:4040';
process.env.ENVIRONMENT = 'prd';
mockPyroscopeInit.mockImplementation(() => {
throw new Error('boom');
});

const tracing = loadTracing();

expect(tracing.startProfiling()).toBe(false);
expect(mockStartWallProfiling).not.toHaveBeenCalled();
expect(console.error).toHaveBeenCalled();
});
});
78 changes: 75 additions & 3 deletions src/tracing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentation
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { NodeSDK } from '@opentelemetry/sdk-node';
import { BatchSpanProcessor, ReadableSpan, SpanProcessor } from '@opentelemetry/sdk-trace-base';
import Pyroscope from '@pyroscope/nodejs';

// OpenTelemetry tracing for dfx-api.
// OpenTelemetry tracing and continuous CPU profiling for dfx-api.
//
// This module is imported first in main.ts so the SDK starts before any
// instrumented library (http, pg/TypeORM, …) is loaded — otherwise the
Expand All @@ -13,8 +14,13 @@ import { BatchSpanProcessor, ReadableSpan, SpanProcessor } from '@opentelemetry/
// OTEL_EXPORTER_OTLP_ENDPOINT (no hardcoded collector address). When the
// variable is unset, tracing is disabled and the app boots unchanged.
//
// The exported helpers are pure and unit-tested; startTracing() has the side
// effect of registering the global SDK.
// Profiling follows the same rule with its own variable
// (PYROSCOPE_SERVER_ADDRESS) and answers a different question: tracing measures
// how long a request waited, profiling measures what burned the CPU while it
// ran. The two are independent — either can be enabled without the other.
//
// The exported helpers are pure and unit-tested; startTracing() and
// startProfiling() have the side effect of registering a global SDK.

/**
* HTTP 4xx (client error) check.
Expand Down Expand Up @@ -87,4 +93,70 @@ export function startTracing(): NodeSDK | undefined {
return sdk;
}

let profiling = false;

/**
* Starts continuous CPU profiling and returns whether the profiler is running.
*
* Disabled unless PYROSCOPE_SERVER_ADDRESS points at a Pyroscope instance, so
* LOC, tests and any environment without one boot untouched.
*/
export function startProfiling(): boolean {
if (!process.env.PYROSCOPE_SERVER_ADDRESS) return false;
if (profiling) return true;

try {
// dev and prd push to the same Pyroscope, exactly as they push to the same
// Tempo, so the profiles need a label that tells them apart. ENVIRONMENT is
// the variable the rest of the app already keys off (see config.ts), which
// keeps this from becoming a second source of truth. Checked explicitly
// because the SDK would otherwise reject the undefined tag value with a
// message that says nothing about where it came from.
const environment = process.env.ENVIRONMENT;
if (!environment) throw new Error('ENVIRONMENT is not set — profiles could not be told apart per environment');

Pyroscope.init({
// Becomes the service_name label Pyroscope indexes by; matches the OTel
// serviceName above so both signals name the same service.
appName: 'dfx-api',
serverAddress: process.env.PYROSCOPE_SERVER_ADDRESS,
tags: { env: environment },
// Without collectCpuTime the wall profiler reports elapsed time, which
// for an event loop that is mostly waiting says little. With it, the
// `wall:cpu:nanoseconds:wall:nanoseconds` series carries actual CPU time
// — the series to chart.
//
// Sampling stays at the 100 Hz default on purpose. Measured on a
// CPU-saturated Node process, halving it to 50 Hz changed throughput by
// less than the run-to-run spread (-4.3 % vs -4.6 % against no profiler):
// the cost is in the sampler being installed at all, not in the sample
// rate, so turning it down buys resolution loss and nothing else.
wall: { collectCpuTime: true },
});

// Wall/CPU only. Heap profiling is a second sampler with its own cost and
// is deliberately left off: this exists to attribute CPU time. Enable it
// via Pyroscope.startHeapProfiling() if allocation sites become the
// question.
Pyroscope.startWallProfiling();
profiling = true;

return true;
} catch (e) {
// The profiler is an observability extra; it must never be the reason the
// API fails to boot. Loud rather than silent: the line lands in Loki, and
// the absence of profiles in Grafana is the second signal.
//
// console is the only channel that exists here: this module runs before
// Nest is bootstrapped, so DfxLogger is not available, and OTel's diag
// logger is a no-op unless a sink is registered — which would turn a
// failure into silence.
// eslint-disable-next-line no-console
console.error('Continuous profiling disabled — Pyroscope failed to start:', e);

return false;
}
}

startTracing();
startProfiling();