@provide-io/telemetry
v0.11.0
Published
Structured logging, OTEL traces/metrics for TypeScript (Node.js and browsers) — parity with provide.telemetry Python package
Downloads
759
Maintainers
Readme
@provide-io/telemetry
Structured logging + OpenTelemetry traces and metrics for TypeScript — feature parity with the provide-telemetry Python package.
Install
npm install @provide-io/telemetryOptional OTEL peer dependencies
To export traces, metrics, and logs to an OTLP endpoint (e.g. OpenObserve, Jaeger, Tempo):
npm install \
@opentelemetry/sdk-trace-base \
@opentelemetry/sdk-metrics \
@opentelemetry/sdk-logs \
@opentelemetry/resources \
@opentelemetry/exporter-trace-otlp-http \
@opentelemetry/exporter-metrics-otlp-http \
@opentelemetry/exporter-logs-otlp-http \
@opentelemetry/api-logs \
@opentelemetry/context-async-hooksAll nine are optional — the library degrades gracefully to no-op providers when they are absent.
@opentelemetry/context-async-hooks is the one worth calling out. On Node it
provides the AsyncLocalStorage context manager that startActiveSpan needs to
carry a span across an await boundary. Without it telemetry still exports, but
child spans are emitted with no parent, so the trace tree is what degrades —
which reads as a tracing bug rather than a missing dependency. When it is absent
on Node, getHealthSnapshot().setupError and getRuntimeStatus().setupError
both say so. Browser and edge builds have no AsyncLocalStorage and are not meant
to; there the library stays silent.
Quick start
import { setupTelemetry, getConfig, getLogger, registerOtelProviders, shutdownTelemetry } from '@provide-io/telemetry';
// Call once at app startup.
setupTelemetry({
serviceName: 'my-app',
environment: 'production',
version: '1.0.0',
logLevel: 'info',
logFormat: 'json',
otelEnabled: true,
tracingEnabled: true,
otlpEndpoint: 'http://localhost:4318',
otlpHeaders: { Authorization: 'Basic ...' },
});
// Activate OTLP exporters (requires peer deps above).
await registerOtelProviders(getConfig());
const log = getLogger('api');
log.info({ event: 'request.received.ok', method: 'GET', path: '/health', status: 200 });
// On shutdown — flushes and drains all OTel providers.
await shutdownTelemetry();API reference
Setup
| Export | Description |
|--------|-------------|
| setupTelemetry(config) | Configure the library. Idempotent — safe to call multiple times. |
| getConfig() | Return the current TelemetryConfig. |
| configFromEnv() | Build config from environment variables (see Configuration). |
| registerOtelProviders(cfg) | Wire OTLP log, trace, and metric exporters for the signals enabled in config. Call after setupTelemetry. |
| flushTelemetry(timeoutMs?) | Drain every registered provider and leave them installed. Resolves false if any missed the deadline. |
| shutdownTelemetry() | Flush and shut down all registered OTel providers. |
Logging
import { getLogger } from '@provide-io/telemetry';
const log = getLogger('my-module');
log.debug({ event: 'cache.miss.ok', key: 'user:42' });
log.info({ event: 'request.complete.ok', status: 200, duration_ms: 14 });
log.warn({ event: 'retry.attempt.warn', attempt: 2 });
log.error({ event: 'db.query.error', error: err.message });Event names follow the DA(R)S pattern: 3 segments (domain.action.status) or 4 segments (domain.action.resource.status).
The SDK stamps service, env and version from config and, while a span is
live, trace_id and span_id on every record. A field you log or bind under
one of those names is replaced by the stamped value; to name a remote service,
use peer.service.
Callsite capture
Two independent knobs select two different outputs from one stack walk. Enabling either one performs the walk; neither implies the other.
| Knob | Output | Keys |
|------|--------|------|
| logIncludeCaller / PROVIDE_LOG_INCLUDE_CALLER | Fields on the log record | filename, lineno |
| logCodeAttributes / PROVIDE_LOG_CODE_ATTRIBUTES | Attributes on the exported OTLP log record | code.file.path, code.line.number, code.function.name |
filename carries the base name of the caller's source file, never a full path —
an absolute path leaks the build machine's layout into every record.
code.file.path carries the whole path, which is what OpenTelemetry defines that
attribute as and what the other SDKs send. lineno and code.line.number are
1-based.
The reported frame is the caller's. The walk identifies this module's own frames
by the file the capture itself is in, so it holds for the source tree, the
published build, and a consumer whose own module happens to be called
logger.ts; pino's frames are matched by path segment under node_modules.
Bundling the SDK and the application into one file leaves nothing to tell those apart, and there the record carries no callsite at all rather than one naming the logger's own line.
code.function.name carries the name of the enclosing function. It is omitted
when the runtime resolves no name for the frame — top-level module code and
anonymous callbacks produce nameless stack frames, and an absent attribute is
more useful than one whose value means "unknown".
Tracing
import { withTrace, getActiveTraceIds, setTraceContext } from '@provide-io/telemetry';
const result = await withTrace('my.operation.ok', async () => {
const { trace_id, span_id } = getActiveTraceIds();
// trace_id / span_id are available here and in any log emitted inside
return doWork();
});Metrics
import { counter, gauge, histogram, observableGauge } from '@provide-io/telemetry';
const requests = counter('http.requests', { unit: '1', description: 'Total HTTP requests' });
requests.add(1, { method: 'GET', status: '200' });
const latency = histogram('http.duration', { unit: 'ms' });
latency.record(42, { route: '/api/users' });
const cpu = gauge('cpu.utilization', { unit: '%' }); // an OTel Gauge: last value wins
cpu.set(72.5);
// Values computed at collection time (each scrape or export interval).
const depth = observableGauge('queue.depth', () => [{ value: queue.length }]);
depth.unregister();Prometheus (Node only)
With OTEL_METRICS_EXPORTER=prometheus (or otlp,prometheus),
registerOtelProviders() attaches a Prometheus pull reader. Mount the handler
on a server you already run, or set PROVIDE_METRICS_PROMETHEUS_LISTEN=true
for a standalone listener on 127.0.0.1:9464/metrics:
import { metricsHandler, renderPrometheus } from '@provide-io/telemetry/prometheus';
app.get('/metrics', metricsHandler()); // node:http, Express, Connect
const { body, contentType } = await renderPrometheus(req.headers.accept);TLS and basic auth for the listener come from a Prometheus web config file
named by PROVIDE_METRICS_PROMETHEUS_WEB_CONFIG, which needs the optional
yaml and bcryptjs packages (npm install yaml bcryptjs). Every scrape also
carries the SDK's own health as provide_telemetry_* series;
PROVIDE_METRICS_HEALTH_OTLP=true exports them over OTLP as well. See
"Serving Metrics for Prometheus to Scrape" in
docs/guide/configuration.md. The browser
build has no pull support.
Context binding
import { bindContext, runWithContext, clearContext } from '@provide-io/telemetry';
bindContext({ request_id: 'req-abc', user_id: 7 });
// All log calls in this async context will include these fields automatically.
clearContext();
// Scoped — context is automatically cleaned up after fn resolves.
await runWithContext({ trace_id: '...' }, async () => { /* ... */ });Session correlation
import { bindSessionContext, getSessionId, clearSessionContext } from '@provide-io/telemetry';
bindSessionContext('sess-abc-123');
// All logs and traces now include session_id automatically.
const sid = getSessionId(); // 'sess-abc-123'
clearSessionContext();Error fingerprinting
import { computeErrorFingerprint } from '@provide-io/telemetry';
try {
throw new Error('connection refused');
} catch (e) {
const err = e as Error;
const fp = computeErrorFingerprint(err.constructor.name, err.stack);
// fp: 12-char hex digest, stable across deploys — use for dedup and alert grouping.
}W3C trace propagation
import { extractW3cContext, bindPropagationContext } from '@provide-io/telemetry';
// In an HTTP handler — extract incoming traceparent/tracestate/baggage.
// Takes a Record<string, string>; node:http's IncomingHttpHeaders needs narrowing.
const ctx = extractW3cContext(req.headers as Record<string, string>);
bindPropagationContext(ctx);PII sanitization
import { sanitize, registerPiiRule } from '@provide-io/telemetry';
// Built-in: redacts password, token, secret, authorization, api_key, ...
const obj = { user: 'alice', password: 'hunter2' }; // pragma: allowlist secret
sanitize(obj);
// sanitize() mutates in place — obj is now { user: 'alice', password: '***' }
// Custom rule (dot-separated path; '*' as wildcard segment)
registerPiiRule({ path: 'user.ssn', mode: 'redact' });sanitize is a package-root export. Import it from @provide-io/telemetry;
the package does not publish a separate sanitize subpath.
Health snapshot
import { getHealthSnapshot } from '@provide-io/telemetry';
const snap = getHealthSnapshot();
// snap.exportFailuresLogs, snap.tracesDropped, ...A log record counts in logsEmitted once it has passed the sinks. One refused
before export — by consent, sampling or backpressure — counts in
logsDropped instead, never in both.
Runtime inspection
import { getRuntimeConfig, getRuntimeStatus } from '@provide-io/telemetry';
const cfg = getRuntimeConfig();
const status = getRuntimeStatus();
console.log(cfg.serviceName);
console.log(status.setupDone, status.providers.traces, status.fallback.logs);Use getRuntimeConfig() after setup or runtime reloads to inspect the applied
snapshot, and getRuntimeStatus() to see signal enablement, provider install
state, fallback mode, and the last setup error without reading internal
modules.
Runtime reconfiguration
Use reconfigureTelemetry() to change config at runtime without a process
restart. It merges a partial TelemetryConfig over the current one and
re-runs setupTelemetry() with the result, rebuilding the logger so fresh
calls to getLogger() observe the new config.
import { reconfigureTelemetry, getRuntimeConfig } from '@provide-io/telemetry';
// Rotate to a new OTLP collector endpoint mid-flight.
reconfigureTelemetry({
otlpEndpoint: 'http://collector.new.internal:4318',
otlpHeaders: { Authorization: 'Basic ...' },
});
console.log(getRuntimeConfig().otlpEndpoint);
// → 'http://collector.new.internal:4318'Guardrails:
- If OTel providers are already registered (i.e.
registerOtelProviders()has run) and a provider-changing field differs —serviceName,environment,version,otelEnabled,tracingEnabled,metricsEnabled, anyotlp*Endpoint/otlp*Headers— this call throwsConfigurationError. Provider swap requires a process restart to avoid losing buffered exports. For hot-reloadable fields only (sampling, backpressure, exporter resilience, schema strictness, SLO toggles, security limits, PII depth), useupdateRuntimeConfig()orreloadRuntimeFromEnv()instead — they never touch provider wiring.
AsyncLocalStorage initialisation (ESM gotcha)
Under Node.js the library uses node:async_hooks.AsyncLocalStorage to
isolate propagation context per async task. In CJS builds the store is
attached synchronously at module load. Under pure ESM (.mjs entrypoints,
tsx --import) require is undefined, so the library falls back to a
fire-and-forget await import('node:async_hooks') that resolves on the
next microtask. Callers that bind propagation context inside top-level
async code before that microtask runs would hit the module-level fallback
store and leak context between concurrent requests.
For code paths that need a hard guarantee (typically: servers that start accepting requests at module scope) prefer the async variant:
import { setupTelemetryAsync } from '@provide-io/telemetry';
// Awaits ALS init before resolving. Throws ConfigurationError when
// AsyncLocalStorage is genuinely unavailable on a Node runtime.
await setupTelemetryAsync({ serviceName: 'my-app' });
// Safe to accept concurrent requests here.The synchronous setupTelemetry() remains the right choice for non-async-init environments (bundled CJS
tests, vitest, scripts that do not race with request serving). It applies
a best-effort ALS check: when the init has not yet settled it schedules a
deferred check that records a setupError and logs a warning if ALS really
is unavailable — it does not throw in that case.
If you would rather compose the primitives yourself, both helpers remain exported:
import {
setupTelemetry,
awaitPropagationInit,
isFallbackMode,
} from '@provide-io/telemetry';
setupTelemetry({ serviceName: 'my-app' });
await awaitPropagationInit();
if (isFallbackMode()) {
throw new Error('AsyncLocalStorage unavailable — refusing to serve requests');
}awaitPropagationInit() always resolves (it never rejects); inspect
isFallbackMode() / isPropagationInitDone() afterwards to branch on the
outcome.
React integration
The core package does not require React. Install React 18+ only when importing the React subpath.
import { useTelemetryContext, TelemetryErrorBoundary } from '@provide-io/telemetry/react';useTelemetryContext(values)
Binds key/value pairs into telemetry context for the lifetime of a component. Automatically cleans up on unmount and re-runs when values change (compared by content, not reference).
function UserDashboard({ userId }: { userId: string }) {
useTelemetryContext({ user_id: userId, page: 'dashboard' });
return <Dashboard />;
}TelemetryErrorBoundary
React error boundary that auto-logs caught render errors via getLogger('react.error_boundary'). Accepts a static fallback or a render-prop receiving the error and a reset callback.
<TelemetryErrorBoundary
fallback={(error, reset) => <ErrorPage error={error} onRetry={reset} />}
onError={(error, info) => reportToSentry(error, info)}
>
<App />
</TelemetryErrorBoundary>Browser support
The library is browser-compatible via conditional exports. OpenTelemetry providers become no-ops in browser environments, so no server-only imports leak into client bundles. No polyfills are required for modern browsers (ES2020+).
Browser-specific options in setupTelemetry():
| Option | Effect |
|--------|--------|
| captureToWindow: true | Buffers structured logs to window.__pinoLogs for devtools inspection |
| consoleOutput: true | Mirrors log output to console.debug / console.log / console.warn / console.error |
Configuration
All options can be set programmatically via setupTelemetry() or via environment variables:
| Env var | Default | Description |
|---------|---------|-------------|
| PROVIDE_TELEMETRY_SERVICE_NAME | provide-service | Service identity attached to all signals |
| PROVIDE_TELEMETRY_ENV | dev | Deployment environment tag (e.g. dev, staging, prod) |
| PROVIDE_TELEMETRY_VERSION | 0.0.0 | Application version tag |
| PROVIDE_TELEMETRY_STRICT_SCHEMA | false | Master switch: when true, overrides event name strictness to on |
| PROVIDE_TELEMETRY_STRICT_CONFIG | false | When true, a configuration value the SDK would otherwise warn about and ignore fails config parsing with ConfigurationError instead. Today that covers OTEL_METRICS_EXPORTER only (prometheus_metrics.metrics_exporter_warnings); it does not touch event-name strictness (PROVIDE_TELEMETRY_STRICT_SCHEMA) or values that are already errors. |
| PROVIDE_LOG_LEVEL | INFO | Log level: TRACE, DEBUG, INFO, WARNING, ERROR, CRITICAL |
| PROVIDE_LOG_FORMAT | console | Renderer: console, json, or pretty |
| PROVIDE_LOG_INCLUDE_TIMESTAMP | true | Add ISO-8601 timestamp to each log event |
| PROVIDE_LOG_INCLUDE_CALLER | true | Add filename and line number to each log event |
| PROVIDE_LOG_SANITIZE | true | Enable PII/sensitive field redaction in log output |
| PROVIDE_LOG_PII_MAX_DEPTH | 8 | Maximum nesting depth for PII/sensitive field traversal during sanitization |
| PROVIDE_LOG_CODE_ATTRIBUTES | false | Attach code attributes to OTel log records |
| PROVIDE_LOG_PRETTY_KEY_COLOR | dim | ANSI color name for keys in pretty format (see named colors below) |
| PROVIDE_LOG_PRETTY_VALUE_COLOR | "" | ANSI color name for values in pretty format (empty = default) |
| PROVIDE_LOG_PRETTY_FIELDS | "" | Comma-separated field names to display in pretty format |
| PROVIDE_LOG_MODULE_LEVELS | "" | Per-module log level overrides (e.g. provide.server=DEBUG,asyncio=WARNING) |
| PROVIDE_LOG_OTLP_ENABLED | true | Enable OTLP log export (independent of PROVIDE_TRACE_ENABLED / PROVIDE_METRICS_ENABLED; set false to skip OTLP handler even when OTEL_EXPORTER_OTLP_ENDPOINT is set) |
| PROVIDE_TRACE_ENABLED | true | Enable OTel tracing provider (falls back to no-op when false) |
| PROVIDE_TRACE_SAMPLE_RATE | 1.0 | Trace sampling rate (0.0-1.0) applied to the OTel SDK ParentBased(TraceIdRatioBased) sampler (and facade when no live provider); combined via min() with PROVIDE_SAMPLING_TRACES_RATE |
| OTEL_EXPORTER_OTLP_ENDPOINT | "" | Shared OTLP endpoint (fallback for all signals) |
| OTEL_EXPORTER_OTLP_HEADERS | "" | Shared OTLP headers (fallback for all signals) |
| OTEL_EXPORTER_OTLP_LOGS_ENDPOINT | "" | Per-signal OTLP endpoint for logs |
| OTEL_EXPORTER_OTLP_LOGS_HEADERS | "" | Per-signal OTLP headers for logs |
| OTEL_EXPORTER_OTLP_TRACES_ENDPOINT | "" | Per-signal OTLP endpoint for traces |
| OTEL_EXPORTER_OTLP_TRACES_HEADERS | "" | Per-signal OTLP headers for traces |
| OTEL_EXPORTER_OTLP_METRICS_ENDPOINT | "" | Per-signal OTLP endpoint for metrics |
| OTEL_EXPORTER_OTLP_METRICS_HEADERS | "" | Per-signal OTLP headers for metrics |
OTEL_METRICS_EXPORTER (otlp, prometheus, otlp,prometheus or none)
and the Prometheus variables are covered under Prometheus.
Any other entry (console, a typo, none mixed with another) does not fail
setup: it is dropped, logged as config.metrics_exporter.value_ignored and
reported in getHealthSnapshot().setupError, falling back to otlp when
nothing usable is left. PROVIDE_TELEMETRY_STRICT_CONFIG=true makes it a
ConfigurationError instead.
OTLP over TLS reads OTEL_EXPORTER_OTLP_CERTIFICATE, _CLIENT_CERTIFICATE
and _CLIENT_KEY, each with per-signal forms that win; a file that cannot be
read or loaded leaves that signal's exporter unbuilt, logs
otlp.tls_file_unreadable and sets getHealthSnapshot().setupError. The full
list is in docs/guide/configuration.md.
Pretty renderer
Set logFormat: 'pretty' (or PROVIDE_LOG_FORMAT=pretty) for human-readable colored output during local development. Color support respects FORCE_COLOR, NO_COLOR, and stderr TTY detection.
setupTelemetry({ logFormat: 'pretty' });Requirements
- Node.js ≥ 22
- TypeScript ≥ 5 (built with TypeScript 6)
License
Apache-2.0. See LICENSE.
