Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/master.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,20 @@ jobs:
# Same suite in real Chromium via the official Playwright Docker image.
- run: npm run test-browser

OTLP-test:
runs-on: ubuntu-24.04
# End-to-end check: export to a real (pinned) OpenTelemetry Collector and assert it parsed both
# our JSON and protobuf output. index.js is built in Docker (deps in the container), so the host
# needs only Docker plus Node to run the driver — no `npm ci`.
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version: 22.x
- run: npm run test-otlp

Lint:
runs-on: ubuntu-24.04
# eslint 10 / eslint-plugin-perfectionist need Node 20+; lint is static, so run it once
Expand Down
20 changes: 20 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# AGENTS

Guidance for agents working on `@larvit/log`. Keep changes aligned with the priorities below.

## What this is

Structured logging with a tiny API and first-class OTLP (logs + traces) over `fetch`, with no OpenTelemetry SDK dependency. Works as a plain stdout/stderr logger when OTLP is not configured.

## Design priorities (in order)

1. **Works everywhere** — Node.js, Bun, Deno and other server runtimes, plus browsers and React Native. Lean on the common JS surface (global `fetch`); add fallbacks where a runtime lacks an API rather than dropping support.
2. **A very easy API** — "just log" must stay trivial. Don't make the caller learn OTLP to use it.
3. **Composable** — instances inherit context/spans/traces and can attach to upstream headers/spans/traces. Favour designs that slot into existing setups.
4. **Low footprint for the consumer** — minimise runtime cost and install weight shipped to consumers. Dev-time build/codegen steps in this repo are fine, as long as they don't reach consumers.

## Working here

- Source is a single `index.ts`, compiled + uglified to `index.js` for publish.
- Tests-first. The suite (`test.ts`) injects `stdout`/`stderr` and stubs the global `fetch`, so the same tests cover console + OTLP in both Node and the browser.
- See [README](README.md) for build/test/release commands. Keep the README and this file in sync with any priority or workflow change.
30 changes: 26 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ Structured logging with a simple interface and support for OTLP.

In priority order:

1. **A simple API** — small surface, easy to drop in.
2. **Runs anywhere JavaScript runs** — both browsers and server-side (Node.js >= 18). The only requirement is a runtime with the global `fetch` (used by the OTLP transport).
3. **Strong OTLP support** — the OTLP payloads are hand-built JSON over `fetch` (no OpenTelemetry SDK dependency) to stay portable across runtimes.
4. **stdout/stderr support** — works as a plain console logger when OTLP is not configured.
1. **Works everywhere** — Node.js, Bun, Deno and other server runtimes, plus browsers and React Native. Leans on the common JavaScript surface (global `fetch`), with graceful fallbacks where a runtime lacks an API.
2. **A very easy API** — "just log" must stay trivial. Developers pick it up fast without needing to understand OTLP internals.
3. **Composable** — attach to upstream headers/spans/traces and inherit logs, spans and traces between instances. A chameleon that slots into most setups.
4. **Low footprint for the consumer** — small runtime cost and install weight in the consumer's app. Build/codegen steps in *this* library's own development are fine, as long as they don't carry over to consumers.

## Installation

