Observability
Ship Lunora's RPC and log telemetry to any OTLP collector — the otlpSink worker option, the zero-config container exporter, and the one wire contract they share.
Last updated:
Every Lunora function call produces two kinds of telemetry: an RPC event (one per query/mutation/action — path, duration, ok/error, shard, fan-out) and log events (the lines your handlers emit). A sink receives them. Locally, the Studio groups error events into an Issues view with no setup. To watch a deployed app you point a sink at an OpenTelemetry collector — your own, a vendor's, or the Lunora cloud — and the worker and any container ship the same telemetry over one protocol.
Sinks
A sink is passed to createWorker as the observability option. Lunora ships
several; combine them with combineSinks.
| Sink | Ships to |
|---|---|
consoleSink() | console (local dev) |
otlpSink({ endpoint }) | any OTLP-over-HTTP collector |
webhookSink({ url }) | an arbitrary HTTP endpoint (your own JSON shape) |
sentrySink({ dsn }) | Sentry |
analyticsEngineSink({ … }) | a Cloudflare Analytics Engine dataset |
pipelineLogSink({ pipeline }) | a Cloudflare Pipeline → R2 (durable log store, read via R2 SQL) |
import { analyticsEngineSink, combineSinks, otlpSink } from "@lunora/runtime";
export default createWorker({
// …schema, functions…
observability: combineSinks(
otlpSink({ endpoint: env.LUNORA_OTLP_ENDPOINT, token: env.LUNORA_OTLP_TOKEN }),
analyticsEngineSink({ dataset: env.ANALYTICS }),
),
});Sinks that carry RPC events accept onlyErrors: true to drop successful RPCs and
export only failures (pipelineLogSink is log-only, so it has no such option;
log events always pass through regardless). All four network/log sinks —
otlpSink, webhookSink, pipelineLogSink, plus sentrySink when you wire its
captureLog — forward ctx.log lines; webhookSink takes a transformLog
redactor mirroring its RPC transform.
:::caution[Upgrading a webhookSink]
webhookSink now ships ctx.log lines (message and structured fields,
which may carry user input) in addition to RPC events — previously it shipped
neither. Two consequences for an existing config:
onlyErrorsdoes not gate log lines (only RPC events); everyctx.logcall egresses to the endpoint.- The RPC
transformredactor does not cover log lines — add atransformLog(same fail-closed contract) if you scrub PII before it leaves the worker.
Set transformLog (or point the sink at a trusted endpoint) before upgrading if
your handlers log user data. sentrySink (opt-in via captureLog) and
otlpSink don't have this expansion.
:::
otlpSink
otlpSink({
endpoint: env.LUNORA_OTLP_ENDPOINT, // required — the collector base URL
token: env.LUNORA_OTLP_TOKEN, // optional — sent as `Authorization: Bearer <token>`
headers: { "x-lunora-deployment": env.LUNORA_DEPLOYMENT_ID }, // optional correlation headers
serviceName: "my-app", // optional — the `service.name` resource attribute (default `"lunora"`)
onlyErrors: false, // optional — export only error spans
});Read endpoint/token from the environment so the platform can inject them at
deploy time with no code change. Each event is one fire-and-forget POST; on a
Worker it is registered with waitUntil so it outlives the response.
pipelineLogSink
Persist every ctx.log line durably to a Cloudflare Pipeline → R2, giving
you a queryable log store (read back with R2 SQL) in your own account — no cloud
required. It is the durable counterpart to otlpSink (which streams to a
collector). Only log lines are stored; RPC-span metrics belong in
analyticsEngineSink.
import { pipelineLogSink } from "@lunora/runtime";
pipelineLogSink({ pipeline: env.LOG_PIPELINE });Each record carries message, level, functionPath, fields, traceId,
spanId, shardKey, userId, and ts. The send is registered with the
request's waitUntil so it survives isolate teardown.
Structured logging with ctx.log
ctx.log spans the full OpenTelemetry severity ramp — trace, debug, info
(and its log alias), warn, error, fatal — and takes either console-style
values or a structured message + fields object:
export const placeOrder = mutation({
handler: async (ctx, args) => {
// Console-style: any number of values, joined into the message.
ctx.log.debug("placing order", args);
// Structured: a message plus a fields object. The fields become
// filterable/indexable log-record attributes.
ctx.log.info("order placed", { orderId: order._id, total: order.total });
// Bind context once with `.with(...)`; every line inherits it
// (per-call fields win on a key clash).
const log = ctx.log.with({ orderId: order._id });
log.warn("inventory low", { sku });
log.fatal("charge failed", { code: err.code });
},
});The (string, object) shape is the structured form; every other shape is
console-style. The rendered message and the structured fields reach the dev
terminal, Workers Logs, the Studio Logs panel, and any sink; the raw positional
args of a console-style call reach only the in-process onLog sink you control.
:::caution[Behavior change]
A two-argument call whose second argument is a plain object — ctx.log.info("saved", user) — is now the structured form: the message is "saved" and user becomes fields, instead of being rendered into the message as saved {…}. Calls with a non-object second argument, or three or more arguments, are unchanged. If you relied on the object being folded into the message text, pass it as a third argument (ctx.log.info("saved", "-", user)) or pre-render it.
:::
Container telemetry
Code running inside a container can't use a worker
sink — it is a separate process. @lunora/container/otel gives it a zero-config exporter that
speaks the exact same wire contract, so container spans and worker spans land in
the same collector side by side.
import { createContainerTelemetry } from "@lunora/container/otel";
// Reads LUNORA_OTLP_ENDPOINT / LUNORA_OTLP_TOKEN from the container env.
const telemetry = createContainerTelemetry();
// Time a unit of work — records an ok span, or an error span if it throws.
const result = await telemetry.trace("transcode", () => transcode(job), { jobId: job.id });
telemetry.emitLog({ level: "info", message: "done", attributes: { jobId: job.id } });
// Before the process exits, flush any in-flight sends.
await telemetry.flush();With no endpoint resolvable the exporter is a silent no-op (telemetry.enabled === false) — trace still runs your work, it just records nothing. The same code
runs unchanged locally and in the cloud.
Each POST is bounded by timeoutMs (default 10s), so a hung collector aborts
instead of pinning a send in flight and stalling flush(); failures are handed to
the optional onError callback and never break the container.
Thread the endpoint/token into the container the same way any other config
reaches it — declare them on defineContainer:
defineContainer({
name: "transcoder",
// …
env: { LUNORA_OTLP_ENDPOINT: env.LUNORA_OTLP_ENDPOINT },
secrets: ["LUNORA_OTLP_TOKEN"],
// The collector host must be reachable from the container egress allow-list.
allowedHosts: ["collector.example.com"],
});The wire contract
Both the worker otlpSink and the container exporter conform to one contract, so
any OTLP-compatible collector — and the Lunora cloud ingest — accepts either
without special-casing.
Transport. OTLP over HTTP
with JSON encoding (not protobuf). Two endpoints, derived from the configured
base endpoint (trailing slashes are tolerated):
| Signal | Request |
|---|---|
| Spans | POST {endpoint}/v1/traces |
| Logs | POST {endpoint}/v1/logs |
Headers.
| Header | Value |
|---|---|
content-type | application/json |
authorization | Bearer <token> — when a token is configured |
x-lunora-deployment (convention) | the deployment id, for the ingest to attribute the sender |
x-lunora-org (convention) | the organization id |
The x-lunora-* headers are a convention the cloud ingest reads to route
telemetry; pass them through the headers option. A collector that ignores them
still accepts the payload.
Encoding. Bodies are standard OTLP ExportTraceServiceRequest /
ExportLogsServiceRequest JSON. Per the OTLP/JSON spec:
traceIdis 16 random bytes as 32 lowercase hex chars;spanIdis 8 bytes as 16 hex chars (the documented exception to proto3 JSON's base64bytes).timeUnixNanofields are the nanoseconds since the epoch as a decimal string (Lunora works in millis, so this is the millisecond value followed by six zeros — exact).resourcecarries aservice.nameattribute; the instrumentationscope.nameis@lunora/runtime(worker) or@lunora/container(container).- Attribute values follow the OTLP
AnyValueunion — strings asstringValue, booleans asboolValue, integers asintValue(a decimal string), floats asdoubleValue.
Span shape. One span per RPC event (worker) or per trace/emitSpan
(container):
| Field | Worker (otlpSink) | Container |
|---|---|---|
kind | 2 (SERVER) | 1 (INTERNAL) |
startTimeUnixNano | end − durationMs | your startMs |
status.code | 1 ok / 2 error (with status.message) | same |
Worker spans carry these attributes:
| Attribute | When | Value |
|---|---|---|
lunora.function_path | always | the function path, e.g. messages:list |
lunora.ok | always | boolean |
lunora.shard_key | if sharded | the shard key |
error.type | on error | the Lunora error code |
lunora.error_status | on error | the HTTP/RPC status (int) |
lunora.fanout.table | on a fan-out | the table fanned across |
lunora.fanout.shards | on a fan-out | shard count (int) |
lunora.fanout.failed | on a fan-out | failed-shard count (int) |
Container spans carry whatever attributes you pass, plus error.type when the
work throws.
Log record shape. body.stringValue is the message; severityText is the
upper-cased level; severityNumber maps as:
| Level | severityNumber |
|---|---|
trace | 1 |
debug | 5 |
info / log | 9 |
warn | 13 |
error | 17 |
fatal | 21 |
Worker log records also carry lunora.function_path, and lunora.shard_key /
lunora.user_id when known. Structured fields (below) become additional
log-record attributes, and each record carries its dispatch's trace_id /
span_id so a line links back to its RPC span.
Privacy
Spans carry error.type and error messages, and log records carry the rendered
message — either can include user-supplied input. Point endpoint only at a
collector you trust, and use onlyErrors to narrow what leaves the deployment.