Skip to content
These docs describe @green-tea/core 26.9.0-beta.1what changed. Install with npm i @green-tea/core@beta.

Observability

green-tea does not integrate with an observability vendor. It emits a correlated event stream and lets you read it. A logger, a metrics exporter and a tracing bridge are three consumers of that one stream — not three features, which is why they are one design.

The rule that shapes all of it: core has one runtime dependency, reflect-metadata, and keeps it. Nothing here imports an exporter.

This is the runtime half of a pair. Graph introspection answers what will run for a route, before a request exists; the event stream answers what did run, for one request that happened. A chain from explain() that does not match the events you observe is the gap worth looking at.

Every request gets an id. Every event it causes carries that id.

app.bus.on('request:step:leave', (event) => {
event.requestId; // "9f0c…" — the same value on every event of this request
event.route; // "/users/:id" — the pattern, never the concrete path
event.durationMs; // how long this step took
});

Without that id the stream is unusable under any concurrency: two requests interleave their step events and nothing tells you which is which.

An incoming x-request-id is adopted, not replaced. A service behind a gateway must not open a second identity for a request that already has one.

A traceparent header is carried through untouched as traceId. Core parses nothing and implements no propagation spec — W3C Trace Context belongs to the exporter that understands it.

The identity survives a mesh hop. A request resolved from a remote teapot carries its requestId and traceId across the wire, and the teapot adopts them rather than opening its own — so the far side’s events join the same trace instead of starting a second investigation. See Mesh, which is still alpha.

route is /users/:id, never /users/42. This is a contract, not an implementation detail: a metrics consumer that labels a counter with the concrete path gets one label per distinct URL, and unbounded label cardinality takes down the metrics backend rather than the application.

Three doors, and which one you want depends on what you have.

