Browse resources

Send data

Send logs

Take OpenTelemetry logs at /v1/logs, joined to the trace that wrote them.

knotel takes OpenTelemetry logs at /v1/logs, in JSON or protobuf, with the same ingest key as traces. A record that carries a trace id is joined to that trace: its lines appear under the waterfall on the trace page, and get_trace returns them beside the spans.

Sending

Point any OTel logs exporter at the endpoint. The key goes in the same header traces use.

OTEL_EXPORTER_OTLP_LOGS_ENDPOINT=https://YOUR-INSTANCE/v1/logs
OTEL_EXPORTER_OTLP_LOGS_HEADERS=x-knotel-key=kn_...

To join a line to its trace, log inside an active span. Every OTel logging bridge copies the current trace and span id onto the record for you; nothing joins a line written outside a span.

Cloudflare Workers

The Workers SDK can record console output as logs. It is off by default, because a chatty Worker writes far more lines than spans:

export default instrument(handler, (env) => ({
  endpoint: "https://YOUR-INSTANCE",
  key: env.KNOTEL_KEY,
  service: "api",
  logs: true,          // console.log/info/warn/error; add { level: "debug" } for debug
}));

Lines are stamped with the active span's ids, so they land on the right trace. Workers only advance the clock on I/O, so several lines can share a timestamp; ties get a microsecond step each so their order survives.

Python

from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter

provider = LoggerProvider()
provider.add_log_record_processor(BatchLogRecordProcessor(OTLPLogExporter()))
logging.getLogger("my_app").addHandler(LoggingHandler(logger_provider=provider))
Attach the handler to your own logger, not the root one. On the root logger at DEBUG it captures urllib3's line about the exporter's own POST, which it then exports, which logs another line: the process spins and never stops sending.

Severity

OTel numbers severity 1-24 in ranges of four. When a sender gives only a name, knotel infers the number from it, so warning, WARN and W all filter as WARN; a name nothing recognises stores as 0 and only matches "all levels".

RangeLevel
1-4TRACE
5-8DEBUG
9-12INFO
13-16WARN
17-20ERROR
21-24FATAL

Asking for WARN and above is the cheapest query on the Logs page: each day is indexed from 13 up, so those searches read an index over a fraction of the rows instead of the whole day.

Retention

Logs are kept for LOG_HOT_WINDOW_DAYS (3 by default), counted from the day a record arrived. That is much shorter than the span window on purpose: a service writes far more lines than spans, and each is a row D1 charges to write. A project whose retention is shorter keeps its lines for that long instead; nothing keeps them longer, because logs have no cold store to move to.

  • Each day is its own table, dropped whole when it ages out. Deleting rows costs as much as writing them; dropping a table costs nothing.
  • Records live in their own database (LOGS_DB), so log volume can't fill the span store and log writes don't queue behind dashboard queries.
  • A range reaching further back than retention simply finds fewer lines. The Logs page says how many it found.

Raise the window in wrangler.jsonc if you have the budget for it; see Usage and costs for what a row write costs.

Reading

  • Logs page: newest first, filtered by service, level and a substring of the message. Click a line for its attributes and a link to its trace.
  • Trace page: a trace's own lines under the waterfall, in the order they were written. Selecting a span dims the lines that weren't written during it.
  • MCP: search_logs for the same filters, and get_trace, which returns a trace's lines with its spans.

Log records are never scaled by a sample rate. Head sampling decides which spans are exported; logging SDKs emit every record regardless, so one row is one line.