---
title: "@octabits-io/telemetry"
summary: "Structured logs, traces and metrics over OTLP/HTTP with no OpenTelemetry SDK and no collector agent: the root is the tracer, the meter, W3C trace-context propagation and the OTLP trace and metric exporters; ./logger is the structured logger and its OTLP log exporter."
install: "pnpm add @octabits-io/telemetry"
availability: "Available from November 2026"
version: "0.7.1"
license: "MIT"
related:
  - name: "@octabits-io/server"
    url: "https://octabits.io/platform/server.md"
canonical_url: "https://octabits.io/platform/telemetry"
---

# `@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.

```bash
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`

```ts
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.

```ts
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:

```ts
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

```ts
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

```ts
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`](https://octabits.io/platform/server.md). 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`](https://octabits.io/platform/octaflow.md) 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

```bash
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.