Skip to content

frogfishio/ratatouille

Repository files navigation

Ratatouille — firehose logging for edge + microservices

A tiny logger + fire‑hose relay for shipping “everything logs” — fast, diskless, and filterable at the edge.

Think of it as DEBUG meets a market data feed: producers emit freely; ops curates later.

Ratatouille treats logs like a live market feed, not a database. Producers speak freely and emit anything (strings, blobs, JSON, haiku). There’s no sacred schema or level system to satisfy. Topics are just routing hints. The slow path is pretty print to the local console for on‑machine debugging. The fast path is a network fire hose that ships everything out, in memory, with backpressure and dropping policies — nothing touches disk by default. Persistence only happens when ops decides to materialize views.

Core stance

  • Logs are noise until curated. Don’t force structure on write. Schema lives in ops (schema‑on‑read).
  • One control surface. Filters, transforms, sampling and routing live in one place (ops), not sprinkled across apps and agents.
  • Topics, not taxonomies. A topic names a stream; it doesn’t define its shape. Mixed content is allowed.
  • Print is optional. Console output is for humans; the real business is the network stream.
  • Diskless by default. Bounded in‑memory queues, counters, and explicit drop metrics instead of silent blocking or surprise files.

Why this way?

  • Forced schemas make heterogeneous sources (e.g., HTTPD vs. app transactions) brittle and spawn endless per‑source configs. Ratatouille rejects that: deliver logs as they are, then let ops carve meaning once, centrally.
  • Late binding means you can change your mind about what matters without redeploying code.
  • Minimal Topic hot path (no parsing, no timers on the emission path) keeps overhead tiny and works in Node, SSR and workers. (Sinks like Relay may use timers for batching.)

What ops gets

  • Smart filtering with DEBUG‑style allow/deny and wildcards; optional sampling and drop policies.
  • Selectable output: pretty text for humans; JSON lines for pipes; or forward raw via Relay (HTTP/TCP/Workers) to whatever stack you run (Grafana/Loki, Influx, OTLP, S3, …).
  • Observability of the logger itself: per‑topic sequence counters and drop counts so you know what was kept, what was shed, and why.

What Ratatouille is not

Ratatouille is not a traditional structured logging framework (log4j/log4js/winston/pino-style).

  • No log levels as a first-class concept ("error" can be a topic, not a stream).
  • No promise of durability, ordering, or delivery. This is telemetry, not an audit trail.
  • No enforced schema. Producers can emit anything; consumers decide what to keep.

If something is critical, do not put it in logs. Use a separate audit/event system.

What problem this solves

When you have hundreds of log producers (autoscaled microservices across Workers/Lambda/K8s/Nomad), "just print to stdout" turns into:

  • too much volume
  • too much cost
  • too much noise
  • too little traceability

Ratatouille is built for the fire-hose reality: producers emit freely, a sink ships the stream to a concentrator/queue, and consumers mine it later.

Architecture

Two keywords:

  1. Insane speed
  2. Never unencrypted at rest

A typical deployment looks like:

producer → triager (TLS/auth, optional) → queue → consumer

  • Producers (apps/workers) emit a fire-hose stream.
  • Triager is an HTTPS tunnel/edge that terminates TLS, authenticates, and forwards bytes. (It does not parse.)
  • Queue (e.g., SegQ or similar) durably buffers the stream for fan-out. If it writes to disk, it must be encrypted at rest.
  • Consumers read from the queue and decide what to keep, index, sample, or drop.

The design goal is that logs are data in transit. If they ever touch disk in the pipeline, they do so encrypted, which helps with GDPR-style risk: you avoid piles of plaintext log files sitting around on nodes.

Logs are not transactional data

Logs are best-effort telemetry. They can drop.

Do not use logs to represent important state like:

  • "X made a purchase"
  • "payment succeeded"
  • "user permissions changed"

Those belong in an audit/event system with durability, idempotency, and query guarantees.

Cross-language contract

Ratatouille is now intended to exist as a family of small runtime-native libraries, starting with:

  • Node.js
  • C
  • Rust

The API shape does not need to match across languages. The shared contract is behavioral and wire-level.

These invariants should hold across implementations:

  • Best-effort telemetry. Emission may drop; this is not an audit channel.
  • Bounded state. Implementations should keep memory bounded and expose drop behavior rather than block forever.
  • Topic-first routing. Topics are the primary filter/control surface.
  • Per-topic sequence. Each topic maintains its own monotonic local sequence counter.
  • Text or NDJSON output. Human mode and machine mode are both first-class.
  • Stable envelope for sinks. Transport-facing output should converge on the same envelope shape.
  • Optional source identity. Implementations may inject src such as app, where, and instance.
  • No producer-side schema enforcement. Producers can emit raw text or native-language payloads; transport format stays predictable.

The canonical transport envelope is:

{
  "ts": "2026-03-11T12:34:56.789Z",
  "seq": 42,
  "topic": "api",
  "src": {
    "app": "edge-auth",
    "where": "node",
    "instance": "prod:abc"
  },
  "meta": null,
  "args": ["hello world"],
  "env": null
}

Notes:

  • meta and env are optional.
  • src is optional but strongly recommended for distributed deployments.
  • Language-native producer APIs may narrow or widen payload types. For example, the C API currently emits a single string payload in args.

Shared contract corpus

The repository now includes a shared cross-language contract corpus in contract/cases.tsv.

The purpose of this corpus is to keep Node, C, and Rust aligned on the behavior that matters most:

  • filter semantics
  • per-topic sequencing
  • emitted vs filtered behavior

