Skip to content
Merged
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
36 changes: 36 additions & 0 deletions docs/use-dkg/node-ui-metrics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Node UI metrics collection

The daemon writes local Node UI metric snapshots to `node-ui.db`. This local
collector is separate from OpenTelemetry metric export:

- `telemetry.metrics.collectionEnabled` controls local SQLite snapshots and
the store queries used to populate them.
- `telemetry.metrics.enabled`, `endpoint`, and `exportIntervalMs` control OTLP
export. Disabling local collection does not disable OTLP export.

Local collection remains enabled by default for backward compatibility. To
disable it, add:

```json
{
"telemetry": {
"metrics": {
"collectionEnabled": false
}
}
}
```

The environment override takes precedence over configuration:

```bash
export DKG_METRICS_COLLECTION_ENABLED=0
```

The environment value accepts `1`, `0`, `true`, or `false`. Invalid config or
environment values fail daemon startup instead of silently enabling the
collector. Restart the daemon after changing the setting.

Disabling the collector stops new local snapshots and store scans. Existing
history remains in SQLite. When collection is enabled, the existing metrics
presence gate and `DKG_METRICS_ALWAYS_COLLECT=1` behavior are unchanged.
6 changes: 6 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ dkg ka publish-async notes -c my-project
dkg query my-project -q "SELECT ?s ?p ?o WHERE { ?s ?p ?o } LIMIT 10"
```

## Node UI metrics collection

Operators can disable local dashboard snapshots independently from
OpenTelemetry metric export; see the
[Node UI metrics operator guide](../../docs/use-dkg/node-ui-metrics.md).

## Running a Core Node (relay operator)

A Core Node is a publicly-reachable host that runs a libp2p circuit-relay v2
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -726,6 +726,8 @@ export interface DkgConfig {
token?: string;
/** PeriodicExportingMetricReader interval. Default 30000ms. */
exportIntervalMs?: number;
/** Enable local Node UI SQLite metric snapshots. Default true. */
collectionEnabled?: boolean;
};
};
/** Shared memory (workspace) data TTL in milliseconds. Default: 30 days (2592000000). Set to 0 to disable cleanup. */
Expand Down
28 changes: 19 additions & 9 deletions packages/cli/src/daemon/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,10 @@ import {
} from '../config.js';
import { projectRuntimeEvmChainConfig } from '../runtime-chain-config.js';
import { resolveOtelSignals, resolveLogExporterMode, isUnknownLogExporter } from '../telemetry-config.js';
import {
formatMetricsCollectorStartupLog,
resolveMetricsCollectorConfig,
} from '../metrics-collector-config.js';
import { createDaemonLogSink } from './log-sink.js';
import { startRpcUsageTelemetry } from './rpc-usage-log.js';
import { startDashboardLogVolumePruner } from './dashboard-log-volume-pruner.js';
Expand Down Expand Up @@ -1133,6 +1137,9 @@ export async function runDaemonInner(
startedAt: number,
): Promise<void> {
configureKaPublishLifecycleDebugLogging(config);
// Resolve the local collector toggle before constructing daemon resources.
// This is independent from OTLP metrics export configuration.
const metricsCollectorConfig = resolveMetricsCollectorConfig(config);
const logFile = logPath();
// Rotate before installing the in-process stdout/stderr tee so startup does
// not race the truncation with fresh log appends. Existing logs survive
Expand Down Expand Up @@ -2642,14 +2649,17 @@ export async function runDaemonInner(
alwaysCollect: process.env.DKG_METRICS_ALWAYS_COLLECT === "1",
});

const metricsCollector = new MetricsCollector(
dashDb,
metricsSource,
dkgDir(),
() => metricsPresence.hasRecentConsumer(),
);
metricsCollector.start();
log("Metrics collector started (30s interval)");
let metricsCollector: MetricsCollector | undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: Keep the collector toggle inside a focused lifecycle helper

What's wrong
The PR adds the new feature flag by threading nullable collector state through a 3.8k-line daemon function. That preserves the existing sprawl and adds another conditional lifecycle concern at the top level instead of making the metrics collector a self-contained subsystem.

Example
A reader now has to connect the early metricsCollectorConfig, the conditional constructor, route-time optional collector use, and optional cleanup to understand the disabled state. That is more daemon-level state for a feature that could own its own start/stop policy.

Suggested direction
Replace the top-level nullable resource pattern with a small startMetricsCollector(...) or createLocalMetricsCollectorLifecycle(...) abstraction that resolves the toggle, starts when enabled, formats/logs startup, and exposes a uniform stop() cleanup handle.

Confidence note
This is a structural concern in an already very large function; the added lines are small, but they add another piece of cross-cutting lifecycle state instead of shrinking the collector ownership boundary.

For Agents
In packages/cli/src/daemon/lifecycle.ts, extract the local Node UI metrics collector lifecycle into a focused helper near the metrics section, returning the collector passed to node-ui routes plus a stop no-op when disabled. Preserve default enabled behavior, disabled no-start behavior, the startup log text, and cleanup behavior.

if (metricsCollectorConfig.enabled) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: Daemon collector toggle is not covered at the wiring point

What's wrong
The operator-facing promise is that disabling local collection stops new SQLite snapshots and store scans. The current tests verify that the config helper returns { enabled: false }, but they do not verify that runDaemonInner uses that value to suppress the MetricsCollector side effect. That leaves the changed behavior unverified at the point where the store scans are actually started.

Example
A regression that left metricsCollector.start() unconditional in runDaemonInner, or accidentally ignored telemetry.metrics.collectionEnabled: false before this block, would still pass the new resolver and doctor tests. A focused test could mock @origintrail-official/dkg-node-ui's MetricsCollector, start runDaemonInner with { telemetry: { metrics: { collectionEnabled: false } } }, abort after startup like the existing daemon wiring tests, and assert the constructor/start were not called and the disabled startup log was emitted.

Suggested direction
Add a small daemon-level wiring regression test instead of relying only on the pure config resolver tests.

For Agents
Look at packages/cli/test/*wiring*.test.ts for the existing runDaemonInner mock harness. Add coverage around packages/cli/src/daemon/lifecycle.ts that preserves default enabled startup, proves collectionEnabled: false skips MetricsCollector construction/start, and ideally proves the env override path reaches the same daemon wiring.

metricsCollector = new MetricsCollector(
dashDb,
metricsSource,
dkgDir(),
() => metricsPresence.hasRecentConsumer(),
);
metricsCollector.start();
}
log(formatMetricsCollectorStartupLog(metricsCollectorConfig));

// --- Telemetry: syslog log streaming (opt-in) ---
const networkKey = network?.networkName?.toLowerCase().includes("testnet")
Expand Down Expand Up @@ -3729,7 +3739,7 @@ export async function runDaemonInner(
// log-derived request totals exact across process lifecycles.
rpcUsageTelemetry.stop();
rateLimiter.destroy();
metricsCollector.stop();
metricsCollector?.stop();
// Stops log exporters AND flushes + shuts down the OTel SDK.
await stopTelemetry();
natStatusWatcherStop?.();
Expand Down
25 changes: 25 additions & 0 deletions packages/cli/src/doctor/checks/config-sanity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ import { join } from 'node:path';
import {
AUTO_UPDATE_GIT_ONLY_FIELDS,
parseAutoUpdateVerifyTagSignature,
type DkgConfig,
} from '../../config.js';
import { resolveMetricsCollectorConfig } from '../../metrics-collector-config.js';
import {
formatAutoUpdateTagVerificationWarning,
resolveAutoUpdateGitRefPlan,
Expand Down Expand Up @@ -97,6 +99,29 @@ export async function runConfigSanityCheck(deps: DoctorDeps): Promise<Finding[]>
}
}

const telemetry = parsed.telemetry;
if (telemetry && typeof telemetry === 'object' && !Array.isArray(telemetry)) {
const metrics = (telemetry as Record<string, unknown>).metrics;
if (metrics && typeof metrics === 'object' && !Array.isArray(metrics)) {
const collectionEnabled = (metrics as Record<string, unknown>).collectionEnabled;
if (collectionEnabled !== undefined) {
try {
resolveMetricsCollectorConfig({
telemetry: { metrics: { collectionEnabled } },
} as unknown as Pick<DkgConfig, 'telemetry'>, {});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: Validate the raw toggle without fabricating a typed runtime config

What's wrong
The doctor check operates on untrusted parsed config, but this change forces it through the daemon runtime type by constructing a fake config object and casting it. That muddies the boundary between raw config validation and resolved runtime config, and it makes future toggle validation likely to grow more nested shape traversal and more casts instead of using one clear parser.

Example
resolveMetricsCollectorConfig({ telemetry: { metrics: { collectionEnabled } } } as unknown as Pick<DkgConfig, 'telemetry'>, {}) is a sign that the reusable boundary is one level too high for raw config validation.

Suggested direction
Move the boolean parsing/validation into a small canonical helper, for example parseMetricsCollectionEnabledConfigValue(value: unknown): boolean, and keep resolveMetricsCollectorConfig as the daemon-facing env-precedence adapter.

For Agents
In packages/cli/src/metrics-collector-config.ts, extract/export a raw-value parser or validator for telemetry.metrics.collectionEnabled; have resolveMetricsCollectorConfig call it, and have config-sanity.ts call it directly against parsed JSON. Preserve env precedence in daemon startup and prove invalid raw config still produces the same doctor finding.

} catch (err) {
findings.push({
check: 'config-sanity',
severity: 'error',
message: err instanceof Error ? err.message : String(err),
advisory: 'Set telemetry.metrics.collectionEnabled to true or false.',
subject: 'telemetry.metrics.collectionEnabled',
});
}
}
}
}

// autoUpdate sub-config
const autoUpdate = parsed.autoUpdate;
if (autoUpdate && typeof autoUpdate === 'object' && !Array.isArray(autoUpdate)) {
Expand Down
50 changes: 50 additions & 0 deletions packages/cli/src/metrics-collector-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { DkgConfig } from './config.js';

export interface ResolvedMetricsCollectorConfig {
enabled: boolean;
}

function resolveEnabled(configValue: unknown, envValue: string | undefined): boolean {
if (envValue !== undefined) {
const normalized = envValue.trim().toLowerCase();
if (normalized === '1' || normalized === 'true') return true;
if (normalized === '0' || normalized === 'false') return false;
throw new Error(
'DKG_METRICS_COLLECTION_ENABLED must be one of 1, 0, true, or false ' +
`(received ${JSON.stringify(envValue)})`,
);
}
if (configValue === undefined) return true;
if (typeof configValue !== 'boolean') {
throw new Error(
'telemetry.metrics.collectionEnabled must be a boolean ' +
`(received ${JSON.stringify(configValue)})`,
);
}
return configValue;
}

/**
* Resolve local Node UI snapshot collection independently from OTLP export.
* The dedicated environment variable wins over config; invalid values fail
* startup rather than silently enabling collection.
*/
export function resolveMetricsCollectorConfig(
config: Pick<DkgConfig, 'telemetry'> | null | undefined,
env: Record<string, string | undefined> = process.env,
): ResolvedMetricsCollectorConfig {
return {
enabled: resolveEnabled(
config?.telemetry?.metrics?.collectionEnabled,
env.DKG_METRICS_COLLECTION_ENABLED,
),
};
}

export function formatMetricsCollectorStartupLog(
resolved: ResolvedMetricsCollectorConfig,
): string {
return resolved.enabled
? 'Metrics collector started (30s interval)'
: 'Metrics collector disabled';
}
28 changes: 28 additions & 0 deletions packages/cli/test/dkg-doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,34 @@ describe('config-sanity check (§4.7.2)', () => {
expect(findings.find((f) => f.subject === 'apiPort' && f.severity === 'error')).toBeDefined();
});

it('rejects invalid local metrics collector toggle types', async () => {
const deps = makeDeps({
fs: {
'/test/.dkg/config.json': JSON.stringify({
telemetry: { metrics: { collectionEnabled: 'yes' } },
}),
},
});
const findings = await runConfigSanityCheck(deps);
expect(findings.find((f) =>
f.subject === 'telemetry.metrics.collectionEnabled' && f.severity === 'error',
)).toBeDefined();
});

it('accepts a boolean local metrics collector toggle', async () => {
const deps = makeDeps({
fs: {
'/test/.dkg/config.json': JSON.stringify({
telemetry: { metrics: { collectionEnabled: false } },
}),
},
});
const findings = await runConfigSanityCheck(deps);
expect(findings.find((f) =>
f.subject === 'telemetry.metrics.collectionEnabled',
)).toBeUndefined();
});

it('warns on deprecated autoUpdate fields set to non-empty values', async () => {
const deps = makeDeps({
fs: {
Expand Down
58 changes: 58 additions & 0 deletions packages/cli/test/metrics-collector-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import {
formatMetricsCollectorStartupLog,
resolveMetricsCollectorConfig,
} from '../src/metrics-collector-config.js';

describe('resolveMetricsCollectorConfig', () => {
it('keeps local metric collection enabled by default', () => {
expect(resolveMetricsCollectorConfig(undefined, {})).toEqual({ enabled: true });
});

it('disables local collection without changing OTLP metrics.enabled', () => {
expect(resolveMetricsCollectorConfig({
telemetry: { metrics: { enabled: true, collectionEnabled: false } },
}, {})).toEqual({ enabled: false });
});

it.each([
['1', true],
['true', true],
['0', false],
['false', false],
])('accepts environment toggle %j', (value, enabled) => {
expect(resolveMetricsCollectorConfig(undefined, {
DKG_METRICS_COLLECTION_ENABLED: value,
})).toEqual({ enabled });
});

it('gives the environment override precedence over config', () => {
expect(resolveMetricsCollectorConfig({
telemetry: { metrics: { collectionEnabled: false } },
}, { DKG_METRICS_COLLECTION_ENABLED: '1' })).toEqual({ enabled: true });
});

it('rejects invalid environment values', () => {
expect(() => resolveMetricsCollectorConfig(undefined, {
DKG_METRICS_COLLECTION_ENABLED: 'yes',
})).toThrow(/DKG_METRICS_COLLECTION_ENABLED/);
});

it('rejects non-boolean config values', () => {
expect(() => resolveMetricsCollectorConfig({
telemetry: { metrics: { collectionEnabled: 'yes' } },
} as never, {})).toThrow(/telemetry\.metrics\.collectionEnabled/);
});
});

describe('formatMetricsCollectorStartupLog', () => {
it('preserves the enabled startup log', () => {
expect(formatMetricsCollectorStartupLog({ enabled: true }))
.toBe('Metrics collector started (30s interval)');
});

it('reports when local collection is disabled', () => {
expect(formatMetricsCollectorStartupLog({ enabled: false }))
.toBe('Metrics collector disabled');
});
});
1 change: 1 addition & 0 deletions packages/cli/vitest.unit.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export default defineConfig({
'test/resolve-standalone-install.test.ts',
'test/auto-update.test.ts',
'test/dkg-doctor.test.ts',
'test/metrics-collector-config.test.ts',
'test/init.test.ts',
'test/nat-status.test.ts',
'test/core-prereq-check.test.ts',
Expand Down
5 changes: 5 additions & 0 deletions tools/observability/RUNBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,11 @@ Restart the node. Local logging (SQLite + daemon.log) is unaffected; this only a

**Logs vs traces/metrics (different transports, same endpoint host):** logs ship via a hand-rolled **OTLP/HTTP JSON** exporter (the OTel Logs SDK is still "Development"), while **traces and metrics use the stable OTel SDK** OTLP/protobuf exporters. The polaris setup today only has a **logs** backend (Loki via Alloy), so leave `telemetry.traces`/`telemetry.metrics` out (or set `enabled: false`) until a traces backend (Tempo) and metrics backend (Mimir/Prometheus) are provisioned — the `node-config.example.json` shows the full three-signal shape and `config.alloy` has the matching commented routing.

The local Node UI metrics collector is independent of OTLP export. Set
`telemetry.metrics.collectionEnabled` to `false` to disable local SQLite
snapshots and store scans without changing OTLP settings. See the
[Node UI metrics operator guide](../../docs/use-dkg/node-ui-metrics.md).

## Step 4 — view in Grafana
- **Per-node:** `https://polaris.xtrmstrngth.com/d/dkg-node-logs` → pick a **Node** → set the time range (top-right) → logs appear. `Level` and `Filter (regex)` narrow further; the bottom panel is volume-by-level.
- **Fleet overview:** `https://polaris.xtrmstrngth.com/d/dkg-fleet-logs` → active-node count, log volume per node, errors per node, recent fleet-wide errors (filter by `Environment`).
Expand Down
5 changes: 4 additions & 1 deletion tools/observability/node-config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