app.bus.on('request:end', (e) => count(e.route)); // you own the app
const metrics: Plugin = (api) => { // you are extending someone's app
api.bus.on('request:end', (e) => count(e.route));
api.onShutdown(() => flush());
};
@Provider({ provides: 'collector', needs: ['events'] }) // you are inside the graph
class Collector {
#off?: () => void;
provide(ctx: { events: Events }) {
this.#off = ctx.events.on('request:end', (e) => count(e.route));
return { collector: registry };
}
dispose() { this.#off?.(); }
}

A plugin is the right home for observation, and the reason is the pairing: bus.on arrives next to onShutdown, so whatever it subscribes to it can also release. @needs('events') exists for the case where you are already a node and reaching for a plugin would be a detour — it gives you the subscribe half alone, so on() hands back its own unsubscribe and a provider is expected to call it in dispose().

app.bus carries emit because the application that owns the app owns its event stream. Nothing else does: a plugin gets { on }, @needs('events') gets { on }, and @needs('bus') fails at boot rather than resolving.

That is not an oversight to be fixed later. A node that could emit could forge any lifecycle event, and an observation channel anything can write to is not one — the numbers coming off it stop being evidence. bus is also a reserved token name, so nothing you declare can quietly become what @needs('bus') resolves to.

Event When
request:start / request:end a request enters and leaves the handler — always paired, and request:end is the one to count
request:failed handler code threwadditional to the request:end that follows
route:matched / route:unmatched a route matched, or none did (covers 404 and 405) — also additional
request:step:enter / :leave / :error one step of the pipeline
boot:provider:start / :ok / :fail a provider booting
stream:open / :close / :error an SSE, ndjson or WebSocket connection — see below
mesh:connect / :disconnect / :rpc:error mesh links
mesh:boot:retry a teapot that has not come up yet, still inside the boot grace (mesh)
plugin:mounted a plugin registered

request:end fires when the handler returns, not when a stream it produced finishes. A route that returns an AsyncIterable is done in milliseconds while its connection may live for hours, and putting both in one latency distribution ruins both numbers.

So streams get their own three events, and they carry the same requestId as the request that opened them — which is what lets you join the two rather than watch them as unrelated feeds:

app.bus.on('stream:open', (e) => open.set(e.requestId!, { route: e.route, at: Date.now() }));
app.bus.on('stream:close', (e) => open.delete(e.requestId!));

traceId comes along too, so a connection reaches your tracing backend under the same trace as the request that opened it. Label on route, the same rule as everywhere else on this page.

A WebSocket upgrade correlates the same way — it is an HTTP request with headers like any other, so an x-request-id from your gateway is adopted rather than replaced, and its events carry transport: 'ws'.

All three fire on every runtime. If you had a stream gauge that read zero anywhere but Node, that was a bug on the Fetch adapter and it is fixed — the events, and the error frame written to the client when a source throws, are now identical on Node, Deno, Bun and workerd.

request:start and request:end always come in pairs

Section titled “request:start and request:end always come in pairs”

Every request:end is preceded by a request:start carrying the same requestId — including a request shed by maxConcurrentRequests, which never reaches a route at all. You can open per-request state on the first and close it on the second without checking how far the request got.

app.bus.on('request:start', () => inFlight++);
app.bus.on('request:end', () => inFlight--);

That is the gauge this guarantee exists for. Without it, the counter would drift the first time a server shed a request — under load, which is when you are looking at it, and it would drift by producing a plausible wrong number rather than an error.

request:end fires when the handler returns, not when a stream it produced finishes.

A streaming route returns in milliseconds while its connection may live for hours. Ending the request span at stream close would put hour-long SSE connections and 2 ms buffered replies in one latency distribution, ruining both. The connection is described by stream:open / stream:close, which carry the same requestId — so you can join the two yourself, and you get a meaningful latency histogram either way.

Lifecycle only. A per-message event on a high-rate stream is a firehose that costs more than it reports, and the cost lands on the workload least able to absorb it. Instrument your own handler if you need it, where you know what the messages mean.

const app = createApp({ modules: [AppModule], logger: myLogger });

Any object with debug, info, warn and error — each taking a message and optional structured fields. Bridging pino or winston is a few lines in either direction; core defines the shape it needs rather than adopting anyone’s.

Given none, the default writes structured JSON, or a human-readable line when the process is attached to a TTY — decided once at boot, not per record.

Nothing in core writes to console. That is enforced by a lint rule, not by good intentions, so every framework diagnostic is redirectable.

The logger is also injectable:

@Step({ provides: 'user', needs: ['db'] })
class LoadUser {
run(@needs('logger') log: Logger) { … }
}

Same object, two access paths. It is registered rather than declared as a provider because it has to outlive the graph on both ends: a provider cannot report its own boot failing, and shutdown warnings happen after the graph is gone.

Off by default. A framework that writes to stdout uninvited is one you have to configure before you can use it.

createApp({ modules: [AppModule], logRequests: true });

It is an ordinary subscriber, so you can do it by hand and take it back:

import { logRequests } from '@green-tea/core';
const stop = logRequests(app.bus, app.logger);
stop(); // removable, not merely optional

A logger that throws does not silently stop writing: the subscriber is wrapped so failures fall back to console. Bus.emit isolates listener failures by design — an observer must never break a request — and the wrapper keeps that guarantee without paying for it in silence.

Everything above works untyped; these exist for when you want the types written down.

Export What it is
Logger the four-method shape createApp({ logger }) accepts
LogLevel 'debug' | 'info' | 'warn' | 'error'
LogFields the optional structured second argument
createDefaultLogger(pretty?) builds the built-in logger; pass true/false to force JSON or human-readable instead of letting it detect a TTY
withConsoleFallback(logger) wraps a logger so a throw inside it falls back to console rather than vanishing
LifecycleEvent the union of every event name in the table above
EventPayload the shape a subscriber receives
Correlation the request-identifying subset spread into each event
Events the read-only { on } slice @needs('events') provides
UNMATCHED_ROUTE the route value carried when nothing matched — compare against this instead of hardcoding '<unmatched>'

withConsoleFallback is worth knowing about if you pass your own logger. A logger that throws while reporting a failure is the worst moment to lose output, and core cannot catch that for you without also swallowing the errors you asked it to report:

import { withConsoleFallback } from '@green-tea/core';
const app = createApp({ modules: [AppModule], logger: withConsoleFallback(myLogger) });
  • Graph introspection — the same pipeline, read before it runs.
  • Errors — what a thrown error becomes on the wire, and in the stream.
  • Testing — asserting on events instead of on log output.

There is no metrics registry in core and no OpenTelemetry exporter in core. Both read the stream above from outside:

app.bus.on('request:end', (e) => histogram.observe(e.durationMs, { route: e.route, status: e.status }));

One request that throws emits three events — request:start, request:failed, request:end — all carrying the same requestId. A 404 emits two: route:unmatched and the request:end that follows. The first exporter anyone writes counts the failure twice, and it fails silently: the metrics simply lie.

Event What it means What to build on it
request:end terminal and universal, for every request shape, and the only one carrying the status the client received the request counter and the latency histogram
request:failed handler code threw — which no status expresses, since a rendered 422 is also a throw a separate error counter
route:unmatched no route ran, covering 404 and 405 a separate counter, if you want one

request:failed and route:unmatched are additional to request:end, not alternatives to it.

route:unmatched is also not a synonym for failure: a file served by createApp({ static }) matched no route either, and answers 200. Read the status from request:end, like everything else.

name is for a human reading a log line, and on the request events it carries the path that arrived. That is fine in a log and disastrous as a metric label: a matched path is bounded by your route table, an unmatched one is bounded by nothing at all, and a scanner walking /aaa, /aab, /aac becomes one label per distinct URL — a memory leak with a metrics backend attached.

route is bounded by construction. When nothing matched it is '<unmatched>', exported as UNMATCHED_ROUTE, so every path that was never a route collapses into one series instead of a fallback you have to invent.

app.bus.on('request:end', (e) => counter.inc({ route: e.route, status: e.status })); // bounded
app.bus.on('request:end', (e) => counter.inc({ route: e.name })); // do not

Per-step timings are already on the events, so a per-node timing breakdown needs no extra instrumentation — the graph already knows every node.

About 0.29 µs per request — roughly 7% of the framework’s own work, or ~2% of a request measured over a real socket, where the transport dominates.

The cost is per request, not per step. Per-step timing and payload construction are skipped entirely when nothing is subscribed, so a deep pipeline pays no more than a shallow one. What remains is fixed: generating the request id, reading two headers, and the checks that decide whether anyone is listening.

Absolute throughput numbers are deliberately not quoted here. The figure above comes from an interleaved A/B — alternating both versions round by round — because the same code measured at the start and end of one session on the same machine differed by 12% as it warmed up. Any before/after that does not interleave is measuring the clock as much as the code.