@octabits-io/telemetry

Structured logs, traces and metrics over OTLP/HTTP — no OpenTelemetry SDK, no agent, no collector sidecar. One hand-written protobuf encoder behind all three signals, plain fetch on the wire, web-platform globals only.

npm install @octabits-io/telemetry @octabits-io/server   # server is a peer: the span registry it shares
Subpath What it is
. Traces and metrics: the tracer, the in-memory meter, W3C trace-context propagation, and the OTLP trace and metric exporters.
./logger Logs: the structured logger (JSON or pretty console) and its OTLP log exporter.

Two entry points because a process may want one without the other — a CLI logs and never traces; a worker may trace and let its platform collect stdout. Importing ./logger does not pull in the tracer, and importing the root does not pull in the logger. They share one protobuf writer and one AnyValue mapping internally, so the three signals cannot drift apart on the wire.

The posture

  • Instrument unconditionally, export as a deployment choice. Spans are always built — so their ids reach your log lines — and go over the wire only when an endpoint is configured. Metrics always aggregate in memory and are pushed only when an endpoint is configured. The console is always available; OTLP is additive.
  • Telemetry must never fail a request. Exports never block the caller, a full buffer drops its oldest records rather than growing without bound, a throwing span processor is swallowed, and failures are reported through onError instead of thrown.
  • Protobuf by default. It is the encoding the OTLP spec requires every receiver to accept; OTLP/JSON is optional in the spec and some backends refuse it outright (VictoriaLogs answers an OTLP/JSON POST with 400 … json encoding isn't supported). The log exporter takes encoding: 'json' for a collector you know accepts it, or for a readable payload while debugging.
  • Propagation is explicit. There is no ambient “current span”: that needs AsyncLocalStorage, which is not on every runtime, and implicit context is the part of the OpenTelemetry SDK that costs the most and surprises the most. A span is created with a parent and passed along by hand — and the request span rides on the Request object itself (see the registry below).
  • Nobody has to depend on this to be instrumented by it. Every consumer in this monorepo takes a Logger, a Tracer and a Meter structurally — a few interface declarations of their own that this package’s implementations satisfy as-is. Do the same: your library should declare the three methods it calls, not import a telemetry package.

Logs — ./logger

import { createLoggerService } from '@octabits-io/telemetry/logger';

const loggerService = createLoggerService({
  config: {
    serviceName: 'my-api',
    serviceVersion: '1.0.0',
    environment: 'production', // JSON output; 'development' → human-readable
    logLevel: 'info',
    // Optional: also export to a collector over OTLP/HTTP.
    otlp: { endpoint: 'http://localhost:4318/v1/logs' },
  },
});

const logger = loggerService.logger;
logger.info('Server started', { port: 3000 });

const requestLogger = logger.child({ requestId: 'abc123' });
requestLogger.info('Processing request');

// On shutdown — drains the export buffer and awaits the in-flight requests.
// Without this, whatever was still buffered is lost.
await loggerService.shutdown();

The LogRecord a collector receives is identical in development and production; only the console rendering differs. logger.error(message, error?, attributes?) lifts error.type / error.message / error.stack onto the record, and the pretty renderer prints the stack raw, below the line.

Redaction

Every entry point redacts by default — the logger, the telemetry service, and createTracer and createMeter on their own — so a credential, an e-mail address or a client’s IP address that reaches a log call, a span or a metric does not reach the console, the collector or the store behind it:

  • by key — a value under a credential- or personal-data-shaped attribute key becomes [REDACTED]: password, secret, token, apikey, authorization, cookie, connection_string, dsn, email, phone, phone_number, telephone, iban, card_number, cvv, ssn, taxid, vat_id, … and their plurals (DEFAULT_SENSITIVE_KEYS is the list). Keys are matched by their last segment (or last two written together), split on ., _, - and camelCase — a credential or a person’s data is what a key ends in. So http.request.header.authorization, access_token, apiKey, x-api-key, user.email and db.connection_string are caught, while gen_ai.token.type (the GenAI token-count convention), gen_ai.usage.input_tokens, email_verified, server.address and http.route are not. A key that names personal data in a word the list does not have — user.address, a name — is yours to add. Nested objects and arrays are walked. Three deliberate exceptions: a boolean is never redacted by its key (browser.mobile is a flag); a pagination cursor is not a credential (page_token, next_page_token, next_token — DEFAULT_NON_SENSITIVE_KEYS); and session.id is kept, because it correlates the spans of one session — the session credential itself travels as a cookie or an authorization header, and both are redacted.
  • by value — a URL’s password (postgres://app:hunter2@db:5432/x becomes postgres://app:[REDACTED]@db:5432/x, the host kept) and e-mail addresses are scrubbed out of every string: the log message, error.message and error.stack, span and event names, status messages, string attributes (DEFAULT_VALUE_PATTERNS, in that order). Addresses in any script (jürgen@münchen.de), punycode domains and the percent-encoded ada%40example.com in a URL are caught whole. A Date under an attribute is its ISO text, an Error its name, message and stack (scrubbed), bytes a [N bytes] label.
  • IP addresses — personal data under the GDPR, so they are cut to their network rather than blanked: an IPv4 address keeps its /24 (203.0.113.7 → 203.0.113.0) wherever it appears in a string, and under an address-shaped key (client.address, network.peer.address, remote_addr, x-forwarded-for, … — DEFAULT_ADDRESS_KEYS) an IPv6 address keeps its /48, with its brackets, port and IPv4 mapping. Enough to read a rate limit or a region, not enough to name a subscriber. The server’s own address (server.address, network.local.address) is left as it is — and only those: network.peer.address on a client span is the upstream server and is cut like any address, so ECONNREFUSED 10.0.12.34:5432 reads 10.0.12.0:5432. A four-part version such as 1.2.3.4 reads as an address and is cut too. ipAddresses: 'redact' replaces them whole; false leaves them.

The logger redacts once, in emit, so the console line and the exported record are the same record — and so is what a sink receives: sink: (record) => … on the logger config is one more destination for every record after redaction, for an in-process view of the recent log (an admin page showing what the log holds, a test); it runs on the logging path, and one that throws is caught. The tracer redacts a span when it ends, before any processor sees it — the exporter and your own spanProcessors alike — and the meter redacts a data point’s attributes before they key the aggregate, so points that differ only in a redacted value merge instead of exporting as duplicate [REDACTED] series. createTelemetryService compiles its policy once and hands it to both. Resource attributes are the deployment’s own and are never redacted.

createLoggerService({
  config: {
    serviceName: 'my-api',
    // Extend the defaults; `valuePatterns` replaces the list, so spread the default to keep it.
    // a custom key is normalised like an attribute key: `customer_name` also covers `customerName` and `customer.name`
    redact: { sensitiveKeys: [...DEFAULT_SENSITIVE_KEYS, 'customer_name'], valuePatterns: [...DEFAULT_VALUE_PATTERNS, /DE\d{20}/], ipAddresses: 'redact' },
  },
});
createTelemetryService({ serviceName: 'my-api', redact: false }); // raw, e.g. a local debugging session
createTracer({ processor, redact: false });                     // the building blocks take the same option

It cannot find personal data in free text beyond its value patterns: a message that interpolates a name still carries the name. Log ids, not people, and put anything else in attributes under a key the policy recognises. createRedactor(policy) is the same redactor on its own, for a record that does not come through this package — and createOtlpLogExporter fed directly does not redact, because it is not what built the record.

createOtlpLogExporter(config) is the exporter on its own, for feeding records that did not come from this package’s Logger — a vendor SDK’s log callback, a second exporter aimed at a different collector. It owns its own buffer, so whoever created it must drain it.

onError must never route back into a Logger that feeds the exporter — a failing collector would then report its own failures forever. It defaults to a plain console.error.

Security events

A fixed list of what a SIEM acts on — sign-ins and their failures, a fresh sign-in at a sensitive step, revoked sessions, denied access, accounts created, disabled and given roles or deputies, credentials and keys, signatures recorded and refused, configuration changes, exports and erasures, and a failed integrity check of stored evidence (SECURITY_EVENT_TYPES) — written as ordinary records in one attribute shape, so a rule written once matches every host:

import { logSecurityEvent } from '@octabits-io/telemetry/logger';

logSecurityEvent(logger, { type: 'authentication.sign_in_failed', actor: { id: userId }, reason: 'bad_password', clientAddress: ip });
logSecurityEvent(logger, { type: 'signature.recorded', actor: { id: userId }, onBehalfOf: holderId, target: { type: 'request', id: requestId }, scopeKey: orgId });
Attribute Value
event.name security.<type>
event.category, event.action the part before the dot; the type
event.outcome success or failure — derived from the type (…_failed, …denied, …refused), or given
user.id, user.kind, user.on_behalf_of who acted — an id, never a name — and for whom
security.target.type, security.target.id what it is about
security.scope_key, security.reason where, and why in a word
client.address the client, cut to its network by the redaction like any address

The field names follow the Elastic Common Schema fields SIEMs map out of the box, with event.name for OpenTelemetry’s log events. The message is fixed text (security: <type>), a failure is warn and everything else info, and an unknown type throws: a typo would hide the event from every rule. They travel wherever the logger’s records go; to send them somewhere else as well, filter a sink with isSecurityLogRecord(record).

Traces and metrics — the root

import { createTelemetryService } from '@octabits-io/telemetry';

const telemetry = createTelemetryService({
  serviceName: 'my-api',
  serviceVersion: '1.4.0',
  environment: 'production',
  // Unset: spans still exist (their ids reach the logs) but go nowhere.
  traces: { endpoint: 'http://localhost:4318/v1/traces' },
  // Unset: metrics aggregate in memory and are never pushed.
  metrics: { endpoint: 'http://localhost:4318/v1/metrics', intervalMs: 30_000 },
});

const span = telemetry.tracer.startSpan('GET /contacts/:id', { kind: 'server' });
span.setAttribute('http.response.status_code', 200);
span.end();

const requests = telemetry.meter.counter('http.server.requests', { unit: '{request}' });
requests.add(1, { 'http.route': '/contacts/:id', 'http.response.status_code': 200 });

await telemetry.shutdown(); // flush both, then stop. Idempotent.

tracer.withSpan(name, options, fn) runs fn inside a span, ends it when the promise settles, records a thrown error and rethrows it.

Metrics are cumulative, not delta: that is what every OTLP receiver accepts and what makes a missed push harmless — the next one carries the totals. Histograms default to the OpenTelemetry SDK’s millisecond-scale boundaries; DURATION_SECONDS_BOUNDARIES is the semantic-convention set for durations in seconds.

Cardinality is yours to bound. An attribute carrying a user id, or a path with ids in it, is a memory leak with a metric name. Instrument with the route pattern, the queue name, the outcome — never the instance.

createTracer({ processor }) and createMeter() are the pieces without the service — they redact by default like the service does, and take the same redact option — and noopTracer / noopMeter are the defaults wherever telemetry is optional. createRecordingSpanProcessor() keeps every ended span in memory for tests.

Correlating a log line with its span

import { traceAttributes } from '@octabits-io/telemetry';

const requestLogger = logger.child(traceAttributes(span)); // { trace_id, span_id }

The log exporter lifts a well-formed trace_id / span_id attribute onto the OTLP record’s own fields, so the collector shows the line under its span. The attributes stay attributes too — a log store indexes those.

The request → span registry

attachSpan(request, span) and spanOf(request) are re-exported here from @octabits-io/server. The request span rides on the Request object itself, so any layer holding the request finds it without a context variable and without knowing which HTTP glue is in front of it. There is exactly one such registry per process and it lives with the HTTP cores that write to it — this package re-exports rather than keeping a second map, which is the one dependency it has.

W3C trace context

parseTraceparent(header) and formatTraceparent(context) are the incoming and outgoing halves; traceparentHeader(span) is the header to send on an outgoing request made under a span. asFlowTracer(tracer, { parent }) adapts the tracer to the two-argument startSpan(name, attributes) shape octaflow takes — declared structurally, so octaflow is never imported.

Endpoints and environment

OTLP/HTTP endpoints include the signal path, and each signal is configured separately — this package reads no environment variable itself, so the host decides the names. The convention in this monorepo:

Variable Goes to Typical value
OTLP_LOGS_ENDPOINT config.otlp.endpoint on ./logger http://localhost:4318/v1/logs
OTLP_TRACES_ENDPOINT traces.endpoint http://localhost:4318/v1/traces
OTLP_METRICS_ENDPOINT metrics.endpoint http://localhost:4318/v1/metrics

headers on any exporter carries auth tokens or routing. fetchImpl is a test seam. Batching knobs (maxBatchSize 512, maxQueueSize 2048, scheduledDelayMs 5000, timeoutMs 10000; the metric pusher’s intervalMs 30000) are expected to be set in code, if at all. Every timer is unref’d where the runtime offers it: telemetry never holds a process open.

Resource attributes — service.name, service.version, deployment.environment, plus anything in attributes — are carried in every environment, including development, because a collector needs them there too.

Portability

The package type-checks with no Node typings (tsconfig.portable.json, run by pnpm typecheck) and imports no node:* module. It runs on fetch, AbortSignal.timeout, crypto.getRandomValues, performance.now, TextEncoder, console and the timers — so the exporters work on a worker, an edge runtime or a browser, not just Node.

Its only dependency is @octabits-io/server, for the span registry above. typescript is an optional peer.

Verify

pnpm --filter @octabits-io/telemetry lint \
  && pnpm --filter @octabits-io/telemetry typecheck \
  && pnpm --filter @octabits-io/telemetry test

Encoding is tested by round trip: every exporter test decodes the bytes its stub fetch received and asserts on the tree, so the assertions cover the protobuf real deployments send rather than an intermediate object.

From @octabits-io/telemetry@0.7.1 in the platform repository.