"_comment_signals": "Three independent signals. LOGS use a hand-rolled OTLP/HTTP JSON exporter (the OTel Logs SDK is still 'Development'); set logs.exporter to 'otlp' — if you leave it unset on a hosted node it defaults to legacy syslog/Graylog, NOT OTLP. TRACES and METRICS use the stable OTel SDK exporters (OTLP/protobuf). IMPORTANT: the polaris backend today is LOGS-ONLY (Alloy → Loki; no Tempo for traces, no Prometheus/Mimir for metrics). So traces/metrics are shown here with enabled:false — they will NOT export until you (a) stand up a traces/metrics backend, (b) point config.alloy at it (see its FULL-SIGNAL section), and (c) flip enabled:true. Pointing them at the logs-only ingest host while enabled would just send spans/metrics into a void.",

"_comment_local_metrics": "metrics.enabled/exportIntervalMs control OTLP export. collectionEnabled independently controls local Node UI SQLite snapshots and store scans.",

"name": "testnet-core-01",

"telemetry": {
Expand All @@ -23,7 +25,8 @@
"enabled": false,
"endpoint": "https://metrics-ingest.example.com/v1/metrics",
"token": "<INGEST_TOKEN>",
"exportIntervalMs": 30000
"exportIntervalMs": 30000,
"collectionEnabled": true
}
}
}
Loading