Run the full suite with:

npm run contract:test

That command:

  • builds the Node package
  • builds the C runners
  • runs the Node contract runner
  • runs the C contract runner
  • runs the Rust contract runner

Ratatouille logger

A tiny, flexible debug logger for Node and SSR that’s easy to read in dev and easy to pipe in prod.

  • Callable topics: const log = Topic("api"); log("hello").
  • Inline colors: Topic("api#ff00aa"), Topic("db#red"), or Topic("auth#random").
  • Structured logs: RATATOUILLE=json emits one JSON object per line.
  • Smart filtering: DEBUG-style allow/deny with wildcards; support multiple envs.
  • Per-topic sequence: Each topic counts calls (#000001, #000002, …) and includes it in output.
  • Zero-dep & SSR-safe: Works in Node; falls back cleanly in browsers/workers.

Install

# if published
npm i @frogfish/ratatouille
# or with pnpm
pnpm add @frogfish/ratatouille

Working locally? Import from source: import Topic, { setDebug } from "./src/topic".

If you want a diskless local collector + tailer, pair this with Ringtail (NDJSON sink + tail).

C

The repository now includes a minimal-dependency C implementation in c/.

Build the static library and example:

make -C c example
./c/build/basic

Supports:

  • topic filtering with DEBUG-style wildcards
  • per-topic sequence counters
  • text and NDJSON output modes
  • stdout sink and callback sink API
  • plain HTTP POSIX sink for Ringtail-compatible NDJSON shipping
  • bounded HTTP relay with queueing and explicit batch flush
  • plain TCP sink for raw NDJSON shipping
  • bounded TCP relay with queueing and explicit batch flush
  • zero external dependencies

Rust

The repository now also includes a zero-dependency Rust crate in rust/.

Build and run the example:

cargo run --manifest-path rust/Cargo.toml --example basic

Supports:

  • topic filtering with DEBUG-style wildcards
  • per-topic sequence counters
  • text and NDJSON output modes
  • stdout sink and custom sink trait support
  • plain HTTP sink and bounded explicit-flush relay
  • plain TCP sink and bounded explicit-flush relay
  • zero external dependencies beyond Rust std

Rust quick start

use ratatouille::{Format, Logger, LoggerConfig, SourceIdentity};

fn main() {
  let mut config = LoggerConfig::default();
  config.filter = Some("api*,-api:noise".into());
  config.format = Format::Ndjson;
  config.source = SourceIdentity {
    app: Some("example".into()),
    r#where: Some("rust".into()),
    instance: Some("local".into()),
  };

  let mut log = Logger::new(config);
  let _ = log.log("api", "hello from Rust");
}

The Rust implementation mirrors the same behavioral contract as Node and C, but with Rust-native APIs and ownership rules.

Rust HTTP transport

The Rust crate now includes a std-only plain HTTP sink and a bounded explicit-flush relay.

Current limits:

  • http:// only
  • no TLS
  • no background thread
  • bounded in-memory queue on the relay path
  • explicit flush_now() for batch delivery

Build and run the relay example:

cargo run --manifest-path rust/Cargo.toml --example http_relay

Example:

use ratatouille::{Format, HttpRelay, HttpRelayConfig, Logger, LoggerConfig};

let relay = HttpRelay::new(HttpRelayConfig {
  url: "http://127.0.0.1:8080/sink".into(),
  batch_bytes: 4096,
  max_queue_bytes: 65536,
  max_queue: 128,
  ..HttpRelayConfig::default()
})?;

let mut cfg = LoggerConfig::default();
cfg.filter = Some("api*".into());
cfg.format = Format::Ndjson;

let mut log = Logger::with_sink(cfg, relay);
let _ = log.log("api", "queued one");
let _ = log.log("api", "queued two");
let _ = log.sink_mut().flush_now()?;
# Ok::<(), std::io::Error>(())

Rust TCP transport

The Rust crate also includes a std-only plain TCP sink and a bounded explicit-flush relay.

Current limits:

  • tcp://host:port only
  • no background thread
  • bounded in-memory queue on the relay path
  • explicit flush_now() for batch delivery

Example:

use ratatouille::{Format, Logger, LoggerConfig, TcpRelay, TcpRelayConfig};

let relay = TcpRelay::new(TcpRelayConfig {
  endpoint: "tcp://127.0.0.1:9000".into(),
  batch_bytes: 4096,
  max_queue_bytes: 65536,
  max_queue: 128,
  ..TcpRelayConfig::default()
})?;

let mut cfg = LoggerConfig::default();
cfg.filter = Some("api*".into());
cfg.format = Format::Ndjson;

let mut log = Logger::with_sink(cfg, relay);
let _ = log.log("api", "queued one");
let _ = log.log("api", "queued two");
let _ = log.sink_mut().flush_now()?;
# Ok::<(), std::io::Error>(())

C quick start

#include "ratatouille.h"

int main(void) {
  rat_config_t cfg = {0};
  cfg.filter = "api*,-api:noise";
  cfg.format = RAT_FORMAT_NDJSON;
  cfg.source.app = "example";
  cfg.source.where = "c";
  cfg.source.instance = "local";

  rat_logger_t *log = rat_logger_create(&cfg);
  if (!log) return 1;

  rat_log(log, "api", "hello from C");
  rat_logf(log, "api", "user=%s req=%d", "alice", 42);

  rat_logger_destroy(log);
  return 0;
}

The C implementation stays intentionally small, but it now includes both HTTP and TCP shipping paths alongside the hot-path producer, filter, sequence tracking, and stable line formatting.

C TCP transport

The C library now includes a POSIX plain TCP sink and a bounded explicit-flush TCP relay.

Current limits:

  • tcp://host:port only
  • no TLS
  • no background thread
  • bounded in-memory queue on the relay path
  • explicit rat_tcp_relay_flush_now() for batch delivery

Build the examples:

make -C c example
./c/build/tcp_sink_example
./c/build/tcp_relay_example

C HTTP sink

The C library now includes a minimal plain-HTTP POSIX sink for local or trusted-network shipping.

Current limits:

  • http:// only
  • no TLS
  • no background thread
  • one POST per emitted line
  • intended as the smallest transport bridge, not the final high-throughput path

Example:

rat_http_sink_config_t http_cfg = {0};
http_cfg.url = "http://127.0.0.1:8080/sink";

rat_http_sink_t *http_sink = rat_http_sink_create(&http_cfg);

rat_config_t cfg = {0};
cfg.filter = "api*";
cfg.format = RAT_FORMAT_NDJSON;
cfg.sink = rat_http_sink_callback;
cfg.sink_userdata = http_sink;

rat_logger_t *log = rat_logger_create(&cfg);
rat_log(log, "api", "hello over HTTP");

Build the transport example with:

make -C c example
./c/build/http_sink_example

If you need encrypted transport, batching, or a non-blocking network path in C, that should be the next transport layer rather than stretching this plain-HTTP sink beyond its intent.

C HTTP relay

The C library now also includes a bounded in-memory HTTP relay.

This is the C equivalent of the Node relay model at a smaller scope:

  • the logging hot path enqueues and returns
  • queue memory is bounded
  • drop behavior is explicit (RAT_DROP_OLDEST or RAT_DROP_NEWEST)
  • flush happens when you call rat_http_relay_flush_now()
  • one flush may POST multiple NDJSON lines in a single request

Current limits:

  • http:// only
  • no TLS
  • no hidden background thread
  • no timer loop inside the library

Example:

rat_http_relay_config_t relay_cfg = {0};
relay_cfg.url = "http://127.0.0.1:8080/sink";
relay_cfg.batch_bytes = 4096;
relay_cfg.max_queue_bytes = 65536;
relay_cfg.max_queue = 128;
relay_cfg.drop_policy = RAT_DROP_OLDEST;

rat_http_relay_t *relay = rat_http_relay_create(&relay_cfg);

rat_config_t cfg = {0};
cfg.filter = "api*";
cfg.format = RAT_FORMAT_NDJSON;
cfg.sink = rat_http_relay_callback;
cfg.sink_userdata = relay;

rat_logger_t *log = rat_logger_create(&cfg);
rat_log(log, "api", "queued one");
rat_log(log, "api", "queued two");

rat_http_relay_flush_now(relay);

Build the relay example with:

make -C c example
./c/build/http_relay_example

Quick start

// ESM default import
import Topic from "@frogfish/ratatouille"; // or "./src/topic"

// Or named import
// import { Topic } from "@frogfish/ratatouille";

// Plain topic
const log = Topic("debug", { svc: "api" });
log("hello world", { user: "alice" }, { requestId: 123 }, "extra arg");

// Colored topic (24‑bit)
const pink = Topic("debug#ff00aa", { svc: "api" });
pink("this prints the topic in #ff00aa");

// Named color
const red = Topic("auth#red");
red("login failed");

// Stable random color from a readable palette
const api = Topic("api#random");
api("picked a deterministic 256‑color for 'api'");

Output (text mode):

[2025-09-05T01:23:45.678Z #000001] debug — hello world {"user":"alice"} {"requestId":123} extra arg
[2025-09-05T01:23:45.790Z #000002] debug — …

Ship logs to Ringtail (recommended local dev collector)

Ratatouille is the producer + optional relay. To actually watch a firehose, you need a sink.

Ringtail is the companion sink/tailer: an in-memory, diskless NDJSON collector you can run locally (or in Nomad) and tail like a live feed.

What gets sent over the wire

By default, Relay.send(payload) emits one NDJSON line per call. If the payload is already an envelope (a plain object with a topic), Relay will pass it through and ensure:

  • ts exists (adds Date.now() if missing)
  • src exists (injects/merges your configured identity)

If the payload is not an envelope, Relay wraps it into a minimal envelope:

{"ts":1730000000000,"topic":"raw","args":["hello"],"src":{"app":"edge-auth","where":"cf-worker","instance":"prod:abc"}}

This keeps the producer API flexible ("log anything") while keeping the transport format predictable.

Ringtail endpoint normalization

If you configure Relay with an HTTP(S) host-only endpoint (no path, or /), it will default to:

  • http(s)://host:port/sink

So these are equivalent:

  • endpoint: "http://127.0.0.1:8080"
  • endpoint: "http://127.0.0.1:8080/sink"

Environment variables (Ringtail + identity)

These are the variables you'll use most often when shipping to Ringtail:

Collector

  • RINGTAIL_URL — base URL for Ringtail (path optional). Examples:
    • http://127.0.0.1:8080
    • http://127.0.0.1:8080/sink
  • RINGTAIL_TOKEN — optional Bearer token (sent as Authorization: Bearer …)

Source identity (injected into every envelope as src)

  • RATATOUILLE_APP — service/app name (e.g. payments, edge-auth)
  • RATATOUILLE_WHERE — runtime label (e.g. nomad, node, cf-worker)
  • RATATOUILLE_INSTANCE — instance label (alloc id / isolate id / hostname-ish)
  • RATATOUILLE_DEFAULT_TOPIC — used when you call Relay.send("a string") (defaults to raw)

Tip: identity is what makes server-side filtering practical: src.app, src.where, src.instance.

Set RINGTAIL_URL to enable shipping; if unset, presets typically only print locally unless you wire a transport explicitly.

Local dev: one-liner mental model

  1. run Ringtail (sink)
  2. run your services with Ratatouille → Relay
  3. tail Ringtail

Example

# terminal A: start sink
ringtail sink --listen 127.0.0.1:8080

# terminal B: watch the stream
ringtail tail http://127.0.0.1:8080

# terminal C: run your app with env pointing at the sink
export RINGTAIL_URL=http://127.0.0.1:8080
export RATATOUILLE_APP=api
export RATATOUILLE_WHERE=dev
node app.js

Presets (recommended)

Presets give you a tiny, reusable log factory that:

  • computes a sensible src identity for the environment
  • optionally wires topics to Ringtail automatically
  • keeps the hot path non-blocking (drops are OK)

Nomad / Node preset

import { createNomadFactory } from "@frogfish/ratatouille/presets/nomad";

// create once (singleton)
export const log = createNomadFactory({
  alsoPrint: true, // print locally while forwarding
});

// use anywhere
const api = log.topic("api", { svc: "api" });
api("hello", { user: "alice" });

Enable shipping by setting RINGTAIL_URL (and optionally RINGTAIL_TOKEN). The Nomad preset derives src from Nomad env vars when present (job/group/task/alloc), but you can override with RATATOUILLE_*.

Optional eager connect:

await log.initLogging();

Lambda preset

import { createLambdaFactory } from "@frogfish/ratatouille/presets/lambda";

export const log = createLambdaFactory({
  alsoPrint: true,
});

export async function handler(event: any) {
  log.topic("lambda", { svc: "edge-auth" })("invoked", { requestId: event?.requestContext?.requestId });
  await log.initLogging();
  return { ok: true };
}

The Lambda preset derives src from AWS Lambda env vars when present, including:

  • AWS_LAMBDA_FUNCTION_NAME
  • AWS_LAMBDA_FUNCTION_VERSION
  • AWS_REGION
  • AWS_LAMBDA_LOG_GROUP_NAME
  • AWS_LAMBDA_LOG_STREAM_NAME

You can still override identity with RATATOUILLE_APP, RATATOUILLE_WHERE, and RATATOUILLE_INSTANCE, or provide src directly.

Workers preset (Cloudflare Workers / browsers)

import { createWorkersFactory } from "@frogfish/ratatouille/presets/workers";

export default {
  async fetch(req: Request, env: any, ctx: ExecutionContext) {
    const log = createWorkersFactory({
      env,
      app: "edge-auth",
      where: "cf-worker",
      alsoPrint: true,
    });

    log.topic("api")("hit", { url: req.url, method: req.method });

    // best-effort: ensures at least one connect/flush path runs before isolate goes idle
    ctx.waitUntil(log.initLogging());

    return new Response("ok");
  },
};

Notes:

  • Workers don't guarantee timers (setInterval) will fire before an isolate is suspended.
  • If you care about getting some logs out, call ctx.waitUntil(relay.flushNow()) in your Worker, or ctx.waitUntil(log.initLogging()) if you're using the preset.

Transport-only (no preset)

If you want full control, wire a transport directly:

import Topic from "@frogfish/ratatouille";
import { createRingtailTransport } from "@frogfish/ratatouille/transports/ringtail";

const rt = createRingtailTransport({
  url: process.env.RINGTAIL_URL || "http://127.0.0.1:8080",
  token: process.env.RINGTAIL_TOKEN,
  includeEnv: true,
  src: {
    app: process.env.RATATOUILLE_APP || "api",
    where: process.env.RATATOUILLE_WHERE || "node",
    instance: process.env.RATATOUILLE_INSTANCE || "local",
  },
});

await rt.connect();

const log = Topic("api").extend((e) => rt.send(e), true);
log("hello");

Filtering and local printing

Ratatouille has two ideas:

  • Emission filter: which topics exist (and therefore reach sinks).
  • Local printing: a developer convenience so you can see what's happening while coding.

Today the filter syntax is DEBUG-style (wildcards, allow/deny). DEBUG=... is supported mainly as a compatibility shim for local dev workflows.

The canonical control surface is RATATOUILLE (especially RATATOUILLE.filter).

  • Patterns are separated by commas or whitespace.
  • * is a wildcard; - negates a pattern.
  • Allow + deny evaluation:
    • If both allow and deny lists are empty → logging is disabled.
    • If allow is empty and deny is non-empty → allow everything except deny matches.
    • Otherwise → enabled if topic matches any allow and no deny.

Examples:

# Enable everything
DEBUG=* node app.js

# Enable API only
DEBUG=api* node app.js

# Enable all except chat
DEBUG=-chat* node app.js

# Mix allow/deny
DEBUG="api*,auth*,-auth:noise" node app.js

Quote values when using * to avoid shell globbing.

Multiple env vars

You can merge more variables (e.g., XYZ) without changing your code by using RATATOUILLE config (see below):

# Use DEBUG and XYZ together
RATATOUILLE='{"debugVars":["DEBUG","XYZ"]}' \
  XYZ=auth* DEBUG=-db* node app.js

Colors

Add a color by suffixing the topic with #…:

  • Hex: #ff00aa, #faf (shorthand)
  • Named (subset): red, green, blue, cyan, magenta, yellow, orange, purple, pink, teal, gray/grey, black, white
  • Random: #random → assigns a deterministic, readable 256‑color based on the topic name

Color only affects the topic label. Messages remain uncolored for readability.

Color output toggles:

  • Auto‑enabled on TTY; disabled if NO_COLOR or FORCE_COLOR=0.
  • Force on/off via RATATOUILLE (below).

Output formats

Text (default)

[ISO‑8601 #SEQ] <topic> <meta> — <args…>
  • #SEQ is a zero‑padded per‑topic sequence (#000001).
  • meta and each argument are pretty‑printed:
    • Uses a safe JSON replacer (handles circular refs, Error objects).
    • Error instances print .stack if present (else name: message).

JSON lines

Enable with RATATOUILLE=json or a JSON config ({"format":"json"}). One JSON object per line:

{"ts":"2025-09-05T01:23:45.678Z","seq":1,"topic":"debug","meta":{"svc":"api"},"args":["hello",{"user":"alice"}]}
  • Handles circulars via a safe replacer ("[Circular]").
  • Serializes Error as {name,message,stack}.

RATATOUILLE config

A single env var that’s either quick flags or a full JSON.

Quick flags

RATATOUILLE=nocolor   # force disable colors
RATATOUILLE=json      # structured JSON output

Full JSON config

{
  "color": "auto" | "on" | "off",  // default "auto"
  "format": "text" | "json",        // default "text"
  "filter": "*,-noisy*",            // primary DEBUG-style filter (preferred over env)
  "debugVars": ["DEBUG", "XYZ"],    // env vars to merge for patterns
  "print": true | false,             // controls console/stderr printing (see below)
  "extra": { /* reserved for future */ }
}

Examples:

# Set filter in RATATOUILLE (preferred) and disable colors; do not print (default)
RATATOUILLE='{"filter":"api*,auth*,-auth:noise","color":"off"}' node app.js

# Back-compat: merge DEBUG + XYZ from env if no RATATOUILLE.filter is set
RATATOUILLE='{"debugVars":["DEBUG","XYZ"]}' XYZ=auth* DEBUG=-db* node app.js

Printing behavior

  • If RATATOUILLE.print is set, it takes precedence.
  • Printing is a developer convenience (a local PrintSink). Sinks (e.g., Relay) are the core fire-hose.
  • If any sink is attached, printing is suppressed by default to avoid double-output. A sink can opt-in to printing via alsoPrint=true.
  • If RATATOUILLE.filter is set and print is not specified, printing defaults to false (opt-in).
  • If no filter is set and filters are derived from env vars like DEBUG, printing defaults to true for drop-in compatibility.

Examples:

# Print JSON logs to console (explicit)
RATATOUILLE='{"format":"json","filter":"*","print":true}' node app.js

# Use DEBUG from env and print by default
DEBUG=* node app.js

Cloudflare Workers configuration

In Workers, RATATOUILLE defined in wrangler.toml [vars] is available as env.RATATOUILLE at runtime, not process.env. Call configureRatatouille once per isolate to apply it:

// worker.ts
import Topic, { configureRatatouille, setDebug, setPrint } from '@frogfish/ratatouille';

let configured = false;
export default {
  async fetch(req: Request, env: any, ctx: ExecutionContext) {
    if (!configured) {
      // Apply TOML var, e.g., '{"format":"json","filter":"*","print":true}'
      if (env.RATATOUILLE) configureRatatouille(env.RATATOUILLE);
      // Or configure explicitly:
      // setDebug('*'); setPrint(true);
      configured = true;
    }
    const log = Topic('worker');
    log('hello', { url: req.url });
    return new Response('ok');
  }
}

Force JSON logs regardless of TTY:

RATATOUILLE='{"format":"json"}' DEBUG=api* node app.js

API reference

import Topic, { setDebug, configureRatatouille, setPrint } from "@frogfish/ratatouille"; // or from "./src/topic"

Topic(name: string, config?: { meta?, env?, print? } | meta): TopicCallable

Creates a callable logger bound to a topic. Second argument is a config object or legacy meta.

  • name: may include an inline color suffix: "topic#ff00aa", "topic#red", "topic#random".
  • config.meta: optional object printed once per line after the topic.
  • config.env: optional environment snapshot added to each line (JSON: env field; text: appended after meta).
  • config.print: per-topic print override; forces/suppresses console output for this topic.
  • Back‑compat: passing a plain object as the second arg is treated as meta.

Returns a function (...args: unknown[]) => void with properties:

  • .topic: string — the base topic name (color suffix stripped)
  • .meta: Record<string, unknown> | undefined — the meta object
  • .enabled: boolean — whether the topic is currently enabled by filters
  • .seq: number — current per‑topic sequence (starts at 0; first call prints #000001)

Usage:

const debug = Topic("debug#random", { meta: { svc: "api" }, env: { region: "iad" }, print: true });
if (debug.enabled) {
  debug("starting", { port: 8080 });
}

// Extend: attach a non-blocking handler that receives JSON envelopes
debug.extend((e) => {
  // e: { ts, seq, topic, meta, args, env }
  // Forward to legacy/bespoke loggers without blocking the request path
  console.log(`[legacy] ${e.topic}#${e.seq} ${JSON.stringify(e)}`);
});

Extensions (bridge to legacy loggers)

Use extend(handler, alsoPrint?) to plug in bespoke or legacy logging without changing call sites.

  • Signature: extend((envelope) => void, alsoPrint?: boolean)
  • Envelope: { ts, seq, topic, meta, args, env } (what JSON mode would emit)
  • Non-blocking: handlers run on a timer/microtask to keep the hot path fast.
  • Gating: handlers represent sinks. They run whenever the topic is enabled by filters, independent of local printing.

Behavior rules:

  • No sinks attached → normal printing (subject to filters and print gate).
  • If any sink is attached with alsoPrint=false (default) → printing is suppressed; only sinks run.
  • If any sink sets alsoPrint=true → sinks run and local printing also happens (subject to the print gate).

Why this design?

  • Lets you “take over” output and route it elsewhere (e.g., log4js, winston, analytics) without double-printing.
  • Gives an opt-in to keep local printing for dev while still forwarding to your sinks.

Examples

  1. Replace printing with a custom sink
const log = Topic('api', { meta: { svc: 'api' } })
  .extend((e) => myLegacySink(e)); // no console printing

log('user login', { id: 42 });
  1. Print locally and forward
const log = Topic('api', { meta: { svc: 'api' } })
  .extend((e) => myLegacySink(e), true); // print + handler

log('started');
  1. Force extensions to run even when global print is disabled
// Global printing off (e.g., RATATOUILLE.filter set, print omitted)
// Make this topic eligible by forcing per-topic print, but suppress local printing via extend default
const log = Topic('api', { print: true }).extend((e) => mySink(e));
log('event'); // handler runs, console stays quiet
  1. Use env/meta to implement levels or routing in the handler
const warn = Topic('app', { meta: { level: 'warn' }, env: { region: 'iad' } })
  .extend((e) => {
    if ((e.meta as any)?.level === 'warn') legacy.warn(e);
    else legacy.info(e);
  });

warn('cpu high', { usage: 0.92 });

Alternative imports

// Named Topic
import { Topic } from "@frogfish/ratatouille";
const log = Topic("api");

// Access Relay and setDebug
import Topic, { setDebug, Relay } from "@frogfish/ratatouille";

setDebug(value?: string): void

Recompile filter patterns at runtime.

  • setDebug("api*,auth*") — override from a string.
  • setDebug() — rebuild from env using configured debugVars (e.g., DEBUG, XYZ).

Useful in tests or REPLs that toggle logging on the fly.


Relay (shipping logs)

Use Relay to batch and forward logs to a collector. It supports two runtimes:

  • Node: TCP (tcp://host:port) and HTTP(S) with keep‑alive.
  • Cloudflare Workers/Browser: HTTP(S) via fetch (no TCP; keep‑alive not user‑controlled).

Install/import

  • Node (first‑class):
    • import { Relay } from "@frogfish/ratatouille" // root export includes Relay in Node
    • or import Relay from "@frogfish/ratatouille/relay"
  • Cloudflare Worker / Browser:
    • import Relay from "@frogfish/ratatouille/relay" // resolves to the Worker variant

Config

type RelayConfig = {
  endpoint: string;               // "tcp://host:port" (Node) or "https://…" (Workers)
  batchMs?: number;               // flush interval (default 100)
  batchBytes?: number;            // max bytes per batch (default 262_144)

  // Bounded memory (best-effort telemetry)
  maxQueueBytes?: number;         // max buffered bytes (default 5MB)
  maxQueue?: number;              // max buffered lines (default 10_000)
  dropPolicy?: "drop_oldest" | "drop_newest"; // default "drop_oldest"

  headers?: Record<string,string>;// extra headers for HTTP(S)
  keepAlive?: boolean;            // Node HTTP(S) keep-alive agent (default true). Ignored in Workers.
  sampleRate?: number;            // 0..1 probability to keep a line (default 1)

  // Transport identity injected into every envelope (as `src`).
  // Example: { app: "payments", where: "node", instance: "prod-eu1" }
  // Defaults can come from env (below); explicit config wins.
  source?: Record<string, unknown>;

  // Default topic used when payload is not already an envelope with a `topic`.
  // Can be overridden via env.
  defaultTopic?: string;          // default "raw"

  // Optional encoder override for `send(payload)`.
  // If you provide this, you control the wire format.
  encode?: (payload: unknown) => string;
}

Transport defaults (endpoint normalization)

Relay POSTs to whatever endpoint you give it. For convenience, if you pass a host-only HTTP(S) URL (no path or just /), Relay will default the path to:

  • http(s)://host:port/sink

This matches Ringtail’s ingestion endpoint naming.

Transport identity (src)

In a fire-hose system, “what” happened is only half the story — you also need where it came from. Relay can inject a small, static source identity into every emitted envelope as src.

Presets (Nomad/Workers) compute src for you automatically; you can still override it with RATATOUILLE_APP, RATATOUILLE_WHERE, and RATATOUILLE_INSTANCE.

You can set it in code:

const relay = new Relay({
  endpoint: "http://127.0.0.1:8080",
  headers: { Authorization: `Bearer ${process.env.RINGTAIL_TOKEN}` },
  source: {
    app: "edge-auth",          // app/service identifier
    where: "cf-worker",        // runtime / environment label
    instance: "prod-eu1:abc",  // deployment slice / isolate id / hostname-ish
  },
});

Or set defaults via environment variables (useful in Nomad/K8s/etc.):

  • RATATOUILLE_APP
  • RATATOUILLE_WHERE
  • RATATOUILLE_INSTANCE
  • RATATOUILLE_DEFAULT_TOPIC

Explicit config wins over env. (Relay merges {...env, ...config.source}.)

Wire envelope (default)

By default, relay.send(payload) emits one NDJSON line containing a minimal envelope.

If payload is already a plain object with a topic, Relay treats it as an envelope and will ensure:

  • ts exists (adds Date.now() if missing)
  • src exists (injects/merges configured source)

Otherwise Relay wraps the payload:

{"ts":1730000000000,"topic":"raw","args":["haiku"],"src":{"app":"edge-auth","where":"cf-worker","instance":"prod-eu1:abc"}}

This keeps Ratatouille’s “emit anything” philosophy while keeping the network transport predictable.

Why envelopes?

  • Server-side filters (e.g. Ringtail admission filters) can match topic reliably.
  • You can filter by src.app, src.where, src.instance without parsing arbitrary payloads.
  • The wire format is stable even when producers log strings/blobs.

“Bullshit mode” (raw lines/chunks)

Sometimes you just want to ship bytes with zero ceremony.

  • sendLine(line) enqueues a pre-formatted NDJSON line exactly as you give it (Relay will only add a trailing \n if missing).
  • sendChunk(chunk) enqueues an arbitrary NDJSON chunk (may contain many lines). Relay does no parsing/validation.

Use this mode when you don’t want envelopes — but note:

  • Server-side topic filtering won’t work unless your raw lines include a topic field.
  • If you want filtering + identity, prefer send() with envelopes.

Node example (HTTP keep‑alive)

import { Relay } from "@frogfish/ratatouille";
import crypto from "crypto";

// Host-only endpoint is fine; Relay defaults to /sink
const relay = new Relay({
  endpoint: process.env.RINGTAIL_URL || "http://127.0.0.1:8080",
  keepAlive: true,               // enables Node http(s).Agent keep-alive
  batchMs: 100,                  // send every ~100ms
  headers: process.env.RINGTAIL_TOKEN
    ? { Authorization: `Bearer ${process.env.RINGTAIL_TOKEN}` }
    : {},
  source: {
    app: process.env.RATATOUILLE_APP || "api",
    where: process.env.RATATOUILLE_WHERE || "node",
    instance: process.env.RATATOUILLE_INSTANCE || `local:${crypto.randomUUID()}`,
  },
});

await relay.connect();

// emit logs
relay.send({ topic: "api", msg: "service started" });

// flush at checkpoints
await relay.flushNow();

// on shutdown
process.on("SIGINT", async () => {
  await relay.flushNow();
  relay.close();
  process.exit(0);
});

Node example (TCP)

import { Relay } from "@frogfish/ratatouille";

const relay = new Relay("tcp://collector.internal:5001");
await relay.connect();
relay.send({ level: "warn", msg: "hot path" });

Cloudflare Worker example (HTTP, batched)

// worker.ts
import Relay from "@frogfish/ratatouille/relay"; // Worker variant (fetch-based)

let relay: any; // lazily initialize with env-bound headers

export default {
  async fetch(req: Request, env: any, ctx: ExecutionContext) {
    if (!relay) {
      const isolateId = crypto.randomUUID();

      relay = new Relay({
        // Host-only endpoint is fine; Relay defaults to /sink
        endpoint: env.RINGTAIL_URL || "http://127.0.0.1:8080",
        batchMs: 100,
        sampleRate: 1, // set <1 to reduce volume (e.g., 0.1)
        headers: env.RINGTAIL_TOKEN
          ? { Authorization: `Bearer ${env.RINGTAIL_TOKEN}` }
          : undefined,
        source: {
          app: env.RATATOUILLE_APP || "edge-auth",
          where: "cf-worker",
          instance: `${env.ENVIRONMENT || "dev"}:${isolateId}`,
        },
      });
      await relay.connect();
    }

    // enqueue structured log lines (non-blocking)
    relay.send({ ts: Date.now(), url: req.url, method: req.method });

    // ensure at least one batch is pushed even if the isolate goes idle soon
    ctx.waitUntil(relay.flushNow());

    return new Response("ok");
  }
};

setInterval timers are not a delivery guarantee in Workers (isolates can go idle). ctx.waitUntil(relay.flushNow()) is the best-effort way to push at least one batch.

Notes for Workers:

  • Only http(s):// endpoints are supported (no raw TCP sockets).
  • The platform may reuse connections under the hood (HTTP/1.1 persistent or HTTP/2), but keep‑alive is not configurable.
  • Create a singleton Relay at module scope; avoid per‑request construction.
  • Tune batchMs / batchBytes for your delivery/overhead trade‑off.

Behavior

  • send(payload) enqueues one NDJSON line (object → JSON + \n, or via encode).
  • sendLine(line) enqueues a pre-formatted NDJSON line (adds trailing \n if missing).
  • sendChunk(chunk) enqueues a pre-formatted NDJSON chunk (may contain multiple lines). No parsing/validation.
  • Batches are limited by batchBytes; oversized single lines/chunks are dropped early.
  • The queue is bounded by maxQueueBytes (primary) and maxQueue (secondary). When full, items are dropped per dropPolicy.
  • Periodic flush runs every batchMs. Call flushNow() to push one batch immediately.
  • status() exposes lightweight counters (queued bytes/items, dropped bytes/items, sent bytes/batches, failures).

Cloudflare Durable Object Aggregator (near real-time)

For sub-second delivery with connection reuse, front Workers can forward logs to a Durable Object (DO) that batches and relays upstream.

Durable Object class

// do-logger.ts
export class LogAggregator {
  state: DurableObjectState;
  env: any;
  q: string[] = [];
  timer: any;

  constructor(state: DurableObjectState, env: any) {
    this.state = state;
    this.env = env;
    this.timer = setInterval(() => this.flush().catch(() => {}), 100);
  }

  async fetch(req: Request): Promise<Response> {
    const url = new URL(req.url);
    if (req.method === 'POST' && url.pathname === '/log') {
      const line = await req.text(); // expected to be a single NDJSON line
      this.q.push(line.endsWith('\n') ? line : line + '\n');
      return new Response('ok');
    }
    if (url.pathname === '/flush') {
      await this.flush();
      return new Response('flushed');
    }
    return new Response('not found', { status: 404 });
  }

  private drain(maxBytes = 262_144): string | undefined {
    if (!this.q.length) return;
    let bytes = 0;
    const batch: string[] = [];
    while (this.q.length && bytes + this.q[0].length <= maxBytes) {
      const x = this.q.shift()!; batch.push(x); bytes += x.length;
    }
    return batch.length ? batch.join('') : undefined;
  }

  private async flush(): Promise<void> {
    const data = this.drain();
    if (!data) return;
    await fetch(this.env.LOG_ENDPOINT, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-ndjson', 'Authorization': `Bearer ${this.env.LOG_TOKEN}` },
      body: data,
    }).catch(() => {});
  }
}

Frontline Worker usage

// worker.ts
export default {
  async fetch(req: Request, env: any, ctx: ExecutionContext) {
    const id = env.LOG_AGGREGATOR.idFromName('logs');
    const stub = env.LOG_AGGREGATOR.get(id);
    // one NDJSON line per event
    const line = JSON.stringify({ ts: Date.now(), url: req.url, method: req.method }) + '\n';
    ctx.waitUntil(stub.fetch('https://do/log', { method: 'POST', body: line }));
    return new Response('ok');
  }
}

Bindings (wrangler.toml):

[[durable_objects.bindings]]
name = "LOG_AGGREGATOR"
class_name = "LogAggregator"

[vars]
LOG_ENDPOINT = "https://logs.example.com/ingest"
LOG_TOKEN = "..."

Cloudflare Queues Pipeline (decoupled)

If per-request latency should never touch logging, enqueue entries and drain them in a consumer Worker.

Producer (frontline Worker)

export default {
  async fetch(req: Request, env: any, ctx: ExecutionContext) {
    const entry = { ts: Date.now(), url: req.url, method: req.method };
    // Do not await; let the platform handle retries/backpressure
    ctx.waitUntil(env.LOG_QUEUE.send(entry));
    return new Response('ok');
  }
}

Consumer Worker

import Relay from '@frogfish/ratatouille/relay';

let relay: Relay | undefined;

export default {
  async queue(batch: MessageBatch<any>, env: any, ctx: ExecutionContext) {
    if (!relay) {
      relay = new Relay({ endpoint: env.LOG_ENDPOINT, batchMs: 100, headers: { Authorization: `Bearer ${env.LOG_TOKEN}` } });
      await relay.connect();
    }
    for (const msg of batch.messages) relay.send(msg.body);
    await relay.flushNow();
  }
}

Bindings (wrangler.toml):

[[queues.producers]]
queue = "LOG_QUEUE"
binding = "LOG_QUEUE"

[[queues.consumers]]
queue = "LOG_QUEUE"
script_name = "log-consumer"

Pattern syntax (recap)

  • Tokens split by commas or whitespace: "api*,-db*", "api* -db*".
  • * matches any substring.
  • A leading - negates a token.
  • Semantics: enabled iff (allowed or implied‑allow‑all) and not denied.

Edge cases:

  • DEBUG="" → disabled.
  • DEBUG="*" → all topics.
  • DEBUG="-chat*" → all except chat… (deny‑only ⇒ allow everything else).

Cross‑platform notes

Unix shells

Quote values containing *:

DEBUG='api*,auth*,-auth:noise' node app.js

PowerShell

$env:DEBUG = 'api*,auth*,-auth:noise'
node app.js

Windows CMD

set DEBUG=api*,auth*,-auth:noise
node app.js

Behavior & internals

  • Topic hot path is tiny — Topic emission does no I/O unless local printing is enabled.
  • Printing — writes synchronously to stdout in Node (fast stream write) and uses console.log elsewhere (Workers/browsers).
  • Sinks (Relay) — use a bounded in-memory queue plus a periodic flush timer (batchMs). Best-effort; drops are explicit.
  • Error renderingError instances print .stack when available; otherwise name: message.
  • Performance — precompiles allow/deny regexes; caches enabled decisions per topic; minimal stringification.

FAQ

Q: What gets colored?
Only the topic label (e.g., debug). Arguments remain uncolored for readability.

Q: How do I ensure colors never print?
Set RATATOUILLE=nocolor or RATATOUILLE='{"color":"off"}'.

Q: Can I force colors even in non‑TTY environments?
Yes: RATATOUILLE='{"color":"on"}'.

Q: What’s #random vs no suffix?
No suffix → uncolored topic. #random → assign a stable 256‑color from a curated palette.

Q: How do I combine multiple env vars for patterns?
RATATOUILLE='{"debugVars":["DEBUG","XYZ"]}' then set DEBUG and XYZ as usual.


License

MIT

Copyright (c) 2026 Alexander R. Croft

About

Ratatouille — firehose logging for edge + microservices

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors