JSON Logging
Requires the Enterprise Edition.
What it does
With the json_logging feature enabled, every log line coming out of
CMDBsyncer is a single-line JSON document shaped according to the
Elastic Common Schema (ECS).
That makes it drop-in for modern cloud log pipelines:
- Kubernetes / Docker — stdout is already captured by the container runtime; Fluent Bit, Vector or Promtail scrape it without any parser configuration.
- Elastic / OpenSearch — the ECS field names match the default Kibana / Discover dashboards out of the box.
- Grafana Loki — the JSON is line-addressable; use
| jsonin LogQL to query fields likeevent.sourceorservice.environment. - AWS CloudWatch / Datadog / Splunk / New Relic — all ingest JSON lines natively and extract ECS fields without custom mapping.
Note
The Mongo-backed log view inside the admin UI is unchanged — it still holds the structured application log for humans. JSON output is an additional stream for machines.
What a log line looks like
{
"@timestamp": "2026-04-23T09:05:12.487Z",
"log": {"level": "info", "logger": "debug"},
"message": "Checkmk Host Export",
"ecs": {"version": "8.11.0"},
"service": {
"name": "cmdbsyncer",
"environment": "prod",
"version": "3.12.14"
},
"host": {"name": "syncer-7f4b"},
"process": {"pid": 17},
"url": {"path": "/api/v1/syncer/cron/trigger/prod-cmk"},
"http": {"request": {"method": "POST"}},
"trace": {"id": "4bf92f3577b34da6a3ce929d0e0e4736"},
"client": {"ip": "10.0.12.44"},
"event": {
"source": "Checkmk Host Export",
"category": "app",
"outcome": "success",
"details": {"created": "3", "updated": "17", "deleted": "0"}
},
"affected_hosts": ["web-01", "web-02"]
}
Fields you can rely on:
| Field | Meaning |
|---|---|
@timestamp |
ISO-8601 UTC, millisecond precision, trailing Z |
log.level |
debug / info / warning / error / critical |
log.logger |
Python logger name (usually debug) |
message |
Human-readable summary |
ecs.version |
Always "8.11.0" |
service.* |
From OTEL_SERVICE_NAME, DEPLOYMENT_ENVIRONMENT, … |
host.name |
socket.gethostname() of the syncer process |
process.pid |
OS PID |
url.path |
Flask request path (only inside a request) |
http.request.method |
HTTP verb (only inside a request) |
client.ip |
Remote address (only inside a request) |
trace.id |
First-match of X-Request-ID, X-Cloud-Trace-Context, X-Amzn-Trace-Id, traceparent |
event.source |
Source string passed to log.log(..., source=…) |
event.outcome |
success / failure |
event.details.* |
Structured key/value details (preserved from log.log). A key that occurs more than once — one error per failed host, for example — becomes an array |
affected_hosts |
List of hostnames when the event references hosts |
For exceptions you additionally get error.type, error.message, and
error.stack_trace. An entry the Syncer logged from inside an exception
handler carries its traceback in error.stack_trace as well.
Everything the Log view in the admin panel shows for a run is in the JSON record too: the same message, source, affected hosts, every detail row, and the traceback.
Enabling it
Two steps, in this order.
1. Install the package and the license
Install the Enterprise package and ship a license that carries the
json_logging claim — see the Enterprise Edition page.
2. Switch the stream on
The stream is off until you ask for it, licence or not: an upgrade must never change the log format of a running installation under its operator.
There is one switch per thing that logs, and they are independent — set the one whose output you want in the collector:
| Switch | What it puts on the stream |
|---|---|
JSON_LOGGING_ENABLED |
The web application and its workers |
JSON_LOGGING_CLI |
Command runs — imports, exports, cron |
Easiest through the web interface: Config → Local Config carries a
JSON log stream preset with all five keys, their defaults and a
line each on what they do. Saving there writes local_config.py for
you.
By hand, for the web application:
config = {
'JSON_LOGGING_ENABLED': True,
}
Restart the application. A single marker line appears on startup, confirming the pipeline is live:
{"@timestamp":"2026-04-23T09:05:11.000Z","log":{"level":"info","logger":"debug"},"message":"ECS JSON logging active","service":{"name":"cmdbsyncer"}}
That covers the web application and its workers. The remaining
defaults — stdout, INFO — match what every cloud collector expects.
Command runs (imports, exports, cron)
Imports, exports and cron runs are usually the events the pipeline is actually after, and they have a switch of their own:
config = {
'JSON_LOGGING_CLI': True, # imports, exports, cron
}
It does not need JSON_LOGGING_ENABLED beside it — wanting the
imports in your collector says nothing about wanting the web log there
too. Set both where you want both.
Note
Command runs reach the stream from version 4.3 on. Earlier
versions built the pipeline only for the web application and its
workers and skipped it for every cmdbsyncer <command> run, so
JSON_LOGGING_CLI has no effect there — imports, exports and cron
stay plain text whatever it is set to. In 4.3.0 and 4.3.1 it also
still needed JSON_LOGGING_ENABLED next to it.
Not when you are watching. A command whose output goes straight to a terminal keeps its plain text, even with the setting on: one JSON record can carry an entire stack trace on a single line, and no collector is reading your terminal anyway. A cron run, a pipe or a redirect is not a terminal, and that is where the stream is produced — so the one setting serves both without a second switch.
cmdbsyncer csv import_hosts prod # terminal → plain text
cmdbsyncer csv import_hosts prod | tee /dev/null # pipe → JSON
Everything the run reports becomes a record. The progress of a run
— the retries, the timeouts, the result per host — is printed straight
to the console by the plugins. On such a run those lines go through the
pipeline as well, so each arrives as its own record with the colour
escapes stripped, under event.source: command_output:
{"@timestamp":"…","log":{"level":"info"},"message":"Try 1 of 2 failed: HTTPConnectionPool(host='cmk.example.com', port=80): Max retries exceeded","event":{"source":"command_output","category":"app","outcome":"success"}}
Without it a collector would see that an export failed but not one of the attempts leading there — only the closing summary record of the run carries a source of its own.
Third-party chatter (mongoengine, urllib3, …) stays suppressed in command runs either way — only the Syncer's own entries are emitted.
Writing to a log file instead
Everything above puts the records on a stream, which is what a
container collector reads. A run started from outside the container
— docker exec out of a host cron, for instance — has no such reader:
its output goes to that exec session and never to the container log
(see Troubleshooting). JSON_LOGGING_FILE sends
the records to a file instead:
config = {
'JSON_LOGGING_ENABLED': True,
'JSON_LOGGING_CLI': True,
'JSON_LOGGING_FILE': '/var/log/syncer/cmdbsyncer.jsonl',
}
The directory has to exist and be writable for the user the Syncer runs as — in a container that means mounting it in. If the path cannot be opened the run says so on stderr and keeps writing to the stream, so a typo never costs you an import.
A file is not a terminal, so this is the one target that also works while you watch a run: the records go to the file, your terminal keeps the readable output. Log rotation is supported — the file is reopened when it is rotated away, no restart needed.
Note
Available in the current 4.3 release line, with the matching
Enterprise package. Before that the records only go to a stream —
see the cookbook entry Host cron calling docker exec
for how to catch them in a file from the outside.
The five keys
Set in local_config.py, or through the JSON log stream preset
under Config → Local Config. The defaults below apply when a key is
absent:
| Key | Default | Purpose |
|---|---|---|
JSON_LOGGING_ENABLED |
False |
The web application and its workers write records. Independent of the key below |
JSON_LOGGING_CLI |
False |
cmdbsyncer <command> runs — imports, exports, cron — write records. Stands on its own; the key above is not needed for it. Runs printing to a terminal keep their plain text, unless the records go to JSON_LOGGING_FILE. Needs version 4.3 or newer |
JSON_LOGGING_FILE |
unset | Write the records into this file instead of the stream — for runs nobody collects the output of, started from outside the container for example. Falls back to the stream when the path cannot be opened |
JSON_LOGGING_STREAM |
'stdout' |
'stdout' or 'stderr' |
JSON_LOGGING_LEVEL |
'INFO' |
Any standard Python level name |
Service identity
These environment variables are automatically mapped into the JSON record without any code change — pass them through your container / systemd unit to identify the instance:
| Env var | ECS field |
|---|---|
OTEL_SERVICE_NAME (preferred), SERVICE_NAME |
service.name |
DEPLOYMENT_ENVIRONMENT (preferred), ENV |
service.environment |
CMDBSYNCER_VERSION (preferred), SERVICE_VERSION |
service.version |
AWS_REGION, CLOUD_REGION |
cloud.region |
If OTEL_SERVICE_NAME is unset, service.name defaults to
"cmdbsyncer".
Cookbook
Kubernetes
env:
- name: OTEL_SERVICE_NAME
value: cmdbsyncer
- name: DEPLOYMENT_ENVIRONMENT
value: prod
- name: CMDBSYNCER_VERSION
valueFrom:
fieldRef:
fieldPath: metadata.labels['app.kubernetes.io/version']
Combine with a Fluent Bit / Vector DaemonSet — no parser needed; the stream is already JSON.
Docker Compose
services:
cmdbsyncer:
environment:
OTEL_SERVICE_NAME: cmdbsyncer
DEPLOYMENT_ENVIRONMENT: prod
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5"
Host cron calling docker exec
A cron job on the host that runs the Syncer inside the container needs two things: no TTY, and somewhere for the records to go that outlives the container.
docker compose exec -T cmdbsyncer \
cmdbsyncer checkmk export_hosts prod
docker compose exec allocates a TTY by default and a run on a
terminal keeps its plain text, so -T is what turns the output into
records (plain docker exec allocates none). A pipe or a redirect on
the host side does not do it: it sits behind that TTY, and the
command inside the container still sees a terminal. With -T in place
it works as usual.
For the destination, redirect the run into a file on a mounted path:
docker compose exec -T cmdbsyncer \
cmdbsyncer checkmk export_hosts prod \
>> /var/log/syncer/cmdbsyncer.jsonl 2>> /var/log/syncer/cmdbsyncer.err
JSON_LOGGING_FILE does that for you from the current 4.3 release on.
Either way the records stay on the host, where they survive the
container and where Filebeat / Vector / the Elastic Agent already tail
files, and mk-job or whatever wraps the job keeps its own output.
Grafana Loki (LogQL)
{app="cmdbsyncer"} | json | event_outcome="failure" | line_format "{{.message}} ({{.event_source}})"
Elastic / OpenSearch
No index template work needed for the fields above — they already
match the ECS datastream. Ingest with the default Filebeat
container / docker input.
Troubleshooting
I see both JSON and plain-text lines
A legacy handler is still installed. The configurator removes
handlers from the debug and root loggers at startup; if custom
code adds its own handler afterwards, that line keeps escaping. Grep
for logging.StreamHandler() in local_config.py or custom
plugins.
trace.id is always missing
No upstream component is setting a trace header. Configure your
ingress / service mesh to forward one of the recognised headers, or
set X-Request-ID at the reverse proxy.
An import or export produces no JSON at all
Command runs need JSON_LOGGING_CLI = True (see above). Without it
only the web application and its workers write to the JSON stream. On
4.3.0 and 4.3.1 the key also needed JSON_LOGGING_ENABLED beside it.
Check as well that the Enterprise package is really installed in the
container you are running in — without it none of these keys do
anything.
I set JSON_LOGGING_CLI and still see plain text
You are running the command on a terminal, where it stays readable on
purpose. Pipe or redirect it — cmdbsyncer … | cat — to see what the
collector will get.
The JSON is in my terminal but not in docker logs
docker logs shows the stream of the container's main process only. A
run started with docker exec writes to that exec session, which is
your terminal — it never reaches the container log, and a collector
scraping the container never sees it. That is Docker behaviour, not a
setting. Cron runs inside the container do reach the container log:
run_cron.sh writes to the main process's streams, because the cron
daemon itself would hand job output to sendmail instead. Everything the
run reports arrives on stdout as records; stderr keeps whatever escapes
the pipeline, an interpreter-level traceback for instance.
For runs that come in from the host — a cron job calling docker exec
— point JSON_LOGGING_FILE at a mounted path instead of chasing the
container log; see Host cron calling docker exec.
Werkzeug request logs are gone
Expected. In production the reverse proxy access log is the right
place for that. Set JSON_LOGGING_LEVEL = 'DEBUG' if you want them
back.