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))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".
| Range | Level |
|---|---|
| 1-4 | TRACE |
| 5-8 | DEBUG |
| 9-12 | INFO |
| 13-16 | WARN |
| 17-20 | ERROR |
| 21-24 | FATAL |
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_logsfor the same filters, andget_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.