Documentation

Tracing

Export traces from a self-hosted server to an OpenTelemetry collector, so a slow request becomes a waterfall instead of a stopwatch.

When a request through your server is slow, the useful question is which part was slow: building the executor stack, initializing plugins, a storage query, or the upstream integration answering. The server already produces spans for all of that — pointing it at a collector is what makes them readable.

Export is off by default and stays off until you set an endpoint. With none set there is no exporter and no buffer, and nothing leaves the process.

Turn it on

Set the collector’s base URL. The server appends /v1/traces.

docker run -d \
  -p 4788:4788 \
  -v executor-data:/data \
  -e OTEL_EXPORTER_OTLP_ENDPOINT=http://collector.local:4318 \
  ghcr.io/usefulsoftwareco/executor-selfhost:latest

Traces are sent as OTLP over HTTP with a JSON payload, which every collector accepts — the OpenTelemetry Collector, Grafana Alloy, Jaeger, or a hosted backend. Port 4318 is the OTLP/HTTP convention, but the URL is whatever you point it at.

Variable Default Purpose
OTEL_EXPORTER_OTLP_ENDPOINT unset Collector base URL. Turns on trace export; /v1/traces is appended.
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT unset Full traces URL, used as-is. Overrides the base for this signal.
OTEL_EXPORTER_OTLP_HEADERS unset key=value,key2=value2 headers, for a collector that needs authentication.
EXECUTOR_OTEL_EXPORT_LOGS false Also export logs. A separate opt-in — logs carry more than timings.
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT unset Full logs URL. Required if only the traces endpoint is set.

Traces report under the service name executor-selfhost. On boot the server logs the endpoint it will export to, so a missing line means export is off.

For a hosted backend, put the credential in the headers:

-e OTEL_EXPORTER_OTLP_ENDPOINT=https://collector.example.com \
-e OTEL_EXPORTER_OTLP_HEADERS='authorization=Bearer <token>'

Using motel locally

motel is a local OTLP store and viewer: one command, a SQLite file, no Docker and no account. It is the quickest way to look at a trace on your own machine.

It needs Bun, and listens on port 27686:

bunx @kitlangton/motel

That starts the ingest server and opens the terminal UI. For ingest without the UI, run bunx @kitlangton/motel server.

If Executor runs directly on the same machine, point it at motel:

OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:27686

From a container, motel is on the host, not in the container’s network — and it binds loopback by default, so it also has to listen on an address the container can reach:

# start motel so it accepts connections from outside the host
MOTEL_OTEL_HOST=0.0.0.0 bunx @kitlangton/motel server

docker run -d \
  -p 4788:4788 \
  -v executor-data:/data \
  --add-host host.docker.internal:host-gateway \
  -e OTEL_EXPORTER_OTLP_ENDPOINT=http://host.docker.internal:27686 \
  ghcr.io/usefulsoftwareco/executor-selfhost:latest

Reading a trace

A single API request looks like this — each line a span, indented under its parent, with the time it took:

http.server GET                        31ms
  executor.stack.http.resolve          28ms
    executor.stack.build               28ms
      executor.stack.scoped_executor   27ms
        executor.plugins.init          16ms
        executor.stack.create_executor  2ms
        executor.subject.touch          7ms
          fumadb.subject.findFirst      2ms
          fumadb.subject.create         5ms
  fumadb.integration.findMany           1ms

The indentation is where the answer lives. Time held by http.server but not by any child is time spent outside the instrumented code — the network in front of the server, or a tunnel. Time inside a single child is the phase to look at next.

Requests arriving with a traceparent header continue that trace rather than starting a new one, so a request through a proxy or tunnel keeps one trace id from end to end.