@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
onErrorinstead 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 takesencoding: '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 aparentand passed along by hand — and the request span rides on theRequestobject itself (see the registry below). - Nobody has to depend on this to be instrumented by it. Every consumer in
this monorepo takes a
Logger, aTracerand aMeterstructurally — 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_KEYSis 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. Sohttp.request.header.authorization,access_token,apiKey,x-api-key,user.emailanddb.connection_stringare caught, whilegen_ai.token.type(the GenAI token-count convention),gen_ai.usage.input_tokens,email_verified,server.addressandhttp.routeare not. A key that names personal data in a word the list does not have —user.address, aname— is yours to add. Nested objects and arrays are walked. Three deliberate exceptions: a boolean is never redacted by its key (browser.mobileis a flag); a pagination cursor is not a credential (page_token,next_page_token,next_token—DEFAULT_NON_SENSITIVE_KEYS); andsession.idis 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/xbecomespostgres://app:[REDACTED]@db:5432/x, the host kept) and e-mail addresses are scrubbed out of every string: the log message,error.messageanderror.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-encodedada%40example.comin a URL are caught whole. ADateunder an attribute is its ISO text, anErrorits 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.addresson a client span is the upstream server and is cut like any address, soECONNREFUSED 10.0.12.34:5432reads10.0.12.0:5432. A four-part version such as1.2.3.4reads as an address and is cut too.ipAddresses: 'redact'replaces them whole;falseleaves 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.