Expand Down Expand Up @@ -111,6 +111,12 @@ const log = new Log({
// Added in 1.3.0
otlpHttpBaseURI: null,

// OTLP wire format: "http/json" (default) or "http/protobuf".
// Both POST to the same endpoint; protobuf sends Content-Type: application/x-protobuf.
// Use protobuf for collectors that don't accept JSON.
// Added in 2.2.0
otlpProtocol: "http/json",

// Group logs together under a specific parent
// Used for spans and traces in Open Telemetry etc.
// Defaults to null, creating no span in otlp
Expand Down Expand Up @@ -159,6 +165,12 @@ exact same tests exercise the console output and the OTLP transport in Node and
The container runs `npm run ci` / `ci-browser` internally — run those directly only if you already
have deps installed locally.

- `npm run test-otlp` — end-to-end OTLP check: exports to a real (pinned) OpenTelemetry Collector and
asserts it parsed both the JSON **and** protobuf output. This validates the hand-built protobuf
encoder against the reference implementation, not just our own decoder. `index.js` is built inside
Docker and the driver runs on the host with plain Node, so — like the suites above — it needs only
Docker, no local `npm install`. See `scripts/run-otlp-tests.mjs` (`OTLP_DEBUG=1` dumps exactly what
the collector received).
- `npm run lint` — eslint over the sources. Linting needs Node 20+ (eslint 10), so CI runs it once
in its own job rather than inside the Node 18–26 test matrix. Needs deps installed locally.

Expand All @@ -184,6 +196,16 @@ To publish manually instead: `npm run build-and-publish`.

## Changelog

### v2.2.0

- OTLP can now export over **HTTP/protobuf**, not only HTTP/JSON. Opt in with
`otlpProtocol: "http/protobuf"` (default stays `"http/json"`); both POST to the same endpoint.
The protobuf encoder is hand-built and dependency-free, so the library stays a single
self-contained file that runs anywhere. Useful for collectors that only accept protobuf.
- Fixed: `clone()` now inherits OTLP settings (`otlpHttpBaseURI`, `otlpProtocol`,
`otlpAdditionalHeaders`) and `printTraceInfo`, which it previously dropped silently. A clone still
gets its own span — it is not made a child of the original.

### v2.1.0

- `Metadata` values may now be `number` or `boolean`, not only `string` (new exported `MetadataValue`
Expand Down
215 changes: 194 additions & 21 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type LogConf = {
logLevel?: LogLevel | "none";
otlpAdditionalHeaders?: Record<string, string>;
otlpHttpBaseURI?: string;
otlpProtocol?: "http/json" | "http/protobuf";
parentLog?: LogInt;
printTraceInfo?: boolean;
spanName?: string;
Expand Down Expand Up @@ -320,6 +321,175 @@ function buildSpanPayload(opts: {
};
}

// --- OTLP/HTTP protobuf encoding -------------------------------------------
// Hand-rolled protobuf wire encoder for the small, frozen OTLP message subset this library emits.
// Zero dependencies keeps the library a single self-contained file that runs in any JS runtime
// (priority: works everywhere). Field numbers below are from the OTLP proto definitions (v1).

const WIRE_VARINT = 0;
const WIRE_FIXED64 = 1;
const WIRE_LEN = 2;

class ProtoWriter {
private readonly buf: number[] = [];

// Uint8Array<ArrayBuffer> (not the ArrayBufferLike default) so the result is a valid fetch BodyInit.
finish(): Uint8Array<ArrayBuffer> {
return new Uint8Array(this.buf);
}

// Non-negative integer < 2^53 (tags, lengths, enums, counts). Modulo/division sidesteps the
// 32-bit truncation of bitwise ops, so no BigInt is needed for these.
private pushVarint(value: number): void {
while (value > 0x7f) {
this.buf.push((value % 128) | 0x80);
value = Math.floor(value / 128);
}
this.buf.push(value);
}

private pushTag(fieldNo: number, wireType: number): void {
this.pushVarint((fieldNo * 8) + wireType);
}

private pushLen(fieldNo: number, data: ArrayLike<number>): void {
this.pushTag(fieldNo, WIRE_LEN);
this.pushVarint(data.length);
for (let i = 0; i < data.length; i++) {
this.buf.push(data[i]);
}
}

// int32/uint32/enum/bool field.
uint(fieldNo: number, value: number): this {
this.pushTag(fieldNo, WIRE_VARINT);
this.pushVarint(value);

return this;
}

// fixed64 field from a decimal string (eg. a ns timestamp that overflows Number). 8 bytes, LE.
fixed64(fieldNo: number, decimal: string): this {
this.pushTag(fieldNo, WIRE_FIXED64);
let rest = BigInt(decimal);
const mask = BigInt(0xff);
const eight = BigInt(8);

for (let i = 0; i < 8; i++) {
this.buf.push(Number(rest & mask));
rest = rest >> eight;
}

return this;
}

string(fieldNo: number, value: string): this {
this.pushLen(fieldNo, new TextEncoder().encode(value));

return this;
}

bytes(fieldNo: number, value: Uint8Array): this {
this.pushLen(fieldNo, value);

return this;
}

// Embedded message: encode into a sub-writer, then write it length-delimited.
message(fieldNo: number, write: (sub: ProtoWriter) => void): this {
const sub = new ProtoWriter();

write(sub);
this.pushLen(fieldNo, sub.buf);

return this;
}
}

function hexToBytes(hex: string): Uint8Array {
const out = new Uint8Array(hex.length / 2);

for (let i = 0; i < out.length; i++) {
out[i] = parseInt(hex.slice(i * 2, (i * 2) + 2), 16);
}

return out;
}

// KeyValue { key = 1, value = 2: AnyValue { string_value = 1 } }
function writeKeyValue(writer: ProtoWriter, attr: OtlpAttribute): void {
writer.string(1, attr.key);
writer.message(2, value => value.string(1, attr.value.stringValue));
}

// Resource / Span / LogRecord attributes are all repeated KeyValue.
function writeAttributes(writer: ProtoWriter, fieldNo: number, attributes: OtlpAttribute[]): void {
for (const attr of attributes) {
writer.message(fieldNo, attrMsg => writeKeyValue(attrMsg, attr));
}
}

function encodeOtlpLogPayload(payload: OtlpLogPayload): Uint8Array<ArrayBuffer> {
const root = new ProtoWriter(); // ExportLogsServiceRequest

for (const resourceLog of payload.resourceLogs) {
root.message(1, resLogs => { // resource_logs = 1
resLogs.message(1, resource => writeAttributes(resource, 1, resourceLog.resource.attributes)); // ResourceLogs.resource = 1
for (const scopeLog of resourceLog.scopeLogs) {
resLogs.message(2, scopeMsg => { // ResourceLogs.scope_logs = 2
for (const record of scopeLog.logRecords) {
scopeMsg.message(2, logRec => { // ScopeLogs.log_records = 2
logRec.fixed64(1, record.timeUnixNano); // time_unix_nano = 1
logRec.uint(2, record.severityNumber); // severity_number = 2
logRec.string(3, record.severityText); // severity_text = 3
logRec.message(5, body => body.string(1, record.body.stringValue)); // body = 5 (AnyValue.string_value)
writeAttributes(logRec, 6, record.attributes ?? []); // attributes = 6
if (record.traceId) logRec.bytes(9, hexToBytes(record.traceId)); // trace_id = 9
if (record.spanId) logRec.bytes(10, hexToBytes(record.spanId)); // span_id = 10
});
}
});
}
});
}

return root.finish();
}

function encodeOtlpSpanPayload(payload: OtlpSpanPayload): Uint8Array<ArrayBuffer> {
const root = new ProtoWriter(); // ExportTraceServiceRequest

for (const resourceSpan of payload.resourceSpans) {
root.message(1, resSpans => { // resource_spans = 1
resSpans.message(1, resource => writeAttributes(resource, 1, resourceSpan.resource.attributes)); // ResourceSpans.resource = 1
for (const scopeSpan of resourceSpan.scopeSpans) {
resSpans.message(2, scopeMsg => { // ResourceSpans.scope_spans = 2
scopeMsg.message(1, scope => scope.string(1, scopeSpan.scope.name)); // ScopeSpans.scope = 1 (InstrumentationScope.name = 1)
for (const span of scopeSpan.spans) {
scopeMsg.message(2, spanMsg => { // ScopeSpans.spans = 2
spanMsg.bytes(1, hexToBytes(span.traceId)); // trace_id = 1
spanMsg.bytes(2, hexToBytes(span.spanId)); // span_id = 2
if (span.parentSpanId) spanMsg.bytes(4, hexToBytes(span.parentSpanId)); // parent_span_id = 4
spanMsg.string(5, span.name); // name = 5
spanMsg.uint(6, span.kind); // kind = 6
spanMsg.fixed64(7, span.startTimeUnixNano); // start_time_unix_nano = 7
spanMsg.fixed64(8, span.endTimeUnixNano); // end_time_unix_nano = 8
writeAttributes(spanMsg, 9, span.attributes); // attributes = 9
if (span.status.code) spanMsg.message(15, status => status.uint(3, span.status.code)); // status = 15 (Status.code = 3)
});
}
});
}
});
}

return root.finish();
}

function encodeOtlpProtobuf(payload: OtlpLogPayload | OtlpSpanPayload): Uint8Array<ArrayBuffer> {
return "resourceLogs" in payload ? encodeOtlpLogPayload(payload) : encodeOtlpSpanPayload(payload);
}

export class Log implements LogInt {
context: Metadata;
ended: boolean = false;
Expand Down Expand Up @@ -408,34 +578,31 @@ export class Log implements LogInt {
conf = { logLevel: conf };
}

if (conf.logLevel === undefined) {
conf.logLevel = this.conf.logLevel;
}

// Resolve the formatter from the effective format, so json<->text can be changed in either direction.
if (conf.entryFormatter === undefined) {
if (conf.format === "json") {
conf.entryFormatter = msgJsonFormatter;
} else if (conf.format === "text") {
conf.entryFormatter = msgTextFormatter;
} else {
conf.entryFormatter = this.conf.entryFormatter;
}
}

if (conf.stderr === undefined) {
conf.stderr = this.conf.stderr;
}

if (conf.stdout === undefined) {
conf.stdout = this.conf.stdout;
}

// Merge context per-key (overrides win) instead of replacing it wholesale.
conf.context = {
...this.context,
...conf.context,
};

// Inherit every other setting not explicitly overridden — log level, sinks, OTLP endpoint/
// protocol/headers, printTraceInfo, etc. — mirroring how the constructor inherits from a
// parentLog. parentLog and spanName are excluded: a clone is its own span, not a child of the
// original. (A manual allow-list here previously dropped OTLP options added after clone existed.)
for (const key of Object.keys(this.conf) as (keyof LogConf)[]) {
if (key !== "parentLog" && key !== "spanName" && conf[key] === undefined) {
conf[key] = this.conf[key] as never;
}
}

return new Log(conf);
}

Expand Down Expand Up @@ -535,8 +702,10 @@ export class Log implements LogInt {
const basePath = base.pathname.replace(/\/$/, ""); // keep any base path prefix, drop a trailing slash
const url = `${base.protocol}//${base.username ? `${base.username}:${base.password}@` : "" }${base.host}${basePath}${path}`;

const headers = {
"Content-Type": "application/json",
const protobuf = this.conf.otlpProtocol === "http/protobuf";

const headers: Record<string, string> = {
"Content-Type": protobuf ? "application/x-protobuf" : "application/json",
};

if (this.conf.otlpAdditionalHeaders) {
Expand All @@ -549,7 +718,7 @@ export class Log implements LogInt {

try {
const res = await fetch(url, {
body: JSON.stringify(payload),
body: protobuf ? encodeOtlpProtobuf(payload) : JSON.stringify(payload),
headers,
method: "POST",
signal: controller.signal,
Expand All @@ -563,11 +732,15 @@ export class Log implements LogInt {
throw err;
}

const resBody = await res.json();
// Protobuf responses are binary (usually empty); a 2xx is success. Only the JSON
// transport inspects the response body.
if (!protobuf) {
const resBody = await res.json();

const resBodyStr = JSON.stringify(resBody);
if (resBodyStr !== "{\"partialSuccess\":{}}" && resBodyStr !== "{}") {
throw new Error("Invalid response body from OTLP service. Expected '{\"partialSuccess\":{}}' or '{}' but got: '" + JSON.stringify(resBody) + "'");
const resBodyStr = JSON.stringify(resBody);
if (resBodyStr !== "{\"partialSuccess\":{}}" && resBodyStr !== "{}") {
throw new Error("Invalid response body from OTLP service. Expected '{\"partialSuccess\":{}}' or '{}' but got: '" + JSON.stringify(resBody) + "'");
}
}

return true;
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"lint": "eslint index.ts tap.ts test.ts",
"replace-version-string": "grep -o '\"version\": \"[^\"]*\"' package.json | cut -d'\"' -f4 | xargs -I {} sed -i.bak 's/__version__/{}/g' index.js && rm -f index.js.bak",
"test-node": "node .tmp/test/test.js",
"test-otlp": "docker build -t larvit-log-test-node . && docker run --rm larvit-log-test-node sh -c \"npm run build >/dev/null && cat index.js\" > index.js && node scripts/run-otlp-tests.mjs",
"test-docker": "docker build -t larvit-log-test-node --build-arg BASE_IMAGE=\"${NODE_IMAGE:-node:22-bookworm-slim}\" . && docker run --rm larvit-log-test-node npm run ci",
"test-browser": "docker build -t larvit-log-test-browser --build-arg BASE_IMAGE=mcr.microsoft.com/playwright:v$(node -p \"require('./package.json').devDependencies.playwright\")-noble . && docker run --rm --init --ipc=host larvit-log-test-browser npm run ci-browser",
"test": "npm run test-docker && npm run test-browser"
Expand Down
Loading