@nodii/telemetry
v0.23.0
Published
Substrate observability library for the Nodii microservice stack — OTel wrapper, log envelope, intent lifecycle, audit signal, agent registry, context propagation. Spec: planning hub docKey=telemetry.
Readme
@nodii/telemetry
Substrate observability library for the Nodii microservice stack. v0.1.0
ships the SDK surface — bootstrap, log envelope, tracer, meter, audit
signal, intent lifecycle, agent registry, context propagation — without
the OTel-SDK wiring (deferred to a v0.1.x follow-up; see
.agent-todo.md TS-12).
Spec: https://planning.dev.nucleus-cloud.in/api/v1/feature-docs?serviceId=nodii-libs&docKey=telemetry
Install
bun add @nodii/telemetryBootstrap (D152)
import { initTelemetry } from "@nodii/telemetry";
initTelemetry({
serviceName: "nodii-billing-service",
env: process.env.APPLICATION_STATE!, // 'dev' | 'uat' | 'prod'
otlpEndpoint: process.env.OTLP_ENDPOINT,
vocabularyRegistry: {
billing_payments_total: {
family: "async_worker",
description: "Payments processed",
decision: "D23",
},
},
intentTypes: {
"billing.invoice.create": {
typeName: "billing.invoice.create",
server_mintable: true,
client_mintable: false,
timeout_seconds: 300,
},
},
});Subsequent initTelemetry() calls warn-log + no-op. Calling any public
API before init throws TelemetryNotInitialized.
Logger (D13)
import { logger } from "@nodii/telemetry";
logger.info("payment.charged", { invoice_id, amount_minor_units });
logger.error("saga.compensation.failed", { saga_id, step, reason });The 11-field envelope (level, ts, msg, service, env, tenant_id,
user_id, request_id, trace_id, span_id, intent_id) is auto-attached
from the active NodiiContext + the init config + the active OTel
span. Do not pass envelope fields yourself — they get filled in.
D15 redaction runs on every line (structured-field keys against the D2
PII list + regex pass on the message string) and is fail-closed:
if redaction throws, the line is DROPPED and
telemetry.sdk.redaction_failure_total{reason} bumps.
Tracer (D26 / D27 / D28)
import { tracer, withSpanAttributes } from "@nodii/telemetry";
await withSpanAttributes({ invoice_id }, async () => {
await tracer.span("billing.invoice.create", async (span) => {
span.setAttribute("payment.method", method);
// ... business logic; mandatory attrs auto-applied
});
});Mandatory attributes (tenant_id, request_id, intent_id) are stamped
on span start. 4 KB per-value cap with _truncated=true marker. D27
status mapping: recordException auto-pairs with ERROR; clean
return defaults to OK.
Auto-instrumentation (D154) — Bun / ESM caveat
When initTelemetry is given an OTLP endpoint, the resolved autoInstrumentation
flags register the OpenTelemetry http / grpc / pg instrumentations, so a
service emits request/query spans without any manual tracer.span calls.
ESM services must load the instrumentation hook before any instrumented module.
OpenTelemetry patches modules via import-in-the-middle; under ESM that only
works if the loader hook is installed at process start, before node:http /
@grpc/grpc-js / pg are first imported. Launch with:
node --import @opentelemetry/instrumentation/hook.mjs ./dist/server.js
# or, with a TS entry under tsx:
node --import tsx --import @opentelemetry/instrumentation/hook.mjs ./src/server.tsWithout the hook, ESM services will see auto-instrumentation reported as enabled
in config but emit no http/grpc/pg spans (the modules are never patched).
CommonJS services need no flag (require-based patching applies automatically).
Bun note: Bun's native server bypasses Node's
httpmodule, so auto-instr HTTP server spans are not produced under Bun regardless of the hook. Run the service under Node (the command above) to get auto-instr server spans; manualtracer.span+ epoch-correct timestamps work under both runtimes.
The httpServer/httpClient and grpcServer/grpcClient flags are coarse
on/off pairs — the underlying instrumentation patches both directions, so
enabling either half instruments both.
Metrics (D18 / D19 / D20 / D24)
import { metrics } from "@nodii/telemetry";
const paymentsCounter = metrics.counter("billing_payments_total", {
family: "async_worker",
description: "Payments processed",
});
paymentsCounter.inc({ tenant_id, pgw, status });Names must exist in the vocabulary registry passed to
initTelemetry({ vocabularyRegistry }) — D24 ADR-gated. Mismatched
family or off-vocab name → MetricVocabularyError + bumps
telemetry.sdk.cardinality_rejected_total{family, reason}.
D20 bare-label discipline: registration rejects tenant.id and
tenantId variants.
D19: exponential histograms, max 160 buckets.
Audit (D17 / D33)
import { audit } from "@nodii/telemetry";
await audit.emit({
action: "payment.refund.issued",
target_kind: "payment",
target_id: payment.id,
payload: { amount_minor_units, reason },
});The library ships the adapter (write path); the emission policy
(method-level interceptor, saga lifecycle events, agent-layer
double-emission) and the audit_log schema live in the audit
doctrine + each consuming service.
Intent lifecycle (D46 / D155)
import { intent } from "@nodii/telemetry";
const id = await intent.begin("billing.invoice.create", { invoice_id });
try {
// work
await intent.complete(id, { result: "ok" });
} catch (err) {
await intent.abandon(id, "error");
throw err;
}intent.begin HARD-validates the type name against the registry
(D155); unknown type → IntentTypeNotRegistered. Activates the intent
on the active NodiiContext (sets intent_id). Nested begins stack
by id match.
intent.progress is a no-op stub at v0.1.0 per D155 § M02
carry-forward.
@agentInvocable (D156)
import { agentInvocable } from "@nodii/telemetry";
class PaymentService {
@agentInvocable({
mode: "composable",
authTier: "confirmation-required",
sideEffects: ["payment.refund"],
idempotency: "keyed-by-request-id",
description: "Refund a captured payment",
})
async refundPayment(req: RefundRequest): Promise<RefundResult> {
/* ... */
}
}Runtime is a no-op at v0.1.0 (D156). The decorator attaches the
config to the method + the class prototype under a symbol-keyed
property; the build-time codegen (separate tool, deferred) walks the
tsc AST + falls back to the runtime registry to emit
agent-methods.generated.json.
Context propagation (D40 / D41)
import {
type NodiiContext,
withContext,
getContext,
requireContext,
} from "@nodii/telemetry";
const ctx: NodiiContext = {
tenant_id: "...",
user_id: "...",
actor_type: "user",
on_behalf_of: null,
role: "owner",
request_id: "...",
intent_id: null,
};
await withContext(ctx, async () => {
/* every log / span / metric / audit emit picks up this context */
});Read with getContext() (returns null when no scope active) or
requireContext() (throws). SDK middleware adapters (deferred to
TS-9) are the ONLY writers of the parent-scope context per D41 lock;
service code uses withContext for child scopes only.
SDK-internal counters
The library increments these on its own behaviors (do not register them in your vocabulary):
telemetry.sdk.redaction_failure_total{reason}— D15 fail-closed drop.telemetry.sdk.cardinality_rejected_total{family, reason}— D18.telemetry.sdk.export_failures_total{signal, reason}— D153.telemetry.sdk.trace_propagation_missing_total— § 5.9 D25.
Deferred to v0.1.x
See .agent-todo.md at the repo root:
- TS-9: Subpath adapters (
/grpc,/bullmq,/outbox,/system-process,/lambda). - TS-12: Real OTel SDK wiring — replaces the no-op
SpanEmitter+ the default JSON log sink with the OTLP exporters. - TS-13: Smoke service per D157 at
ts/telemetry-smoke/. - TS-14: Telemetry-tax-budget benchmark (D28 ≤ 200µs SDK-side per typical RPC).
License
MIT
